mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Keep side conversations open when switching threads (#35011)
## What changed - Add a configurable `toggle_side_conversation` TUI action, bound to `ctrl-/` by default, to switch between a side conversation and its parent without closing either. - Show the active binding in the footer and distinguish switching from closing with `ctrl-c`. - Allow starting a replacement side conversation from the parent, while cleaning up the previous side thread and preserving it if cleanup fails. - Clean up retained side conversations when shutting down the current thread. ## Testing - Cover shortcut remapping, conflicts, and compatibility with existing `ctrl-/` and `ctrl-7` bindings. - Cover side-thread replacement and footer states for both the parent and side conversation. GitOrigin-RevId: a621c21e1c45ba73197d59750b2e05efa5ee1081
This commit is contained in:
@@ -111,6 +111,8 @@ pub struct TuiGlobalKeymap {
|
||||
pub toggle_fast_mode: Option<KeybindingsSpec>,
|
||||
/// Toggle raw scrollback mode for copy-friendly transcript selection.
|
||||
pub toggle_raw_output: Option<KeybindingsSpec>,
|
||||
/// Switch between a side conversation and its parent without closing either.
|
||||
pub toggle_side_conversation: Option<KeybindingsSpec>,
|
||||
}
|
||||
|
||||
/// Chat context keybindings.
|
||||
|
||||
@@ -3240,6 +3240,7 @@
|
||||
"toggle_fast_mode": null,
|
||||
"toggle_raw_output": null,
|
||||
"toggle_shortcuts": null,
|
||||
"toggle_side_conversation": null,
|
||||
"toggle_vim_mode": null
|
||||
},
|
||||
"list": {
|
||||
@@ -3818,6 +3819,14 @@
|
||||
],
|
||||
"description": "Toggle the composer shortcut overlay."
|
||||
},
|
||||
"toggle_side_conversation": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Switch between a side conversation and its parent without closing either."
|
||||
},
|
||||
"toggle_vim_mode": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -3919,6 +3928,7 @@
|
||||
"toggle_fast_mode": null,
|
||||
"toggle_raw_output": null,
|
||||
"toggle_shortcuts": null,
|
||||
"toggle_side_conversation": null,
|
||||
"toggle_vim_mode": null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2398,6 +2398,7 @@ impl App {
|
||||
self.keymap = runtime_keymap.clone();
|
||||
self.chat_widget
|
||||
.apply_keymap_update(keymap_config, &runtime_keymap);
|
||||
self.sync_side_thread_ui();
|
||||
self.chat_widget
|
||||
.return_to_keymap_picker(&context, &action, &runtime_keymap);
|
||||
self.chat_widget.add_info_message(message, /*hint*/ None);
|
||||
@@ -2448,6 +2449,7 @@ impl App {
|
||||
self.keymap = runtime_keymap.clone();
|
||||
self.chat_widget
|
||||
.apply_keymap_update(keymap_config, &runtime_keymap);
|
||||
self.sync_side_thread_ui();
|
||||
self.chat_widget
|
||||
.return_to_keymap_picker(&context, &action, &runtime_keymap);
|
||||
self.chat_widget.add_info_message(
|
||||
|
||||
@@ -142,6 +142,19 @@ impl App {
|
||||
|
||||
let app_keymap_shortcuts_available = self.app_keymap_shortcuts_available();
|
||||
|
||||
let side_toggle_bindings = &self.keymap.app.toggle_side_conversation;
|
||||
if app_keymap_shortcuts_available
|
||||
&& (side_toggle_bindings.is_pressed(key_event)
|
||||
|| side_toggle_bindings.contains(&crate::key_hint::ctrl(KeyCode::Char('/')))
|
||||
&& crate::key_hint::ctrl(KeyCode::Char('7')).is_press(key_event))
|
||||
{
|
||||
if let Err(err) = self.toggle_side_conversation(tui, app_server).await {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to switch side conversation: {err}"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if app_keymap_shortcuts_available && self.keymap.app.toggle_vim_mode.is_pressed(key_event) {
|
||||
self.chat_widget.toggle_vim_mode_and_notify();
|
||||
return;
|
||||
|
||||
@@ -20,7 +20,7 @@ const SIDE_NO_STARTED_CONVERSATION_MESSAGE: &str = concat!(
|
||||
"Send a message first, then try /side again."
|
||||
);
|
||||
const SIDE_ALREADY_OPEN_MESSAGE: &str =
|
||||
"A side conversation is already open. Press Ctrl+C to return before starting another.";
|
||||
"A side conversation is already open. Press ctrl + c to return before starting another.";
|
||||
const SIDE_BOUNDARY_PROMPT: &str = r#"Side conversation boundary.
|
||||
|
||||
Everything before this boundary is inherited history from the parent thread. It is reference context only. It is not your current task.
|
||||
@@ -236,6 +236,19 @@ impl App {
|
||||
.map(|state| (state.parent_thread_id, state.parent_status))
|
||||
else {
|
||||
clear_side_ui(&mut self.chat_widget);
|
||||
if self
|
||||
.side_threads
|
||||
.values()
|
||||
.any(|state| state.parent_thread_id == active_thread_id)
|
||||
&& let Some(binding) =
|
||||
crate::keymap::primary_binding(&self.keymap.app.toggle_side_conversation)
|
||||
{
|
||||
self.chat_widget
|
||||
.set_side_conversation_context_label(Some(format!(
|
||||
"{} for side",
|
||||
binding.display_label()
|
||||
)));
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -256,7 +269,12 @@ impl App {
|
||||
if let Some(parent_status) = parent_status {
|
||||
label_parts.push(parent_status.label(parent_is_main).to_string());
|
||||
}
|
||||
label_parts.push("Ctrl+C to return".to_string());
|
||||
if let Some(binding) =
|
||||
crate::keymap::primary_binding(&self.keymap.app.toggle_side_conversation)
|
||||
{
|
||||
label_parts.push(format!("{} to switch", binding.display_label()));
|
||||
}
|
||||
label_parts.push("ctrl + c to close".to_string());
|
||||
self.chat_widget
|
||||
.set_side_conversation_context_label(Some(format!("Side {}", label_parts.join(" · "))));
|
||||
}
|
||||
@@ -353,12 +371,37 @@ impl App {
|
||||
&self,
|
||||
target_thread_id: ThreadId,
|
||||
) -> Option<ThreadId> {
|
||||
let side_thread_id = self.current_displayed_thread_id()?;
|
||||
if target_thread_id == side_thread_id || !self.side_threads.contains_key(&side_thread_id) {
|
||||
let active_thread_id = self.current_displayed_thread_id()?;
|
||||
let (&side_thread_id, state) = self.side_threads.iter().next()?;
|
||||
if target_thread_id == side_thread_id || target_thread_id == active_thread_id {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(side_thread_id)
|
||||
(active_thread_id == side_thread_id || active_thread_id == state.parent_thread_id)
|
||||
.then_some(side_thread_id)
|
||||
}
|
||||
|
||||
pub(super) async fn toggle_side_conversation(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
) -> Result<()> {
|
||||
let Some(active_thread_id) = self.current_displayed_thread_id() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some((&side_thread_id, state)) = self.side_threads.iter().next() else {
|
||||
return Ok(());
|
||||
};
|
||||
let target_thread_id = if active_thread_id == side_thread_id {
|
||||
state.parent_thread_id
|
||||
} else if active_thread_id == state.parent_thread_id {
|
||||
side_thread_id
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.select_agent_thread(tui, app_server, target_thread_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn discard_side_thread(
|
||||
@@ -484,7 +527,7 @@ impl App {
|
||||
pub(super) fn side_start_block_message(&self) -> Option<&'static str> {
|
||||
if self.primary_thread_id.is_none() {
|
||||
Some(SIDE_MAIN_THREAD_UNAVAILABLE_MESSAGE)
|
||||
} else if !self.side_threads.is_empty() {
|
||||
} else if self.active_side_parent_thread_id().is_some() {
|
||||
Some(SIDE_ALREADY_OPEN_MESSAGE)
|
||||
} else {
|
||||
None
|
||||
@@ -530,7 +573,6 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<()> {
|
||||
let active_thread_id_before_switch = self.active_thread_id;
|
||||
let side_thread_to_discard = self.side_thread_to_discard_after_switch(thread_id);
|
||||
self.select_agent_thread(tui, app_server, thread_id).await?;
|
||||
if self.active_thread_id == Some(thread_id)
|
||||
@@ -539,7 +581,7 @@ impl App {
|
||||
if self.discard_side_thread(app_server, side_thread_id).await {
|
||||
self.surface_pending_inactive_thread_interactive_requests()
|
||||
.await?;
|
||||
} else if active_thread_id_before_switch == Some(side_thread_id) {
|
||||
} else {
|
||||
self.keep_side_thread_visible_after_cleanup_failure(
|
||||
tui,
|
||||
app_server,
|
||||
@@ -565,6 +607,15 @@ impl App {
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
|
||||
if let Some((&side_thread_id, state)) = self.side_threads.iter().next()
|
||||
&& (parent_thread_id != state.parent_thread_id
|
||||
|| !self.discard_side_thread(app_server, side_thread_id).await)
|
||||
{
|
||||
self.restore_side_user_message(user_message.take());
|
||||
self.sync_side_thread_ui();
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
|
||||
self.session_telemetry.counter(
|
||||
"codex.thread.side",
|
||||
/*inc*/ 1,
|
||||
|
||||
@@ -3788,7 +3788,7 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn side_start_block_message_tracks_open_side_conversation() {
|
||||
async fn side_start_block_message_allows_replacing_open_side_conversation() {
|
||||
let mut app = make_test_app().await;
|
||||
assert_eq!(
|
||||
app.side_start_block_message(),
|
||||
@@ -3803,10 +3803,14 @@ async fn side_start_block_message_tracks_open_side_conversation() {
|
||||
app.side_threads
|
||||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||||
|
||||
app.active_thread_id = Some(parent_thread_id);
|
||||
assert_eq!(app.side_start_block_message(), None);
|
||||
|
||||
app.active_thread_id = Some(side_thread_id);
|
||||
assert_eq!(
|
||||
app.side_start_block_message(),
|
||||
Some(
|
||||
"A side conversation is already open. Press Ctrl+C to return before starting another."
|
||||
"A side conversation is already open. Press ctrl + c to return before starting another."
|
||||
)
|
||||
);
|
||||
|
||||
@@ -4269,6 +4273,16 @@ async fn side_discard_selection_keeps_current_side_thread() {
|
||||
app.side_thread_to_discard_after_switch(parent_thread_id),
|
||||
Some(side_thread_id)
|
||||
);
|
||||
|
||||
app.active_thread_id = Some(parent_thread_id);
|
||||
assert_eq!(
|
||||
app.side_thread_to_discard_after_switch(ThreadId::new()),
|
||||
Some(side_thread_id)
|
||||
);
|
||||
assert_eq!(
|
||||
app.side_thread_to_discard_after_switch(side_thread_id),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -14,6 +14,10 @@ use codex_app_server_protocol::WarningNotification;
|
||||
|
||||
impl App {
|
||||
pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) {
|
||||
let side_thread_ids: Vec<ThreadId> = self.side_threads.keys().copied().collect();
|
||||
for side_thread_id in side_thread_ids {
|
||||
self.discard_side_thread(app_server, side_thread_id).await;
|
||||
}
|
||||
if let Some(thread_id) = self.chat_widget.thread_id() {
|
||||
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
|
||||
tracing::warn!("failed to unsubscribe thread {thread_id}: {err}");
|
||||
|
||||
@@ -593,7 +593,7 @@ pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> {
|
||||
if let Some(rest) = label.strip_prefix("Side ") {
|
||||
Line::from(vec!["Side".magenta().bold(), format!(" {rest}").magenta()])
|
||||
} else {
|
||||
Line::from(label.to_string()).magenta()
|
||||
Line::from(vec![Span::from(label.to_string()).magenta()])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
"› Check recently modified functions for compatibility "
|
||||
" "
|
||||
" gpt-5.6-sol Side from main thread · Ctrl+C to return "
|
||||
" gpt-5.6-sol Side from main thread · ctrl + / to switch · ctrl + c to close "
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/side.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" gpt-5.6-sol ctrl + / for side "
|
||||
+1
-1
@@ -6,4 +6,4 @@ expression: terminal.backend()
|
||||
" "
|
||||
"› Check recently modified functions for compatibility "
|
||||
" "
|
||||
" gpt-5.6-sol def… Side from main thread · main needs input · Ctrl+C to return "
|
||||
" Side from main thread · main needs input · ctrl + / to switch · ctrl + c to cl"
|
||||
|
||||
@@ -389,7 +389,7 @@ async fn side_context_label_preserves_status_line_snapshot() {
|
||||
chat.refresh_status_line();
|
||||
chat.set_side_conversation_active(/*active*/ true);
|
||||
chat.set_side_conversation_context_label(Some(
|
||||
"Side from main thread · Ctrl+C to return".to_string(),
|
||||
"Side from main thread · ctrl + / to switch · ctrl + c to close".to_string(),
|
||||
));
|
||||
|
||||
let width = 80;
|
||||
@@ -410,7 +410,8 @@ async fn side_context_label_shows_parent_status_snapshot() {
|
||||
chat.show_welcome_banner = false;
|
||||
chat.set_side_conversation_active(/*active*/ true);
|
||||
chat.set_side_conversation_context_label(Some(
|
||||
"Side from main thread · main needs input · Ctrl+C to return".to_string(),
|
||||
"Side from main thread · main needs input · ctrl + / to switch · ctrl + c to close"
|
||||
.to_string(),
|
||||
));
|
||||
|
||||
let width = 80;
|
||||
@@ -421,3 +422,27 @@ async fn side_context_label_shows_parent_status_snapshot() {
|
||||
.expect("draw side conversation footer");
|
||||
assert_chatwidget_snapshot!("side_context_label_shows_parent_status", terminal.backend());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn side_context_label_shows_hidden_side_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.show_welcome_banner = false;
|
||||
chat.config.tui_status_line = Some(vec!["model-name".to_string()]);
|
||||
chat.refresh_status_line();
|
||||
chat.set_side_conversation_context_label(Some("ctrl + / for side".to_string()));
|
||||
|
||||
let width = 80;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw hidden side conversation footer");
|
||||
let hint_start = width - "ctrl + / for side".len() as u16 - 2;
|
||||
assert_eq!(
|
||||
terminal.backend().buffer()[(hint_start, height - 1)]
|
||||
.style()
|
||||
.fg,
|
||||
Some(ratatui::style::Color::Magenta)
|
||||
);
|
||||
assert_chatwidget_snapshot!("side_context_label_shows_hidden_side", terminal.backend());
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ pub(crate) struct AppKeymap {
|
||||
pub(crate) toggle_fast_mode: Vec<KeyBinding>,
|
||||
/// Toggle raw scrollback mode for copy-friendly transcript selection.
|
||||
pub(crate) toggle_raw_output: Vec<KeyBinding>,
|
||||
/// Switch between a side conversation and its parent without closing either.
|
||||
pub(crate) toggle_side_conversation: Vec<KeyBinding>,
|
||||
}
|
||||
|
||||
/// Chat-level keybindings evaluated at the app event layer.
|
||||
@@ -385,6 +387,12 @@ impl RuntimeKeymap {
|
||||
/// dispatch, or conflict guarantees from this resolver no longer hold.
|
||||
pub(crate) fn from_config(keymap: &TuiKeymap) -> Result<Self, String> {
|
||||
let defaults = Self::built_in_defaults();
|
||||
let side_toggle_default_is_shadowed = keymap.global.toggle_side_conversation.is_none()
|
||||
&& ["ctrl-/", "ctrl-7"].into_iter().any(|alias| {
|
||||
configured_main_surface_alias_is_used(keymap, alias)
|
||||
|| configured_context_alias_is_used(&keymap.list, alias)
|
||||
|| configured_context_alias_is_used(&keymap.approval, alias)
|
||||
});
|
||||
|
||||
let app = AppKeymap {
|
||||
open_transcript: resolve_bindings(
|
||||
@@ -422,6 +430,15 @@ impl RuntimeKeymap {
|
||||
&defaults.app.toggle_raw_output,
|
||||
"tui.keymap.global.toggle_raw_output",
|
||||
)?,
|
||||
toggle_side_conversation: if side_toggle_default_is_shadowed {
|
||||
Vec::new()
|
||||
} else {
|
||||
resolve_bindings(
|
||||
keymap.global.toggle_side_conversation.as_ref(),
|
||||
&defaults.app.toggle_side_conversation,
|
||||
"tui.keymap.global.toggle_side_conversation",
|
||||
)?
|
||||
},
|
||||
};
|
||||
|
||||
let mut chat = ChatKeymap {
|
||||
@@ -809,6 +826,10 @@ impl RuntimeKeymap {
|
||||
keymap.global.toggle_raw_output.as_ref(),
|
||||
app.toggle_raw_output.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.global.toggle_side_conversation.as_ref(),
|
||||
app.toggle_side_conversation.as_slice(),
|
||||
),
|
||||
(keymap.list.move_up.as_ref(), list_move_up.as_slice()),
|
||||
(keymap.list.move_down.as_ref(), list_move_down.as_slice()),
|
||||
(keymap.list.accept.as_ref(), list_accept.as_slice()),
|
||||
@@ -916,6 +937,7 @@ impl RuntimeKeymap {
|
||||
toggle_vim_mode: default_bindings![],
|
||||
toggle_fast_mode: default_bindings![],
|
||||
toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))],
|
||||
toggle_side_conversation: default_bindings![ctrl(KeyCode::Char('/'))],
|
||||
},
|
||||
chat: ChatKeymap {
|
||||
interrupt_turn: default_bindings![plain(KeyCode::Esc)],
|
||||
@@ -1162,6 +1184,15 @@ impl RuntimeKeymap {
|
||||
/// 2. Contexts with hard-coded sequence behavior, such as edit-previous
|
||||
/// backtracking, intentionally stay outside this configurable keymap.
|
||||
fn validate_conflicts(&self) -> Result<(), String> {
|
||||
let mut side_toggle_bindings = self.app.toggle_side_conversation.clone();
|
||||
let slash_binding = key_hint::ctrl(KeyCode::Char('/'));
|
||||
let legacy_slash_binding = key_hint::ctrl(KeyCode::Char('7'));
|
||||
if side_toggle_bindings.contains(&slash_binding)
|
||||
&& !side_toggle_bindings.contains(&legacy_slash_binding)
|
||||
{
|
||||
side_toggle_bindings.push(legacy_slash_binding);
|
||||
}
|
||||
|
||||
validate_unique(
|
||||
"app",
|
||||
[
|
||||
@@ -1175,6 +1206,7 @@ impl RuntimeKeymap {
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
||||
("toggle_side_conversation", side_toggle_bindings.as_slice()),
|
||||
("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()),
|
||||
(
|
||||
"chat.decrease_reasoning_effort",
|
||||
@@ -1218,6 +1250,7 @@ impl RuntimeKeymap {
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
||||
("toggle_side_conversation", side_toggle_bindings.as_slice()),
|
||||
("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()),
|
||||
(
|
||||
"chat.decrease_reasoning_effort",
|
||||
@@ -1267,6 +1300,7 @@ impl RuntimeKeymap {
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
||||
("toggle_side_conversation", side_toggle_bindings.as_slice()),
|
||||
],
|
||||
[
|
||||
("list.move_up", self.list.move_up.as_slice()),
|
||||
@@ -1341,6 +1375,7 @@ impl RuntimeKeymap {
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
||||
("toggle_side_conversation", side_toggle_bindings.as_slice()),
|
||||
(
|
||||
"composer.history_search_previous",
|
||||
self.composer.history_search_previous.as_slice(),
|
||||
@@ -2684,6 +2719,55 @@ mod tests {
|
||||
expect_conflict(&keymap, "clear_terminal", "toggle_fast_mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_side_conversation_can_be_remapped_and_rejects_conflicts() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.global.toggle_side_conversation = Some(one("f12"));
|
||||
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
|
||||
assert_eq!(
|
||||
runtime.app.toggle_side_conversation,
|
||||
vec![key_hint::plain(KeyCode::F(12))]
|
||||
);
|
||||
|
||||
keymap.global.toggle_side_conversation = Some(one("ctrl-l"));
|
||||
expect_conflict(&keymap, "clear_terminal", "toggle_side_conversation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_side_conversation_default_yields_to_existing_configured_bindings() {
|
||||
for binding in ["ctrl-/", "ctrl-7"] {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.global.open_transcript = Some(one(binding));
|
||||
|
||||
let runtime =
|
||||
RuntimeKeymap::from_config(&keymap).expect("existing keymap remains valid");
|
||||
|
||||
assert_eq!(
|
||||
runtime.app.open_transcript,
|
||||
vec![parse_keybinding(binding).expect("configured binding")]
|
||||
);
|
||||
assert!(runtime.app.toggle_side_conversation.is_empty());
|
||||
}
|
||||
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.global.open_transcript = Some(one("ctrl-/"));
|
||||
keymap.global.copy = Some(one("ctrl-7"));
|
||||
|
||||
let runtime =
|
||||
RuntimeKeymap::from_config(&keymap).expect("distinct legacy bindings remain valid");
|
||||
assert!(runtime.app.toggle_side_conversation.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_side_toggle_rejects_legacy_ctrl_slash_alias_conflicts() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.global.open_transcript = Some(one("ctrl-7"));
|
||||
keymap.global.toggle_side_conversation = Some(one("ctrl-/"));
|
||||
|
||||
expect_conflict(&keymap, "open_transcript", "toggle_side_conversation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_main_bindings_that_collide_with_remaining_fixed_shortcuts() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
|
||||
@@ -93,6 +93,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."),
|
||||
gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode),
|
||||
action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."),
|
||||
action("global", "Global", "toggle_side_conversation", "Switch between a side conversation and its parent."),
|
||||
action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."),
|
||||
action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."),
|
||||
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
|
||||
@@ -236,6 +237,7 @@ pub(super) fn binding_slot<'a>(
|
||||
("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode),
|
||||
("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode),
|
||||
("global", "toggle_raw_output") => Some(&mut keymap.global.toggle_raw_output),
|
||||
("global", "toggle_side_conversation") => Some(&mut keymap.global.toggle_side_conversation),
|
||||
("chat", "interrupt_turn") => Some(&mut keymap.chat.interrupt_turn),
|
||||
("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort),
|
||||
("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort),
|
||||
@@ -361,6 +363,7 @@ pub(super) fn bindings_for_action<'a>(
|
||||
("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()),
|
||||
("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()),
|
||||
("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()),
|
||||
("global", "toggle_side_conversation") => Some(runtime_keymap.app.toggle_side_conversation.as_slice()),
|
||||
("chat", "interrupt_turn") => Some(runtime_keymap.chat.interrupt_turn.as_slice()),
|
||||
("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()),
|
||||
("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()),
|
||||
|
||||
+1
-1
@@ -8,9 +8,9 @@ Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard.
|
||||
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
|
||||
Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default
|
||||
Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default
|
||||
Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default
|
||||
Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default
|
||||
Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default
|
||||
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
|
||||
Submit | enter | Composer submit Submit Submit the current composer draft. enter Default
|
||||
Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
108 actions, 1 customized, 2 unbound.
|
||||
109 actions, 1 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
@@ -16,7 +16,7 @@ expression: "render_picker(params, 120)"
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Global Toggle Side Conversation ctrl-/
|
||||
Chat Interrupt Turn esc
|
||||
Chat Decrease Reasoning Effort alt-,, shift-down
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
109 actions, 0 customized, 3 unbound.
|
||||
110 actions, 0 customized, 3 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
@@ -17,6 +17,6 @@ expression: "render_picker(params, 120)"
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global - Toggle Fast Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Chat Interrupt Turn esc
|
||||
Global Toggle Side Conversation ctrl-/
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+3
-3
@@ -2,11 +2,11 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (108 selectable)
|
||||
tab: All (109 selectable)
|
||||
tab: Common (20 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (2) (2 selectable)
|
||||
tab: App (10 selectable)
|
||||
tab: App (11 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (48 selectable)
|
||||
@@ -19,9 +19,9 @@ Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard.
|
||||
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
|
||||
Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default
|
||||
Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default
|
||||
Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default
|
||||
Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default
|
||||
Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default
|
||||
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
|
||||
Submit | enter | Composer submit Submit Submit the current composer draft. enter Default
|
||||
Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
108 actions, 0 customized, 2 unbound.
|
||||
109 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
|
||||
Navigation Approval Debug
|
||||
@@ -17,7 +17,7 @@ expression: "render_picker(params, 78)"
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Global Toggle Side Conversation ctrl-/
|
||||
Chat Interrupt Turn esc
|
||||
Chat Decrease Reasoning Effort alt-,, shift-down
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
108 actions, 0 customized, 2 unbound.
|
||||
109 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
@@ -16,7 +16,7 @@ expression: "render_picker(params, 120)"
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Global Toggle Side Conversation ctrl-/
|
||||
Chat Interrupt Turn esc
|
||||
Chat Decrease Reasoning Effort alt-,, shift-down
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
Reference in New Issue
Block a user