From 2a74e5381ba24605ea911cb9e0dd9a0cfe7e9078 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:35:56 +0800 Subject: [PATCH 1/2] Scrub stale TUI modes when the shell returns to its prompt An ssh session dropping mid-TUI (htop, vim, ...) leaves the pane wedged: the remote app hid the cursor (DECTCEM), entered the alt screen and enabled mouse reporting, and the restore sequences can never arrive. The host shell's next prompt then renders with no cursor anywhere, mouse clicks echo `0;19;42M`-style junk, and scrollback stays broken. The client's reader thread now scrubs this residue when the daemon reports a fresh prompt (OSC 133): at that position in the byte stream no full-screen program owns the pane, so a stranded alt screen, hidden cursor, mouse/focus reporting, or kitty keyboard flags are provably stale. Each reset is emitted only when its mode is actually set (`?1049l` performs a cursor restore when idle, so no blanket resets), and bracketed paste / application cursor keys are left to the line editor, which re-arms them on every read. Reattach self-heals the same way -- the daemon replays the prompt state after the ring. --- CHANGELOG.md | 11 +++ src/terminal/remote.rs | 157 ++++++++++++++++++++++++++++++++++++++++- src/terminal/view.rs | 77 ++++++++++++++++++++ 3 files changed, 244 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8683e8..0a9095d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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 diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index adf6fb64..77758b18 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -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; @@ -448,6 +448,33 @@ 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 @@ -687,6 +714,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 { + 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 +965,90 @@ mod tests { assert!(term.exited_flag.load(Ordering::SeqCst)); } + /// `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 diff --git a/src/terminal/view.rs b/src/terminal/view.rs index e7985611..1d6e06c2 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -3967,4 +3967,81 @@ mod gpui_tests { "a Hidden shape must not collapse the editor anchor to the top-left corner" ); } + + /// 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(); + } } From 3cc95f59152c28de0311878ae9c744a32afdfbd7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:36:39 +0800 Subject: [PATCH 2/2] Close the pane when its shell exits Typing `exit` (or Ctrl-D) left a dead "process exited" pane behind: the view marked itself exited, but nothing ever closed the pane or tab. The reader thread now records whether the pane ended with a *genuine* child exit (`DaemonMsg::Exited`) as opposed to a daemon disconnect, and the view surfaces that as a `ChildExited` gpui event. The app subscribes at the single place all panes are built and closes the emitting leaf: collapsing its split, or closing the tab when it was the only pane (the last tab falls back to the home page). The daemon pane is killed so the session drops it; panes that died while detached clean themselves up on the next attach, when the daemon replays their exit. A disconnect deliberately keeps the dead pane visible -- auto-closing would silently discard (and kill) sessions that may still be alive daemon-side. --- CHANGELOG.md | 9 ++++ src/terminal/remote.rs | 98 ++++++++++++++++++++++++++++++++++++++---- src/terminal/view.rs | 71 ++++++++++++++++++++++++++++++ src/ui/app.rs | 54 ++++++++++++++++++++++- src/ui/pane.rs | 8 ++++ 5 files changed, 230 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a9095d5..60343854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ 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 diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 77758b18..a13b2c8f 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -86,6 +86,17 @@ struct ShellState { last_exit: Option, } +/// 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>>, + shell: Arc>, + exited: Arc, + child_exited: Arc, + zle_reading: Arc, +} + /// 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, + /// 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, /// 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>> = Arc::new(Mutex::new(None)); let shell_state: Arc> = 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>>, proxy: EventProxy, read_half: Stream, - cwd: Arc>>, - shell: Arc>, - exited_flag: Arc, - zle_reading: Arc, + 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(); @@ -480,7 +508,13 @@ impl RemoteTerminal { // 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; } @@ -581,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>>(&self, bytes: B) { @@ -965,6 +1005,48 @@ 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 diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1d6e06c2..f84d26f7 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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 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) => { @@ -3968,6 +3985,60 @@ mod gpui_tests { ); } + /// 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, 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, 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 diff --git a/src/ui/app.rs b/src/ui/app.rs index e9983972..ca155bd5 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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, + window: &mut Window, + cx: &mut Context, + ) { + 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) { // `leaves()` returns owned clones, so the immutable borrow of `self.tabs` @@ -1813,11 +1853,21 @@ fn new_terminal( window: &mut Window, cx: &mut Context, ) -> Entity { - 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 } diff --git a/src/ui/pane.rs b/src/ui/pane.rs index d601fcae..631c16b9 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -213,6 +213,14 @@ impl Pane> { 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) -> 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 {