mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-26 16:02:34 +00:00
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:
@@ -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,5 +1,6 @@
|
||||
mod boxdraw;
|
||||
mod cmd_editor;
|
||||
mod command_cursor;
|
||||
mod completion;
|
||||
pub mod element;
|
||||
pub mod fps;
|
||||
|
||||
+156
-4
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user