mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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
This commit is contained in:
@@ -147,6 +147,20 @@ pub struct TuiGlobalKeymap {
|
||||
pub focus_pane_left: Option<KeybindingsSpec>,
|
||||
/// Focus the open side conversation.
|
||||
pub focus_pane_right: Option<KeybindingsSpec>,
|
||||
/// Minimize the focused pane width.
|
||||
pub minimize_pane_width: Option<KeybindingsSpec>,
|
||||
/// Maximize the focused pane width.
|
||||
pub maximize_pane_width: Option<KeybindingsSpec>,
|
||||
/// Equalize visible pane widths.
|
||||
pub equalize_pane_widths: Option<KeybindingsSpec>,
|
||||
/// Enter keyboard resize mode for visible dividers.
|
||||
pub resize_panes: Option<KeybindingsSpec>,
|
||||
/// Move the divider left when the side pane has focus.
|
||||
pub move_divider_left: Option<KeybindingsSpec>,
|
||||
/// Move the divider right when the side pane has focus.
|
||||
pub move_divider_right: Option<KeybindingsSpec>,
|
||||
/// Zoom or restore the focused pane.
|
||||
pub toggle_pane_zoom: Option<KeybindingsSpec>,
|
||||
/// Open the shared agent-session overview.
|
||||
pub open_agents: Option<KeybindingsSpec>,
|
||||
/// Open the transcript overlay.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -655,7 +655,11 @@ pub(crate) struct App {
|
||||
side_threads: HashMap<ThreadId, SideThreadState>,
|
||||
/// A parked parent view survives child cleanup until its own replay finishes.
|
||||
side_companions: HashMap<ThreadId, SideCompanion>,
|
||||
zoomed_thread: Option<ThreadId>,
|
||||
/// Exact sidebar allocation at the terminal width where the width preset was applied.
|
||||
exact_sidebar_width: Option<(u16, u16)>,
|
||||
divider_drag: Option<side_pane::DividerDrag>,
|
||||
pane_resize: Option<side_pane::PaneResize>,
|
||||
passive_link_drag: Option<(ThreadId, Rect)>,
|
||||
abandoned_side_threads: HashSet<ThreadId>,
|
||||
active_thread_id: Option<ThreadId>,
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ThreadId> {
|
||||
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<ThreadId> {
|
||||
if self.reconnect.offline {
|
||||
return None;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<u16>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct DividerDrag {
|
||||
passive_thread: ThreadId,
|
||||
@@ -44,12 +54,15 @@ impl DividerDrag {
|
||||
}
|
||||
|
||||
fn percent(&self) -> Option<u8> {
|
||||
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<SidePaneLayout> {
|
||||
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<SidePaneLayout> {
|
||||
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<Sid
|
||||
if !app.side_companions.contains_key(&passive_thread) {
|
||||
return None;
|
||||
}
|
||||
let percent = app
|
||||
let preview = app
|
||||
.divider_drag
|
||||
.filter(|drag| {
|
||||
!drag.guide_only
|
||||
@@ -104,9 +124,21 @@ pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option<Sid
|
||||
&& drag.passive_thread == passive_thread
|
||||
&& app.conversation_focus_available(tui)
|
||||
})
|
||||
.and_then(|drag| drag.percent())
|
||||
.or(app.local_settings.tui.sidebar_width_percent);
|
||||
let child_width = preferred_width(area.width, percent);
|
||||
.and_then(|drag| drag.percent());
|
||||
let saved = app.local_settings.tui.sidebar_width_percent;
|
||||
let keyboard_width = app
|
||||
.pane_resize
|
||||
.filter(|mode| mode.focused == active && mode.companion == passive_thread)
|
||||
.and_then(|mode| mode.preview_width);
|
||||
let child_width = match (keyboard_width, preview, app.exact_sidebar_width) {
|
||||
(Some(width), _, _) => 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<Sid
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn begin_pane_resize(&mut self, tui: &mut Tui) -> 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<DividerDrag>,
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -124,7 +124,10 @@ pub(crate) async fn make_test_app() -> Box<App> {
|
||||
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,
|
||||
|
||||
+118
-24
@@ -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<App> {
|
||||
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,
|
||||
|
||||
@@ -109,6 +109,13 @@ pub(crate) struct AppKeymap {
|
||||
pub(crate) hide_pane: Vec<KeyBinding>,
|
||||
pub(crate) focus_pane_left: Vec<KeyBinding>,
|
||||
pub(crate) focus_pane_right: Vec<KeyBinding>,
|
||||
pub(crate) minimize_pane_width: Vec<KeyBinding>,
|
||||
pub(crate) maximize_pane_width: Vec<KeyBinding>,
|
||||
pub(crate) equalize_pane_widths: Vec<KeyBinding>,
|
||||
pub(crate) resize_panes: Vec<KeyBinding>,
|
||||
pub(crate) move_divider_left: Vec<KeyBinding>,
|
||||
pub(crate) move_divider_right: Vec<KeyBinding>,
|
||||
pub(crate) toggle_pane_zoom: Vec<KeyBinding>,
|
||||
/// Open the agent command center.
|
||||
pub(crate) open_agents: Vec<KeyBinding>,
|
||||
/// 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}"),
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
+7
-7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user