mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
## What changed - Replace footer chord hints with a floating guide that shows available completions after a 350 ms delay, including custom prefixes. - Share guide rendering across conversations, transcript overlays, the session picker, startup drafts, hook review, and daemon recovery. Preserve workspace-specific pane labels and filter startup drafts to supported actions. - Adapt the guide to short inline viewports, retain `Esc` cancellation, and redraw when pending chords clear. Consume clicks on the guide so they do not activate controls beneath it. ## Testing Extend tests and snapshots for delayed reveal, timeout, custom prefixes, compact and inline layouts, startup-safe completions, and picker redraws after chord completion or cancellation. GitOrigin-RevId: afa2fe3ae1b5fbea4cd871c17d35206e749c842c
610 lines
24 KiB
Rust
610 lines
24 KiB
Rust
//! Browsing restores its origin and gives selection/search priority over rewind keys.
|
|
|
|
use super::*;
|
|
use crate::app::tests::make_test_app_with_channels;
|
|
use crate::history_cell::HistoryRenderMode;
|
|
use crate::history_cell::PlainHistoryCell;
|
|
use codex_app_server_protocol::ModelProviderError;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[tokio::test]
|
|
async fn provider_error_escape_opens_resume_picker() -> Result<()> {
|
|
for (owned, draft) in [
|
|
(true, ""),
|
|
(true, "unsent draft"),
|
|
(false, ""),
|
|
(false, "unsent draft"),
|
|
] {
|
|
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
|
attach_thread(&mut app, ThreadId::new());
|
|
app.transcript_cells = vec![user_cell("first prompt"), user_cell("second prompt")];
|
|
app.chat_widget.apply_external_edit(draft.to_string());
|
|
app.chat_widget.show_provider_error(
|
|
ModelProviderError::ModelProviderMismatch {
|
|
model_provider: "old-provider".into(),
|
|
current_model_provider: "test-provider".into(),
|
|
},
|
|
/*rejected_message_id*/ None,
|
|
);
|
|
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
|
tui.set_owned_screen(owned)?;
|
|
while events.try_recv().is_ok() {}
|
|
if owned {
|
|
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 16))?;
|
|
app.transcript_view
|
|
.jump_to_entry(&app.transcript_cells, /*index*/ 0);
|
|
for search in [true, false] {
|
|
if search {
|
|
app.transcript_view.begin_search();
|
|
} else {
|
|
assert!(app.transcript_view.begin_select_mode(&app.transcript_cells));
|
|
}
|
|
assert!(app.transcript_view.has_active_interaction());
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
assert!(!app.transcript_view.has_active_interaction());
|
|
assert!(
|
|
!std::iter::from_fn(|| events.try_recv().ok())
|
|
.any(|event| matches!(event, AppEvent::OpenResumePicker))
|
|
);
|
|
// Release Escape so selection's exit guard does not consume the next press.
|
|
app.handle_tui_event(
|
|
&mut tui,
|
|
&mut server,
|
|
TuiEvent::Key(KeyEvent::new_with_kind(
|
|
KeyCode::Esc,
|
|
KeyModifiers::NONE,
|
|
KeyEventKind::Release,
|
|
)),
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
assert_eq!(
|
|
std::iter::from_fn(|| events.try_recv().ok())
|
|
.filter(|event| matches!(event, AppEvent::OpenResumePicker))
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
(
|
|
app.chat_widget.has_provider_error(),
|
|
app.backtrack.primed,
|
|
app.backtrack.overlay_preview_active,
|
|
app.chat_widget.composer_text_with_pending(),
|
|
app.transcript_cells.len(),
|
|
),
|
|
(true, false, false, draft.to_string(), 2)
|
|
);
|
|
tui.set_owned_screen(/*owned*/ false)?;
|
|
server.shutdown().await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn escape_restores_reading_origin_after_details_navigation_and_resize() -> Result<()> {
|
|
let mut app = crate::app::test_support::make_test_app().await;
|
|
attach_thread(&mut app, ThreadId::new());
|
|
app.transcript_cells = vec![
|
|
user_cell("first prompt"),
|
|
Arc::new(PlainHistoryCell::new(
|
|
(0..30)
|
|
.map(|row| format!("response row {row}").into())
|
|
.collect(),
|
|
)),
|
|
user_cell("second prompt"),
|
|
Arc::new(PlainHistoryCell::new(vec!["second response".into(); 30])),
|
|
];
|
|
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
|
tui.set_owned_screen(/*owned*/ true)?;
|
|
for detailed in [false, true] {
|
|
app.transcript_view
|
|
.set_presentation(detailed, HistoryRenderMode::Rich);
|
|
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 16))?;
|
|
app.transcript_view
|
|
.scroll(&app.transcript_cells, /*rows*/ -12);
|
|
let area = Rect::new(
|
|
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 8,
|
|
);
|
|
let mut before = Buffer::empty(area);
|
|
app.transcript_view
|
|
.render(area, &mut before, &app.transcript_cells);
|
|
let escape_count = if detailed { 1 } else { 2 };
|
|
for _ in 0..escape_count {
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
}
|
|
assert_eq!(
|
|
(
|
|
app.backtrack.overlay_preview_active,
|
|
app.transcript_view.is_detailed()
|
|
),
|
|
(true, false)
|
|
);
|
|
for (key, expected_details) in [
|
|
(KeyEvent::from(KeyCode::Left), false),
|
|
(
|
|
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL),
|
|
true,
|
|
),
|
|
(
|
|
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL),
|
|
false,
|
|
),
|
|
] {
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))
|
|
.await?;
|
|
assert_eq!(app.transcript_view.is_detailed(), expected_details);
|
|
let mut selected = Buffer::empty(area);
|
|
app.transcript_view
|
|
.render(area, &mut selected, &app.transcript_cells);
|
|
assert!(buffer_text(&selected).contains("first prompt"));
|
|
}
|
|
assert_eq!(app.backtrack.nth_user_message, 0);
|
|
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 40, /*height*/ 12))?;
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
let mut after = Buffer::empty(area);
|
|
app.transcript_view
|
|
.render(area, &mut after, &app.transcript_cells);
|
|
assert_eq!(buffer_text(&after), buffer_text(&before));
|
|
assert_eq!(
|
|
(
|
|
app.backtrack.overlay_preview_active,
|
|
app.transcript_view.is_detailed(),
|
|
app.transcript_view.is_following()
|
|
),
|
|
(false, detailed, false)
|
|
);
|
|
}
|
|
server.shutdown().await?;
|
|
tui.set_owned_screen(/*owned*/ false)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn browsing_search_and_selection_consume_escape_before_mode_exit() -> Result<()> {
|
|
let mut app = crate::app::test_support::make_test_app().await;
|
|
attach_thread(&mut app, ThreadId::new());
|
|
app.transcript_cells = vec![user_cell("first prompt"), user_cell("second prompt")];
|
|
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
|
tui.set_owned_screen(/*owned*/ true)?;
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 16))?;
|
|
app.transcript_view.begin_search();
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Paste("first".to_string()))
|
|
.await?;
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
|
|
.await?;
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Enter.into()))
|
|
.await?;
|
|
assert_eq!(
|
|
(
|
|
app.transcript_view.is_search_editing(),
|
|
app.backtrack.overlay_preview_active
|
|
),
|
|
(false, true)
|
|
);
|
|
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 16))?;
|
|
for key in [
|
|
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL),
|
|
KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT),
|
|
] {
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))
|
|
.await?;
|
|
}
|
|
assert!(app.transcript_view.has_active_interaction());
|
|
assert_eq!(app.backtrack.nth_user_message, 1);
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
assert!(app.transcript_view.has_active_interaction());
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
assert_eq!(
|
|
(
|
|
app.transcript_view.has_active_interaction(),
|
|
app.backtrack.overlay_preview_active
|
|
),
|
|
(false, true)
|
|
);
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
assert!(!app.backtrack.overlay_preview_active);
|
|
server.shutdown().await?;
|
|
tui.set_owned_screen(/*owned*/ false)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn browsing_footer_adapts_to_width() -> Result<()> {
|
|
let mut app = crate::app::test_support::make_test_app().await;
|
|
attach_thread(&mut app, ThreadId::new());
|
|
app.transcript_cells = vec![user_cell("first prompt"), user_cell("second prompt")];
|
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
|
tui.set_owned_screen(/*owned*/ true)?;
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
for width in [100, 80, 60, 40, 20, 8] {
|
|
let footer = app
|
|
.prompt_navigation_footer(width)
|
|
.expect("browsing footer");
|
|
assert_eq!(footer.text.lines.len(), 1);
|
|
assert!(footer.text.width() <= usize::from(width));
|
|
}
|
|
app.transcript_view.history = TranscriptHistoryState::Failed;
|
|
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 12))?;
|
|
assert!(
|
|
buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
|
|
&tui.terminal,
|
|
))
|
|
.contains("Retry history")
|
|
);
|
|
app.transcript_view.history = TranscriptHistoryState::Complete;
|
|
app.cancel_transcript_browsing(&mut tui);
|
|
tui.set_owned_screen(/*owned*/ false)?;
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
let mut snapshots = Vec::new();
|
|
for width in [80, 32] {
|
|
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 8);
|
|
let footer = app
|
|
.prompt_navigation_footer(width.saturating_sub(/*rhs*/ 2))
|
|
.expect("browsing footer");
|
|
let Some(Overlay::Transcript(overlay)) = &mut app.overlay else {
|
|
panic!("expected inline browsing");
|
|
};
|
|
overlay.browsing_footer = footer.text.lines.into_iter().next();
|
|
let mut buffer = Buffer::empty(area);
|
|
overlay.render(area, &mut buffer);
|
|
snapshots.push(format!("{width} columns\n{}", buffer_text(&buffer)));
|
|
}
|
|
insta::assert_snapshot!("browsing_footer", snapshots.join("\n\n"));
|
|
let Some(Overlay::Transcript(overlay)) = &mut app.overlay else {
|
|
panic!("expected inline browsing");
|
|
};
|
|
overlay.set_history_state(TranscriptHistoryState::Failed);
|
|
let footer = app.prompt_navigation_footer(/*width*/ 78);
|
|
let Some(Overlay::Transcript(overlay)) = &mut app.overlay else {
|
|
panic!("expected inline browsing");
|
|
};
|
|
overlay.browsing_footer = footer.and_then(|footer| footer.text.lines.into_iter().next());
|
|
let area = Rect::new(
|
|
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 8,
|
|
);
|
|
let mut buffer = Buffer::empty(area);
|
|
overlay.render(area, &mut buffer);
|
|
assert!(buffer_text(&buffer).contains("Retry history"));
|
|
app.close_transcript_overlay(&mut tui);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn inline_browsing_is_compact_and_escape_restores_the_existing_overlay() -> Result<()> {
|
|
let mut app = crate::app::test_support::make_test_app().await;
|
|
attach_thread(&mut app, ThreadId::new());
|
|
app.transcript_cells = vec![user_cell("first prompt"), user_cell("second prompt")];
|
|
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
|
app.open_transcript_overlay(&mut tui);
|
|
for (key, expected_details, browsing) in [
|
|
(KeyEvent::from(KeyCode::Esc), false, true),
|
|
(
|
|
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL),
|
|
true,
|
|
true,
|
|
),
|
|
(KeyEvent::from(KeyCode::Esc), true, false),
|
|
] {
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))
|
|
.await?;
|
|
let Some(Overlay::Transcript(overlay)) = &app.overlay else {
|
|
panic!("overlay must remain open")
|
|
};
|
|
assert_eq!(
|
|
(overlay.is_detailed(), app.backtrack.overlay_preview_active),
|
|
(expected_details, browsing)
|
|
);
|
|
}
|
|
app.close_transcript_overlay(&mut tui);
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
assert!(app.overlay.is_none());
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn browsing_details_use_the_remapped_chord_without_cancelling_preview() -> Result<()> {
|
|
let mut app = crate::app::test_support::make_test_app().await;
|
|
attach_thread(&mut app, ThreadId::new());
|
|
app.transcript_cells = vec![
|
|
user_cell("first prompt"),
|
|
user_cell(&"second prompt\n".repeat(80)),
|
|
];
|
|
let config = toml::from_str(
|
|
"[global]\nopen_transcript = [\"ctrl-x ctrl-t\", \"left f6\", \"enter f6\"]\ncopy = [\"ctrl-x ctrl-p\"]\n[pager]\nclose_transcript = [\"ctrl-x ctrl-t\"]\nscroll_up = []\nclose = [\"up ctrl-x\"]\npage_down = [\"ctrl-x ctrl-p\"]\njump_top = [\"ctrl-x ctrl-h\"]\n[composer]\nsubmit = []\n[editor]\nmove_left = []\ninsert_newline = []\n[vim_normal]\nmove_left = []",
|
|
)?;
|
|
app.keymap = RuntimeKeymap::from_config(&config).expect("valid transcript chord");
|
|
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
|
tokio::time::pause();
|
|
for owned in [true, false] {
|
|
tui.set_owned_screen(owned)?;
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
assert!(
|
|
app.prompt_navigation_footer(/*width*/ 100)
|
|
.expect("footer")
|
|
.text
|
|
.to_string()
|
|
.contains("⌃x ⌃t")
|
|
);
|
|
for (keys, expected_details) in [
|
|
(
|
|
[
|
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
|
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL),
|
|
],
|
|
true,
|
|
),
|
|
([KeyCode::Left.into(), KeyCode::F(6).into()], false),
|
|
([KeyCode::Enter.into(), KeyCode::F(6).into()], true),
|
|
] {
|
|
for key in keys {
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))
|
|
.await?;
|
|
assert!(app.backtrack.overlay_preview_active);
|
|
}
|
|
let detailed = match &app.overlay {
|
|
Some(Overlay::Transcript(overlay)) => overlay.is_detailed(),
|
|
_ => app.transcript_view.is_detailed(),
|
|
};
|
|
assert_eq!(detailed, expected_details);
|
|
}
|
|
for completion in ['h', 'p'] {
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
|
|
.await?;
|
|
for code in ['x', completion] {
|
|
app.handle_tui_event(
|
|
&mut tui,
|
|
&mut server,
|
|
TuiEvent::Key(KeyEvent::new(KeyCode::Char(code), KeyModifiers::CONTROL)),
|
|
)
|
|
.await?;
|
|
if code == 'x' {
|
|
tokio::time::advance(crate::keymap::CHORD_REVEAL_DELAY).await;
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
|
|
.await?;
|
|
let text = buffer_text(
|
|
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
|
|
);
|
|
assert!(text.contains("Page Down"), "pending pager chord is visible");
|
|
}
|
|
}
|
|
}
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
|
|
.await?;
|
|
let text = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
|
|
&tui.terminal,
|
|
));
|
|
assert!(
|
|
!text.contains("first prompt"),
|
|
"configured page-down moved the viewport"
|
|
);
|
|
assert!(text.contains("second prompt"));
|
|
assert!(app.backtrack.overlay_preview_active);
|
|
for code in [KeyCode::Char('h'), KeyCode::Right] {
|
|
if let Some(Overlay::Transcript(overlay)) = &mut app.overlay {
|
|
overlay.set_history_state(TranscriptHistoryState::LoadingBeginning);
|
|
} else {
|
|
app.transcript_view.history = TranscriptHistoryState::LoadingBeginning;
|
|
}
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(code.into()))
|
|
.await?;
|
|
let state = match &app.overlay {
|
|
Some(Overlay::Transcript(overlay)) => overlay.history_state(),
|
|
_ => app.transcript_view.history,
|
|
};
|
|
assert_eq!(state, TranscriptHistoryState::LoadingOlder);
|
|
}
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Up.into()))
|
|
.await?;
|
|
assert!(app.backtrack.overlay_preview_active);
|
|
app.handle_tui_event(
|
|
&mut tui,
|
|
&mut server,
|
|
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
|
|
)
|
|
.await?;
|
|
assert!(!app.backtrack.overlay_preview_active);
|
|
}
|
|
server.shutdown().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn browsing_requires_fresh_escape_presses_and_ignores_confirmation_repeats() -> Result<()> {
|
|
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
|
attach_thread(&mut app, ThreadId::new());
|
|
app.transcript_cells = vec![user_cell("first prompt"), user_cell("second prompt")];
|
|
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
|
tui.set_owned_screen(/*owned*/ true)?;
|
|
app.transcript_view
|
|
.set_presentation(/*detailed*/ true, HistoryRenderMode::Rich);
|
|
app.chat_widget.apply_external_edit("draft".to_string());
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
assert!(!app.backtrack.overlay_preview_active);
|
|
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft");
|
|
app.chat_widget.apply_external_edit(String::new());
|
|
app.transcript_view
|
|
.set_presentation(/*detailed*/ false, HistoryRenderMode::Rich);
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
app.handle_tui_event(
|
|
&mut tui,
|
|
&mut server,
|
|
TuiEvent::Key(KeyEvent::new_with_kind(
|
|
KeyCode::Esc,
|
|
KeyModifiers::NONE,
|
|
KeyEventKind::Repeat,
|
|
)),
|
|
)
|
|
.await?;
|
|
assert!(!app.backtrack.overlay_preview_active);
|
|
for key in [KeyCode::PageUp, KeyCode::F(8)] {
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key.into()))
|
|
.await?;
|
|
assert!(!app.backtrack.primed);
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
assert!(!app.backtrack.overlay_preview_active);
|
|
}
|
|
app.handle_tui_event(
|
|
&mut tui,
|
|
&mut server,
|
|
TuiEvent::Mouse(crossterm::event::MouseEvent {
|
|
kind: crossterm::event::MouseEventKind::Moved,
|
|
column: 1,
|
|
row: 1,
|
|
modifiers: KeyModifiers::NONE,
|
|
}),
|
|
)
|
|
.await?;
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
for (code, kind) in [
|
|
(KeyCode::Esc, KeyEventKind::Repeat),
|
|
(KeyCode::Enter, KeyEventKind::Repeat),
|
|
(KeyCode::Enter, KeyEventKind::Release),
|
|
] {
|
|
app.handle_tui_event(
|
|
&mut tui,
|
|
&mut server,
|
|
TuiEvent::Key(KeyEvent::new_with_kind(code, KeyModifiers::NONE, kind)),
|
|
)
|
|
.await?;
|
|
assert!(app.backtrack.overlay_preview_active);
|
|
}
|
|
// Enter during history loading has no selected prompt to confirm.
|
|
app.backtrack.nth_user_message = usize::MAX;
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Enter.into()))
|
|
.await?;
|
|
assert!(app.backtrack.overlay_preview_active);
|
|
assert!(
|
|
!std::iter::from_fn(|| events.try_recv().ok())
|
|
.any(|event| matches!(event, AppEvent::RevertSessionForPromptEdit { .. }))
|
|
);
|
|
for (binding, keys) in [
|
|
("f8", vec![KeyCode::F(8)]),
|
|
("f8 v", vec![KeyCode::F(8), KeyCode::Char('v')]),
|
|
] {
|
|
app.keymap = RuntimeKeymap::from_config(&toml::from_str(&format!(
|
|
"[chat]\ntoggle_voice = '{binding}'"
|
|
))?)
|
|
.expect("valid voice binding");
|
|
let thread_id = app.chat_widget.thread_id().unwrap();
|
|
crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, thread_id);
|
|
for key in keys {
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key.into()))
|
|
.await?;
|
|
}
|
|
assert_eq!(
|
|
(
|
|
app.backtrack.overlay_preview_active,
|
|
app.chat_widget.realtime_conversation_is_running()
|
|
),
|
|
(false, false)
|
|
);
|
|
for _ in 0..2 {
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
|
.await?;
|
|
}
|
|
assert!(app.backtrack.overlay_preview_active);
|
|
}
|
|
app.keymap = RuntimeKeymap::from_config(&toml::from_str(
|
|
"[global]\nopen_external_editor = [\"f6 f7\"]",
|
|
)?)
|
|
.expect("valid editor chord");
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::F(6).into()))
|
|
.await?;
|
|
assert!(!app.backtrack.overlay_preview_active);
|
|
assert!(app.key_chord_matcher.is_pending());
|
|
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::F(7).into()))
|
|
.await?;
|
|
assert_eq!(
|
|
(
|
|
app.backtrack.overlay_preview_active,
|
|
app.chat_widget.external_editor_state()
|
|
),
|
|
(false, ExternalEditorState::Requested)
|
|
);
|
|
server.shutdown().await?;
|
|
tui.set_owned_screen(/*owned*/ false)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn browsing_arrows_and_vim_keys_navigate_without_editing_the_draft() -> Result<()> {
|
|
let mut app = crate::app::test_support::make_test_app().await;
|
|
attach_thread(&mut app, ThreadId::new());
|
|
app.transcript_cells = vec![
|
|
user_cell("first prompt"),
|
|
Arc::new(PlainHistoryCell::new(
|
|
(0..30)
|
|
.map(|row| format!("response row {row}").into())
|
|
.collect(),
|
|
)),
|
|
user_cell("second prompt"),
|
|
];
|
|
let mut tui = crate::tui::test_support::make_test_tui()?;
|
|
tui.set_owned_screen(/*owned*/ true)?;
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
app.handle_backtrack_esc_key(&mut tui);
|
|
app.chat_widget
|
|
.apply_external_edit("untouched draft".to_string());
|
|
let size = Size::new(/*width*/ 80, /*height*/ 16);
|
|
app.render_owned_transcript(&mut tui, size)?;
|
|
let before = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
|
|
&tui.terminal,
|
|
));
|
|
for code in [KeyCode::Up, KeyCode::Char('k')] {
|
|
assert!(app.handle_owned_backtrack_event(&mut tui, &TuiEvent::Key(code.into()))?);
|
|
}
|
|
app.render_owned_transcript(&mut tui, size)?;
|
|
pretty_assertions::assert_ne!(
|
|
buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
|
|
&tui.terminal
|
|
)),
|
|
before
|
|
);
|
|
for (code, selected) in [
|
|
(KeyCode::Down, 1),
|
|
(KeyCode::Char('j'), 1),
|
|
(KeyCode::Char('h'), 0),
|
|
(KeyCode::Char('l'), 1),
|
|
(KeyCode::Left, 0),
|
|
(KeyCode::Right, 1),
|
|
] {
|
|
assert!(app.handle_owned_backtrack_event(&mut tui, &TuiEvent::Key(code.into()))?);
|
|
assert_eq!(app.backtrack.nth_user_message, selected);
|
|
}
|
|
assert_eq!(
|
|
app.chat_widget.composer_text_with_pending(),
|
|
"untouched draft"
|
|
);
|
|
assert!(app.backtrack.overlay_preview_active);
|
|
tui.set_owned_screen(/*owned*/ false)?;
|
|
Ok(())
|
|
}
|