Files
codex/codex-rs/tui/src/app/tests/key_chords.rs
T
Eric Traut 516f2780fd Open new sessions directly from the command center (#45255)
## What changed

- Replace the inline task composer with a session list: `n` opens a blank session in the selected checkout without sending an initial turn or interrupting running agents.
- Use single-letter action shortcuts and `Enter` to open a session. `Esc` cancels search or rename without closing the list; `Ctrl+C` quits from the list.
- Load destination settings for new sessions while carrying over explicit permission choices. Retain blank sessions and their drafts across navigation until the first turn.

## Testing

Update tests and snapshots for command center navigation, shortcuts, blank draft restoration, destination defaults, permission carryover, and directory trust.

GitOrigin-RevId: bddd04fdbe6ac2f5b7bd70293fd04f673b97ba02
2026-09-13 16:14:41 +00:00

638 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::App;
use super::AppServerSession;
use super::Result;
use super::RuntimeKeymap;
use super::TuiEvent;
use super::make_test_app;
use super::start_config_write_test_app_server;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::chatwidget::tests::helpers::render_bottom_popup;
use crate::keymap::KeymapContext;
use crate::test_support::test_path_display;
use crate::tui::Tui;
use codex_app_server_protocol::ToolRequestUserInputOption;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputQuestion;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
async fn chord_app() -> Result<(App, Tui, AppServerSession)> {
let mut app = make_test_app().await;
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x ctrl-t".to_string(),
)));
config.chat.interrupt_turn = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x ctrl-u".to_string(),
)));
config.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("ctrl-x j".to_string())));
config.list.accept = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x enter".to_string(),
)));
let runtime =
RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?;
app.chat_widget.apply_keymap_update(config, &runtime);
app.keymap = runtime;
let app_server = start_config_write_test_app_server(&app).await?;
let tui = crate::tui::test_support::make_test_tui()?;
Ok((app, tui, app_server))
}
async fn press(
app: &mut App,
tui: &mut Tui,
app_server: &mut AppServerSession,
key: KeyEvent,
) -> Result<()> {
app.handle_tui_event(tui, app_server, TuiEvent::Key(key))
.await?;
Ok(())
}
fn ctrl(ch: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
}
#[tokio::test]
async fn vim_buffer_jumps_route_default_chords_in_normal_and_operator_contexts() -> Result<()> {
for (input, command, expected) in [
("one\ntwo\nthree", "gg", "!one\ntwo\nthree"),
("one\ntwo\nthree", "dgg", "!"),
("ag bg", "0fg", "a!g bg"),
("ag bg", "0dfg", "! bg"),
] {
let (mut app, mut tui, mut app_server) = chord_app().await?;
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str(input);
for (index, key) in command.chars().enumerate() {
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char(key).into(),
)
.await?;
if key == 'g' && index + 1 < command.len() {
assert!(app.key_chord_matcher.is_pending());
}
}
assert!(!app.key_chord_matcher.is_pending());
app.chat_widget.insert_str("!");
assert_eq!(app.chat_widget.composer_text_with_pending(), expected);
}
Ok(())
}
#[tokio::test]
async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
assert!(app.key_chord_matcher.is_pending());
assert!(app.overlay.is_none());
insta::assert_snapshot!(
render_bottom_popup(&app.chat_widget, /*width*/ 80)
.replace(&test_path_display("/tmp/project"), "/tmp/project"),
@r"
› Ask Codex to do anything
ctrl + x … waiting for next key esc cancel
"
);
press(&mut app, &mut tui, &mut app_server, ctrl('t')).await?;
assert!(!app.key_chord_matcher.is_pending());
assert!(app.overlay.is_some());
Ok(())
}
#[tokio::test]
async fn completed_global_chords_toggle_output_and_request_external_editor() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
let config = toml::from_str(
"[global]\ntoggle_raw_output = [\"ctrl-x r\"]\nopen_external_editor = [\"ctrl-x e\"]",
)?;
app.keymap = RuntimeKeymap::from_config(&config).expect("valid global chords");
app.chat_widget.apply_keymap_update(config, &app.keymap);
for key in [ctrl('x'), KeyCode::Char('r').into()] {
press(&mut app, &mut tui, &mut app_server, key).await?;
}
assert!(app.chat_widget.raw_output_mode());
for key in [ctrl('x'), KeyCode::Char('e').into()] {
press(&mut app, &mut tui, &mut app_server, key).await?;
}
assert_eq!(
app.chat_widget.external_editor_state(),
super::ExternalEditorState::Requested
);
Ok(())
}
#[tokio::test]
async fn wrong_second_stroke_passes_through_but_escape_is_consumed() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
let wrong_second = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
assert_eq!(
app.route_key_chord_event(&mut tui, wrong_second),
Some(wrong_second)
);
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.route_key_chord_event(&mut tui, escape), None);
assert!(!app.key_chord_matcher.is_pending());
assert!(!app.backtrack.primed);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn legacy_terminal_recovers_vim_escape_before_normal_commands() -> Result<()> {
for (key, expected) in [
(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT), "ab"),
(
KeyEvent::new(KeyCode::Char('D'), KeyModifiers::ALT | KeyModifiers::SHIFT),
"ab",
),
(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT), "abc"),
] {
let (mut app, mut tui, mut app_server) = chord_app().await?;
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str("abc");
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('i').into(),
)
.await?;
press(&mut app, &mut tui, &mut app_server, key).await?;
assert_eq!(app.chat_widget.composer_text_with_pending(), expected);
assert!(
!app.chat_widget
.should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
);
}
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn legacy_terminal_preserves_default_and_configured_editor_alt_bindings() -> Result<()> {
for (key, expected) in [
('b', "!abc def"),
('f', "abc! def"),
('d', "! def"),
('k', "!"),
] {
let (mut app, mut tui, mut app_server) = chord_app().await?;
if key == 'k' {
let mut config = TuiKeymap::default();
config.editor.kill_line_end =
Some(KeybindingsSpec::One(KeybindingSpec("alt-k".to_string())));
let runtime = RuntimeKeymap::from_config(&config)
.map_err(|error| color_eyre::eyre::eyre!(error))?;
app.chat_widget.apply_keymap_update(config, &runtime);
app.keymap = runtime;
}
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str("abc def");
let insert = KeyCode::Char('i').into();
press(&mut app, &mut tui, &mut app_server, insert).await?;
press(&mut app, &mut tui, &mut app_server, ctrl('a')).await?;
press(
&mut app,
&mut tui,
&mut app_server,
KeyEvent::new(KeyCode::Char(key), KeyModifiers::ALT),
)
.await?;
assert!(
app.chat_widget
.should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
);
app.chat_widget.insert_str("!");
assert_eq!(app.chat_widget.composer_text_with_pending(), expected);
}
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn legacy_terminal_preserves_active_global_alt_shortcuts() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str("abc");
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('i').into(),
)
.await?;
press(
&mut app,
&mut tui,
&mut app_server,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::ALT),
)
.await?;
assert!(app.chat_widget.raw_output_mode());
assert!(
app.chat_widget
.should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn legacy_terminal_preserves_configured_composer_alt_shortcuts() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
let mut config = TuiKeymap::default();
config.composer.history_search_previous =
Some(KeybindingsSpec::One(KeybindingSpec("alt-q".to_string())));
let runtime =
RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?;
app.chat_widget.apply_keymap_update(config, &runtime);
app.keymap = runtime;
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str("abc");
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('i').into(),
)
.await?;
press(
&mut app,
&mut tui,
&mut app_server,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT),
)
.await?;
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("reverse-i-search:"));
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn legacy_terminal_preserves_active_alt_chords() -> Result<()> {
for (binding, prefix, completion) in [
(
"alt-q r",
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT),
KeyCode::Char('r').into(),
),
(
"ctrl-x alt-h",
ctrl('x'),
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT),
),
] {
let (mut app, mut tui, mut app_server) = chord_app().await?;
let mut config = TuiKeymap::default();
config.global.toggle_raw_output =
Some(KeybindingsSpec::One(KeybindingSpec(binding.to_string())));
let runtime =
RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?;
app.chat_widget.apply_keymap_update(config, &runtime);
app.keymap = runtime;
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str("abc");
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('i').into(),
)
.await?;
press(&mut app, &mut tui, &mut app_server, prefix).await?;
assert!(app.key_chord_matcher.is_pending());
press(&mut app, &mut tui, &mut app_server, completion).await?;
assert!(app.chat_widget.raw_output_mode());
assert!(!app.key_chord_matcher.is_pending());
}
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn legacy_terminal_preserves_image_paste_without_reading_clipboard() -> Result<()> {
for mode in ['i', 'R'] {
let (mut app, mut tui, mut app_server) = chord_app().await?;
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str("abc");
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char(mode).into(),
)
.await?;
for key in [
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(KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::ALT,
)));
}
Ok(())
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn legacy_terminal_preserves_agent_shortcuts_without_editor_word_bindings() -> Result<()> {
for mode in ['i', 'R'] {
let (mut app, mut tui, mut app_server) = chord_app().await?;
let mut config = TuiKeymap::default();
config.editor.move_word_left = Some(KeybindingsSpec::Many(vec![]));
config.editor.move_word_right = Some(KeybindingsSpec::Many(vec![]));
let runtime =
RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?;
app.chat_widget.apply_keymap_update(config, &runtime);
app.keymap = runtime;
app.chat_widget.toggle_vim_mode_and_notify();
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char(mode).into(),
)
.await?;
for key in ['b', 'f'] {
let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::ALT);
assert!(!app.should_recover_vim_insert_escape(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));
}
}
Ok(())
}
#[tokio::test]
async fn vim_escape_recovery_preserves_enhanced_terminals_and_altgr() -> Result<()> {
for (enhanced_keys_supported, modifiers) in [
(true, KeyModifiers::ALT),
(false, KeyModifiers::ALT | KeyModifiers::CONTROL),
] {
let (mut app, mut tui, mut app_server) = chord_app().await?;
app.enhanced_keys_supported = enhanced_keys_supported;
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str("abc");
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('i').into(),
)
.await?;
press(
&mut app,
&mut tui,
&mut app_server,
KeyEvent::new(KeyCode::Char('x'), modifiers),
)
.await?;
assert!(
app.chat_widget
.should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
);
}
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn recovered_vim_escape_cancels_a_pending_key_chord_first() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str("abc");
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('i').into(),
)
.await?;
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
assert!(app.key_chord_matcher.is_pending());
press(
&mut app,
&mut tui,
&mut app_server,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT),
)
.await?;
assert!(!app.key_chord_matcher.is_pending());
press(&mut app, &mut tui, &mut app_server, KeyCode::Left.into()).await?;
assert_eq!(app.chat_widget.composer_text_with_pending(), "abcx");
assert!(
app.chat_widget
.should_handle_vim_insert_escape(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
);
Ok(())
}
#[tokio::test]
async fn physical_dispatch_band_events_are_dropped() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
let (code, modifiers) = app
.keymap
.app
.open_transcript
.last()
.expect("configured chord appends a dispatch token")
.parts();
press(
&mut app,
&mut tui,
&mut app_server,
KeyEvent::new(code, modifiers),
)
.await?;
assert!(app.overlay.is_none());
Ok(())
}
#[tokio::test]
async fn physical_chords_route_list_and_mixed_request_input_modals() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
app.chat_widget.show_selection_view(SelectionViewParams {
view_id: Some("list"),
items: ["First", "Second"]
.into_iter()
.map(|name| SelectionItem {
name: name.to_string(),
dismiss_on_select: true,
..Default::default()
})
.collect(),
..Default::default()
});
assert_eq!(
app.chat_widget.selected_index_for_present_view("list"),
Some(0)
);
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
assert!(app.key_chord_matcher.is_pending());
assert_eq!(
app.chat_widget.selected_index_for_present_view("list"),
Some(0)
);
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('j').into(),
)
.await?;
assert_eq!(
app.chat_widget.selected_index_for_present_view("list"),
Some(1)
);
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
press(&mut app, &mut tui, &mut app_server, KeyCode::Enter.into()).await?;
assert_eq!(
app.chat_widget.selected_index_for_present_view("list"),
None
);
app.chat_widget
.handle_request_user_input_now(ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![ToolRequestUserInputQuestion {
id: "choice".to_string(),
header: "Pick one".to_string(),
question: "Choose an option.".to_string(),
is_other: false,
is_secret: false,
options: Some(
["First", "Second"]
.into_iter()
.map(|label| ToolRequestUserInputOption {
label: label.to_string(),
description: label.to_string(),
})
.collect(),
),
}],
is_blocking: true,
auto_resolution_ms: None,
});
let contexts = app.chat_widget.keymap_contexts();
assert!(contexts.contains(KeymapContext::Chat));
assert!(contexts.contains(KeymapContext::List));
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("› 1. First"));
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
assert!(app.key_chord_matcher.is_pending());
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('j').into(),
)
.await?;
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("› 2. Second"));
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
press(&mut app, &mut tui, &mut app_server, ctrl('u')).await?;
assert!(app.chat_widget.can_launch_external_editor());
Ok(())
}
#[tokio::test]
async fn dashboard_chord_hint_survives_refresh_and_clears_on_cancel() -> Result<()> {
let mut app = make_test_app().await;
app.keymap =
RuntimeKeymap::from_config(&toml::from_str("[agents]\nnew_task = [\"ctrl-x n\"]")?)
.unwrap();
let mut tui = crate::tui::test_support::make_test_tui()?;
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let before = render_bottom_popup(&app.chat_widget, /*width*/ 80);
assert_eq!(app.route_key_chord_event(&mut tui, ctrl('x')), None);
let _ = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
insta::assert_snapshot!(
render_bottom_popup(&app.chat_widget, /*width*/ 80).lines().last().unwrap(),
@" ctrl + x … waiting for next key esc cancel"
);
assert_eq!(
app.route_key_chord_event(&mut tui, KeyCode::Esc.into()),
None
);
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 80), before);
Ok(())
}
#[tokio::test]
async fn command_center_chords_do_not_capture_search_text() -> Result<()> {
let mut app = make_test_app().await;
app.keymap = RuntimeKeymap::from_config(&toml::from_str(
"[agents]\nnew_task = 'n n'\n[ list ]\naccept = 'ctrl-x enter'",
)?)
.unwrap();
let mut tui = crate::tui::test_support::make_test_tui()?;
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.chat_widget.handle_key_event(KeyCode::Char('f').into());
for key in "new".chars() {
let event = KeyCode::Char(key).into();
assert_eq!(app.route_key_chord_event(&mut tui, event), Some(event));
app.chat_widget.handle_key_event(event);
}
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Search › new"));
assert_eq!(app.route_key_chord_event(&mut tui, ctrl('x')), None);
assert!(app.key_chord_matcher.is_pending());
app.route_key_chord_event(&mut tui, KeyCode::Esc.into());
app.chat_widget.handle_key_event(KeyCode::Esc.into());
assert_eq!(
app.route_key_chord_event(&mut tui, KeyCode::Char('n').into()),
None
);
assert!(app.key_chord_matcher.is_pending());
Ok(())
}