mirror of
https://github.com/openai/codex.git
synced 2026-11-12 14:41:42 +00:00
## 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
638 lines
21 KiB
Rust
638 lines
21 KiB
Rust
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(())
|
||
}
|