fix(terminal): hand the prompt its cursor back when a command exits (#837)

`\e[0 q` / `\e[ q` already fall back to `Config::cursor_style`: alacritty
resolves an unset style to `default_cursor_style`, which we seed from the
config and re-apply on every live config change. What nvim actually sends
on exit is terminfo `Se`, and for `xterm-256color` that is `\e[2 q` -- an
explicit steady block, since that is xterm's default -- so the emulator
honours it and every prompt after nvim/vim is stuck on Block.

Track the shell-integration command marks in the pty reader, cut at their
exact byte offsets: note the cursor style at OSC 133;C and, if the command
left a different one behind by 133;D, restore it (back to "unset" when the
prompt was on the configured default, so it keeps following live config
changes). D is the first thing our precmd writes, so shell hooks that style
their own cursor (vi-mode plugins, precmd echoes) still have the last word.
Replayed snapshots go through the same cuts.

Tests: reset via `\e[0 q` and `\e[ q` lands on a configured bar/underline;
C, `\e[2 q`, D restores bar/underline, in one frame and split across frames;
unit tests for the mark parser and restore rules.
This commit is contained in:
l0ng-ai
2026-09-23 11:25:16 +08:00
parent 5cf4ac5efa
commit d27dfa2ede
3 changed files with 348 additions and 4 deletions
+191
View File
@@ -0,0 +1,191 @@
//! Hands the cursor shape back to the prompt when a command exits (#837).
//!
//! A program that restyles the cursor (DECSCUSR) is supposed to undo it on the
//! way out, and "undo" is whatever `Se` says in its terminfo. We advertise
//! `xterm-256color`, whose `Se` is `\E[2 q` — an explicit *steady block*,
//! because that is xterm's own default — and Neovim sends exactly that on exit
//! to a terminal it takes for xterm (vim goes through the same `Se`). The
//! emulator can only take `2 q` at its word, so every prompt after `nvim` has a
//! block cursor no matter what `cursor_style` says. `\e[0 q`, the one reset
//! that means "the terminal's default", already lands on `cursor_style`: that
//! is `Config::default_cursor_style`, which the emulator falls back to.
//!
//! The shell marks tell us when the program's claim on the cursor ends: `C` is
//! a command starting, `D` is it finishing. [`CommandCursorStyle`] notes the
//! style the prompt had at `C` and, if the command left a different one behind
//! at `D`, puts the prompt's back. `D` is the first thing our precmd writes, so
//! a shell hook that styles its own cursor (a vi-mode plugin, a `precmd` echo)
//! still has the last word.
use alacritty_terminal::event::EventListener;
use alacritty_terminal::term::Term;
use alacritty_terminal::vte::ansi::{CursorStyle, Handler as _};
/// The two shell-integration marks that bracket a command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandMark {
/// OSC 133;C — the command line was accepted and is about to run.
Started,
/// OSC 133;D — it finished and the shell is back.
Finished,
}
impl CommandMark {
/// Reads an OSC payload (`133;C;cargo build`, `133;D;0`, …).
pub fn parse(payload: &[u8]) -> Option<Self> {
let mark = payload.strip_prefix(b"133;")?;
let (&kind, rest) = mark.split_first()?;
if !(rest.is_empty() || rest.first() == Some(&b';')) {
return None;
}
match kind {
b'C' => Some(Self::Started),
b'D' => Some(Self::Finished),
_ => None,
}
}
}
#[derive(Default)]
pub struct CommandCursorStyle {
/// The style in effect when the running command started.
before: Option<CursorStyle>,
}
impl CommandCursorStyle {
pub fn apply<T: EventListener>(&mut self, term: &mut Term<T>, mark: CommandMark) {
match mark {
CommandMark::Started => self.before = Some(term.cursor_style()),
CommandMark::Finished => {
let Some(before) = self.before.take() else {
return;
};
if term.cursor_style() == before {
return;
}
// Back to "no program has asked for anything" first, so a
// prompt that was on the configured default stays on it —
// and keeps following `cursor_style` when that is changed
// later. Only a prompt that had a style of its own gets that
// style pinned back.
term.set_cursor_style(None);
if term.cursor_style() != before {
term.set_cursor_style(Some(before));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::osc::OscTokenizer;
use alacritty_terminal::event::VoidListener;
use alacritty_terminal::term::Config;
use alacritty_terminal::vte::ansi::{CursorShape, Processor};
/// Drives a stream through the emulator the way the pty reader does —
/// advance to each mark, act on it, carry on — for a pane whose configured
/// cursor is `default`, and reports the shape it ends on.
fn shape_after(default: CursorShape, stream: &[u8]) -> CursorShape {
let config = Config {
default_cursor_style: CursorStyle {
shape: default,
blinking: false,
},
..Config::default()
};
let mut term = Term::new(
config,
&crate::terminal::size::TermSize::new(80, 24),
VoidListener,
);
let mut parser: Processor = Processor::new();
let mut tok = OscTokenizer::new(&[b"133"]);
let mut restore = CommandCursorStyle::default();
let mut cuts = Vec::new();
tok.feed_at(stream, |off, payload| {
if let Some(mark) = CommandMark::parse(payload) {
cuts.push((off, mark));
}
});
let mut at = 0;
for (off, mark) in cuts {
parser.advance(&mut term, &stream[at..off]);
at = off;
restore.apply(&mut term, mark);
}
parser.advance(&mut term, &stream[at..]);
term.cursor_style().shape
}
#[test]
fn marks_parse_with_and_without_a_payload() {
assert_eq!(CommandMark::parse(b"133;C"), Some(CommandMark::Started));
assert_eq!(
CommandMark::parse(b"133;C;nvim x"),
Some(CommandMark::Started)
);
assert_eq!(CommandMark::parse(b"133;D;0"), Some(CommandMark::Finished));
assert_eq!(CommandMark::parse(b"133;D"), Some(CommandMark::Finished));
assert_eq!(CommandMark::parse(b"133;A"), None);
assert_eq!(CommandMark::parse(b"133;CX"), None);
assert_eq!(CommandMark::parse(b"7;file:///"), None);
}
/// The #837 repro: nvim's exit sequence under `xterm-256color` is `2 q`.
#[test]
fn a_block_left_by_an_exiting_editor_goes_back_to_the_configured_bar() {
assert_eq!(
shape_after(
CursorShape::Beam,
b"\x1b]133;C;nvim x\x07\x1b[2 q\x1b[?1049h\x1b[?1049l\x1b[2 q\x1b]133;D;0\x07$ "
),
CursorShape::Beam
);
assert_eq!(
shape_after(
CursorShape::Underline,
b"\x1b]133;C\x07\x1b[2 q\x1b]133;D;0\x07"
),
CursorShape::Underline
);
}
#[test]
fn a_style_the_prompt_set_for_itself_is_what_comes_back() {
assert_eq!(
shape_after(
CursorShape::Block,
b"\x1b[4 q$ \x1b]133;C\x07\x1b[6 q\x1b[2 q\x1b]133;D;0\x07"
),
CursorShape::Underline
);
}
#[test]
fn a_hook_after_the_command_mark_has_the_last_word() {
assert_eq!(
shape_after(
CursorShape::Block,
b"\x1b]133;C\x07\x1b[2 q\x1b]133;D;0\x07\x1b[6 q\x1b]133;A\x07$ "
),
CursorShape::Beam
);
}
/// Outside a command nothing is restored: a prompt that restyles its own
/// cursor between marks keeps what it chose.
#[test]
fn a_style_set_at_the_prompt_is_left_alone() {
assert_eq!(
shape_after(
CursorShape::Beam,
b"\x1b]133;C\x07\x1b]133;D;0\x07\x1b[2 q$ "
),
CursorShape::Block
);
}
}
+1
View File
@@ -1,5 +1,6 @@
mod boxdraw;
mod cmd_editor;
mod command_cursor;
mod completion;
pub mod element;
pub mod fps;
+156 -4
View File
@@ -12,6 +12,7 @@ use alacritty_terminal::sync::FairMutex;
use alacritty_terminal::term::{Config, Term, TermMode};
use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle};
use crate::terminal::command_cursor::{CommandCursorStyle, CommandMark};
use crate::terminal::parked_cursor::{CursorCut, ParkedCursorRepair, ParkedCursorScanner};
use std::collections::VecDeque;
@@ -1181,6 +1182,11 @@ impl RemoteTerminal {
let mut replaying_state = true;
let mut cursor_scan = ParkedCursorScanner::new();
let mut parked_cursor = ParkedCursorRepair::default();
// #837: the command marks, cut at exactly where they land so
// the cursor style a finished command left behind is judged
// against the bytes before its `D`, not the whole batch.
let mut command_tok = OscTokenizer::new(&[b"133"]);
let mut command_cursor = CommandCursorStyle::default();
let mut pending: Vec<u8> = buffered;
// Kitty-graphics decode runs on its own thread with newest-frame
// coalescing (issue #213): inflating a full-window browser frame
@@ -1255,10 +1261,13 @@ impl RemoteTerminal {
// the batch splits at each of them: advance the
// emulator to the cut, act on the state that
// sequence left behind, carry on.
let mut cuts: Vec<(usize, CursorCut)> = Vec::new();
let mut cuts: Vec<(usize, ReaderCut)> = Vec::new();
if local_conpty.load(Ordering::Relaxed) {
cursor_scan.feed(&out_batch, |off, c| cuts.push((off, c)));
cursor_scan.feed(&out_batch, |off, c| {
cuts.push((off, ReaderCut::Parked(c)))
});
}
command_cuts(&mut command_tok, &out_batch, &mut cuts);
{
let t0 = trace.then(std::time::Instant::now);
let mut term = term.lock();
@@ -1273,7 +1282,14 @@ impl RemoteTerminal {
for (off, cut) in cuts {
processor.advance(&mut *term, &out_batch[at..off]);
at = off;
parked_cursor.apply(&mut term, cut);
match cut {
ReaderCut::Parked(cut) => {
parked_cursor.apply(&mut term, cut)
}
ReaderCut::Command(mark) => {
command_cursor.apply(&mut term, mark)
}
}
}
processor.advance(&mut *term, &out_batch[at..]);
}
@@ -1396,12 +1412,25 @@ impl RemoteTerminal {
cursor_scan.reset();
parked_cursor.reset();
proxy.replaying.store(true, Ordering::Relaxed);
// The replay carries the command marks too, so
// a pane reattached after `nvim` exited in it
// comes back with the prompt's cursor.
let mut cuts: Vec<(usize, ReaderCut)> = Vec::new();
command_cuts(&mut command_tok, &bytes, &mut cuts);
{
let mut term = term.lock();
if quit.load(Ordering::SeqCst) {
return;
}
processor.advance(&mut *term, &bytes);
let mut at = 0usize;
for (off, cut) in cuts {
processor.advance(&mut *term, &bytes[at..off]);
at = off;
if let ReaderCut::Command(mark) = cut {
command_cursor.apply(&mut term, mark);
}
}
processor.advance(&mut *term, &bytes[at..]);
if processor.sync_timeout().sync_timeout().is_some() {
processor.stop_sync(&mut *term);
}
@@ -3143,6 +3172,29 @@ mod config_tests {
}
}
/// A point in a batch of pty output where the reader stops advancing the
/// emulator to act on the state the bytes before it left behind.
enum ReaderCut {
Parked(CursorCut),
Command(CommandMark),
}
/// Adds the batch's command marks to `cuts`, keeping them in stream order
/// alongside whatever cuts are already there.
fn command_cuts(tok: &mut OscTokenizer, bytes: &[u8], cuts: &mut Vec<(usize, ReaderCut)>) {
let before = cuts.len();
tok.feed_at(bytes, |off, payload| {
if let Some(mark) = CommandMark::parse(payload) {
cuts.push((off, ReaderCut::Command(mark)));
}
});
if before > 0 && cuts.len() > before {
// Stable, so a mark that ends where a cursor show does keeps its place
// after it.
cuts.sort_by_key(|(off, _)| *off);
}
}
fn alacritty_cursor_style(style: ConfigCursorStyle) -> CursorStyle {
let shape = match style {
ConfigCursorStyle::Block => CursorShape::Block,
@@ -4923,6 +4975,7 @@ mod tests {
fn cursor_style_sequence_overrides_and_resets_to_user_default() {
use alacritty_terminal::vte::ansi::CursorShape;
crate::core::config::pin_test_config_dir();
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
let mut user_config = crate::core::config::Config::default();
@@ -4959,6 +5012,105 @@ mod tests {
assert_eq!(shape, CursorShape::Underline);
}
/// Feeds `output` to a pane configured with `configured`, one daemon frame
/// per chunk, and reports the cursor shape once all of it has been parsed.
fn cursor_shape_after(
configured: ConfigCursorStyle,
output: &[&[u8]],
) -> alacritty_terminal::vte::ansi::CursorShape {
use alacritty_terminal::index::{Column, Line};
// The pane reads config.json when it is built; keep that off the
// user's real one.
crate::core::config::pin_test_config_dir();
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
let mut user_config = crate::core::config::Config::default();
user_config.cursor_style = configured;
term.apply_user_config(&user_config);
for chunk in output {
DaemonMsg::Output(chunk.to_vec())
.encode(&mut daemon_side)
.unwrap();
}
// A sentinel painted after everything else: once it is on the grid,
// every chunk before it has been parsed, so the shape read below is
// the final one and not a transient match.
DaemonMsg::Output(b"\x1b[24;1H#".to_vec())
.encode(&mut daemon_side)
.unwrap();
daemon_side.flush().unwrap();
for _ in 0..400 {
if term.term.lock().grid()[Line(23)][Column(0)].c == '#' {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let term = term.term.lock();
assert_eq!(
term.grid()[Line(23)][Column(0)].c,
'#',
"output never parsed"
);
term.cursor_style().shape
}
/// #837: both spellings of the DECSCUSR reset land on `cursor_style`.
#[test]
fn cursor_style_reset_returns_to_a_configured_bar_or_underline() {
use alacritty_terminal::vte::ansi::CursorShape;
for reset in [&b"\x1b[0 q"[..], &b"\x1b[ q"[..]] {
for (configured, want) in [
(ConfigCursorStyle::Bar, CursorShape::Beam),
(ConfigCursorStyle::Underline, CursorShape::Underline),
] {
assert_eq!(
cursor_shape_after(configured, &[b"\x1b[6 q", b"\x1b[2 q", reset]),
want,
"{configured:?} after {reset:?}"
);
}
}
}
/// #837: what nvim actually sends on exit under `xterm-256color` is `Se`,
/// `\e[2 q` — a literal steady block. The command's `D` hands the prompt
/// its configured cursor back, whether the block arrives in the same
/// frame as the marks or in one of its own.
#[test]
fn a_block_an_exiting_command_left_is_undone_at_its_finish_mark() {
use alacritty_terminal::vte::ansi::CursorShape;
for (configured, want) in [
(ConfigCursorStyle::Bar, CursorShape::Beam),
(ConfigCursorStyle::Underline, CursorShape::Underline),
] {
assert_eq!(
cursor_shape_after(
configured,
&[b"\x1b]133;C;nvim x\x07\x1b[2 q\x1b]133;D;0\x07$ "],
),
want,
"{configured:?}, one frame"
);
assert_eq!(
cursor_shape_after(
configured,
&[
b"\x1b]133;C;nvim x\x07",
b"\x1b[6 q",
b"\x1b[2 q\x1b[?1049l",
b"\x1b]133;D;0\x07\x1b]133;A\x07$ ",
],
),
want,
"{configured:?}, split frames"
);
}
}
#[test]
fn reader_surfaces_auth_prompt_and_status() {
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();