From ed8eac40832e6a79f30eaeb3c521912f231d43eb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 5 Nov 2026 07:17:34 +0000 Subject: [PATCH] Add keyboard controls for TUI pane resizing and zoom (#59595) ## What changed Add configurable pane actions with default leader shortcuts: - `leader z` zooms or restores the focused conversation pane. - `leader ,`, `leader .`, and `leader =` minimize, maximize, or equalize pane widths within layout limits. - `leader shift-h` and `leader shift-l` move the divider while the side pane has focus. - `leader r` enters resize mode, highlighting the divider and showing keyboard hints. Arrow keys or `h`/`l` move it one column; Shift+Left/Right move it ten columns. Enter or Esc saves the width to `tui.sidebar_width_percent`. Discard resize previews when terminal focus is lost or the split layout becomes unavailable. Preserve exact column widths at the current terminal size for presets and completed keyboard resizing, avoiding percentage-rounding drift. ## Testing Extend TUI tests and snapshots to cover zoom and restore, width presets, divider movement, resize previews and persistence, and interrupted resizing. Extend keymap tests to cover zoom binding conflicts and custom chords. GitOrigin-RevId: 7dd823fd8c2a0c90368c66f254c4fdd7c863c8a8 --- codex-rs/config/src/tui_keymap.rs | 14 ++ codex-rs/core/config.schema.json | 70 ++++++ codex-rs/tui/src/app.rs | 7 + codex-rs/tui/src/app/input.rs | 6 + codex-rs/tui/src/app/owned_transcript.rs | 15 +- codex-rs/tui/src/app/side.rs | 10 + codex-rs/tui/src/app/side_focus.rs | 41 +++- codex-rs/tui/src/app/side_pane.rs | 222 +++++++++++++++--- codex-rs/tui/src/app/startup.rs | 3 + codex-rs/tui/src/app/test_support.rs | 3 + codex-rs/tui/src/app/tests.rs | 142 +++++++++-- codex-rs/tui/src/keymap.rs | 100 ++++++++ codex-rs/tui/src/keymap/bindings.rs | 14 ++ codex-rs/tui/src/keymap/chords.rs | 16 ++ .../tui/src/keymap/chords/leader_defaults.rs | 9 +- codex-rs/tui/src/keymap_setup/actions.rs | 7 + ...tui__app__tests__keyboard_pane_widths.snap | 61 +++++ ...__tests__keymap_picker_all_tab_search.snap | 14 +- 18 files changed, 689 insertions(+), 65 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__tests__keyboard_pane_widths.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index b9e508fb30..156d106be4 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -147,6 +147,20 @@ pub struct TuiGlobalKeymap { pub focus_pane_left: Option, /// Focus the open side conversation. pub focus_pane_right: Option, + /// Minimize the focused pane width. + pub minimize_pane_width: Option, + /// Maximize the focused pane width. + pub maximize_pane_width: Option, + /// Equalize visible pane widths. + pub equalize_pane_widths: Option, + /// Enter keyboard resize mode for visible dividers. + pub resize_panes: Option, + /// Move the divider left when the side pane has focus. + pub move_divider_left: Option, + /// Move the divider right when the side pane has focus. + pub move_divider_right: Option, + /// Zoom or restore the focused pane. + pub toggle_pane_zoom: Option, /// Open the shared agent-session overview. pub open_agents: Option, /// Open the transcript overlay. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3ad4a9d9da..d91dc91a9c 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5318,6 +5318,7 @@ "change_host": null, "clear_terminal": null, "copy": null, + "equalize_pane_widths": null, "find_transcript": null, "focus_activity": null, "focus_main_composer": null, @@ -5326,13 +5327,19 @@ "focus_sidebar_content": null, "hide_pane": null, "leader": null, + "maximize_pane_width": null, + "minimize_pane_width": null, + "move_divider_left": null, + "move_divider_right": null, "open_agents": null, "open_external_editor": null, "open_transcript": null, "queue": null, + "resize_panes": null, "select_transcript": null, "submit": null, "toggle_fast_mode": null, + "toggle_pane_zoom": null, "toggle_raw_output": null, "toggle_shortcuts": null, "toggle_side_conversation": null, @@ -6146,6 +6153,14 @@ ], "description": "Copy the last agent response to the clipboard." }, + "equalize_pane_widths": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Equalize visible pane widths." + }, "find_transcript": { "allOf": [ { @@ -6210,6 +6225,38 @@ ], "description": "Prefix for persistent, context-aware shortcut menus." }, + "maximize_pane_width": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Maximize the focused pane width." + }, + "minimize_pane_width": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Minimize the focused pane width." + }, + "move_divider_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move the divider left when the side pane has focus." + }, + "move_divider_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move the divider right when the side pane has focus." + }, "open_agents": { "allOf": [ { @@ -6242,6 +6289,14 @@ ], "description": "Queue the current composer draft while a task is running." }, + "resize_panes": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Enter keyboard resize mode for visible dividers." + }, "select_transcript": { "allOf": [ { @@ -6266,6 +6321,14 @@ ], "description": "Toggle Fast mode." }, + "toggle_pane_zoom": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Zoom or restore the focused pane." + }, "toggle_raw_output": { "allOf": [ { @@ -6413,6 +6476,7 @@ "change_host": null, "clear_terminal": null, "copy": null, + "equalize_pane_widths": null, "find_transcript": null, "focus_activity": null, "focus_main_composer": null, @@ -6421,13 +6485,19 @@ "focus_sidebar_content": null, "hide_pane": null, "leader": null, + "maximize_pane_width": null, + "minimize_pane_width": null, + "move_divider_left": null, + "move_divider_right": null, "open_agents": null, "open_external_editor": null, "open_transcript": null, "queue": null, + "resize_panes": null, "select_transcript": null, "submit": null, "toggle_fast_mode": null, + "toggle_pane_zoom": null, "toggle_raw_output": null, "toggle_shortcuts": null, "toggle_side_conversation": null, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6d0ec6ae5c..54efc845f1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -655,7 +655,11 @@ pub(crate) struct App { side_threads: HashMap, /// A parked parent view survives child cleanup until its own replay finishes. side_companions: HashMap, + zoomed_thread: Option, + /// Exact sidebar allocation at the terminal width where the width preset was applied. + exact_sidebar_width: Option<(u16, u16)>, divider_drag: Option, + pane_resize: Option, passive_link_drag: Option<(ThreadId, Rect)>, abandoned_side_threads: HashSet, active_thread_id: Option, @@ -884,6 +888,9 @@ impl App { self.transcript_view .sync_patch_approval(self.chat_widget.current_patch_approval()); tui.link_hover.observe(&event); + if self.handle_pane_resize(tui, &event).await { + return Ok(AppRunControl::Continue); + } self.refresh_link_hover(tui)?; self.invalidate_right_click_paste(&event); self.finish_clipboard(tui, &event); diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index df71a2a166..025a7c3916 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -243,6 +243,12 @@ impl App { return contexts; } let mut contexts = contexts.with_pane_directions(); + if self.open_pane_focus().is_some() { + contexts = contexts.with_open_pane(); + } + if self.can_nudge_side_width(tui) { + contexts = contexts.with_focused_side_pane(); + } if self.main_composer_shortcut_available() { contexts = contexts.with_main_composer_focus(); } diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index c48b731375..2d293813cf 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -218,7 +218,8 @@ impl App { let inset = caller_inset.or_else(|| dock.as_ref().map(|panes| panes.focused)); let prepared = self.prepare_conversation(screen_size.into(), inset, &frames); self.conversation_frame.side_pane = dock; - let divider_hovered = tui.link_hover.shape == tui::PointerShape::Resize; + let highlight_divider = + self.pane_resize.is_some() || tui.link_hover.shape == tui::PointerShape::Resize; // An input refresh opens a fresh terminal buffer. Carry the outer owner's // completed cells through it when only the conversation is being redrawn. let backdrop = caller_inset.map(|_| { @@ -254,7 +255,7 @@ impl App { companion, frame, self.divider_drag, - divider_hovered, + highlight_divider, &frames, ); } @@ -348,7 +349,15 @@ impl App { let mut composer_gap = (!self.chat_widget.has_active_view() && !self.chat_widget.is_external_writer_view()) .then(crate::bottom_pane::ComposerGap::default); - let prompt_footer = self.prompt_navigation_footer(area.width.saturating_sub(/*rhs*/ 2)); + let prompt_footer = if self.pane_resize.is_some() { + Some(crate::bottom_pane::TranscriptFooter { + text: "Resize · ←/h →/l · Shift+←/→ 10 cols · Enter/Esc done".into(), + cursor_column: None, + is_interactive: true, + }) + } else { + self.prompt_navigation_footer(area.width.saturating_sub(/*rhs*/ 2)) + }; let chat_widget = &self.chat_widget; let view = &mut self.transcript_view; let active_key = chat_widget.active_cell_transcript_key(); diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index ed524d507d..cf615f2576 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -240,6 +240,9 @@ impl App { } pub(super) fn sync_side_thread_ui(&mut self) { + if self.zoomed_thread.is_some() && self.zoomed_thread != self.open_pane_focus() { + self.zoomed_thread = None; + } let clear_side_ui = |chat_widget: &mut crate::chatwidget::ChatWidget| { chat_widget.set_side_conversation_context_label(/*label*/ None); chat_widget.set_side_conversation_active(/*active*/ false); @@ -325,6 +328,13 @@ impl App { !self.reconnect.offline || self.active_side_parent_thread_id().is_none() } + /// Zoom follows the displayed conversation even when its peer cannot be selected offline. + pub(super) fn open_pane_focus(&self) -> Option { + let focused = self.current_displayed_thread_id()?; + let child = self.side_pair_key(focused)?; + self.side_threads.get(&child)?.pane_open.then_some(focused) + } + pub(super) fn switchable_side_child(&self) -> Option { if self.reconnect.offline { return None; diff --git a/codex-rs/tui/src/app/side_focus.rs b/codex-rs/tui/src/app/side_focus.rs index 800e8ce8fd..0beb2c928f 100644 --- a/codex-rs/tui/src/app/side_focus.rs +++ b/codex-rs/tui/src/app/side_focus.rs @@ -19,7 +19,34 @@ impl App { bindings.focus_main_composer.is_pressed(key) && self.main_composer_shortcut_available(); let sidebar = bindings.focus_sidebar_content.is_pressed(key); let hide = bindings.hide_pane.is_pressed(key); - if !(left || right || main || sidebar || hide) || !self.conversation_focus_available(tui) { + let width_change = if bindings.move_divider_left.is_pressed(key) { + 2 + } else if bindings.move_divider_right.is_pressed(key) { + -2 + } else { + 0 + }; + let focused_percent = [ + (&bindings.equalize_pane_widths, 50), + (&bindings.minimize_pane_width, 0), + (&bindings.maximize_pane_width, 100), + ] + .into_iter() + .find_map(|(binding, percent)| binding.is_pressed(key).then_some(percent)) + .filter(|_| self.open_pane_focus().is_some()); + let zoom_target = if bindings.toggle_pane_zoom.is_pressed(key) { + self.open_pane_focus() + } else { + None + }; + if width_change != 0 && !self.can_nudge_side_width(tui) { + return false; + } + let resize = bindings.resize_panes.is_pressed(key) && self.open_pane_focus().is_some(); + let resizing = resize || width_change != 0 || focused_percent.is_some(); + if !(left || right || main || sidebar || hide || zoom_target.is_some() || resizing) + || !self.conversation_focus_available(tui) + { return false; } let child = self.switchable_side_child(); @@ -40,7 +67,17 @@ impl App { && !on_child && (self.transcript_view.has_active_interaction() || self.backtrack.overlay_preview_active); - let result = if main { + let result = if resize { + self.begin_pane_resize(tui) + } else if let Some(percent) = focused_percent { + self.set_focused_pane_width(tui, percent).await + } else if width_change != 0 { + self.nudge_side_width(tui, width_change).await + } else if let Some(target) = zoom_target { + self.zoomed_thread = (self.zoomed_thread != Some(target)).then_some(target); + tui.frame_requester().schedule_frame(); + Ok(()) + } else if main { self.focus_main_composer(tui, app_server).await } else if (left && on_child) || (right && !on_child && child.is_some_and(|id| self.side_threads[&id].pane_open)) diff --git a/codex-rs/tui/src/app/side_pane.rs b/codex-rs/tui/src/app/side_pane.rs index 08aa70c2c0..935ce5c796 100644 --- a/codex-rs/tui/src/app/side_pane.rs +++ b/codex-rs/tui/src/app/side_pane.rs @@ -14,6 +14,9 @@ use crate::tui::Tui; use crate::tui::TuiEvent; use codex_protocol::ThreadId; use color_eyre::eyre::Result; +use crossterm::event::KeyCode; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; use crossterm::event::MouseButton::Left; use crossterm::event::MouseEventKind::Down; use crossterm::event::MouseEventKind::Drag; @@ -28,6 +31,13 @@ use std::time::Duration; const GAP: u16 = 1; +#[derive(Clone, Copy)] +pub(super) struct PaneResize { + focused: ThreadId, + companion: ThreadId, + preview_width: Option, +} + #[derive(Clone, Copy)] pub(super) struct DividerDrag { passive_thread: ThreadId, @@ -44,12 +54,15 @@ impl DividerDrag { } fn percent(&self) -> Option { - let total = u32::from(self.screen.width); - (self.width != self.initial_width) - .then(|| ((u32::from(self.width) * 100 + total / 2) / total).clamp(1, 99) as u8) + (self.width != self.initial_width).then(|| width_percent(self.width, self.screen.width)) } } +fn width_percent(width: u16, total: u16) -> u8 { + let total = u32::from(total); + ((u32::from(width) * 100 + total / 2) / total).clamp(1, 99) as u8 +} + fn sidebar_width(width: i32, total: u16) -> u16 { width.clamp(36, i32::from(total.saturating_sub(64 + GAP).max(36))) as u16 } @@ -71,6 +84,13 @@ pub(super) struct SidePaneLayout { /// A wide terminal can show a pair once the outgoing conversation has been captured. pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option { + if app.zoomed_thread.is_some() && app.zoomed_thread == app.current_displayed_thread_id() { + return None; + } + split_layout(app, tui, area) +} + +fn split_layout(app: &App, tui: &Tui, area: Rect) -> Option { if area.width < 100 || app.conversation_frame.has_caller_inset() || !app.side_retention_enabled(tui) @@ -96,7 +116,7 @@ pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option Option sidebar_width(i32::from(width), area.width), + (_, None, Some((total, width))) + if total == area.width && saved == Some(width_percent(width, total)) => + { + width + } + _ => preferred_width(area.width, preview.or(saved)), + }; let [parent, child] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(child_width)]) .spacing(GAP) @@ -125,6 +157,157 @@ pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option Result<()> { + let screen = tui.prepare_draw_size()?.into(); + if let Some(panes) = split_layout(self, tui, screen) + && let Some(focused) = self.current_displayed_thread_id() + { + self.divider_drag = None; + self.passive_link_drag = None; + self.pending_right_click_paste = None; + self.zoomed_thread = None; + self.pane_resize = Some(PaneResize { + focused, + companion: panes.passive_thread, + preview_width: None, + }); + tui.frame_requester().schedule_frame(); + } + Ok(()) + } + + pub(super) async fn handle_pane_resize(&mut self, tui: &Tui, event: &TuiEvent) -> bool { + let Some(mut mode) = self.pane_resize else { + return false; + }; + let screen: Rect = match event { + TuiEvent::Resize(size) => *size, + _ => tui.terminal.last_known_screen_size, + } + .into(); + let Some(panes) = layout(self, tui, screen).filter(|panes| { + !matches!(event, TuiEvent::FocusLost) + && self.conversation_focus_available(tui) + && self.current_displayed_thread_id() == Some(mode.focused) + && panes.passive_thread == mode.companion + }) else { + self.pane_resize = None; + tui.frame_requester().schedule_frame(); + return false; + }; + let TuiEvent::Key(key) = event else { + return matches!(event, TuiEvent::Mouse(_) | TuiEvent::Paste(_)); + }; + if key.kind == KeyEventKind::Release { + return true; + } + let current = screen.right() - panes.divider - GAP; + let step = match (key.code, key.modifiers) { + (KeyCode::Left | KeyCode::Char('h'), KeyModifiers::NONE) => 1, + (KeyCode::Right | KeyCode::Char('l'), KeyModifiers::NONE) => -1, + (KeyCode::Left, KeyModifiers::SHIFT) => 10, + (KeyCode::Right, KeyModifiers::SHIFT) => -10, + (KeyCode::Enter | KeyCode::Esc, KeyModifiers::NONE) + if key.kind == KeyEventKind::Press => + { + self.pane_resize = None; + if mode.preview_width.is_some() + && self + .save_sidebar_width(width_percent(current, screen.width)) + .await + { + self.exact_sidebar_width = Some((screen.width, current)); + } + tui.frame_requester().schedule_frame(); + self.chat_widget.suppress_current_view_key_until_release( + crate::key_hint::KeyBinding::from_event(*key), + std::time::Instant::now(), + crate::activation_key_guard::ActivationKeySuppression::ViewTransition, + ); + return true; + } + _ => return true, + }; + let next = sidebar_width(i32::from(current) + step, screen.width); + if next != current { + mode.preview_width = Some(next); + self.pane_resize = Some(mode); + tui.frame_requester().schedule_frame(); + } + true + } + + pub(super) async fn set_focused_pane_width( + &mut self, + tui: &mut Tui, + focused_percent: u8, + ) -> Result<()> { + let screen: Rect = tui.prepare_draw_size()?.into(); + if split_layout(self, tui, screen).is_none() { + return Ok(()); + } + let side_percent = if self.active_side_parent_thread_id().is_some() { + focused_percent + } else { + 100 - focused_percent + }; + // The sidebar gets the spare column when an equal split is odd. + let available = u32::from(screen.width - GAP); + let preferred = (available * u32::from(side_percent)).div_ceil(100); + let width = sidebar_width(preferred as i32, screen.width); + let percent = width_percent(width, screen.width); + if self.save_sidebar_width(percent).await { + self.exact_sidebar_width = Some((screen.width, width)); + self.zoomed_thread = None; + self.divider_drag = None; + tui.frame_requester().schedule_frame(); + } + Ok(()) + } + + pub(super) fn can_nudge_side_width(&self, tui: &Tui) -> bool { + self.active_side_parent_thread_id().is_some() + && layout(self, tui, tui.terminal.last_known_screen_size.into()).is_some() + } + + pub(super) async fn nudge_side_width(&mut self, tui: &mut Tui, percent: i32) -> Result<()> { + if self.active_side_parent_thread_id().is_some() { + let screen: Rect = tui.prepare_draw_size()?.into(); + if let Some(panes) = layout(self, tui, screen) { + let step = (i32::from(screen.width) * percent.abs() + 50) / 100; + let current = panes.focused.width; + let width = + sidebar_width(i32::from(current) + step * percent.signum(), screen.width); + if width != current { + self.save_sidebar_width(width_percent(width, screen.width)) + .await; + tui.frame_requester().schedule_frame(); + } + } + } + Ok(()) + } + + async fn save_sidebar_width(&mut self, percent: u8) -> bool { + if self.local_settings.tui.sidebar_width_percent != Some(percent) + && let Err(err) = + ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) + .with_edits([ConfigEdit::SetPath { + segments: vec!["tui".into(), "sidebar_width_percent".into()], + value: i64::from(percent).into(), + }]) + .apply() + .await + { + self.chat_widget + .add_error_message(format!("Could not save sidebar width: {err}")); + return false; + } + self.local_settings.tui.sidebar_width_percent = Some(percent); + self.exact_sidebar_width = None; + true + } + pub(super) async fn handle_divider_event( &mut self, tui: &mut Tui, @@ -179,25 +362,8 @@ impl App { drag.width = sidebar_width(width, screen.width); if mouse.kind == Drag(Left) { self.divider_drag = Some(drag); - } else if let Some(percent) = drag.percent() - && self.local_settings.tui.sidebar_width_percent != Some(percent) - { - let edit = ConfigEdit::SetPath { - segments: vec!["tui".into(), "sidebar_width_percent".into()], - value: i64::from(percent).into(), - }; - match ConfigEditsBuilder::for_config_path( - self.local_settings.user_config_path.as_path(), - ) - .with_edits([edit]) - .apply() - .await - { - Ok(()) => self.local_settings.tui.sidebar_width_percent = Some(percent), - Err(err) => self - .chat_widget - .add_error_message(format!("Could not save sidebar width: {err}")), - } + } else if let Some(percent) = drag.percent() { + self.save_sidebar_width(percent).await; } } } @@ -220,7 +386,7 @@ impl SidePaneLayout { companion: &mut SideCompanion, frame: &mut Frame<'_>, drag: Option, - divider_hovered: bool, + highlight_divider: bool, frames: &FrameRequester, ) { companion.flush_answer(); @@ -304,7 +470,7 @@ impl SidePaneLayout { }) - GAP }); - let style = if divider_hovered || drag.is_some() { + let style = if highlight_divider || drag.is_some() { crate::style::accent_style() } else { ratatui::style::Style::default().dim() diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 24b7c712f4..f49a225035 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -1061,7 +1061,10 @@ See the Codex keymap documentation for supported actions and examples." agents_overview, side_threads: HashMap::new(), side_companions: HashMap::new(), + zoomed_thread: None, + exact_sidebar_width: None, divider_drag: None, + pane_resize: None, passive_link_drag: None, abandoned_side_threads: HashSet::new(), active_thread_id: None, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 07505d0f49..3774165650 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -124,7 +124,10 @@ pub(crate) async fn make_test_app() -> Box { agents_overview: Default::default(), side_threads: HashMap::new(), side_companions: HashMap::new(), + zoomed_thread: None, + exact_sidebar_width: None, divider_drag: None, + pane_resize: None, passive_link_drag: None, abandoned_side_threads: HashSet::new(), active_thread_id: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index f77d2e851b..97c372dcf1 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3908,30 +3908,6 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() ); } - read_side_history(&mut app, &mut tui, "child")?; - // The upload finishes before the app event loop can park the now-inactive child. - app.app_event_tx.send(AppEvent::FeedbackSubmitted { - origin_thread_id: Some(side_thread_id), - category: FeedbackCategory::Bug, - include_logs: false, - result: Ok("uploaded-thread".to_string()), - }); - app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id) - .await?; - drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; - app.select_agent_thread(&mut tui, &mut app_server, side_thread_id) - .await?; - drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; - let frame = side_frame(&mut app, &mut tui, size)?; - assert!( - frame.contains(child_row) && frame.contains("New activity"), - "{frame}" - ); - assert!(app.transcript_cells.iter().any(|cell| { - lines_to_single_string(&cell.display_lines(/*width*/ 120)) - .contains("Feedback recorded (no logs).") - })); - // A delta queued just before switching must appear once in the inactive pane. app.transcript_view.jump_to_latest(); app.enqueue_thread_notification( @@ -4048,9 +4024,94 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() assert_eq!(divider_at(&tui, 0), 197); let saved: toml::Value = toml::from_str(&std::fs::read_to_string(&path)?)?; assert_eq!(saved["tui"]["sidebar_width_percent"].as_integer(), Some(34)); + + // The drag scenario used a temporary image path. Keep the keyboard frames portable. + app.transcript_cells.pop(); + for (key, column) in [('H', 191), ('L', 197)] { + keys!(prefix, KeyCode::Char(key).into()); + side_frame(&mut app, &mut tui, wide)?; + assert_eq!(divider_at(&tui, 0), column); + } + let zoom = KeyEvent::from(KeyCode::Char('z')); + keys!(prefix, zoom); + let zoomed = side_frame(&mut app, &mut tui, wide)?; + keys!(prefix, zoom); + let restored = side_frame(&mut app, &mut tui, wide)?; + assert_eq!(divider_at(&tui, 0), 197); + keys!(prefix, zoom); + keys!(toggle); + keys!(toggle); + side_frame(&mut app, &mut tui, wide)?; + assert_eq!(divider_at(&tui, 0), 197); assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id)); + let even = Size::new(/*width*/ 200, wide.height); + tui.terminal = terminal_at(even); + keys!(prefix, zoom); + keys!(prefix, KeyCode::Char('=').into()); + let equalized = side_frame(&mut app, &mut tui, even)?; + assert_app_snapshot!( + "keyboard_pane_widths", + format!("Zoomed\n{zoomed}\nRestored\n{restored}\nEqualized\n{equalized}") + ); + assert_eq!(divider_at(&tui, 0), 99); + + // Minimize and maximize the side, move left, then maximize and minimize the main pane. + for (key, width) in [(',', 36), ('.', 135), ('h', 64), ('.', 163), (',', 64)] { + keys!(prefix, KeyCode::Char(key).into()); + side_frame(&mut app, &mut tui, even)?; + assert_eq!(app.conversation_area(even.into()).width, width); + } + app.local_settings.tui.sidebar_width_percent = Some(30); + side_frame(&mut app, &mut tui, even)?; + assert_eq!(app.conversation_area(even.into()).width, 139); + keys!(prefix, KeyCode::Char('r').into(), KeyCode::Left.into()); + let resizing = side_frame(&mut app, &mut tui, even)?; + let hint = resizing + .lines() + .filter_map(|row| row.split_once('│')) + .map(|(main, _)| main.trim()) + .find(|main| main.starts_with("Resize")); + insta::assert_snapshot!(hint.expect("resize hint in main pane"), @"Resize · ←/h →/l · Shift+←/→ 10 cols · Enter/Esc done"); + let divider_bold = |tui: &tui::Tui| { + let cell = &tui.terminal.previous_buffer()[(divider_at(tui, 4), 4)]; + cell.modifier.contains(ratatui::style::Modifier::BOLD) + }; + let resizing_bold = divider_bold(&tui); + assert_eq!(app.conversation_area(even.into()).width, 138); + assert_eq!(app.local_settings.tui.sidebar_width_percent, Some(30)); + keys!(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT)); + side_frame(&mut app, &mut tui, even)?; + assert_eq!(app.conversation_area(even.into()).width, 128); + keys!(KeyCode::Esc.into()); + assert_eq!(app.local_settings.tui.sidebar_width_percent, Some(36)); + side_frame(&mut app, &mut tui, even)?; + assert_eq!(app.conversation_area(even.into()).width, 128); + insta::assert_snapshot!(format!("{resizing_bold} -> {}", divider_bold(&tui)), @"true -> false"); + + let confirmed = std::fs::read(&path)?; + for interrupt in [ + TuiEvent::FocusLost, + TuiEvent::Resize(Size::new(80, even.height)), + ] { + keys!( + prefix, + KeyCode::Char('r').into(), + KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT) + ); + side_frame(&mut app, &mut tui, even)?; + assert_eq!(app.conversation_area(even.into()).width, 138); + assert!(!app.handle_pane_resize(&tui, &interrupt).await); + side_frame(&mut app, &mut tui, even)?; + assert_eq!(app.conversation_area(even.into()).width, 128); + assert_eq!(std::fs::read(&path)?, confirmed); + } + keys!(prefix, main); + // A divider shortcut with main focus passes its final key back to the composer. + let upper_h = KeyEvent::from(KeyCode::Char('H')); + assert_eq!(app.route_key_chord_event(&mut tui, prefix), None); + assert_eq!(app.route_key_chord_event(&mut tui, upper_h), Some(upper_h)); // Park a streaming parent through the normal focus shortcut, then finish it while parked. let parent = parent_thread_id; let turn = "live-parent"; @@ -4191,6 +4252,33 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() 1, "{finished}" ); + // Check feedback after capturing the layout snapshots and before the clipboard toast appears. + app.select_agent_thread(&mut tui, &mut app_server, side_thread_id) + .await?; + drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; + read_side_history(&mut app, &mut tui, "child")?; + // The upload finishes before the app event loop can park the now-inactive child. + app.app_event_tx.send(AppEvent::FeedbackSubmitted { + origin_thread_id: Some(side_thread_id), + category: FeedbackCategory::Bug, + include_logs: false, + result: Ok("uploaded-thread".to_string()), + }); + app.select_agent_thread(&mut tui, &mut app_server, parent_thread_id) + .await?; + drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; + app.select_agent_thread(&mut tui, &mut app_server, side_thread_id) + .await?; + drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; + let frame = side_frame(&mut app, &mut tui, even)?; + assert!( + frame.contains(child_row) && frame.contains("New activity"), + "{frame}" + ); + assert!(app.transcript_cells.iter().any(|cell| { + lines_to_single_string(&cell.display_lines(/*width*/ 120)) + .contains("Feedback recorded (no logs).") + })); // Copy a retained range with the child composer still focused. app.transcript_view.jump_to_latest(); @@ -7480,7 +7568,10 @@ async fn make_test_app() -> Box { agents_overview: Default::default(), side_threads: HashMap::new(), side_companions: HashMap::new(), + zoomed_thread: None, + exact_sidebar_width: None, divider_drag: None, + pane_resize: None, passive_link_drag: None, abandoned_side_threads: HashSet::new(), active_thread_id: None, @@ -7614,7 +7705,10 @@ pub(super) async fn make_test_app_with_channels() -> ( agents_overview: Default::default(), side_threads: HashMap::new(), side_companions: HashMap::new(), + zoomed_thread: None, + exact_sidebar_width: None, divider_drag: None, + pane_resize: None, passive_link_drag: None, abandoned_side_threads: HashSet::new(), active_thread_id: None, diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index a40247c562..77a87b9e8b 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -109,6 +109,13 @@ pub(crate) struct AppKeymap { pub(crate) hide_pane: Vec, pub(crate) focus_pane_left: Vec, pub(crate) focus_pane_right: Vec, + pub(crate) minimize_pane_width: Vec, + pub(crate) maximize_pane_width: Vec, + pub(crate) equalize_pane_widths: Vec, + pub(crate) resize_panes: Vec, + pub(crate) move_divider_left: Vec, + pub(crate) move_divider_right: Vec, + pub(crate) toggle_pane_zoom: Vec, /// Open the agent command center. pub(crate) open_agents: Vec, /// Open transcript overlay. @@ -781,6 +788,41 @@ impl RuntimeKeymap { &[], "tui.keymap.global.focus_pane_right", )?, + minimize_pane_width: resolve_bindings( + keymap.global.minimize_pane_width.as_ref(), + &[], + "tui.keymap.global.minimize_pane_width", + )?, + maximize_pane_width: resolve_bindings( + keymap.global.maximize_pane_width.as_ref(), + &[], + "tui.keymap.global.maximize_pane_width", + )?, + equalize_pane_widths: resolve_bindings( + keymap.global.equalize_pane_widths.as_ref(), + &[], + "tui.keymap.global.equalize_pane_widths", + )?, + resize_panes: resolve_bindings( + keymap.global.resize_panes.as_ref(), + &[], + "tui.keymap.global.resize_panes", + )?, + move_divider_left: resolve_bindings( + keymap.global.move_divider_left.as_ref(), + &[], + "tui.keymap.global.move_divider_left", + )?, + move_divider_right: resolve_bindings( + keymap.global.move_divider_right.as_ref(), + &[], + "tui.keymap.global.move_divider_right", + )?, + toggle_pane_zoom: resolve_bindings( + keymap.global.toggle_pane_zoom.as_ref(), + &[], + "tui.keymap.global.toggle_pane_zoom", + )?, open_agents: resolve_bindings( keymap.global.open_agents.as_ref(), &defaults.app.open_agents, @@ -1761,6 +1803,13 @@ impl RuntimeKeymap { hide_pane: Vec::new(), focus_pane_left: Vec::new(), focus_pane_right: Vec::new(), + minimize_pane_width: Vec::new(), + maximize_pane_width: Vec::new(), + equalize_pane_widths: Vec::new(), + resize_panes: Vec::new(), + move_divider_left: Vec::new(), + move_divider_right: Vec::new(), + toggle_pane_zoom: Vec::new(), open_agents: default_bindings![], open_transcript: default_bindings![ctrl(KeyCode::Char('t'))], find_transcript: default_bindings![plain(KeyCode::F(3))], @@ -2108,6 +2157,10 @@ impl RuntimeKeymap { )); } } + let minimize = &self.app.minimize_pane_width; + let maximize = &self.app.maximize_pane_width; + let equalize = &self.app.equalize_pane_widths; + let resize = &self.app.resize_panes; #[cfg(unix)] for (action, bindings) in [ ("global.change_host", &self.app.change_host), @@ -2119,6 +2172,13 @@ impl RuntimeKeymap { ), ("global.focus_pane_left", &self.app.focus_pane_left), ("global.focus_pane_right", &self.app.focus_pane_right), + ("global.minimize_pane_width", minimize), + ("global.maximize_pane_width", maximize), + ("global.equalize_pane_widths", equalize), + ("global.resize_panes", resize), + ("global.move_divider_left", &self.app.move_divider_left), + ("global.move_divider_right", &self.app.move_divider_right), + ("global.toggle_pane_zoom", &self.app.toggle_pane_zoom), ("global.hide_pane", &self.app.hide_pane), ("chat.open_presets", &self.chat.open_presets), ("chat.toggle_voice", &self.chat.toggle_voice), @@ -2147,6 +2207,8 @@ impl RuntimeKeymap { side_toggle_bindings.push(legacy_slash_binding); } + let divider_left = self.app.move_divider_left.as_slice(); + let divider_right = self.app.move_divider_right.as_slice(); let main_bindings = [ ("change_host", self.app.change_host.as_slice()), ( @@ -2159,6 +2221,13 @@ impl RuntimeKeymap { ), ("focus_pane_left", self.app.focus_pane_left.as_slice()), ("focus_pane_right", self.app.focus_pane_right.as_slice()), + ("minimize_pane_width", minimize), + ("maximize_pane_width", maximize), + ("equalize_pane_widths", equalize), + ("resize_panes", resize), + ("move_divider_left", divider_left), + ("move_divider_right", divider_right), + ("toggle_pane_zoom", self.app.toggle_pane_zoom.as_slice()), ("hide_pane", self.app.hide_pane.as_slice()), ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), @@ -2338,6 +2407,13 @@ impl RuntimeKeymap { ), ("focus_pane_left", self.app.focus_pane_left.as_slice()), ("focus_pane_right", self.app.focus_pane_right.as_slice()), + ("minimize_pane_width", minimize), + ("maximize_pane_width", maximize), + ("equalize_pane_widths", equalize), + ("resize_panes", resize), + ("move_divider_left", divider_left), + ("move_divider_right", divider_right), + ("toggle_pane_zoom", self.app.toggle_pane_zoom.as_slice()), ("hide_pane", self.app.hide_pane.as_slice()), ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), @@ -2480,6 +2556,16 @@ impl RuntimeKeymap { "global.focus_pane_right", self.app.focus_pane_right.as_slice(), ), + ("global.minimize_pane_width", minimize), + ("global.maximize_pane_width", maximize), + ("global.equalize_pane_widths", equalize), + ("global.resize_panes", resize), + ("global.move_divider_left", divider_left), + ("global.move_divider_right", divider_right), + ( + "global.toggle_pane_zoom", + self.app.toggle_pane_zoom.as_slice(), + ), ("global.hide_pane", self.app.hide_pane.as_slice()), ( "global.select_transcript", @@ -2529,6 +2615,16 @@ impl RuntimeKeymap { "global.focus_pane_right", self.app.focus_pane_right.as_slice(), ), + ("global.minimize_pane_width", minimize), + ("global.maximize_pane_width", maximize), + ("global.equalize_pane_widths", equalize), + ("global.resize_panes", resize), + ("global.move_divider_left", divider_left), + ("global.move_divider_right", divider_right), + ( + "global.toggle_pane_zoom", + self.app.toggle_pane_zoom.as_slice(), + ), ("global.hide_pane", self.app.hide_pane.as_slice()), ("global.focus_activity", self.app.focus_activity.as_slice()), ( @@ -3998,6 +4094,7 @@ mod tests { ("focus_sidebar_content", "list", "move_down"), ("focus_pane_left", "list", "move_down"), ("focus_pane_right", "pager", "page_up"), + ("toggle_pane_zoom", "pager", "page_up"), ] { let config: TuiKeymap = toml::from_str(&format!( "[global]\n{pane} = 'f8'\n[{section}]\n{action} = 'f8'" @@ -4016,6 +4113,7 @@ mod tests { "focus_sidebar_content", "focus_pane_left", "focus_pane_right", + "toggle_pane_zoom", ] { let config: TuiKeymap = toml::from_str(&format!("[global]\n{action} = 'ctrl-z'")).unwrap(); @@ -4057,10 +4155,12 @@ mod tests { let mut keymap = TuiKeymap::default(); keymap.global.open_transcript = Some(one("leader left")); keymap.global.focus_pane_right = Some(one("f12 b")); + keymap.global.toggle_pane_zoom = Some(one("f12 z")); let runtime = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap(); for (action, expected) in [ ("focus_pane_left", "leader h"), ("focus_pane_right", "f12 b"), + ("toggle_pane_zoom", "f12 z"), ] { let installed: Vec<_> = runtime .chords diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 11548c0c33..30dfd90954 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -112,6 +112,13 @@ impl KeymapActionId { | "hide_pane" | "focus_pane_left" | "focus_pane_right" + | "minimize_pane_width" + | "maximize_pane_width" + | "equalize_pane_widths" + | "resize_panes" + | "move_divider_left" + | "move_divider_right" + | "toggle_pane_zoom" ) } @@ -312,6 +319,13 @@ define_runtime_action_bindings! { hide_pane, focus_pane_left, focus_pane_right, + minimize_pane_width, + maximize_pane_width, + equalize_pane_widths, + resize_panes, + move_divider_left, + move_divider_right, + toggle_pane_zoom, open_warnings => runtime_only, open_external_editor, copy, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index af8c80dd9e..e8d745fe70 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -57,6 +57,8 @@ const BACKEND_CYCLING: u32 = 1 << 21; const SIDE_PANE: u32 = 1 << 22; const MAIN_COMPOSER_FOCUS: u32 = 1 << 23; const PANE_DIRECTION: u32 = 1 << 24; +const OPEN_PANE: u32 = 1 << 25; +const FOCUSED_SIDE_PANE: u32 = 1 << 26; const TRANSCRIPT_BROWSING: u32 = 1 << 14; @@ -167,6 +169,14 @@ impl KeymapContextSet { Self(self.0 | MAIN_COMPOSER_FOCUS) } + pub(crate) const fn with_open_pane(self) -> Self { + Self(self.0 | OPEN_PANE) + } + + pub(crate) const fn with_focused_side_pane(self) -> Self { + Self(self.0 | FOCUSED_SIDE_PANE) + } + pub(crate) const fn with_side_pane(self) -> Self { Self(self.0 | SIDE_PANE) } @@ -179,6 +189,12 @@ impl KeymapContextSet { if action.is_side_pane() { let required = match action.action { "focus_pane_left" | "focus_pane_right" => PANE_DIRECTION, + "move_divider_left" | "move_divider_right" => FOCUSED_SIDE_PANE, + "toggle_pane_zoom" + | "equalize_pane_widths" + | "minimize_pane_width" + | "maximize_pane_width" + | "resize_panes" => OPEN_PANE, "focus_main_composer" => MAIN_COMPOSER_FOCUS, _ => SIDE_PANE, }; diff --git a/codex-rs/tui/src/keymap/chords/leader_defaults.rs b/codex-rs/tui/src/keymap/chords/leader_defaults.rs index 5f86e184c7..3b4155aab3 100644 --- a/codex-rs/tui/src/keymap/chords/leader_defaults.rs +++ b/codex-rs/tui/src/keymap/chords/leader_defaults.rs @@ -40,6 +40,13 @@ pub(in crate::keymap) fn register( (Global, "focus_pane_left", Char('h'), "h"), (Global, "focus_pane_right", KeyCode::Right, "right"), (Global, "focus_pane_right", Char('l'), "l"), + (Global, "toggle_pane_zoom", Char('z'), "z"), + (Global, "move_divider_left", Char('H'), "shift-h"), + (Global, "move_divider_right", Char('L'), "shift-l"), + (Global, "equalize_pane_widths", Char('='), "="), + (Global, "resize_panes", Char('r'), "r"), + (Global, "minimize_pane_width", Char(','), ","), + (Global, "maximize_pane_width", Char('.'), "."), ] { if remaining == 0 { break; @@ -61,7 +68,7 @@ pub(in crate::keymap) fn register( action, chord: KeyChord { prefix, - completion: crate::key_hint::plain(completion), + completion: crate::key_hint::KeyBinding::from_event(completion.into()), }, spec: format!("leader {spec}"), }; diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 4534f281b1..d08443a251 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -123,6 +123,13 @@ define_keymap_actions! { hide_pane [Workspace] => "Hide the focused sidebar pane.", focus_pane_left [Workspace] => "Focus the parent conversation.", focus_pane_right [Workspace] => "Focus the open side conversation.", + minimize_pane_width [Workspace] => "Minimize the focused pane width.", + maximize_pane_width [Workspace] => "Maximize the focused pane width.", + equalize_pane_widths [Workspace] => "Equalize visible pane widths.", + resize_panes [Workspace] => "Enter keyboard resize mode for visible dividers.", + move_divider_left [Workspace] => "Move the divider left when the side pane has focus.", + move_divider_right [Workspace] => "Move the divider right when the side pane has focus.", + toggle_pane_zoom [Workspace] => "Zoom or restore the focused pane.", open_agents => "Open the agent command center.", open_transcript => "Open the transcript overlay.", find_transcript => "Find text in the full transcript.", diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__keyboard_pane_widths.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__keyboard_pane_widths.snap new file mode 100644 index 0000000000..69c71c73fb --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__keyboard_pane_widths.snap @@ -0,0 +1,61 @@ +--- +source: tui/src/app/tests.rs +expression: "format!(\"Zoomed\\n{zoomed}\\nRestored\\n{restored}\\nEqualized\\n{equalized}\")" +--- +Zoomed +• unfinished answer + + + + + + + + + + + + + +› xchild draft! + + GPT-6.1-Sol default · Agent + tab to queue message Side from main thread · ⌃/ to switch · ⌃c to close +Restored +parent-24 │• unfinished answer + │ +parent-25 │ + │ +parent-26 │ + │ +parent-27 │ + │ +parent-28 │ + │ +parent-29 │ + │ + │ + │ +› xparent draft-! │› xchild draft! + second line │ + GPT-6.1-Sol default │ GPT-6.1-Sol default · Agent + │ tab to queue message Side from main thread · ⌃/ to switch · ⌃c to close +Equalized +parent-24 │• unfinished answer + │ +parent-25 │ + │ +parent-26 │ + │ +parent-27 │ + │ +parent-28 │ + │ +parent-29 │ + │ + │ + │ +› xparent draft-! │› xchild draft! + second line │ + GPT-6.1-Sol default │ GPT-6.1-Sol default · Agent + │ tab to queue message Side from main thread · ⌃/ to switch · ⌃c to close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap index ef2e1eb7c8..76f477a071 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -7,11 +7,11 @@ Focus Sidebar Content | leader s (ctrl-x s) | Global focus_sidebar_content Focus Hide Pane | leader - (ctrl-x -) | Global hide_pane Hide Pane Hide the focused sidebar pane. leader - (ctrl-x -) Default Focus Pane Left | leader left (ctrl-x left), leader h (ctrl-x h) | Global focus_pane_left Focus Pane Left Focus the parent conversation. leader left (ctrl-x left), leader h (ctrl-x h) Default Focus Pane Right | leader right (ctrl-x right), leader l (ctrl-x l) | Global focus_pane_right Focus Pane Right Focus the open side conversation. leader right (ctrl-x right), leader l (ctrl-x l) Default +Minimize Pane Width | leader , (ctrl-x ,) | Global minimize_pane_width Minimize Pane Width Minimize the focused pane width. leader , (ctrl-x ,) Default +Maximize Pane Width | leader . (ctrl-x .) | Global maximize_pane_width Maximize Pane Width Maximize the focused pane width. leader . (ctrl-x .) Default +Equalize Pane Widths | leader = (ctrl-x =) | Global equalize_pane_widths Equalize Pane Widths Equalize visible pane widths. leader = (ctrl-x =) Default +Resize Panes | leader r (ctrl-x r) | Global resize_panes Resize Panes Enter keyboard resize mode for visible dividers. leader r (ctrl-x r) Default +Move Divider Left | leader shift-h (ctrl-x shift-h) | Global move_divider_left Move Divider Left Move the divider left when the side pane has focus. leader shift-h (ctrl-x shift-h) Default +Move Divider Right | leader shift-l (ctrl-x shift-l) | Global move_divider_right Move Divider Right Move the divider right when the side pane has focus. leader shift-l (ctrl-x shift-l) Default +Toggle Pane Zoom | leader z (ctrl-x z) | Global toggle_pane_zoom Toggle Pane Zoom Zoom or restore the focused pane. leader z (ctrl-x z) Default Open Agents | unbound | Global open_agents Open Agents Open the agent command center. unbound Default -Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default -Find Transcript | f3 | Global find_transcript Find Transcript Find text in the full transcript. f3 Default -Focus Activity | f4 | Global focus_activity Focus Activity Focus transcript activity groups to expand or collapse their details. f4 Default -Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default -Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default -Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default -Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default