From 18ec0476f132abfccd1ee3c98a15098a27eb8dfd 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/10] 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 1882f95ff194..01732e71dfc7 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -3779,6 +3779,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)) } @@ -6820,6 +6826,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, @@ -8998,6 +9005,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, @@ -9966,6 +9974,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); @@ -10089,6 +10098,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, @@ -10140,6 +10152,7 @@ impl App { fn leave_workspace_sidebar(&mut self) { if self.sidebar_rail_focused() { + self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; } } @@ -12702,6 +12715,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) { @@ -13892,6 +13906,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 { @@ -16051,6 +16066,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)?; @@ -17067,6 +17085,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) => { @@ -17079,9 +17152,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) }) { @@ -17090,6 +17165,7 @@ impl App { } } WorkspaceRailTarget::Action(action) => { + self.cancel_workspace_preview(); self.workspace_rail_selection = WorkspaceRailSelection::Action(action); } } @@ -17127,6 +17203,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); } @@ -17136,6 +17213,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); }; @@ -17143,6 +17221,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 { @@ -17878,11 +17957,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); } @@ -17921,14 +18002,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; } @@ -18725,6 +18809,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; } @@ -19412,6 +19497,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() { @@ -19447,6 +19533,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; @@ -21097,8 +21184,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 { @@ -40624,6 +40740,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); @@ -42825,6 +43023,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 6c30d8853336..4450d4b09554 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 f2b0387b3be95947faafe64167f6495aac6622ad 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/10] 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 01732e71dfc7..9e66c524f4ef 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -17107,11 +17107,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; }; @@ -17122,11 +17119,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; @@ -40759,7 +40753,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(); @@ -40811,7 +40808,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 383c887a1b480a6c80d802ee223b80e681c2c657 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/10] 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 9e66c524f4ef..273419933713 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -23611,7 +23611,8 @@ mod tests { SurfaceResizeDecision, SurfaceResizeOwnership, TERMINAL_PAINT_CADENCE, TerminalInput, TerminalPaintPacer, TerminalPointerAdmission, TerminalPointerAdmissionResult, TerminalPointerEncoding, TextInput, 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 2de682f102af3f9d2ac55b9b93b26df79c9ebc4c 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/10] 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 273419933713..5ff67b2eef22 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -23612,13 +23612,12 @@ mod tests { TerminalPaintPacer, TerminalPointerAdmission, TerminalPointerAdmissionResult, TerminalPointerEncoding, TextInput, 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 718f8649d944dcd34735026ece34d74741a3e288 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/10] 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 5ff67b2eef22..a69d05cbb952 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -40739,6 +40739,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; @@ -40755,7 +40758,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); @@ -40784,6 +40787,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; @@ -40810,7 +40816,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 8b62ae7375ce2585eea56d06c105ab67a4c85611 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/10] 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 a69d05cbb952..99785c856fef 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -40828,6 +40828,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 ea1a69054e13710bac972b58d7b1766edafb734d 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/10] 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 99785c856fef..ffc0f7c83151 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -17105,6 +17105,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) = @@ -21302,6 +21313,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() { @@ -40830,7 +40848,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(); @@ -40866,7 +40885,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 4450d4b09554..5a5e6cbf8013 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 be5e5487bd6e4106872cbe6a4039dbec609d1ae8 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/10] 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 ffc0f7c83151..00a3f5752a75 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -40848,8 +40848,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(); @@ -40885,10 +40884,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 e0b55ea2fbedb2b54fcbebf03380170d68eb3bae 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/10] 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 00a3f5752a75..fb03107a9e44 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -10131,6 +10131,7 @@ impl App { } Direction::Right => { if !self.focus_adjacent_rail(kind, 1) { + self.cancel_workspace_preview(); self.focus = FocusTarget::Pane; } true @@ -17116,16 +17117,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) { @@ -17133,11 +17154,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; @@ -19990,6 +20011,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, @@ -21567,6 +21591,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) { @@ -40341,8 +40366,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; @@ -40902,6 +40930,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 5a5e6cbf8013..8963a445bd2b 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 2fed9d4c6d0d548ee20751afedb2d53b4598b09c 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/10] 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 fb03107a9e44..0415a47c48ef 100644 --- a/cmux-tui/crates/cmux-tui/src/app.rs +++ b/cmux-tui/crates/cmux-tui/src/app.rs @@ -11453,6 +11453,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; @@ -35605,6 +35606,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.