From 8bbd2a77a8fd5a422cf9b42fa40fd7956914dbac Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:19:59 -0700 Subject: [PATCH 01/36] feat(tui): preview workspace rail selection --- cmux-tui/crates/cmux-tui/src/app.rs | 201 +++++++++++++++++++++++++++- cmux-tui/docs/keyboard.md | 2 +- cmux-tui/docs/mouse.md | 2 +- 3 files changed, 202 insertions(+), 3 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index a4b592ae108a..3b9612966b50 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -3812,6 +3812,12 @@ enum WorkspaceRailTarget { Action(SidebarActionTarget), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct WorkspacePreview { + origin: WorkspaceId, + target: WorkspaceId, +} + fn rail_page_size(area: Option) -> usize { area.map_or(1, |area| usize::from(area.height.saturating_sub(1)).saturating_div(3).max(1)) } @@ -6897,6 +6903,7 @@ pub struct App { pub sidebar_workspace_selection: usize, pub(crate) sidebar_recoverable_workspace_selection: usize, pub(crate) workspace_rail_selection: WorkspaceRailSelection, + workspace_preview: Option, pub(crate) machine_rail_scroll: usize, pub(crate) machine_footer_scroll: usize, pub(crate) workspace_rail_scroll: usize, @@ -9083,6 +9090,7 @@ fn run_with_machine_updates_inner( sidebar_workspace_selection: 0, sidebar_recoverable_workspace_selection: 0, workspace_rail_selection: WorkspaceRailSelection::default(), + workspace_preview: None, machine_rail_scroll: 0, machine_footer_scroll: 0, workspace_rail_scroll: 0, @@ -10045,6 +10053,7 @@ impl App { if index >= self.tree.workspaces.len() || !self.prepare_pty_input_before_mutation() { return; } + self.commit_workspace_preview(); self.follow_sidebar_workspace(index); self.tree.active_workspace = index; self.select_workspace_for_client(Some(index), None); @@ -10168,6 +10177,9 @@ impl App { } fn focus_rail(&mut self, kind: RailKind) { + if kind != RailKind::Workspace { + self.cancel_workspace_preview(); + } self.focus = match kind { RailKind::Machine => FocusTarget::MachineRail, RailKind::Workspace => FocusTarget::WorkspaceRail, @@ -10219,6 +10231,7 @@ impl App { fn leave_workspace_sidebar(&mut self) { if self.sidebar_rail_focused() { + self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; } } @@ -12795,6 +12808,7 @@ impl App { } self.rebuild_tab_locations(); self.reapply_mux_titles(); + self.reconcile_workspace_preview(); } fn replace_authoritative_tree(&mut self, tree: TreeView, destination_generation: u64) { @@ -14066,6 +14080,7 @@ impl App { self.machine_sidebar_width = self.sidebar_layout.machine.map_or(0, |rect| rect.width); self.tabs_sidebar_width = self.sidebar_layout.tabs.map_or(0, |rect| rect.width); if self.sidebar_width == 0 && self.focus == FocusTarget::WorkspaceRail { + self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; } if self.machine_sidebar_width == 0 && self.focus == FocusTarget::MachineRail { @@ -16288,6 +16303,9 @@ impl App { } fn claim_active_terminal_geometry(&mut self, force: bool) { + if self.workspace_preview.is_some() { + return; + } self.report_client_focus(); let terminal = self.tree.active_screen().and_then(|screen| { let pane = screen.panes.iter().find(|pane| pane.id == screen.active_pane)?; @@ -17341,6 +17359,61 @@ impl App { } } + fn preview_workspace(&mut self, index: usize) { + let Some(target) = self.tree.workspaces.get(index).map(|workspace| workspace.id) else { + return; + }; + let Some(current) = self.tree.active_workspace().map(|workspace| workspace.id) else { + return; + }; + let origin = self.workspace_preview.map_or(current, |preview| preview.origin); + if target == origin { + self.cancel_workspace_preview(); + return; + } + self.workspace_preview = Some(WorkspacePreview { origin, target }); + self.tree.active_workspace = index; + } + + fn commit_workspace_preview(&mut self) { + self.workspace_preview = None; + } + + fn cancel_workspace_preview(&mut self) { + let Some(preview) = self.workspace_preview.take() else { return }; + let Some(index) = self + .tree + .workspaces + .iter() + .position(|workspace| workspace.id == preview.origin) + else { + return; + }; + self.tree.active_workspace = index; + self.sidebar_workspace_selection = index; + self.workspace_rail_selection = WorkspaceRailSelection::Workspace; + } + + fn reconcile_workspace_preview(&mut self) { + let Some(preview) = self.workspace_preview else { return }; + let Some(target_index) = self + .tree + .workspaces + .iter() + .position(|workspace| workspace.id == preview.target) + else { + self.workspace_preview = None; + return; + }; + if !self.tree.workspaces.iter().any(|workspace| workspace.id == preview.origin) { + self.workspace_preview = None; + return; + } + self.tree.active_workspace = target_index; + self.sidebar_workspace_selection = target_index; + self.workspace_rail_selection = WorkspaceRailSelection::Workspace; + } + fn select_workspace_rail_target(&mut self, target: WorkspaceRailTarget) { match target { WorkspaceRailTarget::Workspace(id) => { @@ -17353,9 +17426,11 @@ impl App { } self.sidebar_workspace_selection = index; self.workspace_rail_selection = WorkspaceRailSelection::Workspace; + self.preview_workspace(index); } } WorkspaceRailTarget::Recoverable(id) => { + self.cancel_workspace_preview(); if let Some(index) = self.machine_ui.as_ref().and_then(|ui| { ui.recoverable_workspaces().iter().position(|workspace| workspace.id == id) }) { @@ -17364,6 +17439,7 @@ impl App { } } WorkspaceRailTarget::Action(action) => { + self.cancel_workspace_preview(); self.workspace_rail_selection = WorkspaceRailSelection::Action(action); } } @@ -17401,6 +17477,7 @@ impl App { if self.prefix_armed { self.prefix_armed = false; if input.suppresses_alt_shortcut() { + self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; return KeyboardIngress::Handled(RenderAction::Draw); } @@ -17410,6 +17487,7 @@ impl App { let Some(action) = action_for_binding(&self.config.keys, &binding_key, binding_fallback.as_ref()) else { + self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; return KeyboardIngress::Handled(RenderAction::Draw); }; @@ -17417,6 +17495,7 @@ impl App { let keep_machine_rail_focus = self.machine_sidebar_focused() && action == Action::ProviderMenu; if !(keep_machine_rail_focus || was_sidebar_focused && action == Action::SendPrefix) { + self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; } if was_sidebar_focused && action == Action::FocusSidebar { @@ -18162,11 +18241,13 @@ impl App { && matches!(key.code, KeyCode::Right | KeyCode::Char('l')) { if !self.focus_adjacent_rail(RailKind::Workspace, 1) { + self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; } return Ok(RenderAction::Draw); } if key.code == KeyCode::Esc { + self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; return Ok(RenderAction::Draw); } @@ -18205,14 +18286,17 @@ impl App { if let Some(index) = self.tree.workspaces.iter().position(|workspace| workspace.id == id) { + self.commit_workspace_preview(); self.select_workspace_for_client(Some(index), None); self.focus = FocusTarget::Pane; } } Some(WorkspaceRailTarget::Action(action)) => { + self.cancel_workspace_preview(); return self.invoke_sidebar_action(action); } Some(WorkspaceRailTarget::Recoverable(id)) => { + self.cancel_workspace_preview(); self.request_restore_managed_workspace(&id); self.focus = FocusTarget::Pane; } @@ -19017,6 +19101,7 @@ impl App { Action::ToggleSidebar => { self.sidebar_visible = !self.sidebar_visible; if !self.sidebar_visible { + self.cancel_workspace_preview(); self.session.invalidate_sidebar_plugin_sync(); self.focus = FocusTarget::Pane; } @@ -19705,6 +19790,7 @@ impl App { if self.sidebar_rail_focused() || self.sidebar_focus_pending { return; } + self.cancel_workspace_preview(); self.sidebar_visible = true; let requested = self.config.sidebar.plugin.is_some() && self.sync_sidebar_plugin(true); if self.config.sidebar.plugin.is_none() || self.sidebar_plugin_surface.is_some() { @@ -19740,6 +19826,7 @@ impl App { } fn toggle_sidebar_view(&mut self) { + self.cancel_workspace_preview(); self.sidebar_view = self.sidebar_view.toggled(); if self.config.sidebar.plugin.is_some() { return; @@ -21390,8 +21477,37 @@ impl App { }; let before = hoverable(self.hover); let after = hoverable(Some((x, y))); + let workspace_hover_changed = if self.workspace_sidebar_focused() + && self.sidebar_view == SidebarView::Workspaces + && self.drag.is_none() + { + match self.hit_at(x, y) { + Some(Hit::Workspace { id, .. }) => { + let before = ( + self.sidebar_workspace_selection, + self.workspace_preview, + self.tree.active_workspace, + ); + self.workspace_rail_follow_selection = true; + self.select_workspace_rail_target(WorkspaceRailTarget::Workspace(id)); + before + != ( + self.sidebar_workspace_selection, + self.workspace_preview, + self.tree.active_workspace, + ) + } + _ => false, + } + } else { + false + }; self.hover = Some((x, y)); - Ok(if before != after { RenderAction::Draw } else { RenderAction::None }) + Ok(if before != after || workspace_hover_changed { + RenderAction::Draw + } else { + RenderAction::None + }) } fn handle_right_drag(&mut self, x: u16, y: u16) -> anyhow::Result { @@ -41600,6 +41716,88 @@ mod tests { } } + #[test] + fn workspace_sidebar_previews_selection_until_enter_or_escape() { + let mux = Mux::new("workspace-sidebar-preview-test", SurfaceOptions::default()); + let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + mux.select_workspace(Some(0), None); + let mut app = test_app(Session::Local(mux.clone())); + app.sidebar_view = SidebarView::Workspaces; + app.replace_tree(app.session.tree()); + app.tree.active_workspace = 0; + app.sidebar_workspace_selection = 0; + app.workspace_rail_selection = WorkspaceRailSelection::Workspace; + app.focus = FocusTarget::WorkspaceRail; + app.sync_layout((100, 20)); + + app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)).unwrap(); + app.sync_layout((100, 20)); + assert_eq!(app.tree.active_workspace, 1); + assert_eq!(app.active_surface(), Some(second.id)); + assert_eq!(app.workspace_preview, Some(WorkspacePreview { origin: first.id, target: second.id })); + assert_eq!(mux.with_state(|state| state.active_workspace), 0); + + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.tree.active_workspace, 0); + assert_eq!(app.active_surface(), Some(first.id)); + assert_eq!(app.focus, FocusTarget::Pane); + assert_eq!(app.workspace_preview, None); + + app.focus = FocusTarget::WorkspaceRail; + app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)).unwrap(); + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.tree.active_workspace, 1); + assert_eq!(app.active_surface(), Some(second.id)); + assert_eq!(app.focus, FocusTarget::Pane); + assert_eq!(app.workspace_preview, None); + assert_eq!(mux.with_state(|state| state.active_workspace), 0); + + for surface in [first.id, second.id] { + mux.close_surface(surface).unwrap(); + } + } + + #[test] + fn workspace_sidebar_hover_previews_when_rail_has_focus() { + let mux = Mux::new("workspace-sidebar-hover-preview-test", SurfaceOptions::default()); + let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + mux.select_workspace(Some(0), None); + let mut app = test_app(Session::Local(mux.clone())); + app.sidebar_view = SidebarView::Workspaces; + app.replace_tree(app.session.tree()); + app.tree.active_workspace = 0; + app.sidebar_workspace_selection = 0; + app.workspace_rail_selection = WorkspaceRailSelection::Workspace; + app.focus = FocusTarget::WorkspaceRail; + app.sync_layout((100, 20)); + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let row = app + .hits + .iter() + .find_map(|(rect, hit)| { + matches!(hit, super::Hit::Workspace { index: 1, .. }).then_some(*rect) + }) + .expect("second workspace hit"); + + app.handle_hover_with_admission(row.x, row.y, KeyModifiers::NONE, None).unwrap(); + app.sync_layout((100, 20)); + assert_eq!(app.sidebar_workspace_selection, 1); + assert_eq!(app.tree.active_workspace, 1); + assert_eq!(app.active_surface(), Some(second.id)); + assert_eq!(app.workspace_preview, Some(WorkspacePreview { origin: first.id, target: second.id })); + + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.tree.active_workspace, 0); + assert_eq!(app.focus, FocusTarget::Pane); + + for surface in [first.id, second.id] { + mux.close_surface(surface).unwrap(); + } + } + #[test] fn projection_enter_on_active_surface_returns_focus_to_pane() { let (mux, surface) = test_mux("projection-active-surface-enter-test", None); @@ -43840,6 +44038,7 @@ mod tests { sidebar_workspace_selection: 0, sidebar_recoverable_workspace_selection: 0, workspace_rail_selection: WorkspaceRailSelection::default(), + workspace_preview: None, machine_rail_scroll: 0, machine_footer_scroll: 0, workspace_rail_scroll: 0, diff --git a/cmux-tui/docs/keyboard.md b/cmux-tui/docs/keyboard.md index ce4b86f33cc7..ba71c1350c9b 100644 --- a/cmux-tui/docs/keyboard.md +++ b/cmux-tui/docs/keyboard.md @@ -90,7 +90,7 @@ Workspace navigation follows tmux's outer session lane: `(` and `)` move backwar When the built-in sidebar is focused, its divider becomes a bold accent rail. `Tab` toggles files/workspaces without leaving sidebar focus. In the files view, Up/Down and Ctrl-J/Ctrl-K move the selection, Right descends into a directory, Enter descends or opens a file in a new `$EDITOR` tab, and Left or `h` goes to the parent when the machine rail is absent. `c` sends a safely quoted `cd` to the focused pane, `o` opens `.html` and `.md` files in a browser tab, `.` toggles dotfiles, `/` enters filter mode, and `~` follows the focused pane cwd again. Esc clears a nonempty filter before leaving filter mode. -In the workspaces view, Up/Down move the selection and Enter activates it. A one-level `tabs` view follows the highlighted workspace. Multi-level views such as `workspaces → agents` are collapsible trees: Left collapses, Right expands, Space toggles, and Enter activates the exact workspace, pane, tab, or agent surface. Alt/Option with arrows or `hjkl` always navigates, so Alt-Left and Alt-Right traverse views instead of changing tree expansion. Right from the final view or Esc returns to the pane. Any normal prefixed command leaves sidebar focus and runs through the usual action table. A configured sidebar plugin keeps its existing PTY forwarding behavior. +In the workspaces view, Up/Down or `j`/`k` move the selection and preview that workspace in the pane area without changing the client focus. Enter commits the preview and focuses the workspace. Esc cancels the preview and returns to the workspace that was active before navigation. A one-level `tabs` view follows the highlighted workspace. Multi-level views such as `workspaces → agents` are collapsible trees: Left collapses, Right expands, Space toggles, and Enter activates the exact workspace, pane, tab, or agent surface. Alt/Option with arrows or `hjkl` always navigates, so Alt-Left and Alt-Right traverse views instead of changing tree expansion. Right from the final view or Esc returns to the pane. Any normal prefixed command leaves sidebar focus and runs through the usual action table. A configured sidebar plugin keeps its existing PTY forwarding behavior. When the optional machine rail is visible, `Ctrl-b S` enters through the first view containing workspaces. Alt/Option-Left or Alt/Option-`h` at the left pane boundary enters the rightmost visible view. Left or `h` and Right or `l` traverse the ordered native views. Up/Down or `k`/`j` changes the selected machine, Enter connects to it, `m` opens the provider scope/actions menu when the provider offers one, and Esc returns to the active pane. Clicking a view's one-row top pad focuses it without activating a row. Clicking a machine, workspace, pane, tab, or agent activates it and returns keyboard input to the latest terminal. Sidebar views swallow other unprefixed keys instead of forwarding them to a remote terminal. diff --git a/cmux-tui/docs/mouse.md b/cmux-tui/docs/mouse.md index bef8cb283f3f..d7298d431412 100644 --- a/cmux-tui/docs/mouse.md +++ b/cmux-tui/docs/mouse.md @@ -4,7 +4,7 @@ The files sidebar view (`sidebar.view = "files"`) shows the focused pane's cwd, one row per directory/file, and a count or filter footer. A single click selects a file row. Crossterm's mouse events do not expose an existing double-click concept here, so clicks do not open or descend; use Enter or Right while the sidebar is focused. Toggle to the workspaces view with focused-sidebar `Tab` or the `toggle-sidebar-view` action. -The workspaces view shows two rows per workspace and `+ new workspace`, with no header row. Click either row of a workspace to select it. Click `+ new workspace` to create one. Its terminal-style scrollbar appears one column inside the resize divider only when workspace rows overflow. Wheel over the rail, click the scrollbar's invisible track, or drag its thumb to scroll the workspace list. Drag the sidebar's right border in either built-in view to set a session-local width override. Configured `sidebar.max_width` limits the drag width when it is greater than zero, and the TUI still leaves at least 40 columns for panes. +The workspaces view shows two rows per workspace and `+ new workspace`, with no header row. When the workspace rail has keyboard focus, hovering a workspace row previews it in the pane area and updates the selection without committing focus. Press Enter to commit the preview or Esc to return to the previous workspace. Click either row of a workspace to select it. Click `+ new workspace` to create one. Its terminal-style scrollbar appears one column inside the resize divider only when workspace rows overflow. Wheel over the rail, click the scrollbar's invisible track, or drag its thumb to scroll the workspace list. Drag the sidebar's right border in either built-in view to set a session-local width override. Configured `sidebar.max_width` limits the drag width when it is greater than zero, and the TUI still leaves at least 40 columns for panes. When configured, the machine rail uses the same two-line entry, active marker, and selected-row treatment as the built-in workspace list (rails have no header row; the one-row pad at a rail's top focuses it without activating a row, and every rail leads with its `+` action rows at the bottom). Press a non-active machine entry to connect to it. Click `+ ssh host` to open the native SSH host picker, then filter a discovered alias or choose `Add SSH host…` for a process-local target. Provider-owned connection flows may use the same row for a host address or pairing code. Click `+ new vm` to run a provider create action or choose among configured prototype sources; right-click it (or the machine rail's top pad, when create is unavailable) for provider scope switching and provider actions. The prototype picker adds only a local catalog row and never invokes Docker or a cloud API. From 306136e4c7948f22d7ff72ed69f9e3207e0e368f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:24:00 -0700 Subject: [PATCH 02/36] style(tui): apply hosted rustfmt --- cmux-tui/crates/cmux-tui/src/app.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 3b9612966b50..e39d5b43db72 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -17381,11 +17381,8 @@ impl App { fn cancel_workspace_preview(&mut self) { let Some(preview) = self.workspace_preview.take() else { return }; - let Some(index) = self - .tree - .workspaces - .iter() - .position(|workspace| workspace.id == preview.origin) + let Some(index) = + self.tree.workspaces.iter().position(|workspace| workspace.id == preview.origin) else { return; }; @@ -17396,11 +17393,8 @@ impl App { fn reconcile_workspace_preview(&mut self) { let Some(preview) = self.workspace_preview else { return }; - let Some(target_index) = self - .tree - .workspaces - .iter() - .position(|workspace| workspace.id == preview.target) + let Some(target_index) = + self.tree.workspaces.iter().position(|workspace| workspace.id == preview.target) else { self.workspace_preview = None; return; @@ -41735,7 +41729,10 @@ mod tests { app.sync_layout((100, 20)); assert_eq!(app.tree.active_workspace, 1); assert_eq!(app.active_surface(), Some(second.id)); - assert_eq!(app.workspace_preview, Some(WorkspacePreview { origin: first.id, target: second.id })); + assert_eq!( + app.workspace_preview, + Some(WorkspacePreview { origin: first.id, target: second.id }) + ); assert_eq!(mux.with_state(|state| state.active_workspace), 0); app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap(); @@ -41787,7 +41784,10 @@ mod tests { assert_eq!(app.sidebar_workspace_selection, 1); assert_eq!(app.tree.active_workspace, 1); assert_eq!(app.active_surface(), Some(second.id)); - assert_eq!(app.workspace_preview, Some(WorkspacePreview { origin: first.id, target: second.id })); + assert_eq!( + app.workspace_preview, + Some(WorkspacePreview { origin: first.id, target: second.id }) + ); app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap(); assert_eq!(app.tree.active_workspace, 0); From 40d3cb1c8c3acbf8bc2707e85956ae595199bbbe Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:36:32 -0700 Subject: [PATCH 03/36] fix(tui): import workspace preview in tests --- cmux-tui/crates/cmux-tui/src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index e39d5b43db72..e6e3bec21137 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -23904,7 +23904,8 @@ mod tests { SurfaceResizeDecision, SurfaceResizeOwnership, TERMINAL_PAINT_CADENCE, TerminalInput, TerminalPaintPacer, TerminalPointerAdmission, TerminalPointerAdmissionResult, TerminalPointerEncoding, TextInput, Toast, VIEWPORT_ANIMATION_DURATION, ViewportMotion, - ViewportPaneAreaProjection, WorkspaceRailSelection, action_available_in_mode, + ViewportPaneAreaProjection, WorkspacePreview, WorkspaceRailSelection, + action_available_in_mode, browser_content_size_for_rect, browser_frame_source_crop, browser_hover_forward_allowed, browser_source_crop, canonical_terminal_content, catch_renderer_panic, clamp_split_ratio_for_tab_bars, client_menu_item, clip_horizontal_rect, From de0b69d46564ec3c4b4498b4cb3883f53297da0e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:39:01 -0700 Subject: [PATCH 04/36] style(tui): apply hosted rustfmt --- cmux-tui/crates/cmux-tui/src/app.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index e6e3bec21137..f1e4f0b2192a 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -23905,13 +23905,12 @@ mod tests { TerminalPaintPacer, TerminalPointerAdmission, TerminalPointerAdmissionResult, TerminalPointerEncoding, TextInput, Toast, VIEWPORT_ANIMATION_DURATION, ViewportMotion, ViewportPaneAreaProjection, WorkspacePreview, WorkspaceRailSelection, - action_available_in_mode, - browser_content_size_for_rect, browser_frame_source_crop, browser_hover_forward_allowed, - browser_source_crop, canonical_terminal_content, catch_renderer_panic, - clamp_split_ratio_for_tab_bars, client_menu_item, clip_horizontal_rect, - content_size_for_rect, disable_host_keyboard_protocol, enable_host_keyboard_protocol, - expand_status_tokens, forward_host_input, forward_mux_event, forward_mux_events, - host_mouse_capture_escape_if_changed, host_startup_input_modes, + action_available_in_mode, browser_content_size_for_rect, browser_frame_source_crop, + browser_hover_forward_allowed, browser_source_crop, canonical_terminal_content, + catch_renderer_panic, clamp_split_ratio_for_tab_bars, client_menu_item, + clip_horizontal_rect, content_size_for_rect, disable_host_keyboard_protocol, + enable_host_keyboard_protocol, expand_status_tokens, forward_host_input, forward_mux_event, + forward_mux_events, host_mouse_capture_escape_if_changed, host_startup_input_modes, initial_applied_outer_cursor, initial_host_mouse_capture, keyboard_protocol_accepts, layout_undo_error_completion, negotiate_host_keyboard_protocol_with, outer_cursor_escape, outer_cursor_escape_if_changed, pane_area_projection_work, pane_context_menu_groups, From 9ebe08b0c6a638ae899910c85c1cdc0bcfe41566 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:56:44 -0700 Subject: [PATCH 05/36] test(tui): use workspace ids in preview assertions --- cmux-tui/crates/cmux-tui/src/app.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index f1e4f0b2192a..07d3a01568ef 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -41715,6 +41715,9 @@ mod tests { let mux = Mux::new("workspace-sidebar-preview-test", SurfaceOptions::default()); let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let workspace_tree = Session::Local(mux.clone()).tree(); + let first_workspace = workspace_tree.workspaces[0].id; + let second_workspace = workspace_tree.workspaces[1].id; mux.select_workspace(Some(0), None); let mut app = test_app(Session::Local(mux.clone())); app.sidebar_view = SidebarView::Workspaces; @@ -41731,7 +41734,7 @@ mod tests { assert_eq!(app.active_surface(), Some(second.id)); assert_eq!( app.workspace_preview, - Some(WorkspacePreview { origin: first.id, target: second.id }) + Some(WorkspacePreview { origin: first_workspace, target: second_workspace }) ); assert_eq!(mux.with_state(|state| state.active_workspace), 0); @@ -41760,6 +41763,9 @@ mod tests { let mux = Mux::new("workspace-sidebar-hover-preview-test", SurfaceOptions::default()); let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let workspace_tree = Session::Local(mux.clone()).tree(); + let first_workspace = workspace_tree.workspaces[0].id; + let second_workspace = workspace_tree.workspaces[1].id; mux.select_workspace(Some(0), None); let mut app = test_app(Session::Local(mux.clone())); app.sidebar_view = SidebarView::Workspaces; @@ -41786,7 +41792,7 @@ mod tests { assert_eq!(app.active_surface(), Some(second.id)); assert_eq!( app.workspace_preview, - Some(WorkspacePreview { origin: first.id, target: second.id }) + Some(WorkspacePreview { origin: first_workspace, target: second_workspace }) ); app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap(); From 6255acede5031a2632ae73769439ea53b07b18c2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:47:50 -0700 Subject: [PATCH 06/36] test(tui): cover pane click after workspace preview --- cmux-tui/crates/cmux-tui/src/app.rs | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 07d3a01568ef..5ceb9186c0ac 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -41804,6 +41804,61 @@ mod tests { } } + #[test] + fn workspace_sidebar_preview_commits_when_pane_is_clicked() { + let mux = Mux::new("workspace-sidebar-preview-pane-click-test", SurfaceOptions::default()); + let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let workspace_tree = Session::Local(mux.clone()).tree(); + let first_workspace = workspace_tree.workspaces[0].id; + let second_workspace = workspace_tree.workspaces[1].id; + let second_pane = workspace_tree.workspaces[1].screens[0].active_pane; + mux.select_workspace(Some(0), None); + let mut app = test_app(Session::Local(mux.clone())); + app.sidebar_view = SidebarView::Workspaces; + app.replace_tree(app.session.tree()); + app.tree.active_workspace = 0; + app.sidebar_workspace_selection = 0; + app.workspace_rail_selection = WorkspaceRailSelection::Workspace; + app.focus = FocusTarget::WorkspaceRail; + app.sync_layout((100, 20)); + + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let row = app + .hits + .iter() + .find_map(|(rect, hit)| { + matches!(hit, super::Hit::Workspace { index: 1, .. }).then_some(*rect) + }) + .expect("second workspace hit"); + app.handle_hover_with_admission(row.x, row.y, KeyModifiers::NONE, None).unwrap(); + app.sync_layout((100, 20)); + let area = app + .pane_areas + .iter() + .find(|area| area.surface == second.id) + .copied() + .expect("previewed pane area"); + assert!(area.content.width > 2 && area.content.height > 2); + + let click = (area.content.x + area.content.width / 2, area.content.y + area.content.height / 2); + app.handle_left_down(click.0, click.1, KeyModifiers::NONE).unwrap(); + + assert_eq!(app.workspace_preview, None); + assert_eq!(app.tree.active_workspace, 1); + assert_eq!(app.active_surface(), Some(second.id)); + assert_eq!(app.focus, FocusTarget::Pane); + assert_eq!(mux.session_focus().map(|(pane, _)| pane), Some(second_pane)); + assert_eq!(app.tree.workspaces[app.tree.active_workspace].id, second_workspace); + assert_ne!(first.id, second.id); + assert_ne!(first_workspace, second_workspace); + + for surface in [first.id, second.id] { + mux.close_surface(surface).unwrap(); + } + } + #[test] fn projection_enter_on_active_surface_returns_focus_to_pane() { let (mux, surface) = test_mux("projection-active-surface-enter-test", None); From 0a439aafac51235bbf31804de19fd2e5785f273b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:48:36 -0700 Subject: [PATCH 07/36] fix(tui): commit workspace preview on pane click --- cmux-tui/crates/cmux-tui/src/app.rs | 26 ++++++++++++++++++++++++-- cmux-tui/docs/mouse.md | 2 +- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 5ceb9186c0ac..572a5872b961 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -17379,6 +17379,17 @@ impl App { self.workspace_preview = None; } + fn commit_workspace_preview_for_pane_click(&mut self) { + if self.workspace_preview.is_none() { + return; + } + let workspace = self.tree.active_workspace; + self.commit_workspace_preview(); + self.focus = FocusTarget::Pane; + self.sidebar_focus_pending = false; + self.select_workspace_for_client(Some(workspace), None); + } + fn cancel_workspace_preview(&mut self) { let Some(preview) = self.workspace_preview.take() else { return }; let Some(index) = @@ -21595,6 +21606,13 @@ impl App { return Ok(RenderAction::Draw); } + // A pane press is an explicit choice after a hover preview. Commit it + // before PTY or browser handling can consume the press and clear the + // preview through the ordinary sidebar-focus exit path. + if self.pane_area_at(x, y).is_some() { + self.commit_workspace_preview_for_pane_click(); + } + if let Some((pane, hit)) = self.omnibar_hit_at(x, y) { self.focus_pane_after_input(pane); if let Some(state) = self.omnibar.as_mut() { @@ -41806,7 +41824,8 @@ mod tests { #[test] fn workspace_sidebar_preview_commits_when_pane_is_clicked() { - let mux = Mux::new("workspace-sidebar-preview-pane-click-test", SurfaceOptions::default()); + let mux = + Mux::new("workspace-sidebar-preview-pane-click-test", SurfaceOptions::default()); let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); let workspace_tree = Session::Local(mux.clone()).tree(); @@ -41842,7 +41861,10 @@ mod tests { .expect("previewed pane area"); assert!(area.content.width > 2 && area.content.height > 2); - let click = (area.content.x + area.content.width / 2, area.content.y + area.content.height / 2); + let click = ( + area.content.x + area.content.width / 2, + area.content.y + area.content.height / 2, + ); app.handle_left_down(click.0, click.1, KeyModifiers::NONE).unwrap(); assert_eq!(app.workspace_preview, None); diff --git a/cmux-tui/docs/mouse.md b/cmux-tui/docs/mouse.md index d7298d431412..bce8b7c7c377 100644 --- a/cmux-tui/docs/mouse.md +++ b/cmux-tui/docs/mouse.md @@ -4,7 +4,7 @@ The files sidebar view (`sidebar.view = "files"`) shows the focused pane's cwd, one row per directory/file, and a count or filter footer. A single click selects a file row. Crossterm's mouse events do not expose an existing double-click concept here, so clicks do not open or descend; use Enter or Right while the sidebar is focused. Toggle to the workspaces view with focused-sidebar `Tab` or the `toggle-sidebar-view` action. -The workspaces view shows two rows per workspace and `+ new workspace`, with no header row. When the workspace rail has keyboard focus, hovering a workspace row previews it in the pane area and updates the selection without committing focus. Press Enter to commit the preview or Esc to return to the previous workspace. Click either row of a workspace to select it. Click `+ new workspace` to create one. Its terminal-style scrollbar appears one column inside the resize divider only when workspace rows overflow. Wheel over the rail, click the scrollbar's invisible track, or drag its thumb to scroll the workspace list. Drag the sidebar's right border in either built-in view to set a session-local width override. Configured `sidebar.max_width` limits the drag width when it is greater than zero, and the TUI still leaves at least 40 columns for panes. +The workspaces view shows two rows per workspace and `+ new workspace`, with no header row. When the workspace rail has keyboard focus, hovering a workspace row previews it in the pane area and updates the selection without committing focus. Press Enter to commit the preview or Esc to return to the previous workspace. Click a pane on the right to commit the preview and focus the clicked pane. Click either row of a workspace to select it. Click `+ new workspace` to create one. Its terminal-style scrollbar appears one column inside the resize divider only when workspace rows overflow. Wheel over the rail, click the scrollbar's invisible track, or drag its thumb to scroll the workspace list. Drag the sidebar's right border in either built-in view to set a session-local width override. Configured `sidebar.max_width` limits the drag width when it is greater than zero, and the TUI still leaves at least 40 columns for panes. When configured, the machine rail uses the same two-line entry, active marker, and selected-row treatment as the built-in workspace list (rails have no header row; the one-row pad at a rail's top focuses it without activating a row, and every rail leads with its `+` action rows at the bottom). Press a non-active machine entry to connect to it. Click `+ ssh host` to open the native SSH host picker, then filter a discovered alias or choose `Add SSH host…` for a process-local target. Provider-owned connection flows may use the same row for a host address or pairing code. Click `+ new vm` to run a provider create action or choose among configured prototype sources; right-click it (or the machine rail's top pad, when create is unavailable) for provider scope switching and provider actions. The prototype picker adds only a local catalog row and never invokes Docker or a cloud API. From 505e7bfc4e660cae7903e8538627013d9bc034e7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:52:32 -0700 Subject: [PATCH 08/36] style(tui): apply hosted rustfmt --- cmux-tui/crates/cmux-tui/src/app.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 572a5872b961..4d6e3c5ee850 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -41824,8 +41824,7 @@ mod tests { #[test] fn workspace_sidebar_preview_commits_when_pane_is_clicked() { - let mux = - Mux::new("workspace-sidebar-preview-pane-click-test", SurfaceOptions::default()); + let mux = Mux::new("workspace-sidebar-preview-pane-click-test", SurfaceOptions::default()); let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); let workspace_tree = Session::Local(mux.clone()).tree(); @@ -41861,10 +41860,8 @@ mod tests { .expect("previewed pane area"); assert!(area.content.width > 2 && area.content.height > 2); - let click = ( - area.content.x + area.content.width / 2, - area.content.y + area.content.height / 2, - ); + let click = + (area.content.x + area.content.width / 2, area.content.y + area.content.height / 2); app.handle_left_down(click.0, click.1, KeyModifiers::NONE).unwrap(); assert_eq!(app.workspace_preview, None); From 6555dc9b28d9f8317645a3375fde32c257fc744d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:20:27 -0700 Subject: [PATCH 09/36] fix(tui): settle workspace previews on focus and pointer changes --- cmux-tui/crates/cmux-tui/src/app.rs | 147 ++++++++++++++++++++++++++-- cmux-tui/docs/README.md | 4 +- cmux-tui/docs/keyboard.ja.md | 11 +++ cmux-tui/docs/keyboard.md | 4 +- cmux-tui/docs/mouse.ja.md | 11 +++ cmux-tui/docs/mouse.md | 4 +- 6 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 cmux-tui/docs/keyboard.ja.md create mode 100644 cmux-tui/docs/mouse.ja.md diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 4d6e3c5ee850..9db38a932a5d 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -10210,6 +10210,7 @@ impl App { } Direction::Right => { if !self.focus_adjacent_rail(kind, 1) { + self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; } true @@ -17390,16 +17391,36 @@ impl App { self.select_workspace_for_client(Some(workspace), None); } + /// Commit a workspace preview before any non-motion pointer event reaches + /// a pane. The preview changes the local render tree but must not leave + /// keyboard focus on the rail after a context-menu, middle-button, or + /// wheel interaction selects the previewed pane. + fn commit_workspace_preview_for_pane_pointer(&mut self, x: u16, y: u16) { + if self.pane_area_at(x, y).is_some() { + self.commit_workspace_preview_for_pane_click(); + } + } + + fn align_workspace_sidebar_to_active(&mut self) { + if self.tree.workspaces.is_empty() { + self.tree.active_workspace = 0; + self.sidebar_workspace_selection = 0; + } else { + self.tree.active_workspace = + self.tree.active_workspace.min(self.tree.workspaces.len().saturating_sub(1)); + self.sidebar_workspace_selection = self.tree.active_workspace; + } + self.workspace_rail_selection = WorkspaceRailSelection::Workspace; + } + fn cancel_workspace_preview(&mut self) { let Some(preview) = self.workspace_preview.take() else { return }; - let Some(index) = + if let Some(index) = self.tree.workspaces.iter().position(|workspace| workspace.id == preview.origin) - else { - return; - }; - self.tree.active_workspace = index; - self.sidebar_workspace_selection = index; - self.workspace_rail_selection = WorkspaceRailSelection::Workspace; + { + self.tree.active_workspace = index; + } + self.align_workspace_sidebar_to_active(); } fn reconcile_workspace_preview(&mut self) { @@ -17407,11 +17428,11 @@ impl App { let Some(target_index) = self.tree.workspaces.iter().position(|workspace| workspace.id == preview.target) else { - self.workspace_preview = None; + self.cancel_workspace_preview(); return; }; if !self.tree.workspaces.iter().any(|workspace| workspace.id == preview.origin) { - self.workspace_preview = None; + self.cancel_workspace_preview(); return; } self.tree.active_workspace = target_index; @@ -20282,6 +20303,9 @@ impl App { } _ => {} } + if !matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Down(MouseButton::Left)) { + self.commit_workspace_preview_for_pane_pointer(mouse.column, mouse.row); + } match mouse.kind { MouseEventKind::Down(MouseButton::Left) => self.handle_left_down_with_admission( mouse.column, @@ -21860,6 +21884,7 @@ impl App { } if let Some(area) = self.pane_area_at(x, y).copied() { + self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; if area.content.contains(x, y) { if self.surface_kind(area.surface) == Some(SurfaceKind::Browser) { @@ -41317,8 +41342,11 @@ mod tests { app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::ALT)).unwrap(); assert_eq!(app.focus, FocusTarget::WorkspaceRail); + let workspace = app.tree.workspaces[0].id; + app.workspace_preview = Some(WorkspacePreview { origin: workspace, target: workspace + 1 }); app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT)).unwrap(); assert_eq!(app.focus, FocusTarget::Pane); + assert_eq!(app.workspace_preview, None); app.sidebar_view = SidebarView::Files; app.focus = FocusTarget::WorkspaceRail; @@ -41878,6 +41906,107 @@ mod tests { } } + #[test] + fn workspace_preview_pointer_actions_commit_before_pane_handling() { + let mux = + Mux::new("workspace-sidebar-preview-pointer-actions-test", SurfaceOptions::default()); + let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let workspace_tree = Session::Local(mux.clone()).tree(); + let first_workspace = workspace_tree.workspaces[0].id; + let second_workspace = workspace_tree.workspaces[1].id; + let mut app = test_app(Session::Local(mux.clone())); + app.sidebar_view = SidebarView::Workspaces; + app.replace_tree(app.session.tree()); + app.tree.active_workspace = 1; + app.sidebar_workspace_selection = 1; + app.workspace_rail_selection = WorkspaceRailSelection::Workspace; + app.focus = FocusTarget::WorkspaceRail; + app.sync_layout((100, 20)); + let area = app + .pane_areas + .iter() + .find(|area| area.surface == second.id) + .copied() + .expect("second workspace pane area"); + let point = (area.rect.x, area.rect.y); + let reset_preview = |app: &mut App| { + app.tree.active_workspace = 1; + app.sidebar_workspace_selection = 1; + app.workspace_rail_selection = WorkspaceRailSelection::Workspace; + app.focus = FocusTarget::WorkspaceRail; + app.workspace_preview = + Some(WorkspacePreview { origin: first_workspace, target: second_workspace }); + app.menu = None; + app.drag = None; + app.active_pointer_buttons.clear(); + app.ignored_pty_mouse_buttons.clear(); + }; + + for kind in [ + MouseEventKind::Down(MouseButton::Right), + MouseEventKind::Down(MouseButton::Middle), + MouseEventKind::ScrollDown, + MouseEventKind::ScrollRight, + ] { + reset_preview(&mut app); + app.handle_mouse(MouseEvent { + kind, + column: point.0, + row: point.1, + modifiers: KeyModifiers::NONE, + }) + .unwrap(); + assert_eq!(app.workspace_preview, None, "pointer action {kind:?} left preview active"); + assert_eq!(app.focus, FocusTarget::Pane, "pointer action {kind:?} left rail focused"); + } + + for surface in [first.id, second.id] { + mux.close_surface(surface).unwrap(); + } + } + + #[test] + fn workspace_preview_reconciliation_aligns_selection_when_a_workspace_disappears() { + let mux = Mux::new("workspace-sidebar-preview-reconcile-test", SurfaceOptions::default()); + let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let third = mux.new_workspace(Some("Gamma".into()), Some((80, 24))).unwrap(); + let workspace_tree = Session::Local(mux.clone()).tree(); + let first_workspace = workspace_tree.workspaces[0].id; + let second_workspace = workspace_tree.workspaces[1].id; + let mut app = test_app(Session::Local(mux.clone())); + app.replace_tree(app.session.tree()); + app.workspace_preview = + Some(WorkspacePreview { origin: first_workspace, target: second_workspace }); + + let mut target_removed = app.tree.clone(); + target_removed.workspaces.remove(1); + target_removed.active_workspace = 1; + app.tree = target_removed; + app.sidebar_workspace_selection = 1; + app.reconcile_workspace_preview(); + assert_eq!(app.workspace_preview, None); + assert_eq!(app.tree.active_workspace, 0, "origin remains and must be restored"); + assert_eq!(app.sidebar_workspace_selection, 0); + + let mut origin_removed = workspace_tree; + origin_removed.workspaces.remove(0); + origin_removed.active_workspace = 0; + app.tree = origin_removed; + app.sidebar_workspace_selection = 1; + app.workspace_preview = + Some(WorkspacePreview { origin: first_workspace, target: second_workspace }); + app.reconcile_workspace_preview(); + assert_eq!(app.workspace_preview, None); + assert_eq!(app.tree.active_workspace, 0); + assert_eq!(app.sidebar_workspace_selection, 0); + + for surface in [first.id, second.id, third.id] { + mux.close_surface(surface).unwrap(); + } + } + #[test] fn projection_enter_on_active_surface_returns_focus_to_pane() { let (mux, surface) = test_mux("projection-active-surface-enter-test", None); diff --git a/cmux-tui/docs/README.md b/cmux-tui/docs/README.md index 01d305d454b2..3fb6e8bc90cb 100644 --- a/cmux-tui/docs/README.md +++ b/cmux-tui/docs/README.md @@ -6,8 +6,8 @@ - [Getting started](getting-started.md): build prerequisites, local and headless runs, sockets, detach and attach, session isolation for products built on cmux-tui. - [Concepts](concepts.md): session tree, focus, collapse behavior, tab naming, smart split, terminals, and browsers. -- [Keyboard](keyboard.md): prefix model, modeless Alt layer, default bindings, and `cmux-tui.json` key remapping. -- [Mouse](mouse.md): clickable UI, drag reorder, resize, scrollbars, menus, selection, pointer shape, and dialogs. +- [Keyboard](keyboard.md) ([日本語](keyboard.ja.md)): prefix model, modeless Alt layer, default bindings, and `cmux-tui.json` key remapping. +- [Mouse](mouse.md) ([日本語](mouse.ja.md)): clickable UI, drag reorder, resize, scrollbars, menus, selection, pointer shape, and dialogs. - [Configuration](configuration.md): full `cmux-tui.json` reference with defaults and a worked example. - [Remote daemon](remote.md): authenticated clients, SSH, WebSocket, Iroh, relays, RPC, and port forwarding. The exact agent RPC wire schema is in the [remote RPC contract](../spec/remote-rpc.md). - [Machines](machines.md): optional dual rails, static Unix/SSH targets, relay, `npx cmux` remote setup, and outbound `npx cmux machine-agent` registration. diff --git a/cmux-tui/docs/keyboard.ja.md b/cmux-tui/docs/keyboard.ja.md new file mode 100644 index 000000000000..7c2e413f9ef8 --- /dev/null +++ b/cmux-tui/docs/keyboard.ja.md @@ -0,0 +1,11 @@ +# キーボード + +[English](keyboard.md) + +## フォーカス中のサイドバー + +組み込みサイドバーにフォーカスがあると、仕切りが強調色のレールになります。`Tab` はサイドバーのフォーカスを保ったままファイル表示とワークスペース表示を切り替えます。ファイル表示では、上下矢印または `Ctrl-J` / `Ctrl-K` で選択を移動し、`Right` でディレクトリに入り、`Enter` でディレクトリに入るかファイルを新しい `$EDITOR` タブで開きます。`Left` または `h` は、マシンレールがない場合に親へ移動します。`c` はフォーカス中のペインへ安全に引用した `cd` を送り、`o` は `.html` と `.md` のファイルをブラウザタブで開きます。`.` はドットファイルを切り替え、`/` はフィルターモードに入り、`~` はフォーカス中のペインの cwd を再び追跡します。Esc は、フィルターモードを終了する前に空でないフィルターを消去します。 + +ワークスペース表示では、上下矢印または `j` / `k` で選択を移動し、クライアントのフォーカスを変更せずにそのワークスペースをペイン領域へプレビューします。`Enter` はプレビューを確定してワークスペースへフォーカスを移します。Esc はプレビューをキャンセルし、ナビゲーション前にアクティブだったワークスペースとペインのフォーカスを復元します。1 階層の `tabs` ビューは、強調表示されたワークスペースに追従します。`workspaces → agents` のような多階層ビューは折りたたみ可能なツリーです。`Left` で折りたたみ、`Right` で展開し、Space で切り替え、`Enter` で正確なワークスペース、ペイン、タブ、またはエージェントサーフェスを有効にします。矢印または `hjkl` と Alt/Option を組み合わせると常にナビゲーションになり、Alt-Left と Alt-Right はツリーの展開を変更せずにビューを移動します。最後のビューからの `Right` または Esc はペインへ戻ります。通常のプレフィックス付きコマンドはサイドバーのフォーカスを離れ、通常のアクション表で実行されます。設定されたサイドバープラグインは、既存の PTY 転送動作を維持します。 + +オプションのマシンレールが表示されている場合、`Ctrl-b S` はワークスペースを含む最初のビューから入ります。Alt/Option-Left または Alt/Option-`h` をペイン境界の左端で押すと、右端の表示ビューへ入ります。`Left` または `h` と `Right` または `l` は、ネイティブビューの順序を移動します。上下矢印または `k` / `j` は選択したマシンを変更し、`Enter` は接続し、プロバイダーがスコープやアクションを提供する場合は `m` がメニューを開き、Esc はアクティブなペインへ戻ります。ビュー上部の 1 行パッドをクリックすると行を有効にせずフォーカスします。マシン、ワークスペース、ペイン、タブ、エージェントをクリックするとそれを有効にし、キーボード入力を最後に使ったターミナルへ戻します。サイドバービューは、その他のプレフィックスなしキーをリモートターミナルへ転送せずに処理します。 diff --git a/cmux-tui/docs/keyboard.md b/cmux-tui/docs/keyboard.md index ba71c1350c9b..d1d5551c25a5 100644 --- a/cmux-tui/docs/keyboard.md +++ b/cmux-tui/docs/keyboard.md @@ -1,5 +1,7 @@ # Keyboard +[日本語](keyboard.ja.md) + ## Prefix Model `cmux-tui` uses a tmux-style prefix. The default prefix is `Ctrl-b`. After the prefix, the next key is interpreted as a mux command. Pressing the prefix twice sends a literal `Ctrl-b` to the active surface. @@ -90,7 +92,7 @@ Workspace navigation follows tmux's outer session lane: `(` and `)` move backwar When the built-in sidebar is focused, its divider becomes a bold accent rail. `Tab` toggles files/workspaces without leaving sidebar focus. In the files view, Up/Down and Ctrl-J/Ctrl-K move the selection, Right descends into a directory, Enter descends or opens a file in a new `$EDITOR` tab, and Left or `h` goes to the parent when the machine rail is absent. `c` sends a safely quoted `cd` to the focused pane, `o` opens `.html` and `.md` files in a browser tab, `.` toggles dotfiles, `/` enters filter mode, and `~` follows the focused pane cwd again. Esc clears a nonempty filter before leaving filter mode. -In the workspaces view, Up/Down or `j`/`k` move the selection and preview that workspace in the pane area without changing the client focus. Enter commits the preview and focuses the workspace. Esc cancels the preview and returns to the workspace that was active before navigation. A one-level `tabs` view follows the highlighted workspace. Multi-level views such as `workspaces → agents` are collapsible trees: Left collapses, Right expands, Space toggles, and Enter activates the exact workspace, pane, tab, or agent surface. Alt/Option with arrows or `hjkl` always navigates, so Alt-Left and Alt-Right traverse views instead of changing tree expansion. Right from the final view or Esc returns to the pane. Any normal prefixed command leaves sidebar focus and runs through the usual action table. A configured sidebar plugin keeps its existing PTY forwarding behavior. +In the workspaces view, Up/Down or `j`/`k` move the selection and preview that workspace in the pane area without changing the client focus. Enter commits the preview and focuses the workspace. Esc cancels the preview and restores both the workspace and pane focus that were active before navigation. A one-level `tabs` view follows the highlighted workspace. Multi-level views such as `workspaces → agents` are collapsible trees: Left collapses, Right expands, Space toggles, and Enter activates the exact workspace, pane, tab, or agent surface. Alt/Option with arrows or `hjkl` always navigates, so Alt-Left and Alt-Right traverse views instead of changing tree expansion. Right from the final view or Esc returns to the pane. Any normal prefixed command leaves sidebar focus and runs through the usual action table. A configured sidebar plugin keeps its existing PTY forwarding behavior. When the optional machine rail is visible, `Ctrl-b S` enters through the first view containing workspaces. Alt/Option-Left or Alt/Option-`h` at the left pane boundary enters the rightmost visible view. Left or `h` and Right or `l` traverse the ordered native views. Up/Down or `k`/`j` changes the selected machine, Enter connects to it, `m` opens the provider scope/actions menu when the provider offers one, and Esc returns to the active pane. Clicking a view's one-row top pad focuses it without activating a row. Clicking a machine, workspace, pane, tab, or agent activates it and returns keyboard input to the latest terminal. Sidebar views swallow other unprefixed keys instead of forwarding them to a remote terminal. diff --git a/cmux-tui/docs/mouse.ja.md b/cmux-tui/docs/mouse.ja.md new file mode 100644 index 000000000000..2eab786d9cdd --- /dev/null +++ b/cmux-tui/docs/mouse.ja.md @@ -0,0 +1,11 @@ +# マウス + +[English](mouse.md) + +## クリック対象 + +ファイルサイドバービュー(`sidebar.view = "files"`)には、フォーカス中のペインの cwd、ディレクトリまたはファイルの行、件数またはフィルターのフッターが表示されます。行を 1 回クリックするとファイル行を選択します。Crossterm のマウスイベントには既存のダブルクリックという概念がないため、クリックでは開いたり移動したりしません。サイドバーにフォーカスがある状態で `Enter` または `Right` を使います。フォーカス中のサイドバーで `Tab` を押すか `toggle-sidebar-view` アクションを使うとワークスペース表示に切り替わります。 + +ワークスペース表示には、見出し行なしで各ワークスペース 2 行と `+ new workspace` が表示されます。ワークスペースレールにキーボードフォーカスがあると、ワークスペース行にカーソルを合わせたとき、そのワークスペースをペイン領域にプレビューし、フォーカスを確定せずに選択を更新します。`Enter` はプレビューを確定してペインへフォーカスを移します。Esc はプレビューをキャンセルし、直前のワークスペースとペインのフォーカスを復元します。右側のペインをクリックするとプレビューを確定し、クリックしたペインにフォーカスします。ワークスペースのどちらの行をクリックしても選択できます。`+ new workspace` をクリックすると作成します。ワークスペース行が表示領域を超えた場合だけ、ターミナル風スクロールバーがリサイズ仕切りの 1 列内側に表示されます。レール上でホイールを回すか、スクロールバーの透明なトラックをクリックするか、つまみをドラッグして一覧をスクロールします。組み込みビューのサイドバー右端をドラッグすると、その TUI セッションだけの幅を設定できます。`sidebar.max_width` が 0 より大きい場合はドラッグ幅を制限し、TUI はペイン用に少なくとも 40 列を残します。 + +各ペインには境界ボックスがあります。ペイン内をクリックするとフォーカスします。上端の境界はタブバーです。タブチップをクリックして選択し、`+` をクリックして PTY タブを作成し、`‹` または `›` をクリックしてタブチップをスクロールします。バー上でホイールを回すと、アクティブなタブを表示したままチップをスクロールします。 diff --git a/cmux-tui/docs/mouse.md b/cmux-tui/docs/mouse.md index bce8b7c7c377..e8e92c472ae1 100644 --- a/cmux-tui/docs/mouse.md +++ b/cmux-tui/docs/mouse.md @@ -1,10 +1,12 @@ # Mouse +[日本語](mouse.ja.md) + ## Click Targets The files sidebar view (`sidebar.view = "files"`) shows the focused pane's cwd, one row per directory/file, and a count or filter footer. A single click selects a file row. Crossterm's mouse events do not expose an existing double-click concept here, so clicks do not open or descend; use Enter or Right while the sidebar is focused. Toggle to the workspaces view with focused-sidebar `Tab` or the `toggle-sidebar-view` action. -The workspaces view shows two rows per workspace and `+ new workspace`, with no header row. When the workspace rail has keyboard focus, hovering a workspace row previews it in the pane area and updates the selection without committing focus. Press Enter to commit the preview or Esc to return to the previous workspace. Click a pane on the right to commit the preview and focus the clicked pane. Click either row of a workspace to select it. Click `+ new workspace` to create one. Its terminal-style scrollbar appears one column inside the resize divider only when workspace rows overflow. Wheel over the rail, click the scrollbar's invisible track, or drag its thumb to scroll the workspace list. Drag the sidebar's right border in either built-in view to set a session-local width override. Configured `sidebar.max_width` limits the drag width when it is greater than zero, and the TUI still leaves at least 40 columns for panes. +The workspaces view shows two rows per workspace and `+ new workspace`, with no header row. When the workspace rail has keyboard focus, hovering a workspace row previews it in the pane area and updates the selection without committing focus. Press Enter to commit the preview and focus the pane, or Esc to restore the previous workspace and pane focus. Click a pane on the right to commit the preview and focus the clicked pane. Click either row of a workspace to select it. Click `+ new workspace` to create one. Its terminal-style scrollbar appears one column inside the resize divider only when workspace rows overflow. Wheel over the rail, click the scrollbar's invisible track, or drag its thumb to scroll the workspace list. Drag the sidebar's right border in either built-in view to set a session-local width override. Configured `sidebar.max_width` limits the drag width when it is greater than zero, and the TUI still leaves at least 40 columns for panes. When configured, the machine rail uses the same two-line entry, active marker, and selected-row treatment as the built-in workspace list (rails have no header row; the one-row pad at a rail's top focuses it without activating a row, and every rail leads with its `+` action rows at the bottom). Press a non-active machine entry to connect to it. Click `+ ssh host` to open the native SSH host picker, then filter a discovered alias or choose `Add SSH host…` for a process-local target. Provider-owned connection flows may use the same row for a host address or pairing code. Click `+ new vm` to run a provider create action or choose among configured prototype sources; right-click it (or the machine rail's top pad, when create is unavailable) for provider scope switching and provider actions. The prototype picker adds only a local catalog row and never invokes Docker or a cloud API. From 5a60bd3ddb13e1a718c8d5f740e8f0d2a9d70a73 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:35:14 -0700 Subject: [PATCH 10/36] fix(tui): clear previews when sessions reset --- cmux-tui/crates/cmux-tui/src/app.rs | 18 ++++++++++++++++++ cmux-tui/docs/keyboard.ja.md | 2 +- cmux-tui/docs/keyboard.md | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 9db38a932a5d..bc7a7e9955a4 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -11541,6 +11541,7 @@ impl App { self.tree.active_workspace.min(self.tree.workspaces.len().saturating_sub(1)); self.sidebar_recoverable_workspace_selection = 0; self.workspace_rail_selection = WorkspaceRailSelection::Workspace; + self.workspace_preview = None; self.tabs_rail_selection = 0; self.tabs_rail_scroll = 0; self.tabs_rail_follow_selection = true; @@ -36283,6 +36284,23 @@ mod tests { assert!(app.pending_pointer_motion.is_none()); } + #[test] + fn session_presentation_reset_clears_workspace_preview() { + let mux = Mux::new("reset-workspace-preview-test", SurfaceOptions::default()); + let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let tree = Session::Local(mux.clone()).tree(); + let mut app = test_app(Session::Local(mux.clone())); + app.workspace_preview = + Some(WorkspacePreview { origin: tree.workspaces[0].id, target: tree.workspaces[1].id }); + + app.reset_session_presentation(tree); + + assert!(app.workspace_preview.is_none()); + mux.close_surface(first.id).unwrap(); + mux.close_surface(second.id).unwrap(); + } + #[test] fn session_presentation_reset_clears_rendered_terminal_caches() { let mux = Mux::new("reset-rendered-terminal-state-test", SurfaceOptions::default()); diff --git a/cmux-tui/docs/keyboard.ja.md b/cmux-tui/docs/keyboard.ja.md index 7c2e413f9ef8..16913c2fd31c 100644 --- a/cmux-tui/docs/keyboard.ja.md +++ b/cmux-tui/docs/keyboard.ja.md @@ -6,6 +6,6 @@ 組み込みサイドバーにフォーカスがあると、仕切りが強調色のレールになります。`Tab` はサイドバーのフォーカスを保ったままファイル表示とワークスペース表示を切り替えます。ファイル表示では、上下矢印または `Ctrl-J` / `Ctrl-K` で選択を移動し、`Right` でディレクトリに入り、`Enter` でディレクトリに入るかファイルを新しい `$EDITOR` タブで開きます。`Left` または `h` は、マシンレールがない場合に親へ移動します。`c` はフォーカス中のペインへ安全に引用した `cd` を送り、`o` は `.html` と `.md` のファイルをブラウザタブで開きます。`.` はドットファイルを切り替え、`/` はフィルターモードに入り、`~` はフォーカス中のペインの cwd を再び追跡します。Esc は、フィルターモードを終了する前に空でないフィルターを消去します。 -ワークスペース表示では、上下矢印または `j` / `k` で選択を移動し、クライアントのフォーカスを変更せずにそのワークスペースをペイン領域へプレビューします。`Enter` はプレビューを確定してワークスペースへフォーカスを移します。Esc はプレビューをキャンセルし、ナビゲーション前にアクティブだったワークスペースとペインのフォーカスを復元します。1 階層の `tabs` ビューは、強調表示されたワークスペースに追従します。`workspaces → agents` のような多階層ビューは折りたたみ可能なツリーです。`Left` で折りたたみ、`Right` で展開し、Space で切り替え、`Enter` で正確なワークスペース、ペイン、タブ、またはエージェントサーフェスを有効にします。矢印または `hjkl` と Alt/Option を組み合わせると常にナビゲーションになり、Alt-Left と Alt-Right はツリーの展開を変更せずにビューを移動します。最後のビューからの `Right` または Esc はペインへ戻ります。通常のプレフィックス付きコマンドはサイドバーのフォーカスを離れ、通常のアクション表で実行されます。設定されたサイドバープラグインは、既存の PTY 転送動作を維持します。 +ワークスペース表示では、上下矢印または `j` / `k` で選択を移動し、クライアントのフォーカスを変更せずにそのワークスペースをペイン領域へプレビューします。`Enter` はプレビューを確定してペインへフォーカスを移します。Esc はプレビューをキャンセルし、ナビゲーション前にアクティブだったワークスペースとペインのフォーカスを復元します。1 階層の `tabs` ビューは、強調表示されたワークスペースに追従します。`workspaces → agents` のような多階層ビューは折りたたみ可能なツリーです。`Left` で折りたたみ、`Right` で展開し、Space で切り替え、`Enter` で正確なワークスペース、ペイン、タブ、またはエージェントサーフェスを有効にします。矢印または `hjkl` と Alt/Option を組み合わせると常にナビゲーションになり、Alt-Left と Alt-Right はツリーの展開を変更せずにビューを移動します。最後のビューからの `Right` または Esc はペインへ戻ります。通常のプレフィックス付きコマンドはサイドバーのフォーカスを離れ、通常のアクション表で実行されます。設定されたサイドバープラグインは、既存の PTY 転送動作を維持します。 オプションのマシンレールが表示されている場合、`Ctrl-b S` はワークスペースを含む最初のビューから入ります。Alt/Option-Left または Alt/Option-`h` をペイン境界の左端で押すと、右端の表示ビューへ入ります。`Left` または `h` と `Right` または `l` は、ネイティブビューの順序を移動します。上下矢印または `k` / `j` は選択したマシンを変更し、`Enter` は接続し、プロバイダーがスコープやアクションを提供する場合は `m` がメニューを開き、Esc はアクティブなペインへ戻ります。ビュー上部の 1 行パッドをクリックすると行を有効にせずフォーカスします。マシン、ワークスペース、ペイン、タブ、エージェントをクリックするとそれを有効にし、キーボード入力を最後に使ったターミナルへ戻します。サイドバービューは、その他のプレフィックスなしキーをリモートターミナルへ転送せずに処理します。 diff --git a/cmux-tui/docs/keyboard.md b/cmux-tui/docs/keyboard.md index d1d5551c25a5..be75d21c8c3a 100644 --- a/cmux-tui/docs/keyboard.md +++ b/cmux-tui/docs/keyboard.md @@ -92,7 +92,7 @@ Workspace navigation follows tmux's outer session lane: `(` and `)` move backwar When the built-in sidebar is focused, its divider becomes a bold accent rail. `Tab` toggles files/workspaces without leaving sidebar focus. In the files view, Up/Down and Ctrl-J/Ctrl-K move the selection, Right descends into a directory, Enter descends or opens a file in a new `$EDITOR` tab, and Left or `h` goes to the parent when the machine rail is absent. `c` sends a safely quoted `cd` to the focused pane, `o` opens `.html` and `.md` files in a browser tab, `.` toggles dotfiles, `/` enters filter mode, and `~` follows the focused pane cwd again. Esc clears a nonempty filter before leaving filter mode. -In the workspaces view, Up/Down or `j`/`k` move the selection and preview that workspace in the pane area without changing the client focus. Enter commits the preview and focuses the workspace. Esc cancels the preview and restores both the workspace and pane focus that were active before navigation. A one-level `tabs` view follows the highlighted workspace. Multi-level views such as `workspaces → agents` are collapsible trees: Left collapses, Right expands, Space toggles, and Enter activates the exact workspace, pane, tab, or agent surface. Alt/Option with arrows or `hjkl` always navigates, so Alt-Left and Alt-Right traverse views instead of changing tree expansion. Right from the final view or Esc returns to the pane. Any normal prefixed command leaves sidebar focus and runs through the usual action table. A configured sidebar plugin keeps its existing PTY forwarding behavior. +In the workspaces view, Up/Down or `j`/`k` move the selection and preview that workspace in the pane area without changing the client focus. Enter commits the preview and focuses the pane. Esc cancels the preview and restores both the workspace and pane focus that were active before navigation. A one-level `tabs` view follows the highlighted workspace. Multi-level views such as `workspaces → agents` are collapsible trees: Left collapses, Right expands, Space toggles, and Enter activates the exact workspace, pane, tab, or agent surface. Alt/Option with arrows or `hjkl` always navigates, so Alt-Left and Alt-Right traverse views instead of changing tree expansion. Right from the final view or Esc returns to the pane. Any normal prefixed command leaves sidebar focus and runs through the usual action table. A configured sidebar plugin keeps its existing PTY forwarding behavior. When the optional machine rail is visible, `Ctrl-b S` enters through the first view containing workspaces. Alt/Option-Left or Alt/Option-`h` at the left pane boundary enters the rightmost visible view. Left or `h` and Right or `l` traverse the ordered native views. Up/Down or `k`/`j` changes the selected machine, Enter connects to it, `m` opens the provider scope/actions menu when the provider offers one, and Esc returns to the active pane. Clicking a view's one-row top pad focuses it without activating a row. Clicking a machine, workspace, pane, tab, or agent activates it and returns keyboard input to the latest terminal. Sidebar views swallow other unprefixed keys instead of forwarding them to a remote terminal. From 334806c4493dbb2d6e5dfff6a8736b4f672d0f92 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:22:19 -0700 Subject: [PATCH 11/36] test(tui): preserve workspace preview across child rails --- cmux-tui/crates/cmux-tui/src/app.rs | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index bc7a7e9955a4..ed61baf023d5 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -41984,6 +41984,64 @@ mod tests { } } + #[test] + fn workspace_preview_survives_workspace_dependent_rail_navigation() { + let mux = Mux::new("workspace-sidebar-preview-child-rail-test", SurfaceOptions::default()); + let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let tree = Session::Local(mux.clone()).tree(); + let first_workspace = tree.workspaces[0].id; + let second_workspace = tree.workspaces[1].id; + let mut app = test_app(Session::Local(mux.clone())); + app.config.sidebar.columns_explicit = true; + app.config.sidebar.columns = vec![ + crate::config::SidebarColumn { + kind: SidebarColumnKind::Workspaces, + width: 24, + max_width: 0, + }, + crate::config::SidebarColumn { kind: SidebarColumnKind::Tabs, width: 24, max_width: 0 }, + ]; + app.config.sidebar.views = app + .config + .sidebar + .columns + .iter() + .map(|column| SidebarViewSpec::legacy(column.kind, column.width, column.max_width)) + .collect(); + app.config.sidebar.views_explicit = true; + app.replace_tree(app.session.tree()); + app.tree.active_workspace = 0; + app.sidebar_workspace_selection = 1; + app.workspace_rail_selection = WorkspaceRailSelection::Workspace; + app.workspace_preview = + Some(WorkspacePreview { origin: first_workspace, target: second_workspace }); + app.tree.active_workspace = 1; + app.focus = FocusTarget::WorkspaceRail; + app.sync_layout((100, 20)); + + app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)).unwrap(); + + assert_eq!(app.focus, FocusTarget::TabsRail); + assert_eq!( + app.workspace_preview, + Some(WorkspacePreview { origin: first_workspace, target: second_workspace }) + ); + assert_eq!(app.sidebar_workspace_selection, 1); + assert!(app.sidebar_tab_targets().iter().all(|target| target.workspace == 1)); + assert!(app.sidebar_tab_targets().iter().any(|target| target.surface == second.id)); + + app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)).unwrap(); + + assert_eq!(app.focus, FocusTarget::Pane); + assert_eq!(app.workspace_preview, None); + assert_eq!(app.tree.active_workspace, 0); + + for surface in [first.id, second.id] { + mux.close_surface(surface).unwrap(); + } + } + #[test] fn workspace_preview_reconciliation_aligns_selection_when_a_workspace_disappears() { let mux = Mux::new("workspace-sidebar-preview-reconcile-test", SurfaceOptions::default()); From 4aba89d22002bae75441a2e502d12e0f794e4e92 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:22:31 -0700 Subject: [PATCH 12/36] fix(tui): keep workspace preview across child rails --- cmux-tui/crates/cmux-tui/src/app.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index ed61baf023d5..f3496879e5dd 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -10177,7 +10177,18 @@ impl App { } fn focus_rail(&mut self, kind: RailKind) { - if kind != RailKind::Workspace { + // A workspace preview is a navigation transaction across the + // workspace-dependent rails. Keep it while moving between the + // workspace, tabs, and projection rails so those child views follow + // the highlighted workspace. A machine rail or pane transition ends + // the transaction. + let keep_workspace_preview = self.workspace_preview.is_some() + && matches!( + self.focus, + FocusTarget::WorkspaceRail | FocusTarget::TabsRail | FocusTarget::ProjectionRail(_) + ) + && matches!(kind, RailKind::Workspace | RailKind::Tabs | RailKind::Projection(_)); + if !keep_workspace_preview { self.cancel_workspace_preview(); } self.focus = match kind { From 89e5e6ed3ea5e3c2cafea2efec213bbb61eaa4d6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:44:03 -0700 Subject: [PATCH 13/36] test(tui): configure workspace sidebar regression --- cmux-tui/crates/cmux-tui/src/app.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index f3496879e5dd..8ccba4bede23 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -42004,6 +42004,7 @@ mod tests { let first_workspace = tree.workspaces[0].id; let second_workspace = tree.workspaces[1].id; let mut app = test_app(Session::Local(mux.clone())); + app.sidebar_view = SidebarView::Workspaces; app.config.sidebar.columns_explicit = true; app.config.sidebar.columns = vec![ crate::config::SidebarColumn { From 1a51f873e879b25aeb2ebb89846e97cfec9ec3c2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:52:13 -0700 Subject: [PATCH 14/36] test(tui): cover workspace row preview commit --- cmux-tui/crates/cmux-tui/src/app.rs | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 8ccba4bede23..b856a970aab6 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -41879,6 +41879,44 @@ mod tests { } } + #[test] + fn workspace_sidebar_row_click_commits_hover_preview() { + let mux = Mux::new("workspace-sidebar-preview-row-click-test", SurfaceOptions::default()); + let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + mux.select_workspace(Some(0), None); + let mut app = test_app(Session::Local(mux.clone())); + app.sidebar_view = SidebarView::Workspaces; + app.replace_tree(app.session.tree()); + app.tree.active_workspace = 0; + app.sidebar_workspace_selection = 0; + app.workspace_rail_selection = WorkspaceRailSelection::Workspace; + app.focus = FocusTarget::WorkspaceRail; + app.sync_layout((100, 20)); + + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let row = app + .hits + .iter() + .find_map(|(rect, hit)| { + matches!(hit, super::Hit::Workspace { index: 1, .. }).then_some(*rect) + }) + .expect("second workspace hit"); + + app.handle_hover_with_admission(row.x, row.y, KeyModifiers::NONE, None).unwrap(); + assert!(app.workspace_preview.is_some()); + app.handle_left_down(row.x, row.y, KeyModifiers::NONE).unwrap(); + + assert_eq!(app.tree.active_workspace, 1); + assert_eq!(app.workspace_preview, None); + assert_eq!(app.focus, FocusTarget::Pane); + + for surface in [first.id, second.id] { + mux.close_surface(surface).unwrap(); + } + } + #[test] fn workspace_sidebar_preview_commits_when_pane_is_clicked() { let mux = Mux::new("workspace-sidebar-preview-pane-click-test", SurfaceOptions::default()); From 453d91fb0d5156ada412a9e3969afdea4b0420a5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:57:05 -0700 Subject: [PATCH 15/36] test(tui): cover tabs preview activation --- cmux-tui/crates/cmux-tui/src/app.rs | 53 +++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index b856a970aab6..74cccbbbc531 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -42092,6 +42092,59 @@ mod tests { } } + #[test] + fn workspace_preview_commits_when_tabs_rail_target_is_activated() { + let mux = + Mux::new("workspace-sidebar-preview-tabs-activate-test", SurfaceOptions::default()); + let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let tree = Session::Local(mux.clone()).tree(); + let first_workspace = tree.workspaces[0].id; + let second_workspace = tree.workspaces[1].id; + let mut app = test_app(Session::Local(mux.clone())); + app.sidebar_view = SidebarView::Workspaces; + app.config.sidebar.columns_explicit = true; + app.config.sidebar.columns = vec![ + crate::config::SidebarColumn { + kind: SidebarColumnKind::Workspaces, + width: 24, + max_width: 0, + }, + crate::config::SidebarColumn { kind: SidebarColumnKind::Tabs, width: 24, max_width: 0 }, + ]; + app.config.sidebar.views = app + .config + .sidebar + .columns + .iter() + .map(|column| SidebarViewSpec::legacy(column.kind, column.width, column.max_width)) + .collect(); + app.config.sidebar.views_explicit = true; + app.replace_tree(app.session.tree()); + app.tree.active_workspace = 0; + app.sidebar_workspace_selection = 1; + app.workspace_rail_selection = WorkspaceRailSelection::Workspace; + app.workspace_preview = + Some(WorkspacePreview { origin: first_workspace, target: second_workspace }); + app.tree.active_workspace = 1; + app.focus = FocusTarget::WorkspaceRail; + app.sync_layout((100, 20)); + + app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.focus, FocusTarget::TabsRail); + assert!(app.workspace_preview.is_some()); + + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).unwrap(); + + assert_eq!(app.focus, FocusTarget::Pane); + assert_eq!(app.workspace_preview, None); + assert_eq!(app.tree.active_workspace, 1); + + for surface in [first.id, second.id] { + mux.close_surface(surface).unwrap(); + } + } + #[test] fn workspace_preview_reconciliation_aligns_selection_when_a_workspace_disappears() { let mux = Mux::new("workspace-sidebar-preview-reconcile-test", SurfaceOptions::default()); From edbe140801b29c1f467c0d5f81a3039a08781221 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:57:17 -0700 Subject: [PATCH 16/36] fix(tui): commit child rail workspace previews --- cmux-tui/crates/cmux-tui/src/app.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 74cccbbbc531..fdeb81d8ad05 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -10034,6 +10034,7 @@ impl App { if !self.tree.select_surface(target.surface) { return Ok(()); } + self.commit_workspace_preview(); self.follow_sidebar_workspace(target.workspace); self.pane_focus_history.record(target.pane); self.claim_active_terminal_geometry(true); @@ -10068,6 +10069,7 @@ impl App { if !self.prepare_pty_input_before_mutation() { return Ok(()); } + self.commit_workspace_preview(); self.follow_sidebar_workspace(workspace); self.tree.active_workspace = workspace; if let Some(workspace_view) = self.tree.workspaces.get_mut(workspace) { From 4d67fffe6a96dc5f9db3f60650b8ea139357fb5f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:10:14 -0700 Subject: [PATCH 17/36] refactor(tui): unify workspace preview pane input commit --- cmux-tui/crates/cmux-tui/src/app.rs | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index fdeb81d8ad05..1bf8aba4e6cc 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -17394,7 +17394,10 @@ impl App { self.workspace_preview = None; } - fn commit_workspace_preview_for_pane_click(&mut self) { + fn commit_workspace_preview_for_pane_input(&mut self, x: u16, y: u16) { + if self.pane_area_at(x, y).is_none() { + return; + } if self.workspace_preview.is_none() { return; } @@ -17405,16 +17408,6 @@ impl App { self.select_workspace_for_client(Some(workspace), None); } - /// Commit a workspace preview before any non-motion pointer event reaches - /// a pane. The preview changes the local render tree but must not leave - /// keyboard focus on the rail after a context-menu, middle-button, or - /// wheel interaction selects the previewed pane. - fn commit_workspace_preview_for_pane_pointer(&mut self, x: u16, y: u16) { - if self.pane_area_at(x, y).is_some() { - self.commit_workspace_preview_for_pane_click(); - } - } - fn align_workspace_sidebar_to_active(&mut self) { if self.tree.workspaces.is_empty() { self.tree.active_workspace = 0; @@ -20318,7 +20311,7 @@ impl App { _ => {} } if !matches!(mouse.kind, MouseEventKind::Moved | MouseEventKind::Down(MouseButton::Left)) { - self.commit_workspace_preview_for_pane_pointer(mouse.column, mouse.row); + self.commit_workspace_preview_for_pane_input(mouse.column, mouse.row); } match mouse.kind { MouseEventKind::Down(MouseButton::Left) => self.handle_left_down_with_admission( @@ -21647,9 +21640,7 @@ impl App { // A pane press is an explicit choice after a hover preview. Commit it // before PTY or browser handling can consume the press and clear the // preview through the ordinary sidebar-focus exit path. - if self.pane_area_at(x, y).is_some() { - self.commit_workspace_preview_for_pane_click(); - } + self.commit_workspace_preview_for_pane_input(x, y); if let Some((pane, hit)) = self.omnibar_hit_at(x, y) { self.focus_pane_after_input(pane); From 41b312152534cdafb22b5f5f0291bd67607b0c8e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:25:55 -0700 Subject: [PATCH 18/36] fix(tui): restore workspace preview rail scroll --- cmux-tui/crates/cmux-tui/src/app.rs | 112 +++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 17 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 1bf8aba4e6cc..0f93f8e67e1f 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -3816,6 +3816,7 @@ enum WorkspaceRailTarget { struct WorkspacePreview { origin: WorkspaceId, target: WorkspaceId, + origin_scroll: usize, } fn rail_page_size(area: Option) -> usize { @@ -17382,11 +17383,14 @@ impl App { return; }; let origin = self.workspace_preview.map_or(current, |preview| preview.origin); + let origin_scroll = self + .workspace_preview + .map_or(self.workspace_rail_scroll, |preview| preview.origin_scroll); if target == origin { self.cancel_workspace_preview(); return; } - self.workspace_preview = Some(WorkspacePreview { origin, target }); + self.workspace_preview = Some(WorkspacePreview { origin, target, origin_scroll }); self.tree.active_workspace = index; } @@ -17422,6 +17426,7 @@ impl App { fn cancel_workspace_preview(&mut self) { let Some(preview) = self.workspace_preview.take() else { return }; + self.workspace_rail_scroll = preview.origin_scroll; if let Some(index) = self.tree.workspaces.iter().position(|workspace| workspace.id == preview.origin) { @@ -36295,8 +36300,11 @@ mod tests { let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); let tree = Session::Local(mux.clone()).tree(); let mut app = test_app(Session::Local(mux.clone())); - app.workspace_preview = - Some(WorkspacePreview { origin: tree.workspaces[0].id, target: tree.workspaces[1].id }); + app.workspace_preview = Some(WorkspacePreview { + origin: tree.workspaces[0].id, + target: tree.workspaces[1].id, + origin_scroll: 0, + }); app.reset_session_presentation(tree); @@ -41365,7 +41373,8 @@ mod tests { assert_eq!(app.focus, FocusTarget::WorkspaceRail); let workspace = app.tree.workspaces[0].id; - app.workspace_preview = Some(WorkspacePreview { origin: workspace, target: workspace + 1 }); + app.workspace_preview = + Some(WorkspacePreview { origin: workspace, target: workspace + 1, origin_scroll: 0 }); app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT)).unwrap(); assert_eq!(app.focus, FocusTarget::Pane); assert_eq!(app.workspace_preview, None); @@ -41802,7 +41811,11 @@ mod tests { assert_eq!(app.active_surface(), Some(second.id)); assert_eq!( app.workspace_preview, - Some(WorkspacePreview { origin: first_workspace, target: second_workspace }) + Some(WorkspacePreview { + origin: first_workspace, + target: second_workspace, + origin_scroll: 0, + }) ); assert_eq!(mux.with_state(|state| state.active_workspace), 0); @@ -41826,6 +41839,48 @@ mod tests { } } + #[test] + fn workspace_preview_escape_restores_origin_rail_scroll() { + let mux = Mux::new("workspace-preview-escape-scroll-test", SurfaceOptions::default()); + for index in 0..8 { + mux.new_workspace(Some(format!("workspace-{index}")), None).unwrap(); + } + let mut app = test_app(Session::Local(mux)); + app.sidebar_view = SidebarView::Workspaces; + app.replace_tree(app.session.tree()); + app.tree.active_workspace = 0; + app.sidebar_workspace_selection = 0; + app.workspace_rail_selection = WorkspaceRailSelection::Workspace; + app.workspace_rail_scroll = 0; + app.focus = FocusTarget::WorkspaceRail; + app.sync_layout((80, 10)); + + let mut terminal = Terminal::new(TestBackend::new(80, 10)).unwrap(); + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + for _ in 0..7 { + app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)).unwrap(); + } + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + + assert_eq!(app.tree.active_workspace, 7); + assert!( + app.workspace_rail_scroll > 0, + "preview target should reveal in the overflowing rail" + ); + assert_eq!( + app.workspace_preview.map(|preview| preview.origin_scroll), + Some(0), + "preview must retain the origin viewport" + ); + + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap(); + + assert_eq!(app.tree.active_workspace, 0); + assert_eq!(app.sidebar_workspace_selection, 0); + assert_eq!(app.workspace_rail_scroll, 0); + assert_eq!(app.workspace_preview, None); + } + #[test] fn workspace_sidebar_hover_previews_when_rail_has_focus() { let mux = Mux::new("workspace-sidebar-hover-preview-test", SurfaceOptions::default()); @@ -41860,7 +41915,11 @@ mod tests { assert_eq!(app.active_surface(), Some(second.id)); assert_eq!( app.workspace_preview, - Some(WorkspacePreview { origin: first_workspace, target: second_workspace }) + Some(WorkspacePreview { + origin: first_workspace, + target: second_workspace, + origin_scroll: 0, + }) ); app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap(); @@ -41995,8 +42054,11 @@ mod tests { app.sidebar_workspace_selection = 1; app.workspace_rail_selection = WorkspaceRailSelection::Workspace; app.focus = FocusTarget::WorkspaceRail; - app.workspace_preview = - Some(WorkspacePreview { origin: first_workspace, target: second_workspace }); + app.workspace_preview = Some(WorkspacePreview { + origin: first_workspace, + target: second_workspace, + origin_scroll: 0, + }); app.menu = None; app.drag = None; app.active_pointer_buttons.clear(); @@ -42057,8 +42119,11 @@ mod tests { app.tree.active_workspace = 0; app.sidebar_workspace_selection = 1; app.workspace_rail_selection = WorkspaceRailSelection::Workspace; - app.workspace_preview = - Some(WorkspacePreview { origin: first_workspace, target: second_workspace }); + app.workspace_preview = Some(WorkspacePreview { + origin: first_workspace, + target: second_workspace, + origin_scroll: 0, + }); app.tree.active_workspace = 1; app.focus = FocusTarget::WorkspaceRail; app.sync_layout((100, 20)); @@ -42068,7 +42133,11 @@ mod tests { assert_eq!(app.focus, FocusTarget::TabsRail); assert_eq!( app.workspace_preview, - Some(WorkspacePreview { origin: first_workspace, target: second_workspace }) + Some(WorkspacePreview { + origin: first_workspace, + target: second_workspace, + origin_scroll: 0, + }) ); assert_eq!(app.sidebar_workspace_selection, 1); assert!(app.sidebar_tab_targets().iter().all(|target| target.workspace == 1)); @@ -42117,8 +42186,11 @@ mod tests { app.tree.active_workspace = 0; app.sidebar_workspace_selection = 1; app.workspace_rail_selection = WorkspaceRailSelection::Workspace; - app.workspace_preview = - Some(WorkspacePreview { origin: first_workspace, target: second_workspace }); + app.workspace_preview = Some(WorkspacePreview { + origin: first_workspace, + target: second_workspace, + origin_scroll: 0, + }); app.tree.active_workspace = 1; app.focus = FocusTarget::WorkspaceRail; app.sync_layout((100, 20)); @@ -42149,8 +42221,11 @@ mod tests { let second_workspace = workspace_tree.workspaces[1].id; let mut app = test_app(Session::Local(mux.clone())); app.replace_tree(app.session.tree()); - app.workspace_preview = - Some(WorkspacePreview { origin: first_workspace, target: second_workspace }); + app.workspace_preview = Some(WorkspacePreview { + origin: first_workspace, + target: second_workspace, + origin_scroll: 0, + }); let mut target_removed = app.tree.clone(); target_removed.workspaces.remove(1); @@ -42167,8 +42242,11 @@ mod tests { origin_removed.active_workspace = 0; app.tree = origin_removed; app.sidebar_workspace_selection = 1; - app.workspace_preview = - Some(WorkspacePreview { origin: first_workspace, target: second_workspace }); + app.workspace_preview = Some(WorkspacePreview { + origin: first_workspace, + target: second_workspace, + origin_scroll: 0, + }); app.reconcile_workspace_preview(); assert_eq!(app.workspace_preview, None); assert_eq!(app.tree.active_workspace, 0); From 664291bd8f678d604eaa55fef95c1122ca9cfce2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:36:51 -0700 Subject: [PATCH 19/36] fix(tui): close preview on child rail exits --- cmux-tui/crates/cmux-tui/src/app.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 0f93f8e67e1f..4d98889bdc2c 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -10251,6 +10251,11 @@ impl App { } } + fn leave_sidebar_to_pane(&mut self) { + self.cancel_workspace_preview(); + self.focus = FocusTarget::Pane; + } + fn host_input_is_ready( deferred_len: usize, deferred_bytes: usize, @@ -17856,7 +17861,7 @@ impl App { key: &KeyEvent, ) -> anyhow::Result { if key.code == KeyCode::Esc { - self.focus = FocusTarget::Pane; + self.leave_sidebar_to_pane(); return Ok(RenderAction::Draw); } let rows = self.projection_rows(view_index); @@ -17892,7 +17897,7 @@ impl App { { self.projection_rail_state_mut(view_index).collapsed.remove(&branch); } else if !self.focus_adjacent_rail(RailKind::Projection(view_index), 1) { - self.focus = FocusTarget::Pane; + self.leave_sidebar_to_pane(); } return Ok(RenderAction::Draw); } @@ -18353,12 +18358,12 @@ impl App { } if matches!(key.code, KeyCode::Right | KeyCode::Char('l')) { if !self.focus_adjacent_rail(RailKind::Tabs, 1) { - self.focus = FocusTarget::Pane; + self.leave_sidebar_to_pane(); } return Ok(RenderAction::Draw); } if key.code == KeyCode::Esc { - self.focus = FocusTarget::Pane; + self.leave_sidebar_to_pane(); return Ok(RenderAction::Draw); } if matches!( @@ -21710,10 +21715,11 @@ impl App { // Any click outside the plugin rect returns keyboard focus to the // panes; otherwise typing would keep going to the plugin PTY after // the user clicked into a pane. + let pointer_hit = self.hit_at(x, y); self.leave_workspace_sidebar(); self.sidebar_focus_pending = false; - if let Some(hit) = self.hit_at(x, y) { + if let Some(hit) = pointer_hit { match hit { Hit::Machine { index, key } => { self.machine_rail_follow_selection = true; From 6ad4a0f61d19a67abc854ffa7666f9213ae29820 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:38:37 -0700 Subject: [PATCH 20/36] test(tui): cover child rail preview cancellation --- cmux-tui/crates/cmux-tui/src/app.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 4d98889bdc2c..8c26c22054c0 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -41793,6 +41793,33 @@ mod tests { } } + #[test] + fn child_sidebar_exits_cancel_workspace_preview() { + let mux = Mux::new("workspace-preview-child-exit-test", SurfaceOptions::default()); + mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.workspace_preview = Some(WorkspacePreview { + origin: app.tree.workspaces[0].id, + target: app.tree.workspaces[1].id, + origin_scroll: 0, + }); + app.focus = FocusTarget::TabsRail; + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.focus, FocusTarget::Pane); + assert!(app.workspace_preview.is_none()); + + app.workspace_preview = Some(WorkspacePreview { + origin: app.tree.workspaces[0].id, + target: app.tree.workspaces[1].id, + origin_scroll: 0, + }); + app.focus = FocusTarget::ProjectionRail(0); + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).unwrap(); + assert_eq!(app.focus, FocusTarget::Pane); + assert!(app.workspace_preview.is_none()); + } + #[test] fn workspace_sidebar_previews_selection_until_enter_or_escape() { let mux = Mux::new("workspace-sidebar-preview-test", SurfaceOptions::default()); From 0f8a4d8c50b6b4b9b32e8b1cb6ef31d67ec24ab8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:15:51 -0700 Subject: [PATCH 21/36] test(tui): refresh preview fixture tree --- cmux-tui/crates/cmux-tui/src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 8c26c22054c0..e48b9f33d8c1 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -41798,7 +41798,8 @@ mod tests { let mux = Mux::new("workspace-preview-child-exit-test", SurfaceOptions::default()); mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); - let mut app = test_app(Session::Local(mux)); + let mut app = test_app(Session::Local(mux.clone())); + app.replace_tree(app.session.tree()); app.workspace_preview = Some(WorkspacePreview { origin: app.tree.workspaces[0].id, target: app.tree.workspaces[1].id, From 6b17b7a5834982a00a56df46f7ca0f1edf80d7c7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:23:29 -0700 Subject: [PATCH 22/36] test(tui): harden sidebar preview lifecycle paths --- cmux-tui/crates/cmux-tui/src/app.rs | 303 ++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index e48b9f33d8c1..22e8048d2dd4 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -41821,6 +41821,309 @@ mod tests { assert!(app.workspace_preview.is_none()); } + #[test] + fn child_sidebar_directional_and_escape_exits_restore_workspace_preview_origin() { + let mux = Mux::new("workspace-preview-child-exit-keys-test", SurfaceOptions::default()); + mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.replace_tree(app.session.tree()); + let origin = app.tree.workspaces[0].id; + let target = app.tree.workspaces[1].id; + + for (focus, key) in [ + (FocusTarget::TabsRail, KeyCode::Right), + (FocusTarget::TabsRail, KeyCode::Char('l')), + (FocusTarget::TabsRail, KeyCode::Esc), + (FocusTarget::ProjectionRail(0), KeyCode::Right), + (FocusTarget::ProjectionRail(0), KeyCode::Char('l')), + (FocusTarget::ProjectionRail(0), KeyCode::Esc), + ] { + app.tree.active_workspace = 1; + app.sidebar_workspace_selection = 1; + app.workspace_rail_scroll = 9; + app.workspace_preview = Some(WorkspacePreview { + origin, + target, + origin_scroll: 3, + }); + app.focus = focus; + + app.handle_key(KeyEvent::new(key, KeyModifiers::NONE)).unwrap(); + + assert_eq!(app.focus, FocusTarget::Pane, "child exit key {key:?}"); + assert_eq!(app.workspace_preview, None, "child exit key {key:?}"); + assert_eq!(app.tree.active_workspace, 0, "child exit key {key:?}"); + assert_eq!(app.sidebar_workspace_selection, 0, "child exit key {key:?}"); + assert_eq!(app.workspace_rail_scroll, 3, "child exit key {key:?}"); + } + } + + #[test] + fn disappearing_tabs_rail_during_sync_layout_cancels_workspace_preview() { + let mux = Mux::new("workspace-preview-tabs-layout-cancel-test", SurfaceOptions::default()); + mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.config.sidebar.columns_explicit = true; + app.config.sidebar.columns = vec![ + crate::config::SidebarColumn { + kind: SidebarColumnKind::Workspaces, + width: 24, + max_width: 0, + }, + crate::config::SidebarColumn { kind: SidebarColumnKind::Tabs, width: 24, max_width: 0 }, + ]; + app.config.sidebar.views = app + .config + .sidebar + .columns + .iter() + .map(|column| SidebarViewSpec::legacy(column.kind, column.width, column.max_width)) + .collect(); + app.config.sidebar.views_explicit = true; + app.replace_tree(app.session.tree()); + app.sync_layout((100, 20)); + let origin = app.tree.workspaces[0].id; + let target = app.tree.workspaces[1].id; + let tabs_id = app.config.sidebar.views[1].id.clone(); + app.tree.active_workspace = 1; + app.sidebar_workspace_selection = 1; + app.workspace_rail_scroll = 8; + app.workspace_preview = Some(WorkspacePreview { + origin, + target, + origin_scroll: 2, + }); + app.focus = FocusTarget::TabsRail; + app.hidden_sidebar_views + .entry(app.config.sidebar.active_profile.clone()) + .or_default() + .insert(tabs_id); + + app.sync_layout((100, 20)); + + assert_eq!(app.focus, FocusTarget::Pane); + assert_eq!(app.workspace_preview, None); + assert_eq!(app.tree.active_workspace, 0); + assert_eq!(app.sidebar_workspace_selection, 0); + assert_eq!(app.workspace_rail_scroll, 2); + } + + #[test] + fn hiding_focused_tabs_rail_cancels_workspace_preview() { + let mux = Mux::new("workspace-preview-tabs-visible-cancel-test", SurfaceOptions::default()); + mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.config.sidebar.columns_explicit = true; + app.config.sidebar.columns = vec![ + crate::config::SidebarColumn { + kind: SidebarColumnKind::Workspaces, + width: 24, + max_width: 0, + }, + crate::config::SidebarColumn { kind: SidebarColumnKind::Tabs, width: 24, max_width: 0 }, + ]; + app.config.sidebar.views = app + .config + .sidebar + .columns + .iter() + .map(|column| SidebarViewSpec::legacy(column.kind, column.width, column.max_width)) + .collect(); + app.config.sidebar.views_explicit = true; + app.replace_tree(app.session.tree()); + app.sync_layout((100, 20)); + let origin = app.tree.workspaces[0].id; + let target = app.tree.workspaces[1].id; + app.tree.active_workspace = 1; + app.sidebar_workspace_selection = 1; + app.workspace_rail_scroll = 7; + app.workspace_preview = Some(WorkspacePreview { + origin, + target, + origin_scroll: 1, + }); + app.focus = FocusTarget::TabsRail; + + app.set_sidebar_view_visible(1, false); + + assert_eq!(app.focus, FocusTarget::Pane); + assert_eq!(app.workspace_preview, None); + assert_eq!(app.tree.active_workspace, 0); + assert_eq!(app.sidebar_workspace_selection, 0); + assert_eq!(app.workspace_rail_scroll, 1); + } + + #[test] + fn rendered_tab_click_commits_preview_target_after_rail_focus_cancellation() { + let mux = Mux::new("workspace-preview-rendered-tab-click-test", SurfaceOptions::default()); + let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.config.sidebar.columns_explicit = true; + app.config.sidebar.columns = vec![ + crate::config::SidebarColumn { + kind: SidebarColumnKind::Workspaces, + width: 24, + max_width: 0, + }, + crate::config::SidebarColumn { kind: SidebarColumnKind::Tabs, width: 24, max_width: 0 }, + ]; + app.config.sidebar.views = app + .config + .sidebar + .columns + .iter() + .map(|column| SidebarViewSpec::legacy(column.kind, column.width, column.max_width)) + .collect(); + app.config.sidebar.views_explicit = true; + app.replace_tree(app.session.tree()); + app.sync_layout((100, 20)); + let origin = app.tree.workspaces[0].id; + let target = app.tree.workspaces[1].id; + app.tree.active_workspace = 1; + app.sidebar_workspace_selection = 1; + app.workspace_rail_selection = WorkspaceRailSelection::Workspace; + app.workspace_preview = Some(WorkspacePreview { + origin, + target, + origin_scroll: 0, + }); + app.focus = FocusTarget::TabsRail; + + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let tab = app + .hits + .iter() + .find_map(|(rect, hit)| { + matches!(hit, super::Hit::SidebarTab { surface, .. } if *surface == second.id) + .then_some(*rect) + }) + .expect("previewed workspace tab hit"); + + app.handle_left_down(tab.x, tab.y, KeyModifiers::NONE).unwrap(); + + assert_eq!(app.workspace_preview, None); + assert_eq!(app.tree.active_workspace, 1); + assert_eq!(app.active_surface(), Some(second.id)); + assert_eq!(app.focus, FocusTarget::Pane); + assert_ne!(first.id, second.id); + } + + #[test] + fn switching_to_profile_without_focused_child_rail_cancels_workspace_preview() { + let mux = Mux::new("workspace-preview-profile-cancel-test", SurfaceOptions::default()); + mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + let full = vec![ + SidebarViewSpec::legacy(SidebarColumnKind::Workspaces, 24, 0), + SidebarViewSpec::legacy(SidebarColumnKind::Tabs, 24, 0), + ]; + let workspace_only = vec![SidebarViewSpec::legacy(SidebarColumnKind::Workspaces, 24, 0)]; + app.config.sidebar.profiles = vec![ + SidebarProfileSpec { id: "full".into(), name: "Full".into(), views: full.clone() }, + SidebarProfileSpec { + id: "workspace-only".into(), + name: "Workspace only".into(), + views: workspace_only, + }, + ]; + app.config.sidebar.active_profile = "full".into(); + app.config.sidebar.views = full; + app.config.sidebar.views_explicit = true; + app.replace_tree(app.session.tree()); + app.sync_layout((100, 20)); + let origin = app.tree.workspaces[0].id; + let target = app.tree.workspaces[1].id; + app.tree.active_workspace = 1; + app.sidebar_workspace_selection = 1; + app.workspace_rail_scroll = 6; + app.workspace_preview = Some(WorkspacePreview { + origin, + target, + origin_scroll: 0, + }); + app.focus = FocusTarget::TabsRail; + + app.activate_sidebar_profile(1); + + assert_eq!(app.focus, FocusTarget::Pane); + assert_eq!(app.workspace_preview, None); + assert_eq!(app.tree.active_workspace, 0); + assert_eq!(app.sidebar_workspace_selection, 0); + assert_eq!(app.workspace_rail_scroll, 0); + } + + #[test] + fn reloading_config_without_focused_projection_cancels_workspace_preview() { + let mux = Mux::new("workspace-preview-config-reload-cancel-test", SurfaceOptions::default()); + mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.config.sidebar.views = vec![SidebarViewSpec { + id: "workspace-agents".into(), + levels: vec![SidebarResourceKind::Workspaces, SidebarResourceKind::Agents], + actions: Vec::new(), + actions_position: crate::config::ActionsPosition::Bottom, + width: 30, + max_width: 0, + collapse_priority: 30, + }]; + app.config.sidebar.views_explicit = true; + app.replace_tree(app.session.tree()); + let origin = app.tree.workspaces[0].id; + let target = app.tree.workspaces[1].id; + app.tree.active_workspace = 1; + app.sidebar_workspace_selection = 1; + app.workspace_rail_scroll = 5; + app.workspace_preview = Some(WorkspacePreview { + origin, + target, + origin_scroll: 0, + }); + app.focus = FocusTarget::ProjectionRail(0); + + let path = std::env::temp_dir().join(format!( + "cmux-tui-preview-reload-{}-{}.json", + std::process::id(), + std::thread::current().name().unwrap_or("test") + )); + std::fs::write(&path, r#"{"sidebar":{"views":[{"id":"workspaces","levels":["workspaces"]}]}}"#) + .unwrap(); + let old_config = std::env::var_os("CMUX_TUI_CONFIG"); + let old_mux_config = std::env::var_os("CMUX_MUX_CONFIG"); + // SAFETY: this test restores both config environment variables before returning. + unsafe { + std::env::set_var("CMUX_TUI_CONFIG", &path); + std::env::remove_var("CMUX_MUX_CONFIG"); + } + + app.reload_config(); + + // SAFETY: restore the process environment captured above. + unsafe { + match old_config { + Some(value) => std::env::set_var("CMUX_TUI_CONFIG", value), + None => std::env::remove_var("CMUX_TUI_CONFIG"), + } + match old_mux_config { + Some(value) => std::env::set_var("CMUX_MUX_CONFIG", value), + None => std::env::remove_var("CMUX_MUX_CONFIG"), + } + } + let _ = std::fs::remove_file(path); + + assert_eq!(app.focus, FocusTarget::Pane); + assert_eq!(app.workspace_preview, None); + assert_eq!(app.tree.active_workspace, 0); + assert_eq!(app.sidebar_workspace_selection, 0); + assert_eq!(app.workspace_rail_scroll, 0); + } + #[test] fn workspace_sidebar_previews_selection_until_enter_or_escape() { let mux = Mux::new("workspace-sidebar-preview-test", SurfaceOptions::default()); From 227529a4ac12e2f111a27f14bfa9292e23fea548 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:24:00 -0700 Subject: [PATCH 23/36] style(tui): format preview lifecycle tests --- cmux-tui/crates/cmux-tui/src/app.rs | 46 ++++++++--------------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 22e8048d2dd4..8197d9df4009 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -41842,11 +41842,7 @@ mod tests { app.tree.active_workspace = 1; app.sidebar_workspace_selection = 1; app.workspace_rail_scroll = 9; - app.workspace_preview = Some(WorkspacePreview { - origin, - target, - origin_scroll: 3, - }); + app.workspace_preview = Some(WorkspacePreview { origin, target, origin_scroll: 3 }); app.focus = focus; app.handle_key(KeyEvent::new(key, KeyModifiers::NONE)).unwrap(); @@ -41890,11 +41886,7 @@ mod tests { app.tree.active_workspace = 1; app.sidebar_workspace_selection = 1; app.workspace_rail_scroll = 8; - app.workspace_preview = Some(WorkspacePreview { - origin, - target, - origin_scroll: 2, - }); + app.workspace_preview = Some(WorkspacePreview { origin, target, origin_scroll: 2 }); app.focus = FocusTarget::TabsRail; app.hidden_sidebar_views .entry(app.config.sidebar.active_profile.clone()) @@ -41940,11 +41932,7 @@ mod tests { app.tree.active_workspace = 1; app.sidebar_workspace_selection = 1; app.workspace_rail_scroll = 7; - app.workspace_preview = Some(WorkspacePreview { - origin, - target, - origin_scroll: 1, - }); + app.workspace_preview = Some(WorkspacePreview { origin, target, origin_scroll: 1 }); app.focus = FocusTarget::TabsRail; app.set_sidebar_view_visible(1, false); @@ -41986,11 +41974,7 @@ mod tests { app.tree.active_workspace = 1; app.sidebar_workspace_selection = 1; app.workspace_rail_selection = WorkspaceRailSelection::Workspace; - app.workspace_preview = Some(WorkspacePreview { - origin, - target, - origin_scroll: 0, - }); + app.workspace_preview = Some(WorkspacePreview { origin, target, origin_scroll: 0 }); app.focus = FocusTarget::TabsRail; let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); @@ -42042,11 +42026,7 @@ mod tests { app.tree.active_workspace = 1; app.sidebar_workspace_selection = 1; app.workspace_rail_scroll = 6; - app.workspace_preview = Some(WorkspacePreview { - origin, - target, - origin_scroll: 0, - }); + app.workspace_preview = Some(WorkspacePreview { origin, target, origin_scroll: 0 }); app.focus = FocusTarget::TabsRail; app.activate_sidebar_profile(1); @@ -42060,7 +42040,8 @@ mod tests { #[test] fn reloading_config_without_focused_projection_cancels_workspace_preview() { - let mux = Mux::new("workspace-preview-config-reload-cancel-test", SurfaceOptions::default()); + let mux = + Mux::new("workspace-preview-config-reload-cancel-test", SurfaceOptions::default()); mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); let mut app = test_app(Session::Local(mux)); @@ -42080,11 +42061,7 @@ mod tests { app.tree.active_workspace = 1; app.sidebar_workspace_selection = 1; app.workspace_rail_scroll = 5; - app.workspace_preview = Some(WorkspacePreview { - origin, - target, - origin_scroll: 0, - }); + app.workspace_preview = Some(WorkspacePreview { origin, target, origin_scroll: 0 }); app.focus = FocusTarget::ProjectionRail(0); let path = std::env::temp_dir().join(format!( @@ -42092,8 +42069,11 @@ mod tests { std::process::id(), std::thread::current().name().unwrap_or("test") )); - std::fs::write(&path, r#"{"sidebar":{"views":[{"id":"workspaces","levels":["workspaces"]}]}}"#) - .unwrap(); + std::fs::write( + &path, + r#"{"sidebar":{"views":[{"id":"workspaces","levels":["workspaces"]}]}}"#, + ) + .unwrap(); let old_config = std::env::var_os("CMUX_TUI_CONFIG"); let old_mux_config = std::env::var_os("CMUX_MUX_CONFIG"); // SAFETY: this test restores both config environment variables before returning. From 2c1b77bf694ba730c47e44e8fc45aea41a8fdaca Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:41:18 -0700 Subject: [PATCH 24/36] fix(tui): preserve rendered tab target on preview click --- cmux-tui/crates/cmux-tui/src/app.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 8197d9df4009..33271f4ed763 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -21716,6 +21716,7 @@ impl App { // panes; otherwise typing would keep going to the plugin PTY after // the user clicked into a pane. let pointer_hit = self.hit_at(x, y); + let captured_tab_targets = self.sidebar_tab_targets(); self.leave_workspace_sidebar(); self.sidebar_focus_pending = false; @@ -21751,9 +21752,10 @@ impl App { self.drag = Some(Drag::WorkspaceArm { workspace: id, at: (x, y) }); } Hit::SidebarTab { surface, .. } => { - let targets = self.sidebar_tab_targets(); - if let Some((index, target)) = - targets.iter().enumerate().find(|(_, target)| target.surface == surface) + if let Some((index, target)) = captured_tab_targets + .iter() + .enumerate() + .find(|(_, target)| target.surface == surface) { self.tabs_rail_follow_selection = true; self.tabs_rail_selection = index; From 314ec664f6e37c08003ddc3fd963a56a16b09b9b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:01:24 -0700 Subject: [PATCH 25/36] Fix workspace preview row click commit --- cmux-tui/crates/cmux-tui/src/app.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 33271f4ed763..bebf04fdbdfb 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -21717,6 +21717,18 @@ impl App { // the user clicked into a pane. let pointer_hit = self.hit_at(x, y); let captured_tab_targets = self.sidebar_tab_targets(); + + // A workspace row is an activation gesture. Keep the rendered target + // active until `activate_workspace` commits it, rather than cancelling + // the preview first and re-previewing the target. + if let Some(Hit::Workspace { index, id }) = pointer_hit { + self.workspace_rail_follow_selection = true; + self.focus = FocusTarget::Pane; + self.activate_workspace(index); + self.drag = Some(Drag::WorkspaceArm { workspace: id, at: (x, y) }); + self.sidebar_focus_pending = false; + return Ok(RenderAction::Draw); + } self.leave_workspace_sidebar(); self.sidebar_focus_pending = false; From 5239f5a1b010f6bd87c7d87063b9a2831db52148 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:32:50 -0700 Subject: [PATCH 26/36] Cancel previews when sidebar focus is lost --- cmux-tui/crates/cmux-tui/src/app.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index bebf04fdbdfb..5f65b468b98b 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -9774,6 +9774,12 @@ impl App { ) } + fn cancel_preview_on_sidebar_focus_loss(&mut self) { + if self.workspace_preview.is_some() && !self.sidebar_rail_focused() { + self.cancel_workspace_preview(); + } + } + fn focused_rail_kind(&self) -> Option { match self.focus { FocusTarget::MachineRail => Some(RailKind::Machine), @@ -14101,7 +14107,6 @@ impl App { self.machine_sidebar_width = self.sidebar_layout.machine.map_or(0, |rect| rect.width); self.tabs_sidebar_width = self.sidebar_layout.tabs.map_or(0, |rect| rect.width); if self.sidebar_width == 0 && self.focus == FocusTarget::WorkspaceRail { - self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; } if self.machine_sidebar_width == 0 && self.focus == FocusTarget::MachineRail { @@ -14115,6 +14120,7 @@ impl App { { self.focus = FocusTarget::Pane; } + self.cancel_preview_on_sidebar_focus_loss(); let area = self.sidebar_layout.content; self.content_area = area; if self.surface_only.is_none() { @@ -22867,6 +22873,7 @@ impl App { hidden.insert(view_id); if hides_focused { self.focus = FocusTarget::Pane; + self.cancel_preview_on_sidebar_focus_loss(); } } } From ad0008ca7229ccaf4acb620d7c6333c8903eedb0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:04:04 -0700 Subject: [PATCH 27/36] Tighten workspace preview input paths --- cmux-tui/crates/cmux-tui/src/app.rs | 28 +++++++++++++++++++------- cmux-tui/crates/cmux-tui/src/config.rs | 8 +++----- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 5f65b468b98b..557e4304f38e 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -21534,14 +21534,23 @@ impl App { && self.drag.is_none() { match self.hit_at(x, y) { - Some(Hit::Workspace { id, .. }) => { + Some(Hit::Workspace { index, id }) => { let before = ( self.sidebar_workspace_selection, self.workspace_preview, self.tree.active_workspace, ); self.workspace_rail_follow_selection = true; - self.select_workspace_rail_target(WorkspaceRailTarget::Workspace(id)); + let already_targets_row = self.sidebar_workspace_selection == index + && self.workspace_rail_selection == WorkspaceRailSelection::Workspace + && (self.workspace_preview.is_some_and(|preview| preview.target == id) + || self + .tree + .active_workspace() + .is_some_and(|workspace| workspace.id == id)); + if !already_targets_row { + self.select_workspace_rail_target(WorkspaceRailTarget::Workspace(id)); + } before != ( self.sidebar_workspace_selection, @@ -21722,7 +21731,8 @@ impl App { // panes; otherwise typing would keep going to the plugin PTY after // the user clicked into a pane. let pointer_hit = self.hit_at(x, y); - let captured_tab_targets = self.sidebar_tab_targets(); + let captured_tab_targets = + matches!(pointer_hit, Some(Hit::SidebarTab { .. })).then(|| self.sidebar_tab_targets()); // A workspace row is an activation gesture. Keep the rendered target // active until `activate_workspace` commits it, rather than cancelling @@ -21770,10 +21780,10 @@ impl App { self.drag = Some(Drag::WorkspaceArm { workspace: id, at: (x, y) }); } Hit::SidebarTab { surface, .. } => { - if let Some((index, target)) = captured_tab_targets - .iter() - .enumerate() - .find(|(_, target)| target.surface == surface) + if let Some((index, target)) = + captured_tab_targets.as_ref().and_then(|targets| { + targets.iter().enumerate().find(|(_, target)| target.surface == surface) + }) { self.tabs_rail_follow_selection = true; self.tabs_rail_selection = index; @@ -42095,6 +42105,10 @@ mod tests { r#"{"sidebar":{"views":[{"id":"workspaces","levels":["workspaces"]}]}}"#, ) .unwrap(); + // Config environment variables are process-global. Share the config + // test lock so this test cannot overlap any other environment-based + // config test in the same test process. + let _env_guard = crate::config::CONFIG_ENV_LOCK.lock().unwrap(); let old_config = std::env::var_os("CMUX_TUI_CONFIG"); let old_mux_config = std::env::var_os("CMUX_MUX_CONFIG"); // SAFETY: this test restores both config environment variables before returning. diff --git a/cmux-tui/crates/cmux-tui/src/config.rs b/cmux-tui/crates/cmux-tui/src/config.rs index 10644fa54c57..1c91e04311e4 100644 --- a/cmux-tui/crates/cmux-tui/src/config.rs +++ b/cmux-tui/crates/cmux-tui/src/config.rs @@ -155,6 +155,9 @@ use wait_timeout::ChildExt; use crate::localization::catalog; +#[cfg(test)] +pub(crate) static CONFIG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// For a field typed `Option>`: makes an explicit `null` in the /// input deserialize to `Some(None)` rather than the `None` an absent key /// also produces, so callers can tell "not set" from "set to null". @@ -5586,13 +5589,8 @@ mod tests { assert!(!diagnostic.contains("typo")); } use std::ffi::OsString; - use std::sync::Mutex; use std::sync::atomic::{AtomicU64, Ordering}; - /// Config env vars are process-global state; tests that set them must not - /// run concurrently with each other. - static CONFIG_ENV_LOCK: Mutex<()> = Mutex::new(()); - #[test] fn startup_snapshot_invokes_loader_once() { let loads = Cell::new(0); From eee2a0dde95f9a111c2e1e19132ac7c52d622f6b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:19:13 -0700 Subject: [PATCH 28/36] Cancel previews on rail removal --- cmux-tui/crates/cmux-tui/src/app.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 557e4304f38e..f952302ee02f 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -13709,6 +13709,7 @@ impl App { self.focus_rail(kind); } else { self.focus = FocusTarget::Pane; + self.cancel_preview_on_sidebar_focus_loss(); } } if let (Some(help), Some(rows)) = (self.shortcut_help.as_mut(), shortcut_rows) { @@ -22920,6 +22921,7 @@ impl App { return; } self.focus = FocusTarget::Pane; + self.cancel_preview_on_sidebar_focus_loss(); } } From 0618e6bb6631d98f16d6d88ed28ef50d8fa334f9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:23:56 -0700 Subject: [PATCH 29/36] Preserve preview across rail pad clicks --- cmux-tui/crates/cmux-tui/src/app.rs | 77 ++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index f952302ee02f..8c61b0be63a5 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -10185,19 +10185,22 @@ impl App { .unwrap_or_default() } + fn can_preserve_workspace_preview_for_rail(&self, kind: RailKind) -> bool { + self.workspace_preview.is_some() + && matches!( + self.focus, + FocusTarget::WorkspaceRail | FocusTarget::TabsRail | FocusTarget::ProjectionRail(_) + ) + && matches!(kind, RailKind::Workspace | RailKind::Tabs | RailKind::Projection(_)) + } + fn focus_rail(&mut self, kind: RailKind) { // A workspace preview is a navigation transaction across the // workspace-dependent rails. Keep it while moving between the // workspace, tabs, and projection rails so those child views follow // the highlighted workspace. A machine rail or pane transition ends // the transaction. - let keep_workspace_preview = self.workspace_preview.is_some() - && matches!( - self.focus, - FocusTarget::WorkspaceRail | FocusTarget::TabsRail | FocusTarget::ProjectionRail(_) - ) - && matches!(kind, RailKind::Workspace | RailKind::Tabs | RailKind::Projection(_)); - if !keep_workspace_preview { + if !self.can_preserve_workspace_preview_for_rail(kind) { self.cancel_workspace_preview(); } self.focus = match kind { @@ -21746,7 +21749,13 @@ impl App { self.sidebar_focus_pending = false; return Ok(RenderAction::Draw); } - self.leave_workspace_sidebar(); + let preserve_preview_for_rail_click = matches!( + pointer_hit, + Some(Hit::RailPad(kind)) if self.can_preserve_workspace_preview_for_rail(kind) + ); + if !preserve_preview_for_rail_click { + self.leave_workspace_sidebar(); + } self.sidebar_focus_pending = false; if let Some(hit) = pointer_hit { @@ -42030,6 +42039,58 @@ mod tests { assert_ne!(first.id, second.id); } + #[test] + fn mouse_rail_pad_keeps_workspace_preview_between_dependent_rails() { + let mux = Mux::new("workspace-preview-rail-pad-focus-test", SurfaceOptions::default()); + mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.config.sidebar.columns_explicit = true; + app.config.sidebar.columns = vec![ + crate::config::SidebarColumn { + kind: SidebarColumnKind::Workspaces, + width: 24, + max_width: 0, + }, + crate::config::SidebarColumn { kind: SidebarColumnKind::Tabs, width: 24, max_width: 0 }, + ]; + app.config.sidebar.views = app + .config + .sidebar + .columns + .iter() + .map(|column| SidebarViewSpec::legacy(column.kind, column.width, column.max_width)) + .collect(); + app.config.sidebar.views_explicit = true; + app.replace_tree(app.session.tree()); + app.sync_layout((100, 20)); + let origin = app.tree.workspaces[0].id; + let target = app.tree.workspaces[1].id; + app.tree.active_workspace = 1; + app.sidebar_workspace_selection = 1; + app.workspace_preview = Some(WorkspacePreview { origin, target, origin_scroll: 0 }); + app.focus = FocusTarget::WorkspaceRail; + + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); + terminal.draw(|frame| crate::ui::draw(&mut app, frame)).unwrap(); + let pad = app + .hits + .iter() + .find_map(|(rect, hit)| { + matches!(hit, super::Hit::RailPad(RailKind::Tabs)).then_some(*rect) + }) + .expect("tabs rail pad hit"); + + app.handle_left_down(pad.x, pad.y, KeyModifiers::NONE).unwrap(); + + assert_eq!(app.focus, FocusTarget::TabsRail); + assert_eq!( + app.workspace_preview, + Some(WorkspacePreview { origin, target, origin_scroll: 0 }) + ); + assert_eq!(app.tree.active_workspace, 1); + } + #[test] fn switching_to_profile_without_focused_child_rail_cancels_workspace_preview() { let mux = Mux::new("workspace-preview-profile-cancel-test", SurfaceOptions::default()); From d0069ef95105247ac1d5a71b098f5a4e15ec33db Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:28:41 -0700 Subject: [PATCH 30/36] Guard workspace preview routing changes --- cmux-tui/crates/cmux-tui/src/app.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 8c61b0be63a5..d342e162b368 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -17397,14 +17397,25 @@ impl App { let Some(current) = self.tree.active_workspace().map(|workspace| workspace.id) else { return; }; + if self.tree.active_workspace == index + && self.workspace_preview.is_some_and(|preview| preview.target == target) + { + return; + } let origin = self.workspace_preview.map_or(current, |preview| preview.origin); let origin_scroll = self .workspace_preview .map_or(self.workspace_rail_scroll, |preview| preview.origin_scroll); if target == origin { + if self.workspace_preview.is_some() && !self.prepare_pty_input_before_mutation() { + return; + } self.cancel_workspace_preview(); return; } + if !self.prepare_pty_input_before_mutation() { + return; + } self.workspace_preview = Some(WorkspacePreview { origin, target, origin_scroll }); self.tree.active_workspace = index; } From 3b55ed6c870368c4b4518b7ca6ee4a902b7061aa Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:39:12 -0700 Subject: [PATCH 31/36] test workspace preview revision gate --- cmux-tui/crates/cmux-tui/src/app.rs | 95 ++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index d342e162b368..5b16845586dc 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -6905,6 +6905,14 @@ pub struct App { pub(crate) sidebar_recoverable_workspace_selection: usize, pub(crate) workspace_rail_selection: WorkspaceRailSelection, workspace_preview: Option, + /// Workspace-list revision last reconciled for the active preview. + /// Unrelated tree snapshots can then skip a full workspace scan. + workspace_preview_revision: Option, + /// Target index for `workspace_preview_revision`. + /// Reapply the preview in O(1) while the workspace list is unchanged. + workspace_preview_target_index: Option, + #[cfg(test)] + workspace_preview_reconcile_count: usize, pub(crate) machine_rail_scroll: usize, pub(crate) machine_footer_scroll: usize, pub(crate) workspace_rail_scroll: usize, @@ -9092,6 +9100,10 @@ fn run_with_machine_updates_inner( sidebar_recoverable_workspace_selection: 0, workspace_rail_selection: WorkspaceRailSelection::default(), workspace_preview: None, + workspace_preview_revision: None, + workspace_preview_target_index: None, + #[cfg(test)] + workspace_preview_reconcile_count: 0, machine_rail_scroll: 0, machine_footer_scroll: 0, workspace_rail_scroll: 0, @@ -11570,6 +11582,8 @@ impl App { self.sidebar_recoverable_workspace_selection = 0; self.workspace_rail_selection = WorkspaceRailSelection::Workspace; self.workspace_preview = None; + self.workspace_preview_revision = None; + self.workspace_preview_target_index = None; self.tabs_rail_selection = 0; self.tabs_rail_scroll = 0; self.tabs_rail_follow_selection = true; @@ -17417,11 +17431,15 @@ impl App { return; } self.workspace_preview = Some(WorkspacePreview { origin, target, origin_scroll }); + self.workspace_preview_revision = Some(self.tree.workspace_revision); + self.workspace_preview_target_index = Some(index); self.tree.active_workspace = index; } fn commit_workspace_preview(&mut self) { self.workspace_preview = None; + self.workspace_preview_revision = None; + self.workspace_preview_target_index = None; } fn commit_workspace_preview_for_pane_input(&mut self, x: u16, y: u16) { @@ -17451,7 +17469,13 @@ impl App { } fn cancel_workspace_preview(&mut self) { - let Some(preview) = self.workspace_preview.take() else { return }; + let Some(preview) = self.workspace_preview.take() else { + self.workspace_preview_revision = None; + self.workspace_preview_target_index = None; + return; + }; + self.workspace_preview_revision = None; + self.workspace_preview_target_index = None; self.workspace_rail_scroll = preview.origin_scroll; if let Some(index) = self.tree.workspaces.iter().position(|workspace| workspace.id == preview.origin) @@ -17462,6 +17486,11 @@ impl App { } fn reconcile_workspace_preview(&mut self) { + #[cfg(test)] + { + self.workspace_preview_reconcile_count = + self.workspace_preview_reconcile_count.saturating_add(1); + } let Some(preview) = self.workspace_preview else { return }; let Some(target_index) = self.tree.workspaces.iter().position(|workspace| workspace.id == preview.target) @@ -17476,6 +17505,8 @@ impl App { self.tree.active_workspace = target_index; self.sidebar_workspace_selection = target_index; self.workspace_rail_selection = WorkspaceRailSelection::Workspace; + self.workspace_preview_revision = Some(self.tree.workspace_revision); + self.workspace_preview_target_index = Some(target_index); } fn select_workspace_rail_target(&mut self, target: WorkspaceRailTarget) { @@ -42102,6 +42133,64 @@ mod tests { assert_eq!(app.tree.active_workspace, 1); } + #[test] + fn replace_tree_reconciles_preview_only_when_workspace_revision_changes() { + let mux = Mux::new("workspace-preview-revision-gate-test", SurfaceOptions::default()); + mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + let mut initial = app.session.tree(); + initial.workspace_revision = 7; + app.replace_tree(initial); + let origin = app.tree.workspaces[0].id; + let target = app.tree.workspaces[1].id; + app.tree.active_workspace = 0; + app.sidebar_workspace_selection = 0; + app.preview_workspace(1); + assert_eq!(app.workspace_preview_revision, Some(7)); + let reconcile_count = app.workspace_preview_reconcile_count; + + let mut unrelated = app.tree.clone(); + unrelated.workspace_revision = 7; + // The authoritative snapshot still points at the origin workspace. + // The preview fast path must restore the rendered target without an + // O(workspaces) reconciliation scan. + unrelated.active_workspace = 0; + unrelated.workspaces[1].screens[0].panes[0].tabs[0].title = "updated".into(); + app.replace_tree(unrelated); + assert_eq!(app.workspace_preview_revision, Some(7)); + assert_eq!(app.workspace_preview_target_index, Some(1)); + assert_eq!(app.tree.active_workspace, 1); + assert_eq!(app.sidebar_workspace_selection, 1); + assert_eq!(app.workspace_preview_reconcile_count, reconcile_count); + assert_eq!( + app.workspace_preview, + Some(WorkspacePreview { origin, target, origin_scroll: 0 }) + ); + + let mut reordered = app.tree.clone(); + reordered.workspace_revision = 8; + reordered.workspaces.swap(0, 1); + reordered.active_workspace = 0; + app.replace_tree(reordered); + assert_eq!(app.workspace_preview_revision, Some(8)); + assert_eq!(app.workspace_preview_reconcile_count, reconcile_count + 1); + assert_eq!(app.tree.active_workspace, 0); + assert_eq!(app.tree.workspaces[0].id, target); + assert_eq!(app.sidebar_workspace_selection, 0); + + let mut removed = app.tree.clone(); + removed.workspace_revision = 9; + removed.workspaces.retain(|workspace| workspace.id != target); + removed.active_workspace = 0; + app.replace_tree(removed); + assert_eq!(app.workspace_preview, None); + assert_eq!(app.workspace_preview_revision, None); + assert_eq!(app.workspace_preview_reconcile_count, reconcile_count + 2); + assert_eq!(app.tree.active_workspace, 0); + assert_eq!(app.tree.workspaces[0].id, origin); + } + #[test] fn switching_to_profile_without_focused_child_rail_cancels_workspace_preview() { let mux = Mux::new("workspace-preview-profile-cancel-test", SurfaceOptions::default()); @@ -44924,6 +45013,10 @@ mod tests { sidebar_recoverable_workspace_selection: 0, workspace_rail_selection: WorkspaceRailSelection::default(), workspace_preview: None, + workspace_preview_revision: None, + workspace_preview_target_index: None, + #[cfg(test)] + workspace_preview_reconcile_count: 0, machine_rail_scroll: 0, machine_footer_scroll: 0, workspace_rail_scroll: 0, From c77779c62b4f81d4042246005229feb561757c2d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:40:53 -0700 Subject: [PATCH 32/36] coalesce workspace preview reconciliation --- cmux-tui/crates/cmux-tui/src/app.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 5b16845586dc..a649fa3302b7 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -12852,7 +12852,23 @@ impl App { } self.rebuild_tab_locations(); self.reapply_mux_titles(); - self.reconcile_workspace_preview(); + if let Some(preview) = self.workspace_preview { + let target_index = self.workspace_preview_target_index.filter(|&index| { + self.tree + .workspaces + .get(index) + .is_some_and(|workspace| workspace.id == preview.target) + }); + if self.workspace_preview_revision == Some(self.tree.workspace_revision) + && let Some(target_index) = target_index + { + self.tree.active_workspace = target_index; + self.sidebar_workspace_selection = target_index; + self.workspace_rail_selection = WorkspaceRailSelection::Workspace; + } else { + self.reconcile_workspace_preview(); + } + } } fn replace_authoritative_tree(&mut self, tree: TreeView, destination_generation: u64) { @@ -17486,12 +17502,16 @@ impl App { } fn reconcile_workspace_preview(&mut self) { + let Some(preview) = self.workspace_preview else { + self.workspace_preview_revision = None; + self.workspace_preview_target_index = None; + return; + }; #[cfg(test)] { self.workspace_preview_reconcile_count = self.workspace_preview_reconcile_count.saturating_add(1); } - let Some(preview) = self.workspace_preview else { return }; let Some(target_index) = self.tree.workspaces.iter().position(|workspace| workspace.id == preview.target) else { From 199d10a10faaf53f7ff361d818f119c3413f2cb3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:04:48 -0700 Subject: [PATCH 33/36] test(tui): guard denied workspace preview selection --- cmux-tui/crates/cmux-tui/src/app.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index a649fa3302b7..b2694eadb41e 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -42153,6 +42153,28 @@ mod tests { assert_eq!(app.tree.active_workspace, 1); } + #[test] + fn denied_workspace_preview_keeps_sidebar_selection_aligned() { + let mux = Mux::new("workspace-preview-admission-denied-test", SurfaceOptions::default()); + mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux)); + app.replace_tree(app.session.tree()); + app.machine_ui = Some({ + let mut ui = provider_machine_ui(); + ui.session_available = false; + ui + }); + let target = app.tree.workspaces[1].id; + + app.select_workspace_rail_target(WorkspaceRailTarget::Workspace(target)); + + assert_eq!(app.sidebar_workspace_selection, 0); + assert_eq!(app.workspace_rail_selection, WorkspaceRailSelection::Workspace); + assert_eq!(app.tree.active_workspace, 0); + assert!(app.workspace_preview.is_none()); + } + #[test] fn replace_tree_reconciles_preview_only_when_workspace_revision_changes() { let mux = Mux::new("workspace-preview-revision-gate-test", SurfaceOptions::default()); From 8c1d10327e00bce0bc68544f44600033a2fe39dc Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:05:08 -0700 Subject: [PATCH 34/36] fix(tui): keep preview selection atomic --- cmux-tui/crates/cmux-tui/src/app.rs | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index b2694eadb41e..02d2d8901c36 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -17420,17 +17420,17 @@ impl App { } } - fn preview_workspace(&mut self, index: usize) { + fn preview_workspace(&mut self, index: usize) -> bool { let Some(target) = self.tree.workspaces.get(index).map(|workspace| workspace.id) else { - return; + return false; }; let Some(current) = self.tree.active_workspace().map(|workspace| workspace.id) else { - return; + return false; }; if self.tree.active_workspace == index && self.workspace_preview.is_some_and(|preview| preview.target == target) { - return; + return true; } let origin = self.workspace_preview.map_or(current, |preview| preview.origin); let origin_scroll = self @@ -17438,18 +17438,19 @@ impl App { .map_or(self.workspace_rail_scroll, |preview| preview.origin_scroll); if target == origin { if self.workspace_preview.is_some() && !self.prepare_pty_input_before_mutation() { - return; + return false; } self.cancel_workspace_preview(); - return; + return true; } if !self.prepare_pty_input_before_mutation() { - return; + return false; } self.workspace_preview = Some(WorkspacePreview { origin, target, origin_scroll }); self.workspace_preview_revision = Some(self.tree.workspace_revision); self.workspace_preview_target_index = Some(index); self.tree.active_workspace = index; + true } fn commit_workspace_preview(&mut self) { @@ -17535,13 +17536,14 @@ impl App { if let Some(index) = self.tree.workspaces.iter().position(|workspace| workspace.id == id) { - if self.sidebar_workspace_selection != index { - self.tabs_rail_selection = 0; - self.tabs_rail_scroll = 0; + if self.preview_workspace(index) { + if self.sidebar_workspace_selection != index { + self.tabs_rail_selection = 0; + self.tabs_rail_scroll = 0; + } + self.sidebar_workspace_selection = index; + self.workspace_rail_selection = WorkspaceRailSelection::Workspace; } - self.sidebar_workspace_selection = index; - self.workspace_rail_selection = WorkspaceRailSelection::Workspace; - self.preview_workspace(index); } } WorkspaceRailTarget::Recoverable(id) => { From d0ccc3da469d27d4d918703b85f61f00936d386d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:00:31 -0700 Subject: [PATCH 35/36] fix(tui): preserve preview on dependent rail clicks --- cmux-tui/crates/cmux-tui/src/app.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 02d2d8901c36..2c9032da2dbd 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -21813,11 +21813,19 @@ impl App { self.sidebar_focus_pending = false; return Ok(RenderAction::Draw); } - let preserve_preview_for_rail_click = matches!( - pointer_hit, - Some(Hit::RailPad(kind)) if self.can_preserve_workspace_preview_for_rail(kind) - ); - if !preserve_preview_for_rail_click { + // Dependent-rail clicks are part of the preview transaction. Classify + // the hit before leaving the sidebar so a tab click can commit the + // rendered preview target and a rail-pad click can move focus without + // cancelling it. Crossterm reports these as left-button Down events, + // while Ratatui's hit map supplies the rendered target geometry. + let preserve_preview_for_hit = match pointer_hit { + Some(Hit::RailPad(kind)) => self.can_preserve_workspace_preview_for_rail(kind), + Some(Hit::SidebarTab { .. }) => { + self.workspace_preview.is_some() && self.sidebar_rail_focused() + } + _ => false, + }; + if !preserve_preview_for_hit { self.leave_workspace_sidebar(); } self.sidebar_focus_pending = false; From dd2e32dfafe8a95efbc74f88d290db38abe71733 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:07:27 -0700 Subject: [PATCH 36/36] fix(tui): commit preview before sidebar tab activation --- cmux-tui/crates/cmux-tui/src/app.rs | 35 ++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui/src/app.rs b/cmux-tui/crates/cmux-tui/src/app.rs index 2c9032da2dbd..3a19b95809e7 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -10050,10 +10050,13 @@ impl App { if !self.prepare_pty_input_before_mutation() { return Ok(()); } + // Commit before selecting the surface. Selection can move focus and + // trigger preview reconciliation, which must not restore the preview + // origin after this explicit tab activation. + self.commit_workspace_preview(); if !self.tree.select_surface(target.surface) { return Ok(()); } - self.commit_workspace_preview(); self.follow_sidebar_workspace(target.workspace); self.pane_focus_history.record(target.pane); self.claim_active_terminal_geometry(true); @@ -33023,6 +33026,36 @@ mod tests { } } + #[test] + fn sidebar_tab_activation_keeps_selected_dependent_surface_after_preview_commit() { + let mux = Mux::new("workspace-sidebar-preview-tab-click-test", SurfaceOptions::default()); + let first = mux.new_workspace(Some("Alpha".into()), Some((80, 24))).unwrap(); + let second = mux.new_workspace(Some("Beta".into()), Some((80, 24))).unwrap(); + let mut app = test_app(Session::Local(mux.clone())); + app.sidebar_view = SidebarView::Workspaces; + app.replace_tree(app.session.tree()); + app.tree.active_workspace = 0; + app.workspace_preview = + Some(WorkspacePreview { origin: first.id, target: second.id, origin_scroll: 0 }); + app.sync_layout((100, 20)); + + let target = app + .sidebar_tab_targets() + .into_iter() + .find(|target| target.workspace == 1) + .expect("dependent workspace tab target"); + let selected_surface = target.surface; + app.activate_sidebar_tab(&target).unwrap(); + + assert_eq!(app.workspace_preview, None); + assert_eq!(app.tree.active_surface(), Some(selected_surface)); + assert_eq!(app.tree.active_workspace, 1); + + for surface in [first.id, second.id] { + mux.close_surface(surface).unwrap(); + } + } + #[test] fn tab_switch_moves_size_lease_without_dropping_hidden_surface() { let mux = Mux::new("visible-tab-sizing-test", SurfaceOptions::default());