Add a shortcut to hide the focused side conversation pane (#59130)

## What changed

Add `tui.keymap.global.hide_pane`, defaulting to `leader -` in workspace mode. Hiding a focused side conversation returns to its parent while retaining the conversation, drafts, and view state for reopening.

Expose the action in the workspace keymap picker, validate binding conflicts, and suppress held shortcut keys after switching focus.

## Testing

Add coverage for hiding and reopening at split-pane and narrow widths, preserving conversation state, shortcut availability, key capture, and repeat/release handling.

GitOrigin-RevId: 6897c34b208041e6f90ae0e18c781eebd62566bd
This commit is contained in:
Felipe Coury
2026-11-03 12:08:29 +00:00
committed by copyberry
parent fc39cfcacd
commit eb15d3a0f5
30 changed files with 573 additions and 121 deletions
+2
View File
@@ -137,6 +137,8 @@ pub struct TuiGlobalKeymap {
pub leader: Option<KeybindingsSpec>,
/// Change where a new conversation runs.
pub change_host: Option<KeybindingsSpec>,
/// Hide the focused sidebar pane.
pub hide_pane: Option<KeybindingsSpec>,
/// Open the shared agent-session overview.
pub open_agents: Option<KeybindingsSpec>,
/// Open the transcript overlay.
+10
View File
@@ -5279,6 +5279,7 @@
"copy": null,
"find_transcript": null,
"focus_activity": null,
"hide_pane": null,
"leader": null,
"open_agents": null,
"open_external_editor": null,
@@ -6108,6 +6109,14 @@
],
"description": "Focus activity groups in the owned transcript to inspect their details."
},
"hide_pane": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Hide the focused sidebar pane."
},
"leader": {
"allOf": [
{
@@ -6321,6 +6330,7 @@
"copy": null,
"find_transcript": null,
"focus_activity": null,
"hide_pane": null,
"leader": null,
"open_agents": null,
"open_external_editor": null,
+19 -2
View File
@@ -966,7 +966,7 @@ impl App {
&event,
TuiEvent::Key(_) | TuiEvent::Mouse(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost
) {
self.expire_pending_key_chord();
self.expire_pending_key_chord(tui);
self.handle_draw_pre_render(tui, screen_size)?;
}
@@ -992,7 +992,7 @@ impl App {
let mut event = if let TuiEvent::Key(mut key_event) = event {
let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
if self.should_recover_vim_insert_escape(key_event)
if self.should_recover_vim_insert_escape(tui, key_event)
&& !(tui.is_owned_screen()
&& crate::transcript_view::JumpTarget::from_key(key_event).is_some())
{
@@ -1012,6 +1012,23 @@ impl App {
};
self.cancel_primed_browsing_for_event(&event);
if let TuiEvent::Key(key) = &event
&& self.keymap.app.hide_pane.is_pressed(*key)
&& self.side_pane_shortcuts_available(tui)
{
if key.kind == KeyEventKind::Press {
let activation = self
.routed_key_activation
.take()
.unwrap_or_else(|| KeyBinding::from_event(*key));
if let Err(err) = self.hide_focused_side_pane(tui, app_server).await {
self.chat_widget.add_error_message(err.to_string());
}
self.chat_widget
.suppress_current_view_key_until_release(activation, Instant::now());
}
return Ok(AppRunControl::Continue);
}
let voice_toggle = |app: &Self, key: KeyEvent| {
key.kind == KeyEventKind::Press
&& app
@@ -84,7 +84,7 @@ async fn run_lifecycle_modal<T>(
}
tui::TuiEvent::Key(_) | tui::TuiEvent::Paste(_) | tui::TuiEvent::FocusLost | tui::TuiEvent::Mouse(_) => {}
tui::TuiEvent::Draw | tui::TuiEvent::Resize(_) | tui::TuiEvent::Resume | tui::TuiEvent::FocusGained => {
app.expire_pending_key_chord();
app.expire_pending_key_chord(tui);
app.render_chat_widget_frame(tui, screen_size)?;
}
}
+8 -2
View File
@@ -3806,7 +3806,10 @@ impl App {
}
};
let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) {
let runtime_keymap = match RuntimeKeymap::for_session(
&keymap_config,
self.local_settings.workspace_enabled(&self.config),
) {
Ok(runtime_keymap) => runtime_keymap,
Err(err) => {
let params = crate::keymap_setup::build_keymap_conflict_params(
@@ -3899,7 +3902,10 @@ impl App {
}
};
let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) {
let runtime_keymap = match RuntimeKeymap::for_session(
&keymap_config,
self.local_settings.workspace_enabled(&self.config),
) {
Ok(runtime_keymap) => runtime_keymap,
Err(err) => {
self.app_event_tx.send(AppEvent::FollowTranscript);
+49 -28
View File
@@ -12,8 +12,12 @@ use crate::keymap::keymap_action_ids;
mod select_mode;
impl App {
pub(super) fn should_recover_vim_insert_escape(&self, key_event: KeyEvent) -> bool {
let active_contexts = self.active_keymap_contexts();
pub(super) fn should_recover_vim_insert_escape(
&self,
tui: &tui::Tui,
key_event: KeyEvent,
) -> bool {
let active_contexts = self.key_chord_contexts(tui);
let rename_active = self
.chat_widget
.selected_index_for_active_view(AGENTS_OVERVIEW_VIEW_ID)
@@ -92,6 +96,7 @@ impl App {
if self.overlay.is_none() && self.chat_widget.handle_quick_dial_menu(key_event) {
return None;
}
let contexts = self.key_chord_contexts(tui);
let select_mode_chord = self.select_mode_owns_chord(tui, key_event);
if (matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event))
|| (tui.is_owned_screen()
@@ -103,36 +108,43 @@ impl App {
|| crate::transcript_view::JumpTarget::from_key(key_event).is_none())))
&& !select_mode_chord
{
let close_chord = tui.is_owned_screen()
let close = tui.is_owned_screen()
&& self.overlay.is_none()
&& self.transcript_view.is_detailed()
&& !self.transcript_view.has_active_interaction()
&& !self.backtrack.overlay_preview_active
&& match self.key_chord_matcher.clone().advance(
key_event,
&self.keymap.chords,
self.active_keymap_contexts(),
) {
crate::keymap::KeyChordMatch::Completed(event) => {
self.keymap.pager.close_transcript.is_pressed(event)
}
crate::keymap::KeyChordMatch::Pending(prefix) => {
self.keymap.chords.bindings.iter().any(|binding| {
binding.action.context == crate::keymap::KeymapContext::Pager
&& binding.action.action == "close_transcript"
&& binding.chord.prefix == prefix
})
}
crate::keymap::KeyChordMatch::PassThrough
| crate::keymap::KeyChordMatch::Cancelled
| crate::keymap::KeyChordMatch::Ignored => false,
};
if !close_chord {
&& !self.backtrack.overlay_preview_active;
let allowed = |action: crate::keymap::KeymapActionId| {
(close
&& action.context == crate::keymap::KeymapContext::Pager
&& action.action == "close_transcript")
|| (action.is_side_pane() && contexts.contains_action(action))
};
let chord = match self.key_chord_matcher.clone().advance(
key_event,
&self.keymap.chords,
contexts,
) {
crate::keymap::KeyChordMatch::Completed(event) => {
self.keymap.chords.bindings.iter().any(|binding| {
allowed(binding.action)
&& crate::keymap::dispatch_event(binding.action) == Some(event)
})
}
crate::keymap::KeyChordMatch::Pending(prefix) => self
.keymap
.chords
.bindings
.iter()
.any(|binding| binding.chord.prefix == prefix && allowed(binding.action)),
crate::keymap::KeyChordMatch::PassThrough
| crate::keymap::KeyChordMatch::Cancelled
| crate::keymap::KeyChordMatch::Ignored => false,
};
if !chord {
self.cancel_pending_key_chord();
return Some(key_event);
}
}
let contexts = self.active_keymap_contexts();
let was_pending = self.key_chord_matcher.is_pending();
if !was_pending
&& contexts.is_warnings()
@@ -184,7 +196,7 @@ impl App {
}
crate::keymap::KeyChordMatch::Completed(dispatch_event) => {
self.set_key_chord_hint_override(/*items*/ None);
// Keep the real key so the newly opened config editor ignores it until release.
// A view transition needs the physical completion key to suppress held input.
self.routed_key_activation = Some(KeyBinding::from_event(key_event));
Some(dispatch_event)
}
@@ -196,11 +208,11 @@ impl App {
}
}
pub(super) fn expire_pending_key_chord(&mut self) {
pub(super) fn expire_pending_key_chord(&mut self, tui: &tui::Tui) {
if self.chat_widget.take_key_chord_reset() {
self.cancel_pending_key_chord();
}
let contexts = self.active_keymap_contexts();
let contexts = self.key_chord_contexts(tui);
if self.key_chord_matcher.expire(contexts) {
self.set_key_chord_hint_override(/*items*/ None);
}
@@ -225,6 +237,15 @@ impl App {
self.chat_widget.set_footer_hint_override(items);
}
fn key_chord_contexts(&self, tui: &tui::Tui) -> crate::keymap::KeymapContextSet {
let contexts = self.active_keymap_contexts();
if self.side_pane_shortcuts_available(tui) {
contexts.with_side_pane()
} else {
contexts
}
}
pub(super) fn active_keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
use crate::keymap::KeymapContext;
use crate::keymap::KeymapContextSet;
+1 -1
View File
@@ -24,7 +24,7 @@ impl App {
if !select_mode {
return false;
}
let contexts = self.active_keymap_contexts();
let contexts = self.key_chord_contexts(tui);
let search_editing = matches!(
&self.overlay,
Some(Overlay::Transcript(overlay)) if overlay.is_search_editing()
@@ -742,6 +742,11 @@ impl App {
});
}
self.render_thread_snapshot(tui, app_server, thread_id, snapshot, !is_replay_only)?;
if self.side_retention_enabled(tui)
&& let Some(side) = self.side_threads.get_mut(&thread_id)
{
side.pane_open = true;
}
if is_replay_only
&& self
.thread_event_channels
+39 -3
View File
@@ -222,6 +222,8 @@ pub(super) struct SideThreadState {
pub(super) parent_thread_id: ThreadId,
/// Parent-thread condition that changed while this side thread is visible.
pub(super) parent_status: Option<SideParentStatus>,
/// A hidden pane keeps its child and parked display for the next focus switch.
pub(super) pane_open: bool,
}
impl SideThreadState {
@@ -229,6 +231,7 @@ impl SideThreadState {
Self {
parent_thread_id,
parent_status: None,
pane_open: true,
}
}
}
@@ -316,9 +319,18 @@ impl App {
}
pub(super) fn side_retention_enabled(&self, tui: &tui::Tui) -> bool {
self.config.features.enabled(Feature::TuiWorkspace)
&& self.local_settings.transcript_mode.is_owned()
&& tui.is_owned_screen()
self.local_settings.workspace_enabled(&self.config) && tui.is_owned_screen()
}
pub(super) fn side_pane_shortcuts_available(&self, tui: &tui::Tui) -> bool {
self.side_retention_enabled(tui)
&& !self.reconnect.offline
&& self.overlay.is_none()
&& self.chat_widget.no_modal_or_popup_active()
&& self
.current_displayed_thread_id()
.and_then(|id| self.side_pair_key(id))
.is_some()
}
pub(super) fn side_pair_key(&self, thread_id: ThreadId) -> Option<ThreadId> {
@@ -453,6 +465,30 @@ impl App {
Ok(())
}
pub(super) async fn hide_focused_side_pane(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
) -> Result<()> {
let Some(child) = self.current_displayed_thread_id() else {
return Ok(());
};
let Some(side) = self.side_threads.get(&child) else {
return Ok(());
};
let parent = side.parent_thread_id;
self.select_agent_thread(tui, app_server, parent).await?;
if self.current_displayed_thread_id() == Some(parent)
&& let Some(side) = self.side_threads.get_mut(&child)
{
side.pane_open = false;
tui.frame_requester().schedule_frame();
self.surface_pending_inactive_thread_interactive_requests()
.await?;
}
Ok(())
}
pub(super) async fn discard_side_thread(
&mut self,
app_server: &mut AppServerSession,
+11 -5
View File
@@ -33,10 +33,16 @@ pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option<Sid
return None;
}
let active = app.current_displayed_thread_id()?;
let side = app.side_threads.get(&active);
let passive_thread = match side {
Some(child) => child.parent_thread_id,
None => app.side_thread_for_parent(active)?,
let child = app.side_pair_key(active)?;
let side = app.side_threads.get(&child)?;
if !side.pane_open {
return None;
}
let focused_child = active == child;
let passive_thread = if focused_child {
side.parent_thread_id
} else {
child
};
if !app.side_companions.contains_key(&passive_thread) {
return None;
@@ -46,7 +52,7 @@ pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option<Sid
Layout::horizontal([Constraint::Fill(1), Constraint::Length(child_width)])
.spacing(2)
.areas(area);
let (focused, passive) = if side.is_some() {
let (focused, passive) = if focused_child {
(child, parent)
} else {
(parent, child)
+9 -6
View File
@@ -962,14 +962,17 @@ impl App {
));
}
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let runtime_keymap =
RuntimeKeymap::from_config(&local_settings.tui.keymap).map_err(|err| {
color_eyre::eyre::eyre!(
"Invalid `tui.keymap` configuration: {err}\n\
let runtime_keymap = RuntimeKeymap::for_session(
&local_settings.tui.keymap,
local_settings.workspace_enabled(&config),
)
.map_err(|err| {
color_eyre::eyre::eyre!(
"Invalid `tui.keymap` configuration: {err}\n\
Fix the config and retry.\n\
See the Codex keymap documentation for supported actions and examples."
)
});
)
});
let runtime_keymap = startup_try!(runtime_keymap);
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
+5
View File
@@ -15,6 +15,11 @@ use codex_models_manager::test_support::get_model_offline_for_tests;
pub(super) fn enable_side_retention(app: &mut App) -> Result<()> {
app.config.features.enable(Feature::TuiWorkspace)?;
app.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Owned;
app.keymap = crate::keymap::RuntimeKeymap::for_session(
&app.local_settings.tui.keymap,
app.local_settings.workspace_enabled(&app.config),
)
.map_err(color_eyre::eyre::Report::msg)?;
Ok(())
}
+107 -12
View File
@@ -3363,16 +3363,14 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
app.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(
app.chat_widget.config_ref(),
));
app.select_permission_profile(
&mut app_server,
PermissionProfileSelection {
profile_id: ":read-only".into(),
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::User),
display_label: "Read Only".into(),
},
)
.await;
let read_only = PermissionProfileSelection {
profile_id: ":read-only".into(),
approval_policy: Some(AskForApproval::OnRequest),
approvals_reviewer: Some(ApprovalsReviewer::User),
display_label: "Read Only".into(),
};
app.select_permission_profile(&mut app_server, read_only.clone())
.await;
let control = Box::pin(app.handle_start_side(
&mut tui,
@@ -3530,6 +3528,28 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
))
);
macro_rules! keys {
($($key:expr),+) => {{
for key in [$($key),+] {
boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key))).await?;
}
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
}};
}
let leader = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
let [minus, repeat, release] = [
KeyEventKind::Press,
KeyEventKind::Repeat,
KeyEventKind::Release,
]
.map(|kind| KeyEvent::new_with_kind(KeyCode::Char('-'), KeyModifiers::NONE, kind));
assert!(app.side_pane_shortcuts_available(&tui));
// Hide is consumed on the parent without closing the visible pane or typing its completion.
keys!(leader, minus, repeat, release);
assert!(!app.chat_widget.is_in_paste_burst());
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
assert_eq!(app.chat_widget.composer_text_with_pending(), "parent draft");
for width in [100, 99, 100] {
let frame = side_frame(&mut app, &mut tui, Size::new(width, /*height*/ 18))?;
assert!(
@@ -3580,8 +3600,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
let inactive = composer_gutter(&mut app, &mut tui)?;
let toggle = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL);
boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(toggle))).await?;
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
keys!(toggle);
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
let wide = side_frame(&mut app, &mut tui, size)?;
assert!(
@@ -3595,6 +3614,82 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
format!("Inactive\n{inactive}\n\nFocused\n{focused}")
);
// The pending permission change refuses a parent switch even though selection returns Ok.
app.pending_server_profiles
.insert(side_thread_id, read_only);
keys!(leader, minus, repeat, release);
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
assert_eq!(app.chat_widget.composer_text_with_pending(), "child draft!");
assert!(!app.chat_widget.is_in_paste_burst());
let refused = side_frame(&mut app, &mut tui, size)?;
assert!(
refused.contains(parent_row) && refused.contains(child_row) && refused.contains('│'),
"{refused}"
);
app.pending_server_profiles.remove(&side_thread_id);
let channel = Arc::clone(&app.thread_event_channels[&side_thread_id].store);
let mut frames = Vec::new();
for width in [100, 99] {
let size = Size::new(width, /*height*/ 18);
side_frame(&mut app, &mut tui, size)?;
let prefix = if width == 100 {
leader
} else {
app.local_settings.tui.keymap = toml::from_str("[global]\nleader = 'f12'")?;
enable_side_retention(&mut app)?;
KeyCode::F(12).into()
};
assert!(app.transcript_view.begin_select_mode(&app.transcript_cells));
keys!(prefix, minus);
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
keys!(repeat, release);
assert!(!app.chat_widget.is_in_paste_burst());
let hidden = side_frame(&mut app, &mut tui, size)?;
assert!(
hidden.contains(parent_row) && !hidden.contains(child_row) && !hidden.contains('│'),
"{hidden}"
);
if width == 100 {
frames.push(format!("{width} columns, hidden\n{hidden}"));
assert_eq!(app.chat_widget.composer_text_with_pending(), "parent draft");
// A new physical press after release belongs to the parent, including its final raw key.
keys!(minus, toggle);
} else {
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"parent draft-"
);
boxed_future!(app.handle_start_side(
&mut tui,
&mut app_server,
parent_thread_id,
/*user_message*/ None
))
.await?;
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
}
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
assert!(Arc::ptr_eq(
&channel,
&app.thread_event_channels[&side_thread_id].store
));
assert!(app.transcript_view.is_select_mode());
app.transcript_view.end_select_mode();
let reopened = side_frame(&mut app, &mut tui, size)?;
assert!(
reopened.contains(child_row) && reopened.contains("child draft!"),
"{reopened}"
);
assert_eq!(
reopened.contains("parent draft-"),
width == 100,
"{reopened}"
);
frames.push(format!("{width} columns, reopened\n{reopened}"));
}
assert_app_snapshot!("hide_and_reopen_side_conversation", frames.join("\n\n"));
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 {
+77 -11
View File
@@ -609,12 +609,12 @@ async fn legacy_terminal_preserves_image_paste_without_reading_clipboard() -> Re
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT),
KeyEvent::new(KeyCode::Char('V'), KeyModifiers::ALT | KeyModifiers::SHIFT),
] {
assert!(!app.should_recover_vim_insert_escape(key));
assert!(!app.should_recover_vim_insert_escape(&tui, key));
}
assert!(app.should_recover_vim_insert_escape(KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::ALT,
)));
assert!(app.should_recover_vim_insert_escape(
&tui,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT,)
));
}
Ok(())
}
@@ -642,12 +642,12 @@ async fn legacy_terminal_preserves_agent_shortcuts_without_editor_word_bindings(
for key in ['b', 'f'] {
let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::ALT);
assert!(!app.should_recover_vim_insert_escape(event));
assert!(!app.should_recover_vim_insert_escape(&tui, event));
}
app.chat_widget.insert_str("abc");
for key in ['b', 'f'] {
let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::ALT);
assert!(app.should_recover_vim_insert_escape(event));
assert!(app.should_recover_vim_insert_escape(&tui, event));
}
}
Ok(())
@@ -1044,6 +1044,36 @@ async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Re
before
);
assert!(!app.key_chord_matcher.is_pending());
super::enable_side_retention(&mut app)?;
let parent = ThreadId::new();
app.active_thread_id = Some(parent);
app.side_threads
.insert(ThreadId::new(), super::SideThreadState::new(parent));
app.keymap = RuntimeKeymap::for_session(
&toml::from_str("[pager]\nscroll_up = 'f6 u'\nscroll_down = 'f6 d'")?,
/*workspace*/ true,
)
.unwrap();
for (stroke, expected) in [
('u', before),
(
'd',
app.transcript_view.selected_text(&app.transcript_cells),
),
] {
press(&mut app, &mut tui, &mut app_server, KeyCode::F(6).into()).await?;
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char(stroke).into(),
)
.await?;
assert_eq!(
app.transcript_view.selected_text(&app.transcript_cells),
expected
);
}
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
Ok(())
@@ -1139,8 +1169,19 @@ async fn key_capture_receives_voice_shortcut_and_chord_prefix() -> Result<()> {
let (mut app, mut rx, _op_rx) = super::make_test_app_with_channels().await;
let mut app_server = start_config_write_test_app_server(&app).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
for config in ["", "[chat]\ntoggle_voice = 'f8 v'"] {
app.keymap = RuntimeKeymap::from_config(&toml::from_str(config)?).unwrap();
super::enable_side_retention(&mut app)?;
tui.set_owned_screen(true)?;
let parent = ThreadId::new();
app.active_thread_id = Some(parent);
app.side_threads
.insert(ThreadId::new(), super::SideThreadState::new(parent));
for (config, key) in [
("", 8),
("[chat]\ntoggle_voice = 'f8 v'", 8),
("[global]\nhide_pane = 'f12'", 12),
] {
app.keymap =
RuntimeKeymap::for_session(&toml::from_str(config)?, /*workspace*/ true).unwrap();
app.chat_widget.open_keymap_capture(
"composer".into(),
"submit".into(),
@@ -1148,10 +1189,35 @@ async fn key_capture_receives_voice_shortcut_and_chord_prefix() -> Result<()> {
crate::app_event::KeymapCaptureMode::SingleKey,
&app.keymap,
);
press(&mut app, &mut tui, &mut app_server, KeyCode::F(8).into()).await?;
press(&mut app, &mut tui, &mut app_server, KeyCode::F(key).into()).await?;
assert!(
matches!(rx.try_recv()?, crate::app_event::AppEvent::KeymapCaptured { key, .. } if key == "f8")
matches!(rx.try_recv()?, crate::app_event::AppEvent::KeymapCaptured { key: captured, .. } if captured == format!("f{key}"))
);
}
// A printable remap belongs to the composer unless this session and thread own a pane.
app.keymap = RuntimeKeymap::for_session(
&toml::from_str("[global]\nhide_pane = '%'")?,
/*workspace*/ true,
)
.unwrap();
for (thread, enabled, owned, offline, draft) in [
(parent, true, true, false, ""),
(ThreadId::new(), true, true, false, "%"),
(parent, false, true, false, "%"),
(parent, true, false, false, "%"),
(parent, true, true, true, "%"),
] {
app.active_thread_id = Some(thread);
app.config
.features
.set_enabled(codex_features::Feature::TuiWorkspace, enabled)?;
tui.set_owned_screen(owned)?;
app.reconnect.offline = offline;
app.chat_widget.apply_external_edit(String::new());
for key in [KeyCode::Char('%'), KeyCode::Right] {
press(&mut app, &mut tui, &mut app_server, key.into()).await?;
}
assert_eq!(app.chat_widget.composer_text_with_pending(), draft);
}
Ok(())
}
@@ -95,7 +95,7 @@ async fn inactive_thread_user_verification_replays_queued_request_once() -> Resu
#[tokio::test]
async fn side_toggle_surfaces_pending_verification_from_an_inactive_thread() -> Result<()> {
Box::pin(async {
let check_return = async |hide: bool| -> Result<()> {
let (mut app, _events, _ops) = make_test_app_with_channels().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
@@ -116,6 +116,8 @@ async fn side_toggle_surfaces_pending_verification_from_an_inactive_thread() ->
),
);
let mut tui = crate::tui::test_support::make_test_tui()?;
enable_side_retention(&mut app)?;
tui.set_owned_screen(/*owned*/ true)?;
app.select_agent_thread(&mut tui, &mut app_server, side_thread_id)
.await?;
assert_eq!(app.active_thread_id, Some(side_thread_id));
@@ -134,17 +136,26 @@ async fn side_toggle_surfaces_pending_verification_from_an_inactive_thread() ->
.await?;
assert!(!app.chat_widget.has_active_view());
app.toggle_side_conversation(&mut tui, &mut app_server)
.await?;
if hide {
app.hide_focused_side_pane(&mut tui, &mut app_server)
.await?;
} else {
app.toggle_side_conversation(&mut tui, &mut app_server)
.await?;
}
assert_eq!(app.active_thread_id, Some(primary_thread_id));
assert!(app.chat_widget.has_active_view());
assert!(
app.chat_widget.has_active_view(),
"missing verification prompt after return (hide={hide})"
);
assert!(
render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Approve deployment?")
);
app_server.shutdown().await?;
Ok(())
})
.await
};
Box::pin(check_return(/*hide*/ false)).await?;
Box::pin(check_return(/*hide*/ true)).await
}
#[tokio::test]
+4 -1
View File
@@ -391,7 +391,10 @@ impl App {
return;
}
let local_settings = self.local_settings.reloaded(&config);
let keymap = match RuntimeKeymap::from_config(&local_settings.tui.keymap) {
let keymap = match RuntimeKeymap::for_session(
&local_settings.tui.keymap,
local_settings.workspace_enabled(&config),
) {
Ok(keymap) => keymap,
Err(error) => return self.chat_widget.add_error_message(error),
};
+1 -2
View File
@@ -69,8 +69,7 @@ impl ChatWidget {
}
}
if let Some(activated_at) = handoff.active_at() {
self.bottom_pane
.suppress_current_view_key_until_release(handoff.activation_key, activated_at);
self.suppress_current_view_key_until_release(handoff.activation_key, activated_at);
}
self.bottom_pane.config_editor_opened();
}
+5 -1
View File
@@ -76,7 +76,11 @@ impl ChatWidget {
&header_model,
&model_catalog.try_list_models().unwrap_or_default(),
);
let runtime_keymap = RuntimeKeymap::from_config(&local_settings.tui.keymap).ok();
let runtime_keymap = RuntimeKeymap::for_session(
&local_settings.tui.keymap,
local_settings.workspace_enabled(&config),
)
.ok();
let default_keymap = RuntimeKeymap::defaults();
let copy_last_response_binding = runtime_keymap
.as_ref()
@@ -87,6 +87,15 @@ impl ChatWidget {
std::mem::take(&mut self.bottom_pane.key_chord_reset_requested)
}
pub(crate) fn suppress_current_view_key_until_release(
&mut self,
key: KeyBinding,
activated_at: Instant,
) {
self.bottom_pane
.suppress_current_view_key_until_release(key, activated_at);
}
pub(crate) fn consume_view_transition_key_event(&mut self, key_event: KeyEvent) -> bool {
self.bottom_pane
.consume_view_transition_key_event(key_event)
+5 -1
View File
@@ -28,7 +28,10 @@ impl ChatWidget {
/// overrides. If the config is invalid, the user sees the parse error instead of a partial
/// picker that could commit edits against stale runtime state.
pub(crate) fn open_keymap_picker(&mut self) {
match RuntimeKeymap::from_config(&self.local_settings.tui.keymap) {
match RuntimeKeymap::for_session(
&self.local_settings.tui.keymap,
self.local_settings.workspace_enabled(self.config_ref()),
) {
Ok(runtime_keymap) => {
let params = keymap_setup::build_keymap_picker_params_with_filter(
&runtime_keymap,
@@ -156,6 +159,7 @@ impl ChatWidget {
keymap_setup::KeymapActionFilter {
fast_mode_enabled: self.fast_mode_enabled(),
multi_backend_enabled: self.config.features.enabled(Feature::TuiMultiBackend),
workspace_enabled: self.local_settings.workspace_enabled(self.config_ref()),
}
}
@@ -889,8 +889,10 @@ impl ChatWidget {
SlashCommand::Keymap => match trimmed.to_ascii_lowercase().as_str() {
"" => self.open_keymap_picker(),
"debug" => {
match crate::keymap::RuntimeKeymap::from_config(&self.local_settings.tui.keymap)
{
match crate::keymap::RuntimeKeymap::for_session(
&self.local_settings.tui.keymap,
self.local_settings.workspace_enabled(self.config_ref()),
) {
Ok(runtime_keymap) => self.open_keymap_debug(&runtime_keymap),
Err(err) => {
self.add_error_message(format!(
+52 -4
View File
@@ -104,6 +104,7 @@ pub(crate) struct RuntimeKeymap {
pub(crate) struct AppKeymap {
/// Change where a new conversation runs.
pub(crate) change_host: Vec<KeyBinding>,
pub(crate) hide_pane: Vec<KeyBinding>,
/// Open the agent command center.
pub(crate) open_agents: Vec<KeyBinding>,
/// Open transcript overlay.
@@ -654,7 +655,7 @@ impl RuntimeKeymap {
DEFAULTS
.get_or_init(|| {
Self::resolve(&TuiKeymap::default()).unwrap_or_else(|error| {
Self::resolve(&TuiKeymap::default(), /*workspace*/ false).unwrap_or_else(|error| {
panic!("built-in keymap defaults must be valid: {error}")
})
})
@@ -675,10 +676,18 @@ impl RuntimeKeymap {
if keymap == &TuiKeymap::default() {
return Ok(Self::defaults());
}
Self::resolve(keymap)
Self::resolve(keymap, /*workspace*/ false)
}
fn resolve(keymap: &TuiKeymap) -> Result<Self, String> {
pub(crate) fn for_session(keymap: &TuiKeymap, workspace: bool) -> Result<Self, String> {
if workspace {
Self::resolve(keymap, workspace)
} else {
Self::from_config(keymap)
}
}
fn resolve(keymap: &TuiKeymap, workspace: bool) -> Result<Self, String> {
let defaults = Self::built_in_defaults();
let chords = Arc::new(RuntimeChordKeymap::from_config(keymap)?);
let side_toggle_default_is_shadowed = keymap.global.toggle_side_conversation.is_none()
@@ -730,6 +739,11 @@ impl RuntimeKeymap {
&defaults.app.change_host,
"tui.keymap.global.change_host",
)?,
hide_pane: resolve_bindings(
keymap.global.hide_pane.as_ref(),
&[],
"tui.keymap.global.hide_pane",
)?,
open_agents: resolve_bindings(
keymap.global.open_agents.as_ref(),
&defaults.app.open_agents,
@@ -1678,7 +1692,7 @@ impl RuntimeKeymap {
}
}
resolved.configure_search(keymap)?;
chords::leader_defaults::register(keymap, &mut resolved);
chords::leader_defaults::register(keymap, &mut resolved, workspace);
resolved.validate_conflicts()?;
chords::validate_chord_conflicts(&resolved)?;
chords::install_dispatch_bindings(&mut resolved)?;
@@ -1705,6 +1719,7 @@ impl RuntimeKeymap {
Self {
app: AppKeymap {
change_host: Vec::new(),
hide_pane: Vec::new(),
open_agents: default_bindings![],
open_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
find_transcript: default_bindings![plain(KeyCode::F(3))],
@@ -2056,6 +2071,7 @@ impl RuntimeKeymap {
for (action, bindings) in [
("global.change_host", &self.app.change_host),
("global.open_agents", &self.app.open_agents),
("global.hide_pane", &self.app.hide_pane),
("chat.open_presets", &self.chat.open_presets),
("chat.toggle_voice", &self.chat.toggle_voice),
] {
@@ -2085,6 +2101,7 @@ impl RuntimeKeymap {
let main_bindings = [
("change_host", self.app.change_host.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()),
("find_transcript", self.app.find_transcript.as_slice()),
@@ -2253,6 +2270,7 @@ impl RuntimeKeymap {
validate_no_shadow_with_allowed_overlaps(
"main",
[
("hide_pane", self.app.hide_pane.as_slice()),
("open_agents", self.app.open_agents.as_slice()),
("open_transcript", self.app.open_transcript.as_slice()),
("find_transcript", self.app.find_transcript.as_slice()),
@@ -2378,6 +2396,7 @@ impl RuntimeKeymap {
validate_unique(
"transcript",
[
("global.hide_pane", self.app.hide_pane.as_slice()),
(
"global.select_transcript",
self.app.select_transcript.as_slice(),
@@ -2410,6 +2429,7 @@ impl RuntimeKeymap {
validate_unique(
"activity",
context_bindings(KeymapContext::List).chain([
("global.hide_pane", self.app.hide_pane.as_slice()),
("global.focus_activity", self.app.focus_activity.as_slice()),
(
"global.find_transcript",
@@ -3871,6 +3891,27 @@ mod tests {
#[test]
fn toggle_side_conversation_can_be_remapped_and_rejects_conflicts() {
for (section, action) in [("pager", "page_up"), ("list", "move_down")] {
let config: TuiKeymap = toml::from_str(&format!(
"[global]\nhide_pane = 'f8'\n[{section}]\n{action} = 'f8'"
))
.unwrap();
let error = RuntimeKeymap::for_session(&config, /*workspace*/ true).unwrap_err();
assert!(
error.contains("global.hide_pane") && error.contains(action),
"{error}"
);
}
#[cfg(unix)]
{
let mut config = TuiKeymap::default();
config.global.hide_pane = Some(one("ctrl-z"));
assert!(
RuntimeKeymap::for_session(&config, /*workspace*/ true)
.unwrap_err()
.contains("suspend")
);
}
let mut keymap = TuiKeymap::default();
keymap.global.toggle_side_conversation = Some(one("f12"));
@@ -4063,6 +4104,13 @@ mod tests {
keymap.composer.toggle_shortcuts = Some(KeybindingsSpec::Many(vec![]));
let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse");
assert!(runtime.composer.toggle_shortcuts.is_empty());
keymap.global.hide_pane = Some(KeybindingsSpec::Many(vec![]));
let runtime = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap();
assert!(
runtime
.primary_hint(KeymapContext::Global, "hide_pane")
.is_none()
);
}
#[test]
+10
View File
@@ -103,12 +103,21 @@ pub(crate) struct KeymapActionId {
}
impl KeymapActionId {
pub(crate) fn is_side_pane(self) -> bool {
self.context == KeymapContext::Global && self.action == "hide_pane"
}
/// Whether two actions can be active together, including list-specific exceptions.
pub(super) fn overlaps(self, other: Self) -> bool {
use KeymapContext::Chat;
use KeymapContext::Global;
use KeymapContext::List;
if self.is_side_pane() && matches!(other.context, List | KeymapContext::Pager)
|| other.is_side_pane() && matches!(self.context, List | KeymapContext::Pager)
{
return true;
}
match (self.context, other.context) {
// Presets cannot open while a list owns input.
(Chat, List) => self.action != "open_presets",
@@ -290,6 +299,7 @@ define_runtime_action_bindings! {
find_transcript,
select_transcript,
focus_activity,
hide_pane,
open_warnings => runtime_only,
open_external_editor,
copy,
+8
View File
@@ -54,6 +54,7 @@ const RAW_KEY_CAPTURE: u32 = 1 << 18;
const VOICE_TOGGLE_BLOCKED: u32 = 1 << 19;
const TRANSCRIPT_ACTIONS: u32 = 1 << 20;
const BACKEND_CYCLING: u32 = 1 << 21;
const SIDE_PANE: u32 = 1 << 22;
const TRANSCRIPT_BROWSING: u32 = 1 << 14;
@@ -156,11 +157,18 @@ impl KeymapContextSet {
}
}
pub(crate) const fn with_side_pane(self) -> Self {
Self(self.0 | SIDE_PANE)
}
/// Whether this input path can dispatch the action, including focus-specific exclusions.
pub(crate) fn contains_action(self, action: KeymapActionId) -> bool {
if action.context == KeymapContext::Global && action.action == "change_host" {
return self.0 & BACKEND_CYCLING != 0;
}
if action.is_side_pane() {
return self.0 & SIDE_PANE != 0;
}
if action.context == KeymapContext::Chat && action.action == "open_presets" {
return self.contains(KeymapContext::Chat)
&& self.contains(KeymapContext::Global)
@@ -1,4 +1,4 @@
//! Install implicit preset and selection shortcuts only for free leader chords.
//! Install implicit backend, preset, selection, and side-pane shortcuts for free leader chords.
//! Explicit bindings, including an explicit unbind, always take priority.
//! Implicit chords fit within the shared leader expansion limit.
@@ -13,7 +13,11 @@ use super::runtime_action_bindings;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeymap) {
pub(in crate::keymap) fn register(
keymap: &TuiKeymap,
resolved: &mut RuntimeKeymap,
workspace: bool,
) {
let mut remaining = MAX_LEADER_CHORD_BINDINGS.saturating_sub(
resolved
.chords
@@ -22,36 +26,29 @@ pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeym
.filter(|binding| binding.is_leader())
.count(),
);
for (configured, context, action, completion, spec) in [
(
keymap.global.change_host.as_ref(),
KeymapContext::Global,
"change_host",
'b',
"leader b",
),
(
keymap.global.select_transcript.as_ref(),
KeymapContext::Global,
"select_transcript",
'[',
"leader [",
),
(
keymap.chat.open_presets.as_ref(),
KeymapContext::Chat,
"open_presets",
'p',
"leader p",
),
use KeymapContext::Chat;
use KeymapContext::Global;
for (context, name, completion) in [
(Global, "change_host", 'b'),
(Global, "select_transcript", '['),
(Chat, "open_presets", 'p'),
(Global, "hide_pane", '-'),
] {
if remaining == 0 {
break;
}
if configured.is_some() {
let action = KeymapActionId {
context,
action: name,
};
if (!workspace && action.is_side_pane())
|| !matches!(
super::configured_binding_for_action(keymap, action),
Some(None)
)
{
continue;
}
let action = KeymapActionId { context, action };
for prefix in resolved.chords.leader.clone() {
let binding = RuntimeChordBinding {
action,
@@ -59,7 +56,7 @@ pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeym
prefix,
completion: crate::key_hint::plain(KeyCode::Char(completion)),
},
spec: spec.into(),
spec: format!("leader {completion}"),
};
if !resolved.chords.bindings.iter().any(|existing| {
binding.overlaps(existing.action) && existing.chord == binding.chord
+16 -6
View File
@@ -896,13 +896,15 @@ mod tests {
#[test]
fn picker_covers_every_replaceable_action() {
let runtime = RuntimeKeymap::defaults();
let keymap = TuiKeymap::default();
let runtime = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap();
let params = build_keymap_picker_params_with_filter(
&runtime,
&TuiKeymap::default(),
&keymap,
KeymapActionFilter {
multi_backend_enabled: true,
fast_mode_enabled: true,
multi_backend_enabled: true,
workspace_enabled: true,
},
);
let all_tab = selection_tab(&params, KEYMAP_ALL_TAB_ID);
@@ -1276,13 +1278,21 @@ mod tests {
#[test]
fn picker_all_tab_items_remain_searchable() {
let runtime = RuntimeKeymap::defaults();
let params = build_keymap_picker_params(&runtime, &TuiKeymap::default());
let keymap = TuiKeymap::default();
let runtime = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap();
let params = build_keymap_picker_params_with_filter(
&runtime,
&keymap,
KeymapActionFilter {
workspace_enabled: true,
..Default::default()
},
);
let all_tab = selection_tab(&params, KEYMAP_ALL_TAB_ID);
let snapshot = all_tab
.items
.iter()
.take(12)
.take(13)
.map(|item| {
format!(
"{} | {} | {}",
+4
View File
@@ -36,12 +36,14 @@ pub(super) struct KeymapActionDescriptor {
enum KeymapActionFeature {
FastMode,
MultiBackend,
Workspace,
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct KeymapActionFilter {
pub(crate) fast_mode_enabled: bool,
pub(crate) multi_backend_enabled: bool,
pub(crate) workspace_enabled: bool,
}
impl KeymapActionDescriptor {
@@ -50,6 +52,7 @@ impl KeymapActionDescriptor {
None => true,
Some(KeymapActionFeature::FastMode) => filter.fast_mode_enabled,
Some(KeymapActionFeature::MultiBackend) => filter.multi_backend_enabled,
Some(KeymapActionFeature::Workspace) => filter.workspace_enabled,
}
}
}
@@ -115,6 +118,7 @@ macro_rules! define_keymap_actions {
define_keymap_actions! {
global "Global" {
change_host [MultiBackend] => "Change where a new conversation runs.",
hide_pane [Workspace] => "Hide the focused sidebar pane.",
open_agents => "Open the agent command center.",
open_transcript => "Open the transcript overlay.",
find_transcript => "Find text in the full transcript.",
+7
View File
@@ -44,6 +44,13 @@ impl From<&Config> for LocalSettings {
}
impl LocalSettings {
pub(crate) fn workspace_enabled(&self, config: &Config) -> bool {
self.transcript_mode.is_owned()
&& config
.features
.enabled(codex_features::Feature::TuiWorkspace)
}
fn with_accessibility_preferences(
config: &Config,
system_motion: crate::motion::MotionMode,
@@ -0,0 +1,63 @@
---
source: tui/src/app/tests.rs
expression: "frames.join(\"\\n\\n\")"
---
100 columns, hidden
parent-24
parent-25
parent-26
parent-27
parent-28
parent-29
New activity · ↓ Back to bottom · esc
› parent draft
GPT-6.1-Sol default · /tmp/project · Main [default]
New activity · esc latest
100 columns, reopened
parent-24 │ child-24
│
parent-25 │ child-25
│
parent-26 │ child-26
│
parent-27 │ child-27
│
parent-28 │ child-28
│
parent-29 │ child-29
│
│
│
› parent draft- │ › child draft!
│
GPT-6.1-Sol default · /tmp/project │ GPT-6.1-Sol default · /tmp/projec…
│ Side from main thread · ⌃/ to swit
99 columns, reopened
child-24
child-25
child-26
child-27
child-28
child-29
› child draft!
GPT-6.1-Sol default · /tmp/project · Agent
Side from main thread · ⌃/ to switch · ⌃c to close
@@ -2,6 +2,7 @@
source: tui/src/keymap_setup.rs
expression: snapshot
---
Hide Pane | leader - (ctrl-x -) | Global hide_pane Hide Pane Hide the focused sidebar pane. leader - (ctrl-x -) 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