mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Merge pull request #8 from l0ng-ai/fix/stale-tui-modes-and-exit-close
Fix stale TUI residue after ssh drops and auto-close exited panes
This commit is contained in:
@@ -19,6 +19,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Typing `exit` (or Ctrl-D) left a dead "process exited" pane behind instead
|
||||
of closing it. A pane whose shell genuinely ends now closes itself —
|
||||
collapsing its split, or closing the tab when it was the only pane (the
|
||||
last tab falls back to the home page), like every other terminal. A pane
|
||||
that merely *lost its daemon connection* still stays visible: auto-closing
|
||||
those would silently discard — and kill — sessions that may still be alive
|
||||
daemon-side. Panes that died while detached clean themselves up on the next
|
||||
attach the same way.
|
||||
|
||||
- A full-screen TUI dying without restoring the terminal — the canonical case
|
||||
being an ssh session dropping mid-`htop`/`vim` — left the pane stranded on
|
||||
the alt screen with a hidden cursor and live mouse reporting: a visible
|
||||
prompt with no cursor anywhere, mouse clicks echoing `0;19;42M`-style junk,
|
||||
and broken scrollback. The client now scrubs this residue the moment the
|
||||
shell reports its next prompt (OSC 133): it leaves the stranded alt screen,
|
||||
re-shows the DECTCEM-hidden cursor, and disables stale mouse/focus reporting
|
||||
and kitty keyboard flags — each reset only when its mode is actually set.
|
||||
Reattach self-heals the same way, since the daemon replays the prompt state
|
||||
after the ring.
|
||||
|
||||
- Windows shell integration never engaged even for the default shell: detection
|
||||
keyed off `portable-pty`'s `get_shell()`, which reports `%ComSpec%` (cmd.exe)
|
||||
regardless of what's actually spawned, so the PowerShell default was mistaken
|
||||
|
||||
+246
-9
@@ -30,7 +30,7 @@ use std::thread::JoinHandle;
|
||||
|
||||
use alacritty_terminal::event::{Event as AlacEvent, EventListener};
|
||||
use alacritty_terminal::sync::FairMutex;
|
||||
use alacritty_terminal::term::{Config, Term};
|
||||
use alacritty_terminal::term::{Config, Term, TermMode};
|
||||
use alacritty_terminal::vte::ansi;
|
||||
|
||||
use crate::core::osc::OscTokenizer;
|
||||
@@ -86,6 +86,17 @@ struct ShellState {
|
||||
last_exit: Option<i32>,
|
||||
}
|
||||
|
||||
/// The shared handles the reader thread writes into as daemon frames arrive;
|
||||
/// `RemoteTerminal` keeps the other ends for the view to read. Bundled so
|
||||
/// `spawn_reader`'s signature stays readable as signals accrue.
|
||||
struct ReaderSignals {
|
||||
cwd: Arc<Mutex<Option<PathBuf>>>,
|
||||
shell: Arc<Mutex<ShellState>>,
|
||||
exited: Arc<AtomicBool>,
|
||||
child_exited: Arc<AtomicBool>,
|
||||
zle_reading: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// A terminal whose PTY lives in the daemon. Mirrors `backend::Terminal`'s public
|
||||
/// surface so the view can treat the two interchangeably.
|
||||
pub struct RemoteTerminal {
|
||||
@@ -117,6 +128,14 @@ pub struct RemoteTerminal {
|
||||
/// Set true by the reader thread once the child exits or the daemon
|
||||
/// disconnects. `poll_exited()` copies this into the `exited` field.
|
||||
exited_flag: Arc<AtomicBool>,
|
||||
/// Set true only on a *genuine* child exit (`DaemonMsg::Exited` — the
|
||||
/// shell ended: `exit`, Ctrl-D, a crash), never on a daemon disconnect or
|
||||
/// protocol desync, which also flip `exited_flag`. The distinction gates
|
||||
/// pane auto-close: a pane whose shell ended closes itself, while a pane
|
||||
/// that merely lost its connection stays visible (auto-closing it would
|
||||
/// silently discard — and `close_tab` would try to kill — a session that
|
||||
/// may still be alive daemon-side).
|
||||
child_exited: Arc<AtomicBool>,
|
||||
/// Whether zle is reading the keyboard right now, sniffed client-side from
|
||||
/// *live* OSC 133 marks: `B` (prompt end — zle takes over immediately
|
||||
/// after) arms it, any other mark disarms it, and Snapshot replays never
|
||||
@@ -202,16 +221,20 @@ impl RemoteTerminal {
|
||||
let cwd: Arc<Mutex<Option<PathBuf>>> = Arc::new(Mutex::new(None));
|
||||
let shell_state: Arc<Mutex<ShellState>> = Arc::new(Mutex::new(ShellState::default()));
|
||||
let exited_flag = Arc::new(AtomicBool::new(false));
|
||||
let child_exited = Arc::new(AtomicBool::new(false));
|
||||
let zle_reading = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let reader_thread = Self::spawn_reader(
|
||||
term.clone(),
|
||||
proxy,
|
||||
read_half,
|
||||
cwd.clone(),
|
||||
shell_state.clone(),
|
||||
exited_flag.clone(),
|
||||
zle_reading.clone(),
|
||||
ReaderSignals {
|
||||
cwd: cwd.clone(),
|
||||
shell: shell_state.clone(),
|
||||
exited: exited_flag.clone(),
|
||||
child_exited: child_exited.clone(),
|
||||
zle_reading: zle_reading.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
@@ -225,6 +248,7 @@ impl RemoteTerminal {
|
||||
cwd,
|
||||
shell_state,
|
||||
exited_flag,
|
||||
child_exited,
|
||||
zle_reading,
|
||||
reader_thread: Some(reader_thread),
|
||||
})
|
||||
@@ -246,14 +270,18 @@ impl RemoteTerminal {
|
||||
term: Arc<FairMutex<Term<EventProxy>>>,
|
||||
proxy: EventProxy,
|
||||
read_half: Stream,
|
||||
cwd: Arc<Mutex<Option<PathBuf>>>,
|
||||
shell: Arc<Mutex<ShellState>>,
|
||||
exited_flag: Arc<AtomicBool>,
|
||||
zle_reading: Arc<AtomicBool>,
|
||||
signals: ReaderSignals,
|
||||
) -> JoinHandle<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("tty7-remote-reader".to_string())
|
||||
.spawn(move || {
|
||||
let ReaderSignals {
|
||||
cwd,
|
||||
shell,
|
||||
exited: exited_flag,
|
||||
child_exited,
|
||||
zle_reading,
|
||||
} = signals;
|
||||
// The client end of the visible-output path: keep it off the
|
||||
// efficiency cores (see `core::threads`).
|
||||
crate::core::threads::promote_to_user_interactive();
|
||||
@@ -448,12 +476,45 @@ impl RemoteTerminal {
|
||||
last_exit,
|
||||
};
|
||||
}
|
||||
// The shell just reported a fresh prompt, so at
|
||||
// this position in the byte stream no full-screen
|
||||
// program owns the pane. Any TUI state still in
|
||||
// the grid — a stranded alt screen, a DECTCEM-
|
||||
// hidden cursor, mouse/focus reporting, kitty
|
||||
// keyboard flags — is residue from a program that
|
||||
// died without restoring it (an ssh session
|
||||
// dropping mid-TUI is the canonical case: the
|
||||
// restore sequences can never arrive). Feed the
|
||||
// resets through the same parser path as PTY
|
||||
// output, right here between frames: every byte
|
||||
// the dead program did send has already applied
|
||||
// (`flush_batch!` above), and the prompt text /
|
||||
// next command's bytes only come in later frames,
|
||||
// so this can never fight a live program's own
|
||||
// mode changes. Runs on the attach path too —
|
||||
// the daemon sends `Prompt` after `Snapshot` —
|
||||
// so a stale replay ring self-heals on reattach.
|
||||
if active && at_prompt {
|
||||
let mut term = term.lock();
|
||||
let resets = stale_mode_resets(*term.mode());
|
||||
if !resets.is_empty() {
|
||||
processor.advance(&mut *term, &resets);
|
||||
drop(term);
|
||||
proxy.send_event(AlacEvent::Wakeup);
|
||||
}
|
||||
}
|
||||
}
|
||||
DaemonMsg::Exited { .. } => {
|
||||
// Child gone: apply what it printed last, then
|
||||
// mark the emulator exited and flip the shared
|
||||
// flag so the next `poll_exited()` surfaces it.
|
||||
// This is the one exit path where the child
|
||||
// *really* ended (vs the connection dying), so
|
||||
// record that before the teardown's events fire
|
||||
// — the view reads it to decide whether the
|
||||
// pane should close itself.
|
||||
flush_batch!();
|
||||
child_exited.store(true, Ordering::SeqCst);
|
||||
teardown();
|
||||
break 'main;
|
||||
}
|
||||
@@ -554,6 +615,12 @@ impl RemoteTerminal {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pane's child process genuinely exited (as opposed to the
|
||||
/// daemon connection dropping — see the `child_exited` field docs).
|
||||
pub fn child_exited(&self) -> bool {
|
||||
self.child_exited.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Send raw bytes (keyboard input, pasted text, query replies) to the pane as
|
||||
/// a `ClientMsg::Input` frame. Mirrors `Terminal::write`'s signature exactly.
|
||||
pub fn write<B: Into<Cow<'static, [u8]>>>(&self, bytes: B) {
|
||||
@@ -687,6 +754,50 @@ impl Drop for RemoteTerminal {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reset sequence that clears stale full-screen-TUI state from a grid that
|
||||
/// provably has no full-screen owner (the shell just drew its prompt). Each
|
||||
/// reset is emitted only when the corresponding mode is actually set, because
|
||||
/// some are not idempotent when idle: `?1049l` on the primary screen performs
|
||||
/// a cursor *restore*, so it must never fire as a blanket reset.
|
||||
///
|
||||
/// Deliberately left alone: bracketed paste and application cursor keys —
|
||||
/// zle/fish own those around the prompt and re-arm them on every read, so
|
||||
/// resetting here could race the line editor's own enable — and anything the
|
||||
/// parser doesn't track (nothing to detect staleness against).
|
||||
fn stale_mode_resets(mode: TermMode) -> Vec<u8> {
|
||||
let mut seq = Vec::new();
|
||||
// Leave the alternate screen first: the resets below then apply to the
|
||||
// primary screen's state (kitty keyboard flags are tracked per screen).
|
||||
if mode.contains(TermMode::ALT_SCREEN) {
|
||||
seq.extend_from_slice(b"\x1b[?1049l");
|
||||
}
|
||||
if !mode.contains(TermMode::SHOW_CURSOR) {
|
||||
seq.extend_from_slice(b"\x1b[?25h");
|
||||
}
|
||||
if mode.intersects(TermMode::MOUSE_MODE) {
|
||||
seq.extend_from_slice(b"\x1b[?1000l\x1b[?1002l\x1b[?1003l");
|
||||
}
|
||||
if mode.contains(TermMode::SGR_MOUSE) {
|
||||
seq.extend_from_slice(b"\x1b[?1006l");
|
||||
}
|
||||
if mode.contains(TermMode::UTF8_MOUSE) {
|
||||
seq.extend_from_slice(b"\x1b[?1005l");
|
||||
}
|
||||
if mode.contains(TermMode::FOCUS_IN_OUT) {
|
||||
seq.extend_from_slice(b"\x1b[?1004l");
|
||||
}
|
||||
// While ALT_SCREEN is set, `mode` shows the *alt* screen's kitty flags;
|
||||
// the `?1049l` above restores the primary screen's stack, which may
|
||||
// itself be polluted (e.g. a remote kitty-protocol app ran before the
|
||||
// TUI that died). So zero the flags whenever either screen could be
|
||||
// dirty — at a shell prompt zero is always correct, since kitty-aware
|
||||
// line editors re-arm on every read.
|
||||
if mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL) || mode.contains(TermMode::ALT_SCREEN) {
|
||||
seq.extend_from_slice(b"\x1b[=0;1u");
|
||||
}
|
||||
seq
|
||||
}
|
||||
|
||||
/// Post a best-effort desktop notification via `notify-rust`. The single
|
||||
/// notification entry point for the whole app: both the OSC 9 / 777 escape-sequence
|
||||
/// path (the reader thread) and the "long command finished" heuristic in the view
|
||||
@@ -894,6 +1005,132 @@ mod tests {
|
||||
assert!(term.exited_flag.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
/// A `DaemonMsg::Exited` frame (the child really ended) must set
|
||||
/// `child_exited`; a bare daemon disconnect (EOF) must not — both flip
|
||||
/// `exited_flag`. The distinction is what keeps pane auto-close from
|
||||
/// firing on a lost connection and destroying a session that may still be
|
||||
/// alive daemon-side.
|
||||
#[test]
|
||||
fn child_exit_is_distinguished_from_daemon_disconnect() {
|
||||
// A genuine child exit: the daemon reports it explicitly.
|
||||
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
|
||||
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
|
||||
DaemonMsg::Exited { code: Some(0) }
|
||||
.encode(&mut daemon_side)
|
||||
.unwrap();
|
||||
for _ in 0..200 {
|
||||
if term.exited_flag.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(term.exited_flag.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
term.child_exited(),
|
||||
"an Exited frame is a genuine child exit"
|
||||
);
|
||||
|
||||
// A daemon disconnect: the socket just closes.
|
||||
let (client_side, daemon_side) = UnixStream::pair().unwrap();
|
||||
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
|
||||
drop(daemon_side);
|
||||
for _ in 0..200 {
|
||||
if term.exited_flag.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(term.exited_flag.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
!term.child_exited(),
|
||||
"a disconnect is not a child exit — auto-close must not fire"
|
||||
);
|
||||
}
|
||||
|
||||
/// `stale_mode_resets` maps each residue bit to its reset — and nothing
|
||||
/// more. The guards matter as much as the resets: `?1049l` on a grid that
|
||||
/// is *not* on the alt screen performs a cursor restore, so a clean (or
|
||||
/// merely cursor-hidden) mode must never emit it.
|
||||
#[test]
|
||||
fn stale_mode_resets_target_only_the_dirty_bits() {
|
||||
// A healthy prompt-time mode: nothing to reset.
|
||||
let clean = TermMode::SHOW_CURSOR | TermMode::LINE_WRAP | TermMode::BRACKETED_PASTE;
|
||||
assert!(stale_mode_resets(clean).is_empty());
|
||||
|
||||
// Hidden cursor alone (a Claude-Code-style TUI, no alt screen):
|
||||
// exactly `?25h`, and crucially no `?1049l`.
|
||||
let hidden = TermMode::LINE_WRAP;
|
||||
assert_eq!(stale_mode_resets(hidden), b"\x1b[?25h");
|
||||
|
||||
// The full ssh-drop-mid-htop residue: alt screen + hidden cursor +
|
||||
// mouse reporting. The alt-screen exit leads (later resets must land
|
||||
// on the primary screen), and the kitty zeroing rides along because
|
||||
// the primary screen's flags are unobservable from the alt screen.
|
||||
let residue = TermMode::ALT_SCREEN | TermMode::MOUSE_DRAG | TermMode::SGR_MOUSE;
|
||||
let seq = stale_mode_resets(residue);
|
||||
let text = String::from_utf8_lossy(&seq).into_owned();
|
||||
assert!(text.starts_with("\x1b[?1049l"));
|
||||
assert!(text.contains("\x1b[?25h"));
|
||||
assert!(text.contains("\x1b[?1002l"));
|
||||
assert!(text.contains("\x1b[?1006l"));
|
||||
assert!(text.ends_with("\x1b[=0;1u"));
|
||||
|
||||
// Kitty keyboard flags alone (the same drop during a kitty-protocol
|
||||
// app): just the zeroing, nothing screen-related.
|
||||
let kitty = TermMode::SHOW_CURSOR | TermMode::DISAMBIGUATE_ESC_CODES;
|
||||
assert_eq!(stale_mode_resets(kitty), b"\x1b[=0;1u");
|
||||
}
|
||||
|
||||
/// End-to-end through the reader thread: a TUI's mode changes arrive as
|
||||
/// `Output`, the connection "dies" (no restore sequences), and the host
|
||||
/// shell's next prompt report must scrub the residue from the local grid.
|
||||
/// This is the ssh-drop-mid-TUI bug at the transport level.
|
||||
#[test]
|
||||
fn prompt_report_scrubs_stale_tui_modes() {
|
||||
let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
|
||||
let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
|
||||
|
||||
// htop over ssh: alt screen, hidden cursor, drag + SGR mouse. Then the
|
||||
// network drops — no `?1049l`/`?25h`/mouse-off ever arrives.
|
||||
DaemonMsg::Output(b"\x1b[?1049h\x1b[?25l\x1b[?1002h\x1b[?1006h".to_vec())
|
||||
.encode(&mut daemon_side)
|
||||
.unwrap();
|
||||
// ssh exits; the host shell's integration reports a fresh prompt.
|
||||
DaemonMsg::Prompt {
|
||||
active: true,
|
||||
at_prompt: true,
|
||||
last_exit: Some(255),
|
||||
}
|
||||
.encode(&mut daemon_side)
|
||||
.unwrap();
|
||||
daemon_side.flush().unwrap();
|
||||
|
||||
let mut mode = TermMode::NONE;
|
||||
for _ in 0..200 {
|
||||
mode = *term.term.lock().mode();
|
||||
let scrubbed = !mode.contains(TermMode::ALT_SCREEN)
|
||||
&& mode.contains(TermMode::SHOW_CURSOR)
|
||||
&& !mode.intersects(TermMode::MOUSE_MODE)
|
||||
&& !mode.contains(TermMode::SGR_MOUSE);
|
||||
if scrubbed && term.at_prompt() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(
|
||||
!mode.contains(TermMode::ALT_SCREEN),
|
||||
"the prompt report must pull the grid off the stranded alt screen"
|
||||
);
|
||||
assert!(
|
||||
mode.contains(TermMode::SHOW_CURSOR),
|
||||
"the prompt report must re-show the DECTCEM-hidden cursor"
|
||||
);
|
||||
assert!(
|
||||
!mode.intersects(TermMode::MOUSE_MODE) && !mode.contains(TermMode::SGR_MOUSE),
|
||||
"the prompt report must disable stale mouse reporting"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for the "restored pane types `11;rgb:…` at the prompt" bug:
|
||||
/// queries replayed from an attach `Snapshot` must NOT be re-answered —
|
||||
/// they were answered when they ran live, and answering again writes the
|
||||
|
||||
@@ -43,6 +43,15 @@ actions!(
|
||||
]
|
||||
);
|
||||
|
||||
/// Emitted when the pane's child process has genuinely exited (`exit`,
|
||||
/// Ctrl-D, a crashed shell) — as opposed to the daemon connection dropping,
|
||||
/// which keeps the dead pane visible. `Tty7App` subscribes (see
|
||||
/// `new_terminal`) and closes the pane in response: collapsing its split, or
|
||||
/// closing the tab when it was the only pane.
|
||||
pub struct ChildExited;
|
||||
|
||||
impl gpui::EventEmitter<ChildExited> for TerminalView {}
|
||||
|
||||
pub struct TerminalView {
|
||||
pub terminal: RemoteTerminal,
|
||||
/// Daemon-assigned id of the pane this view mirrors. Persisted in the session
|
||||
@@ -619,6 +628,14 @@ impl TerminalView {
|
||||
AlacEvent::ChildExit(_) | AlacEvent::Exit => {
|
||||
self.terminal.exited = true;
|
||||
self.title = "tty7 — process exited".to_string();
|
||||
// A genuine child exit closes the pane (the app subscribes and
|
||||
// collapses the split / closes the tab). A daemon disconnect
|
||||
// reaches this same arm but must NOT auto-close: the session
|
||||
// may still be alive daemon-side, and closing would both hide
|
||||
// the failure and kill the pane.
|
||||
if self.terminal.child_exited() {
|
||||
cx.emit(ChildExited);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
AlacEvent::ClipboardStore(_, text) => {
|
||||
@@ -3967,4 +3984,135 @@ mod gpui_tests {
|
||||
"a Hidden shape must not collapse the editor anchor to the top-left corner"
|
||||
);
|
||||
}
|
||||
|
||||
/// A genuine child exit (`DaemonMsg::Exited`) must surface as a
|
||||
/// `ChildExited` gpui event — the app's cue to close the pane/tab (the
|
||||
/// "typing `exit` leaves a dead pane behind" bug). A daemon disconnect
|
||||
/// marks the view exited through the same `AlacEvent::Exit` arm but must
|
||||
/// emit nothing: auto-closing on a lost connection would silently discard
|
||||
/// (and kill) a pane that may still be alive daemon-side.
|
||||
#[gpui::test]
|
||||
fn child_exit_emits_the_close_event_but_disconnect_does_not(cx: &mut TestAppContext) {
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
let subscribe = |window: &gpui::WindowHandle<TerminalView>, cx: &mut TestAppContext| {
|
||||
let got = Rc::new(Cell::new(false));
|
||||
let seen = got.clone();
|
||||
window
|
||||
.update(cx, |_, _, cx| {
|
||||
let this = cx.entity();
|
||||
cx.subscribe(&this, move |_, _, _: &ChildExited, _| seen.set(true))
|
||||
.detach();
|
||||
})
|
||||
.unwrap();
|
||||
got
|
||||
};
|
||||
let wait_exited = |window: &gpui::WindowHandle<TerminalView>, cx: &mut TestAppContext| {
|
||||
for _ in 0..400 {
|
||||
cx.run_until_parked();
|
||||
let exited = window
|
||||
.update(cx, |view, _, _| view.terminal.exited)
|
||||
.unwrap();
|
||||
if exited {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
panic!("the view never noticed the exit");
|
||||
};
|
||||
|
||||
// The child really exits: the daemon says so.
|
||||
let (window, mut daemon) = harness(cx);
|
||||
let got = subscribe(&window, cx);
|
||||
DaemonMsg::Exited { code: Some(0) }
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
wait_exited(&window, cx);
|
||||
assert!(got.get(), "a genuine child exit must emit ChildExited");
|
||||
|
||||
// The connection just drops.
|
||||
let (window, daemon) = harness(cx);
|
||||
let got = subscribe(&window, cx);
|
||||
drop(daemon);
|
||||
wait_exited(&window, cx);
|
||||
assert!(!got.get(), "a daemon disconnect must not emit ChildExited");
|
||||
}
|
||||
|
||||
/// Regression for the "cursor vanishes after an ssh session dies mid-TUI"
|
||||
/// bug. Over ssh, a remote full-screen TUI entered the alt screen and hid
|
||||
/// the cursor (`\e[?1049h\e[?25l`). The network then drops: the restore
|
||||
/// sequences (`\e[?25h`, `\e[?1049l`) never arrive, ssh exits, and the
|
||||
/// *host* shell draws its prompt (reported via OSC 133 → `Prompt`).
|
||||
///
|
||||
/// Before the prompt-time scrub in the remote reader (see
|
||||
/// `stale_mode_resets`), the grid stayed stranded on the alt screen with
|
||||
/// a `Hidden` cursor shape, so *neither* cursor painted:
|
||||
/// `element::build_grid` filters hidden grid cursors, and the inline
|
||||
/// editor (which would ignore the stale-Hidden shape, see the test above)
|
||||
/// never engaged because `input_active()` requires being off the alt
|
||||
/// screen — a visible prompt with no cursor anywhere. The prompt report
|
||||
/// must instead scrub the residue: off the alt screen, cursor shown,
|
||||
/// editor live again.
|
||||
#[gpui::test]
|
||||
fn ssh_drop_mid_tui_recovers_at_the_next_prompt(cx: &mut TestAppContext) {
|
||||
use alacritty_terminal::vte::ansi::CursorShape;
|
||||
|
||||
let (window, mut daemon) = harness(cx);
|
||||
|
||||
// Bytes that arrived over ssh before the drop: the remote TUI enters
|
||||
// the alt screen and hides the cursor. The connection dies before any
|
||||
// restore sequence is sent.
|
||||
DaemonMsg::Output(b"\x1b[?1049h\x1b[?25l".to_vec())
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
// ssh exits; the host shell's integration reports a fresh prompt.
|
||||
DaemonMsg::Prompt {
|
||||
active: true,
|
||||
at_prompt: true,
|
||||
last_exit: Some(255), // ssh's exit code after a connection loss
|
||||
}
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
|
||||
let mut state = (false, true, true);
|
||||
for _ in 0..400 {
|
||||
cx.run_until_parked();
|
||||
state = window
|
||||
.update(cx, |view, _, _| {
|
||||
let hidden = matches!(
|
||||
view.terminal.term.lock().renderable_content().cursor.shape,
|
||||
CursorShape::Hidden
|
||||
);
|
||||
(view.at_shell_prompt(), view.on_alt_screen(), hidden)
|
||||
})
|
||||
.unwrap();
|
||||
if state == (true, false, false) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
|
||||
let (at_prompt, on_alt, hidden) = state;
|
||||
assert!(at_prompt, "the host shell is back at its prompt");
|
||||
assert!(
|
||||
!on_alt,
|
||||
"the prompt report must pull the grid off the stranded alt screen"
|
||||
);
|
||||
assert!(
|
||||
!hidden,
|
||||
"the prompt report must re-show the DECTCEM-hidden cursor"
|
||||
);
|
||||
|
||||
// With the residue scrubbed, the inline editor engages and owns the
|
||||
// caret again — the user sees a cursor at the prompt.
|
||||
window
|
||||
.update(cx, |view, _, _| {
|
||||
assert!(
|
||||
view.input_active(),
|
||||
"off the alt screen and at the prompt, the editor is live"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+52
-2
@@ -14,7 +14,7 @@ use gpui_component::{ActiveTheme as _, IndexPath, TitleBar};
|
||||
use crate::core::actions::*;
|
||||
use crate::core::config::{Config, NewTabPosition, ShellConfig, color_or, hsla_to_hex6};
|
||||
use crate::core::session::{Session, SessionAxis, SessionPane, SessionTab};
|
||||
use crate::terminal::view::TerminalView;
|
||||
use crate::terminal::view::{ChildExited, TerminalView};
|
||||
use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView};
|
||||
use crate::ui::pane::{CloseOutcome, Pane};
|
||||
use crate::ui::settings::{ColorKey, SettingsSection, SettingsState};
|
||||
@@ -746,6 +746,46 @@ impl Tty7App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the pane whose shell just exited on its own (`ChildExited` from
|
||||
/// the view — `exit`, Ctrl-D, a crashed shell): collapse its split, or
|
||||
/// close its tab when it was the only pane. Unlike `close_pane` this
|
||||
/// targets the *emitting* leaf, not the focused one — the exit can happen
|
||||
/// in a background tab. The daemon pane is killed even though its child is
|
||||
/// already dead: the daemon still lists it for reattach, and killing is
|
||||
/// what drops it from the session.
|
||||
fn on_child_exited(
|
||||
&mut self,
|
||||
view: Entity<TerminalView>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = view.entity_id();
|
||||
let Some(index) = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|tab| tab.pane.leaves().iter().any(|l| l.entity_id() == id))
|
||||
else {
|
||||
return; // already closed (e.g. by the user racing the exit)
|
||||
};
|
||||
match self.tabs[index].pane.close_leaf(&view) {
|
||||
// The exited pane was the tab's only leaf: close the whole tab
|
||||
// (which snapshots it for reopen and kills its daemon panes).
|
||||
CloseOutcome::RemoveSelf => self.close_tab(index, window, cx),
|
||||
// Unreachable — containment was just checked — but never close a
|
||||
// tab we failed to locate the leaf in.
|
||||
CloseOutcome::NotFound => {}
|
||||
CloseOutcome::Collapsed => {
|
||||
crate::terminal::RemoteTerminal::kill_pane(view.read(cx).pane_id);
|
||||
if index == self.active {
|
||||
self.maximized = None;
|
||||
self.focus_active(window, cx);
|
||||
}
|
||||
self.save_session(cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycle focus among the panes of the active tab.
|
||||
fn cycle_pane(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// `leaves()` returns owned clones, so the immutable borrow of `self.tabs`
|
||||
@@ -1813,11 +1853,21 @@ fn new_terminal(
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Tty7App>,
|
||||
) -> Entity<TerminalView> {
|
||||
cx.new(|cx| {
|
||||
let view = cx.new(|cx| {
|
||||
let mut view = TerminalView::new(working_directory, restore_pane, window, cx)
|
||||
.expect("failed to start terminal");
|
||||
// Inherit the current global font size so new panes match existing ones.
|
||||
view.font_size = px(font_size);
|
||||
view
|
||||
});
|
||||
// A pane whose shell exits on its own (`exit`, Ctrl-D, a crash) closes
|
||||
// itself, like every other terminal. This is the single place all panes
|
||||
// are built — new tab, split, session restore — so the subscription
|
||||
// covers them all; restore even cleans up panes that died while no
|
||||
// client was attached (the daemon replays their exit on reattach).
|
||||
cx.subscribe_in(&view, window, |app, view, _: &ChildExited, window, cx| {
|
||||
app.on_child_exited(view.clone(), window, cx);
|
||||
})
|
||||
.detach();
|
||||
view
|
||||
}
|
||||
|
||||
@@ -213,6 +213,14 @@ impl Pane<Entity<TerminalView>> {
|
||||
self.close_leaf_where(&|v| v.read(cx).focus_handle.contains_focused(window, cx))
|
||||
}
|
||||
|
||||
/// Remove a specific leaf (matched by entity identity), collapsing its
|
||||
/// parent split into the sibling. Used when a pane closes for a reason
|
||||
/// other than user focus — its child exited on its own — so the leaf to
|
||||
/// remove is the exited one, wherever focus happens to be.
|
||||
pub fn close_leaf(&mut self, target: &Entity<TerminalView>) -> CloseOutcome {
|
||||
self.close_leaf_where(&|v| v.entity_id() == target.entity_id())
|
||||
}
|
||||
|
||||
/// Render the subtree. `show_focus` draws a focus ring on the active leaf
|
||||
/// (suppressed when the tab has a single pane).
|
||||
pub fn render(&self, show_focus: bool, window: &mut Window, cx: &mut App) -> gpui::AnyElement {
|
||||
|
||||
Reference in New Issue
Block a user