mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¶ms, 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(¶ms, KEYMAP_ALL_TAB_ID);
|
||||
let snapshot = all_tab
|
||||
.items
|
||||
.iter()
|
||||
.take(12)
|
||||
.take(13)
|
||||
.map(|item| {
|
||||
format!(
|
||||
"{} | {} | {}",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+63
@@ -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
|
||||
+1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user