From d27dfa2edecfafe36250fc074a9150ee95952690 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:25:16 +0800 Subject: [PATCH] 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. --- src/terminal/command_cursor.rs | 191 +++++++++++++++++++++++++++++++++ src/terminal/mod.rs | 1 + src/terminal/remote.rs | 160 ++++++++++++++++++++++++++- 3 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 src/terminal/command_cursor.rs diff --git a/src/terminal/command_cursor.rs b/src/terminal/command_cursor.rs new file mode 100644 index 00000000..248be844 --- /dev/null +++ b/src/terminal/command_cursor.rs @@ -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 { + 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, +} + +impl CommandCursorStyle { + pub fn apply(&mut self, term: &mut Term, 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 + ); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 21d408ca..d0cb14ef 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,5 +1,6 @@ mod boxdraw; mod cmd_editor; +mod command_cursor; mod completion; pub mod element; pub mod fps; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index d132c28c..23745a62 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -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 = 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();