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:
l0ng-ai
2026-07-07 10:47:12 +08:00
committed by GitHub
5 changed files with 474 additions and 11 deletions
+20
View File
@@ -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
View File
@@ -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
+148
View File
@@ -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
View File
@@ -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
}
+8
View File
@@ -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 {