diff --git a/crates/bitty-runtime/src/runtime/workspaces.rs b/crates/bitty-runtime/src/runtime/workspaces.rs index 95689e06f..449002d3d 100644 --- a/crates/bitty-runtime/src/runtime/workspaces.rs +++ b/crates/bitty-runtime/src/runtime/workspaces.rs @@ -664,6 +664,13 @@ impl Runtime { if self.focus.focused() == Some(id) { if let Some(first) = self.layout.leaf_ids().into_iter().next() { self.focus = Focus::with_focus(first); + } else if let Some(next) = self.pinned.ids().last().copied() { + // CTX-1337 (#2222): discarding one pin over an empty base + // must hand focus to the topmost surviving pin. Clearing + // focus here left a painted pin untypable. + self.focus = Focus::with_focus(next); + } else if let Some(next) = self.floats.paint_ids().last().copied() { + self.focus = Focus::with_focus(next); } else { self.focus.clear(); } @@ -680,6 +687,39 @@ impl Runtime { true } + /// Retires the live layout's sole tiled leaf after its shell closed while + /// a pinned panel survives (issue #2222 / CTX-1337). + /// + /// Pinned panels are window-global and live outside every workspace + /// layout (CTX-1077), so unlike a workspace-local float they never cover + /// the tiled base. A closed last tiled panel therefore leaves an empty + /// base that the pin composites over; keeping the session-less leaf + /// would strand a ghost cursor and drop input (the #2221 family). Remove + /// the leaf and bind both focus and primary-grid ownership to the + /// topmost pin — the accepted focus/primary states for a composited view + /// outside the layout (`Runtime::set_focus`, `discard_pinned`). + /// + /// Returns the surviving pin, or `None` with state untouched when + /// `closed` is not the sole live leaf or no pin survives. + pub fn retire_sole_tiled_leaf_for_pinned(&mut self, closed: ViewId) -> Option { + let leaves = self.layout.leaf_ids(); + if leaves.len() != 1 || leaves[0] != closed { + return None; + } + let survivor = self.pinned.ids().last().copied()?; + let mut layout = self.layout.clone(); + layout.remove_leaf(closed)?; + // Install the empty base through the explicit-close funnel: it + // re-homes the primary owner that left the tree and clears the + // layout-derived focus. The pin is re-bound immediately below. + self.set_layout_closing(layout, closed); + let focused = self.set_focus(survivor); + debug_assert!(focused, "pinned survivor must be focusable"); + self.primary_view = Some(survivor); + self.sync_primary_geometry(); + Some(survivor) + } + /// Discards `id` from whichever floating store holds it (issue #2141). /// /// Returns `true` when `id` was a detached float or pinned leaf. @@ -1058,17 +1098,42 @@ impl Runtime { } // CTX-1077: the stashed focus may name a leaf that left every layout // since (pinned, then unpinned into another workspace, then switched - // back here). Clamp to the first live leaf so focus never dangles - // behind the read guard; a still-pinned focus is kept as-is. + // back here). Clamp to a live view of this slot so focus never + // dangles behind the read guard; a still-pinned focus is kept as-is. // CTX-1147: a focus naming a detached float of the loaded slot is // kept the same way (floats ride the slot like layout leaves). - if self.focus.focused().is_some_and(|id| { - !self.layout.leaf_ids().contains(&id) - && !self.pinned.contains(id) - && !self.floats.contains(id) + // CTX-1337 (#2222): an empty base can legitimately hold only a pin, + // but once that pin was unpinned into another workspace it is no + // longer live here. Clear the stale focus and the primary ownership + // it carried; otherwise input from the empty surface would route + // into a hidden foreign shell. + if let Some(focused) = self.focus.focused().filter(|id| { + !self.layout.leaf_ids().contains(id) + && !self.pinned.contains(*id) + && !self.floats.contains(*id) }) { - if let Some(first) = self.layout.leaf_ids().into_iter().next() { - self.focus = Focus::with_focus(first); + let replacement = self + .layout + .leaf_ids() + .into_iter() + .next() + .or_else(|| self.floats.paint_ids().last().copied()) + .or_else(|| self.pinned.ids().last().copied()); + match replacement { + Some(next) => { + self.focus = Focus::with_focus(next); + if self.primary_view == Some(focused) { + self.primary_view = Some(next); + } + } + None => { + self.focus.clear(); + // No live view is left in this slot, so the runtime-global + // primary has no surface to paint here. Leaving it on a + // foreign leaf would make `route_input_bytes` fall back + // into a hidden shell from the empty surface. + self.primary_view = None; + } } } if let Some(focused) = self.focus.focused() { @@ -4376,6 +4441,159 @@ mod tests { assert_eq!(rt.focused_view(), Some(second)); } + #[test] + fn retire_sole_tiled_leaf_for_pinned_issue_2222() { + // Issue #2222: an empty tiled base with a surviving pin is a valid + // composited state. The dead leaf retires and the pin becomes both + // focus and primary owner; without a pin the call is a no-op. + let mut rt = fresh(); + let host = rt.focused_view().expect("focus"); + assert!( + rt.retire_sole_tiled_leaf_for_pinned(host).is_none(), + "no pin survivor: state untouched" + ); + assert_eq!(rt.layout().leaf_ids(), vec![host]); + + let pinned = ViewId::new(2); + let leaf = rt.layout().find_leaf(host).cloned().expect("leaf"); + rt.set_layout(LayoutNode::split( + SplitAxis::Horizontal, + 0.5, + LayoutNode::leaf(leaf), + LayoutNode::leaf(View::new(pinned, 40, 24)), + )); + assert!(rt.set_focus(pinned)); + rt.pin_floating(pinned).expect("pin"); + assert_eq!(rt.pinned_views(), vec![pinned]); + assert_eq!(rt.layout().leaf_ids(), vec![host]); + assert_eq!(rt.focused_view(), Some(host)); + assert_eq!(rt.primary_view(), Some(host)); + + assert_eq!( + rt.retire_sole_tiled_leaf_for_pinned(host), + Some(pinned), + "the pin survives as the empty base's owner" + ); + assert!( + rt.layout().leaf_ids().is_empty(), + "the dead host leaf must be removed" + ); + assert_eq!(rt.focused_view(), Some(pinned)); + assert_eq!(rt.primary_view(), Some(pinned)); + assert!( + rt.retire_sole_tiled_leaf_for_pinned(host).is_none(), + "already retired: no second mutation" + ); + } + + #[test] + fn load_empty_base_clears_foreign_focus_after_cross_workspace_unpin_issue_2222() { + // Issue #2222 review F1: the pin-only base in ws1 names the pin as + // focus. Unpinning it into ws2 makes it a ws2 leaf; switching back to + // ws1 must not keep focus/primary on that foreign view, or empty- + // surface input would route into the hidden ws2 shell. + let mut rt = fresh(); + let host = rt.focused_view().expect("focus"); + let pinned = ViewId::new(2); + let leaf = rt.layout().find_leaf(host).cloned().expect("leaf"); + rt.set_layout(LayoutNode::split( + SplitAxis::Horizontal, + 0.5, + LayoutNode::leaf(leaf), + LayoutNode::leaf(View::new(pinned, 40, 24)), + )); + assert!(rt.set_focus(pinned)); + rt.pin_floating(pinned).expect("pin"); + assert_eq!(rt.layout().leaf_ids(), vec![host]); + assert_eq!( + rt.retire_sole_tiled_leaf_for_pinned(host), + Some(pinned), + "ws1 reaches the pin-only empty base" + ); + assert_eq!(rt.layout().leaf_ids(), Vec::new()); + assert_eq!(rt.focused_view(), Some(pinned)); + assert_eq!(rt.primary_view(), Some(pinned)); + + rt.workspace_new().expect("second workspace opens"); + assert_eq!(rt.workspace_count(), 2); + assert_eq!(rt.active_workspace_index(), 1); + assert!(rt.set_focus(pinned), "the composited pin is focusable"); + assert_eq!(rt.unpin_floating(pinned), Ok(pinned)); + assert!(rt.layout().leaf_ids().contains(&pinned)); + assert!(rt.pinned_views().is_empty()); + + assert!(rt.workspace_switch(0)); + assert_eq!( + rt.layout().leaf_ids(), + Vec::new(), + "ws1 still holds the pin-only empty base" + ); + assert_eq!(rt.focused_view(), None, "foreign pin leaves no focus"); + assert_eq!( + rt.primary_view(), + None, + "empty surface must not keep a foreign primary owner" + ); + assert!( + !rt.push_input_bytes(b"x"), + "empty-surface input must not reach the hidden ws2 shell" + ); + } + + #[test] + fn discard_pinned_falls_back_to_surviving_pin_on_empty_base_issue_2222() { + // Issue #2222 review F2: with two pins over an empty base, closing + // the focused pin must hand focus and primary to the topmost survivor + // instead of clearing both and leaving a painted pin untypable. + let mut rt = fresh(); + let host = rt.focused_view().expect("focus"); + let first_pin = ViewId::new(2); + let second_pin = ViewId::new(3); + let leaf = rt.layout().find_leaf(host).cloned().expect("leaf"); + rt.set_layout(LayoutNode::split( + SplitAxis::Horizontal, + 0.5, + LayoutNode::leaf(leaf), + LayoutNode::split( + SplitAxis::Horizontal, + 0.5, + LayoutNode::leaf(View::new(first_pin, 40, 24)), + LayoutNode::leaf(View::new(second_pin, 40, 24)), + ), + )); + assert!(rt.set_focus(first_pin)); + rt.pin_floating(first_pin).expect("first pin"); + assert!(rt.set_focus(second_pin)); + rt.pin_floating(second_pin).expect("second pin"); + assert_eq!(rt.pinned_views(), vec![first_pin, second_pin]); + assert_eq!(rt.layout().leaf_ids(), vec![host]); + assert_eq!( + rt.retire_sole_tiled_leaf_for_pinned(host), + Some(second_pin), + "topmost pin owns the empty base" + ); + assert_eq!(rt.focused_view(), Some(second_pin)); + assert_eq!(rt.primary_view(), Some(second_pin)); + + assert!(rt.discard_pinned(second_pin)); + assert_eq!(rt.pinned_views(), vec![first_pin]); + assert_eq!( + rt.focused_view(), + Some(first_pin), + "the surviving pin must keep focus" + ); + assert_eq!( + rt.primary_view(), + Some(first_pin), + "the surviving pin must keep primary ownership" + ); + + assert!(rt.discard_pinned(first_pin)); + assert!(rt.layout().leaf_ids().is_empty()); + assert_eq!(rt.focused_view(), None, "no live view remains"); + assert_eq!(rt.primary_view(), None); + } + #[test] fn window_leaf_count_covers_floats_pins_and_inactive_floats_issue_2141() { let mut rt = fresh(); diff --git a/crates/bitty-terminal/src/chrome_keys.rs b/crates/bitty-terminal/src/chrome_keys.rs index 77936451a..b7d1d3f8e 100644 --- a/crates/bitty-terminal/src/chrome_keys.rs +++ b/crates/bitty-terminal/src/chrome_keys.rs @@ -1471,8 +1471,14 @@ impl TerminalApp { .zoom .backup_leaf_count(&self.runtime) .map_or(self.runtime.leaf_count() <= 1, |count| count == 1); + // CTX-1337 (#2222): a pinned panel is window-global and + // survives an emptied workspace, so its composited base + // keeps the workspace meaningful; never prune it away from + // the pin. Floats stay workspace-local, so they still prune + // exactly like the CTX-1297 path. let prune_last_tiled_workspace = close_last_tiled_panel && self.runtime.floated_count() == 0 + && self.runtime.pinned_count() == 0 && self.runtime.workspace_count() > 1; let zoom_backup_views = if prune_last_tiled_workspace { self.runtime @@ -1613,6 +1619,44 @@ impl TerminalApp { self.runtime.leaf_count(), self.runtime.focused_view() ); + } else if self.runtime.pinned_count() > 0 { + // CTX-1337 (#2222): a pinned panel survives outside + // the live layout, so the closed tiled host is a + // pane close. Unlike a float the pin never covers + // the dead leaf: leaving it behind painted a ghost + // cursor in the vacated region and swallowed input + // (no focused writer, no primary owner). Tear down + // the shell, retire the leaf, and hand focus plus + // primary ownership to the pin. + let was_primary = self.runtime.is_primary_view(&focused); + if was_primary { + if self.runtime.close_primary_session() { + eprintln!( + "bitty: keymap close_view tore down primary shell {focused:?} (pinned base drained)" + ); + } + } else if self.runtime.close_pane_session(&focused) { + eprintln!( + "bitty: keymap close_view tore down last tiled panel shell {focused:?} (pinned base drained)" + ); + } + if let Some(survivor) = + self.runtime.retire_sole_tiled_leaf_for_pinned(focused) + { + eprintln!( + "bitty: keymap close_view -> pinned base drained, {survivor:?} focused/primary" + ); + } else { + eprintln!( + "warning: keymap close_view could not retire pinned base leaf {focused:?} — ignoring" + ); + } + eprintln!( + "bitty: keymap close_view -> pins={} leafs={} focused={:?}", + self.runtime.pinned_count(), + self.runtime.leaf_count(), + self.runtime.focused_view() + ); } else if self.runtime.is_primary_view(&focused) { if self.runtime.close_primary_session() { eprintln!( @@ -3800,8 +3844,9 @@ mod tests { use bitty_config::ChromeAction; // A pinned panel is window-global, so closing the only tiled panel in - // the only workspace must keep that workspace and use the existing - // session-close fallback rather than replacing it. + // the only workspace must keep that workspace. CTX-1337 (#2222): the + // dead tiled leaf retires and the pin takes focus plus primary + // ownership; the stale session-less leaf must never survive. let mut app = workspace_test_app(); app.runtime.set_layout(two_pane_layout()); assert!(app.runtime.set_focus(ViewId::new(1))); @@ -3814,8 +3859,43 @@ mod tests { assert!(!app.apply_chrome_action(ChromeAction::CloseView)); assert_eq!(app.runtime.workspace_count(), 1); + assert_eq!(app.runtime.layout().leaf_ids(), Vec::new()); + assert_eq!(app.runtime.pinned_views(), vec![ViewId::new(1)]); + assert_eq!(app.runtime.focused_view(), Some(ViewId::new(1))); + assert_eq!(app.runtime.primary_view(), Some(ViewId::new(1))); + } + + #[test] + fn close_view_keeps_pinned_empty_base_across_workspaces_issue_2222() { + use bitty_config::ChromeAction; + + // CTX-1337 (#2222): a pin is window-global, so its composited base + // is never pruned away from it. Closing the last tiled panel keeps + // the workspace and hands focus/primary to the pin even when another + // workspace exists (unlike a workspace-local float, which dies with + // its pruned workspace). + let mut app = workspace_test_app(); + app.runtime.set_layout(two_pane_layout()); + assert!(app.runtime.set_focus(ViewId::new(1))); + app.apply_chrome_action(ChromeAction::TogglePinned); + assert_eq!(app.runtime.pinned_views(), vec![ViewId::new(1)]); + app.runtime.workspace_new().expect("second workspace opens"); + assert_eq!(app.runtime.workspace_count(), 2); + assert!(app.runtime.workspace_switch(0)); assert_eq!(app.runtime.layout().leaf_ids(), vec![ViewId::new(2)]); + assert!(app.runtime.set_focus(ViewId::new(2))); + + assert!(!app.apply_chrome_action(ChromeAction::CloseView)); + + assert_eq!( + app.runtime.workspace_count(), + 2, + "a pin-owning base must not be pruned" + ); + assert_eq!(app.runtime.layout().leaf_ids(), Vec::new()); assert_eq!(app.runtime.pinned_views(), vec![ViewId::new(1)]); + assert_eq!(app.runtime.focused_view(), Some(ViewId::new(1))); + assert_eq!(app.runtime.primary_view(), Some(ViewId::new(1))); } #[test] @@ -3843,10 +3923,19 @@ mod tests { assert!(!app.runtime.has_pty(), "primary shell must be torn down"); assert!(!app.runtime.has_pty_reader()); assert!(!app.runtime.has_pty_writer()); - assert_eq!(app.runtime.primary_view(), None); + assert_eq!( + app.runtime.primary_view(), + Some(pinned), + "CTX-1337 (#2222): the surviving pin owns the empty base" + ); assert_eq!(app.runtime.primary_spawn_recipe(), None); - assert_eq!(app.runtime.layout().leaf_ids(), vec![primary]); + assert_eq!( + app.runtime.layout().leaf_ids(), + Vec::new(), + "the dead primary leaf retires instead of staying session-less" + ); assert_eq!(app.runtime.pinned_views(), vec![pinned]); + assert_eq!(app.runtime.focused_view(), Some(pinned)); assert_eq!(app.runtime.workspace_count(), 1); assert!(!app.runtime.workspace_is_occupied(0)); assert!( diff --git a/crates/bitty-terminal/src/terminal_app.rs b/crates/bitty-terminal/src/terminal_app.rs index acdc05b13..c53928533 100644 --- a/crates/bitty-terminal/src/terminal_app.rs +++ b/crates/bitty-terminal/src/terminal_app.rs @@ -2438,6 +2438,22 @@ impl TerminalApp { && self.runtime.leaf_count() <= 1 { self.restore_zoom(); + // CTX-1337 (#2222): a pinned panel survives outside the + // live layout, so the exited last tiled pane must not + // keep its session-less leaf focused. Retire the leaf + // and hand focus plus primary ownership to the pin, + // exactly like the close_view path. + if self.runtime.pinned_count() > 0 + && self.runtime.floated_count() == 0 + && self + .runtime + .retire_sole_tiled_leaf_for_pinned(view) + .is_some() + { + crate::logging::info(|| { + format!("bitty: pane shell {view:?} exited — pinned base leaf retired") + }); + } self.runtime.close_pane_session(&view); self.push_pending_exit(view.0, status.code()); closed_any = true; @@ -2514,6 +2530,22 @@ impl TerminalApp { || self.runtime.pinned_views().contains(&primary_view) { self.runtime.discard_floating_view(primary_view); + } else if self.runtime.pinned_count() > 0 + && self.runtime.floated_count() == 0 + && self + .runtime + .retire_sole_tiled_leaf_for_pinned(primary_view) + .is_some() + { + // CTX-1337 (#2222): the last tiled primary shell exited + // while a pinned panel survives. Retire the dead leaf and + // leave the pin as focus plus primary owner instead of a + // session-less ghost tile. + crate::logging::info(|| { + format!( + "bitty: primary shell {primary_view:?} exited — pinned base leaf retired" + ) + }); } else { let mut layout = self.runtime.layout().clone(); if crate::chrome_keys::close_focused_leaf(&mut layout, primary_view) { diff --git a/crates/bitty-terminal/src/tests.rs b/crates/bitty-terminal/src/tests.rs index 54d740b80..0fb3cee5a 100644 --- a/crates/bitty-terminal/src/tests.rs +++ b/crates/bitty-terminal/src/tests.rs @@ -9843,6 +9843,189 @@ fn reap_exited_last_float_prunes_emptied_workspace_issue_2221() { ); } +/// Issue #2222 / CTX-1337: closing the last tiled host while a pinned panel +/// survives must remove the host leaf instead of leaving a session-less +/// focused tile. The pinned store lives outside every layout, so unlike a +/// float it never covers the dead leaf: focus and primary ownership must +/// hand to the pin. +#[test] +#[cfg(unix)] +fn close_host_with_pinned_panel_hands_focus_and_primary_to_pin_issue_2222() { + require_pty!(); + let mut app = editor_test_app(); + let host = ViewId::new(1); + let pinned = ViewId::new(2); + app.runtime + .spawn_shell_for_view_in(pinned, "/bin/sh", &[], 80, 24, None) + .expect("spawn pinned shell"); + assert!(app.runtime.set_focus(pinned)); + assert!(!app.apply_chrome_action(bitty_config::ChromeAction::TogglePinned)); + assert_eq!(app.runtime.pinned_views(), vec![pinned]); + assert_eq!(app.runtime.layout().leaf_ids(), vec![host]); + assert!(app.runtime.set_focus(host), "host must hold focus"); + + let _ = app.drive_tick(); + let host_frame = app + .runtime + .present_frames() + .into_iter() + .find(|frame| frame.view == host) + .expect("host frame before close"); + + assert!( + !app.apply_chrome_action(bitty_config::ChromeAction::CloseView), + "host teardown with a surviving pin is a pane close, not a window close" + ); + + assert_eq!( + app.runtime.layout().leaf_ids(), + Vec::new(), + "the closed host leaf must be removed, never left session-less" + ); + assert_eq!(app.runtime.pinned_views(), vec![pinned]); + assert_eq!( + app.runtime.focused_view(), + Some(pinned), + "focus must follow the surviving pinned view" + ); + assert_eq!( + app.runtime.primary_view(), + Some(pinned), + "primary ownership must hand to the surviving pinned view" + ); + assert!( + app.runtime.push_input_bytes(b"probe"), + "input must route to the surviving pinned shell" + ); + + let _ = app.drive_tick(); + assert!( + app.runtime + .present_frames() + .iter() + .all(|frame| frame.view != host), + "no frame may remain for the closed host view" + ); + assert_eq!(host_frame.view, host, "pre-close frame was the host"); +} + +/// Issue #2222 / CTX-1337: the owner-visible reap variant. Typing `exit` in +/// the last tiled primary shell while a pinned panel survives must drop the +/// dead leaf and leave the pinned view as focus and primary owner. +#[test] +#[cfg(unix)] +fn reap_exited_primary_with_pinned_survivor_hands_focus_and_primary_issue_2222() { + require_pty!(); + let mut app = editor_test_app(); + let host = ViewId::new(1); + let pinned = ViewId::new(2); + app.runtime + .spawn_shell_for_view_in(pinned, "/bin/sh", &[], 80, 24, None) + .expect("spawn pinned shell"); + assert!(app.runtime.set_focus(pinned)); + assert!(!app.apply_chrome_action(bitty_config::ChromeAction::TogglePinned)); + assert_eq!(app.runtime.pinned_views(), vec![pinned]); + assert_eq!(app.runtime.layout().leaf_ids(), vec![host]); + assert!(app.runtime.set_focus(host)); + + let script = write_fake_editor("exit-pinned-survivor", "#!/bin/sh\nexit 0\n"); + let script_arg = script.to_string_lossy().into_owned(); + app.runtime + .spawn_shell(script_arg.as_str()) + .expect("spawn primary shell"); + + let outcome = reap_until_change_2169(&mut app, 10); + assert_eq!( + outcome, + terminal_app::ShellExitOutcome::PaneClosed, + "primary exit with a pinned survivor is a pane close" + ); + assert_eq!( + app.runtime.layout().leaf_ids(), + Vec::new(), + "the exited host leaf must be removed, never left session-less" + ); + assert_eq!(app.runtime.pinned_views(), vec![pinned]); + assert_eq!( + app.runtime.focused_view(), + Some(pinned), + "focus must follow the surviving pinned view" + ); + assert_eq!( + app.runtime.primary_view(), + Some(pinned), + "primary ownership must hand to the surviving pinned view" + ); + assert!( + app.runtime.push_input_bytes(b"probe"), + "input must route to the surviving pinned shell" + ); + + let _ = app.drive_tick(); + assert!( + app.runtime + .present_frames() + .iter() + .all(|frame| frame.view != host), + "no frame may remain for the exited host view" + ); + let _ = std::fs::remove_file(&script); +} + +/// Issue #2222 / CTX-1337: the pane-session reap variant of the pinned +/// survivor. A tiled pane shell (not the primary) exits last while a pinned +/// pane remains; the dead leaf must retire instead of staying session-less. +#[test] +#[cfg(unix)] +fn reap_exited_last_tiled_pane_with_pinned_survivor_issue_2222() { + require_pty!(); + let mut app = editor_test_app(); + let host = ViewId::new(1); + let pinned = ViewId::new(2); + app.runtime + .spawn_shell_for_view_in(pinned, "/bin/sh", &[], 80, 24, None) + .expect("spawn pinned shell"); + assert!(app.runtime.set_focus(pinned)); + assert!(!app.apply_chrome_action(bitty_config::ChromeAction::TogglePinned)); + assert_eq!(app.runtime.pinned_views(), vec![pinned]); + assert_eq!(app.runtime.layout().leaf_ids(), vec![host]); + + let script = write_fake_editor("exit-pinned-pane", "#!/bin/sh\nexit 0\n"); + let script_arg = script.to_string_lossy().into_owned(); + app.runtime + .spawn_shell_for_view_in(host, script_arg.as_str(), &[], 80, 24, None) + .expect("spawn tiled pane shell"); + + let outcome = reap_until_change_2169(&mut app, 10); + assert_eq!( + outcome, + terminal_app::ShellExitOutcome::PaneClosed, + "tiled pane exit with a pinned survivor is a pane close" + ); + assert_eq!( + app.runtime.layout().leaf_ids(), + Vec::new(), + "the exited tiled leaf must be removed, never left session-less" + ); + assert_eq!(app.runtime.pinned_views(), vec![pinned]); + assert_eq!( + app.runtime.focused_view(), + Some(pinned), + "focus must follow the surviving pinned view" + ); + assert_eq!( + app.runtime.primary_view(), + Some(pinned), + "primary ownership must hand to the surviving pinned view" + ); + assert!( + app.runtime.push_input_bytes(b"probe"), + "input must route to the surviving pinned shell" + ); + + let _ = std::fs::remove_file(&script); +} + #[test] fn live_reload_adopts_keymaps_leader_hints_and_opacity_on_the_app() { // CTX-0898 (#1522): the app half of a live reload swaps the chrome key