From 7b9deac3a53b8188f2c7edbe894d84c1a02c3783 Mon Sep 17 00:00:00 2001 From: reviewer Date: Sun, 18 Oct 2026 23:27:34 +0800 Subject: [PATCH 1/3] [CTX-1336] fix(ui): remove ghost host leaf on float teardown --- .../bitty-runtime/src/runtime/workspaces.rs | 6 + crates/bitty-terminal/src/chrome_keys.rs | 58 +++++ crates/bitty-terminal/src/tests.rs | 231 ++++++++++++++++++ 3 files changed, 295 insertions(+) diff --git a/crates/bitty-runtime/src/runtime/workspaces.rs b/crates/bitty-runtime/src/runtime/workspaces.rs index e8f448266..8c81203df 100644 --- a/crates/bitty-runtime/src/runtime/workspaces.rs +++ b/crates/bitty-runtime/src/runtime/workspaces.rs @@ -623,6 +623,12 @@ 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.floats.paint_ids().last().copied() { + // CTX-1336 (#2221): an empty tiled base can still composite + // other detached floats. Never clear focus while a live + // float remains: clearing would drop keyboard routing even + // though a visible, typable panel survives. + self.focus = Focus::with_focus(next); } else { self.focus.clear(); } diff --git a/crates/bitty-terminal/src/chrome_keys.rs b/crates/bitty-terminal/src/chrome_keys.rs index 608801102..77936451a 100644 --- a/crates/bitty-terminal/src/chrome_keys.rs +++ b/crates/bitty-terminal/src/chrome_keys.rs @@ -1526,9 +1526,30 @@ impl TerminalApp { if self.runtime.floated_views().contains(&focused) || self.runtime.pinned_views().contains(&focused) { + // CTX-1336 (#2221): a float can be the active + // workspace's last live view (its tiled base closed). + // Discarding it must not strand a view-less slot: prune + // the emptied workspace exactly like the CTX-1297 + // last-tiled-panel path, so focus follows a live + // workspace instead of a blank surface. + let prune_after_discard = self.runtime.floated_views() == vec![focused] + && self.runtime.layout().leaf_count() == 0 + && self.runtime.workspace_count() > 1; if self.runtime.discard_floating_view(focused) { eprintln!("bitty: keymap close_view -> discarded floating {focused:?}"); } + if prune_after_discard { + let workspace = self.runtime.active_workspace_index(); + if self.runtime.workspace_close_at(workspace).is_ok() { + eprintln!( + "bitty: keymap close_view -> last float closed, empty workspace pruned" + ); + } else { + eprintln!( + "warning: keymap close_view could not prune empty workspace — ignoring" + ); + } + } eprintln!( "bitty: keymap close_view -> leafs={} focused={:?}", self.runtime.leaf_count(), @@ -1555,6 +1576,43 @@ impl TerminalApp { "warning: keymap close_view could not prune empty workspace — ignoring" ); } + } else if self.runtime.floated_count() > 0 { + // CTX-1336 (#2221): a detached float survives in + // this workspace, so the closed tiled host is a + // pane close. Tearing down only its session kept a + // session-less leaf that stayed focused: the empty + // leaf painted a cursor ghost in the vacated region + // and input was silently dropped (no focused writer, + // no primary owner). Remove the leaf and hand focus + // to the live float instead. + 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:?} (float base drained)" + ); + } + } else if self.runtime.close_pane_session(&focused) { + eprintln!( + "bitty: keymap close_view tore down last tiled panel shell {focused:?} (float base drained)" + ); + } + // A bare last leaf must go: the tiling-first helper + // refuses it, the layout-level remove yields the + // empty base that the float composite expects + // (G1/DEC-0099: the float stays a float). + let mut layout = self.runtime.layout().clone(); + if layout.remove_leaf(focused).is_some() { + self.runtime.set_layout_closing(layout, focused); + } + if let Some(survivor) = self.runtime.floated_views().last().copied() { + self.runtime.set_focus(survivor); + } + eprintln!( + "bitty: keymap close_view -> float base drained, leafs={} focused={:?}", + self.runtime.leaf_count(), + self.runtime.focused_view() + ); } else if self.runtime.is_primary_view(&focused) { if self.runtime.close_primary_session() { eprintln!( diff --git a/crates/bitty-terminal/src/tests.rs b/crates/bitty-terminal/src/tests.rs index 96f885c2c..c9d573b29 100644 --- a/crates/bitty-terminal/src/tests.rs +++ b/crates/bitty-terminal/src/tests.rs @@ -9546,6 +9546,237 @@ fn reap_exited_shell_while_zoomed_releases_backup_leaf_issue_2167() { let _ = std::fs::remove_file(&script); } +/// Issue #2221 / CTX-1336: closing the last tiled host while a detached +/// float survives must remove the host leaf instead of leaving a +/// session-less tile focused. The old path tore the primary session down +/// but kept its leaf, so the empty focused leaf painted a cursor ghost in +/// the vacated region and swallowed keyboard input until `ctrl+tab`. +#[test] +#[cfg(unix)] +fn close_host_with_detached_float_leaves_no_ghost_or_dead_input_issue_2221() { + require_pty!(); + let mut app = editor_test_app(); + let host = ViewId::new(1); + let float = ViewId::new(2); + app.runtime + .spawn_shell_for_view_in(float, "/bin/sh", &[], 80, 24, None) + .expect("spawn float shell"); + app.runtime + .apply_floating_command(bitty_runtime::FLOATING_CMD_TOGGLE, Some(float)) + .expect("detach float"); + assert_eq!(app.runtime.floated_views(), vec![float]); + assert!(app.runtime.set_focus(host), "host must hold focus"); + assert_eq!(app.runtime.focused_view(), Some(host)); + + // Present once so the headless surface holds the pre-close frame; the + // host content origin is the exact cell that used to paint the ghost + // cursor after teardown. + let _ = app.drive_tick(); + let host_frame = app + .runtime + .present_frames() + .into_iter() + .find(|frame| frame.view == host) + .expect("host frame before close"); + let pad = usize::try_from(app.runtime.window_padding_physical()).expect("pad fits"); + let origin_x = usize::try_from(host_frame.content.x).expect("x fits") + pad; + let origin_y = usize::try_from(host_frame.content.y).expect("y fits") + pad; + + assert!( + !app.apply_chrome_action(bitty_config::ChromeAction::CloseView), + "host teardown with a surviving float 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.floated_views(), vec![float]); + assert_eq!( + app.runtime.focused_view(), + Some(float), + "focus must follow the surviving float" + ); + assert_ne!( + app.runtime.primary_view(), + Some(host), + "primary ownership must not point at the closed leaf" + ); + assert!( + app.runtime.push_input_bytes(b"probe"), + "input must route to the surviving float 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" + ); + let rgba = app.runtime.headless_rgba().expect("headless frame"); + let width = + usize::try_from(app.runtime.config().window_extent().width()).expect("width fits usize"); + let bg = bitty_render::grid::DEFAULT_BG; + assert_eq!( + probe_rgba(&rgba, width, origin_x, origin_y), + bg, + "the vacated host cell must repaint to background, never a ghost cursor" + ); +} + +/// Issue #2221 / CTX-1336: closing the last live view (the surviving float) +/// must be the window-close gesture. It must never discard the float into a +/// session-less view that paints blank and drops input while the process +/// stays alive. +#[test] +fn close_last_live_float_requests_window_close_not_dead_view_issue_2221() { + let mut app = editor_test_app(); + let host = ViewId::new(1); + let float = ViewId::new(2); + app.runtime + .apply_floating_command(bitty_runtime::FLOATING_CMD_TOGGLE, Some(float)) + .expect("detach float"); + assert_eq!(app.runtime.floated_views(), vec![float]); + assert!(app.runtime.set_focus(host)); + + assert!( + !app.apply_chrome_action(bitty_config::ChromeAction::CloseView), + "host teardown with a surviving float is a pane close" + ); + assert_eq!(app.runtime.layout().leaf_ids(), Vec::new()); + assert_eq!(app.runtime.focused_view(), Some(float)); + + // No PTY and no primary shell: `close_confirm = when_busy` proceeds. + assert!( + app.apply_chrome_action(bitty_config::ChromeAction::CloseView), + "closing the last live view must request window close, never fall through" + ); + assert_eq!( + app.runtime.layout().leaf_ids(), + Vec::new(), + "no session-less replacement leaf may be created" + ); + assert!( + !app.runtime + .primary_view() + .is_some_and(|view| !app.runtime.floated_views().contains(&view)), + "primary ownership must never dangle on a non-live view" + ); +} + +/// Issue #2221 / CTX-1336: a workspace whose only live view was a float +/// (its tiled base already closed) must be pruned when the float closes, +/// exactly like CTX-1297 does for the last tiled panel. Leaving the slot +/// behind strands a view-less workspace that paints nothing and accepts +/// no input. +#[test] +fn close_last_float_prunes_emptied_workspace_issue_2221() { + let mut app = editor_test_app(); + let host = ViewId::new(1); + let float = ViewId::new(2); + app.runtime + .apply_floating_command(bitty_runtime::FLOATING_CMD_TOGGLE, Some(float)) + .expect("detach float"); + assert!(app.runtime.set_focus(host)); + assert!(!app.apply_chrome_action(bitty_config::ChromeAction::CloseView)); + assert_eq!(app.runtime.layout().leaf_ids(), Vec::new()); + assert_eq!(app.runtime.workspace_count(), 1); + + // A second workspace keeps a live panel, so the float-only workspace + // is prunable rather than the whole window's last view. + app.apply_chrome_action(bitty_config::ChromeAction::WorkspaceNew); + assert_eq!(app.runtime.workspace_count(), 2); + assert!(app.runtime.workspace_switch(0)); + assert!( + app.runtime.set_focus(float), + "the float must be focusable after the switch back" + ); + + assert!( + !app.apply_chrome_action(bitty_config::ChromeAction::CloseView), + "closing the emptied workspace's last float is a pane close" + ); + assert_eq!( + app.runtime.workspace_count(), + 1, + "the view-less workspace must be pruned, never stranded" + ); + assert!(app.runtime.floated_views().is_empty()); + let focused = app + .runtime + .focused_view() + .expect("focus must land on the surviving workspace's panel"); + assert!( + app.runtime.layout().leaf_ids().contains(&focused), + "focus must be a live member of the surviving layout" + ); +} + +fn probe_rgba(rgba: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] { + let idx = (y * width + x) * 4; + [rgba[idx], rgba[idx + 1], rgba[idx + 2], rgba[idx + 3]] +} + +/// Issue #2221 / CTX-1336: with an empty tiled base and two detached +/// floats, closing the focused float must land focus on the surviving +/// float. Clearing focus because the layout has no leaves would silently +/// drop input while a visible, typable panel remains. +#[test] +#[cfg(unix)] +fn close_float_on_empty_base_focuses_surviving_float_issue_2221() { + require_pty!(); + let mut app = editor_test_app(); + let host = ViewId::new(1); + let left = ViewId::new(2); + let right = ViewId::new(3); + app.runtime.set_layout(LayoutNode::split( + SplitAxis::Horizontal, + 0.5, + LayoutNode::leaf(View::new(host, 80, 24)), + LayoutNode::split( + SplitAxis::Vertical, + 0.5, + LayoutNode::leaf(View::new(left, 80, 24)), + LayoutNode::leaf(View::new(right, 80, 24)), + ), + )); + for view in [left, right] { + app.runtime + .spawn_shell_for_view_in(view, "/bin/sh", &[], 80, 24, None) + .expect("spawn float shell"); + app.runtime + .apply_floating_command(bitty_runtime::FLOATING_CMD_TOGGLE, Some(view)) + .expect("detach float"); + } + assert_eq!(app.runtime.floated_views(), vec![left, right]); + assert!(app.runtime.set_focus(host)); + assert!(!app.apply_chrome_action(bitty_config::ChromeAction::CloseView)); + assert_eq!(app.runtime.layout().leaf_ids(), Vec::new()); + assert_eq!( + app.runtime.focused_view(), + Some(right), + "the topmost surviving float must take focus after the base drains" + ); + + assert!( + !app.apply_chrome_action(bitty_config::ChromeAction::CloseView), + "closing one of two floats is a pane close" + ); + assert_eq!(app.runtime.floated_views(), vec![left]); + assert_eq!( + app.runtime.focused_view(), + Some(left), + "focus must move to the remaining float, never clear" + ); + assert!( + app.runtime.push_input_bytes(b"probe"), + "input must route to the surviving float shell" + ); +} + #[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 From e10acb596951e6446623df7fe1d17daeb4463330 Mon Sep 17 00:00:00 2001 From: reviewer Date: Sun, 18 Oct 2026 23:47:56 +0800 Subject: [PATCH 2/3] [CTX-1336] fix(ui): prune emptied workspace when last float shell exits --- .../bitty-runtime/src/runtime/workspaces.rs | 5 ++ crates/bitty-terminal/src/terminal_app.rs | 28 +++++++++ crates/bitty-terminal/src/tests.rs | 61 +++++++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/crates/bitty-runtime/src/runtime/workspaces.rs b/crates/bitty-runtime/src/runtime/workspaces.rs index 8c81203df..95689e06f 100644 --- a/crates/bitty-runtime/src/runtime/workspaces.rs +++ b/crates/bitty-runtime/src/runtime/workspaces.rs @@ -1667,6 +1667,11 @@ impl Runtime { }; self.active_workspace = active; self.load_slot(active); + // CTX-1336 (#2221): closing a workspace installs a different slot. + // Like `workspace_switch`, that is a layout/focus change with no PTY + // damage of its own, so force the next frame; otherwise the closed + // workspace's last frame stays on screen until unrelated input. + self.pending_full_redraw = true; self.mru_front(active); // CTX-0873: dropping to one workspace releases the bar band. self.refresh_chrome_band(); diff --git a/crates/bitty-terminal/src/terminal_app.rs b/crates/bitty-terminal/src/terminal_app.rs index bcc6f197d..acdc05b13 100644 --- a/crates/bitty-terminal/src/terminal_app.rs +++ b/crates/bitty-terminal/src/terminal_app.rs @@ -2396,8 +2396,36 @@ impl TerminalApp { if self.runtime.floated_views().contains(&view) || self.runtime.pinned_views().contains(&view) { + let was_float = self.runtime.floated_views().contains(&view); self.restore_zoom(); self.runtime.discard_floating_view(view); + // CTX-1336 (#2221): the exited float may have been the + // active workspace's last live view (its tiled base + // already closed). Leaving the slot with no layout + // leaves and no floats strands a blank, dead workspace + // until the user switches away or closes it. Prune it + // exactly like the CTX-1297/close_view paths; a single + // remaining workspace keeps the window-close gesture + // handled by the effective-window-count gate above. + if was_float + && self.runtime.layout().leaf_count() == 0 + && self.runtime.floated_views().is_empty() + && self.runtime.workspace_count() > 1 + { + let workspace = self.runtime.active_workspace_index(); + if self.runtime.workspace_close_at(workspace).is_ok() { + crate::logging::info(|| { + format!( + "bitty: pane shell {view:?} exited — empty workspace pruned" + ) + }); + } else { + crate::logging::warn(|| { + "bitty: could not prune empty workspace after float exit" + .to_string() + }); + } + } self.push_pending_exit(view.0, status.code()); closed_any = true; continue; diff --git a/crates/bitty-terminal/src/tests.rs b/crates/bitty-terminal/src/tests.rs index c9d573b29..5757e6ca3 100644 --- a/crates/bitty-terminal/src/tests.rs +++ b/crates/bitty-terminal/src/tests.rs @@ -9777,6 +9777,67 @@ fn close_float_on_empty_base_focuses_surviving_float_issue_2221() { ); } +/// Issue #2221 / CTX-1336: the owner's exact report after the float is the +/// last live view: typing `exit` in it must not leave a blank slot. +#[test] +#[cfg(unix)] +fn reap_exited_last_float_prunes_emptied_workspace_issue_2221() { + require_pty!(); + let mut app = editor_test_app(); + let host = ViewId::new(1); + let float = ViewId::new(2); + app.runtime + .spawn_shell_for_view_in(float, "/bin/sh", &[], 80, 24, None) + .expect("spawn float shell"); + app.runtime + .apply_floating_command(bitty_runtime::FLOATING_CMD_TOGGLE, Some(float)) + .expect("detach float"); + assert!(app.runtime.set_focus(host)); + assert!(!app.apply_chrome_action(bitty_config::ChromeAction::CloseView)); + assert_eq!(app.runtime.layout().leaf_ids(), Vec::new()); + assert_eq!(app.runtime.focused_view(), Some(float)); + + // A second workspace keeps a live leaf, so the float exit is a pane + // close (PaneClosed) instead of the whole-window close gesture. + app.apply_chrome_action(bitty_config::ChromeAction::WorkspaceNew); + assert_eq!(app.runtime.workspace_count(), 2); + assert!(app.runtime.workspace_switch(0)); + assert!(app.runtime.set_focus(float)); + + assert!( + app.runtime.push_input_bytes(b"exit\n"), + "typed exit must reach the float shell" + ); + let outcome = reap_until_change_2169(&mut app, 10); + assert_eq!( + outcome, + terminal_app::ShellExitOutcome::PaneClosed, + "float exit with another workspace live is a pane close" + ); + assert_eq!( + app.runtime.workspace_count(), + 1, + "the emptied float-only workspace must be pruned, never left blank" + ); + assert!(app.runtime.floated_views().is_empty()); + let focused = app + .runtime + .focused_view() + .expect("focus must land on the surviving workspace's panel"); + assert!( + app.runtime.layout().leaf_ids().contains(&focused), + "focus must be a live member of the surviving layout, never a dead slot" + ); + assert!( + app.runtime.primary_view().is_none_or(|view| app + .runtime + .layout() + .leaf_ids() + .contains(&view)), + "primary ownership must not dangle" + ); +} + #[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 From 20b6a6148ef399bd277df7707bd88d3ea84b9c2f Mon Sep 17 00:00:00 2001 From: reviewer Date: Mon, 19 Oct 2026 00:34:08 +0800 Subject: [PATCH 3/3] [CTX-1336] test(ui): gate probe_rgba to its platform --- crates/bitty-terminal/src/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bitty-terminal/src/tests.rs b/crates/bitty-terminal/src/tests.rs index 5757e6ca3..cbb3bb928 100644 --- a/crates/bitty-terminal/src/tests.rs +++ b/crates/bitty-terminal/src/tests.rs @@ -9715,6 +9715,7 @@ fn close_last_float_prunes_emptied_workspace_issue_2221() { ); } +#[cfg(unix)] fn probe_rgba(rgba: &[u8], width: usize, x: usize, y: usize) -> [u8; 4] { let idx = (y * width + x) * 4; [rgba[idx], rgba[idx + 1], rgba[idx + 2], rgba[idx + 3]]