mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
## What changed Split conversation rendering into preparation, painting, and post-draw steps. Use the supplied conversation bounds for transcript wrapping, composer layout, suggestion overlays, cursor placement, and the empty-state animation. Preserve surrounding text and image placements during input-triggered redraws. Keep centered dialogs relative to the full viewport and restore neighboring content when they close. ## Testing Extend regression tests and add a snapshot for conversations rendered at an offset, covering selection, link hit testing, cursor placement, scroll preservation, neighboring images, and dialog dismissal. GitOrigin-RevId: 0dbca3f8d1064f2d58856c3bc77a4dab1c099cdd
1024 lines
39 KiB
Rust
1024 lines
39 KiB
Rust
//! Composer input resumes after transcript selection without changing search or keymap ownership.
|
||
|
||
use super::tests::attach_thread;
|
||
use super::*;
|
||
use crate::app::tests::make_test_app_with_channels;
|
||
use crate::app_command::AppCommand as Op;
|
||
use crate::chatwidget::tests::helpers::normalize_snapshot_paths;
|
||
use crate::chatwidget::tests::helpers::render_bottom_popup;
|
||
use crate::history_cell::HistoryRenderMode;
|
||
use crate::history_cell::PlainHistoryCell;
|
||
use codex_app_server_protocol::ItemCompletedNotification;
|
||
use codex_app_server_protocol::Turn;
|
||
use codex_app_server_protocol::TurnCompletedNotification;
|
||
use codex_app_server_protocol::TurnItemsView;
|
||
use codex_app_server_protocol::TurnStartedNotification;
|
||
use codex_app_server_protocol::UserInput;
|
||
use codex_protocol::config_types::ModeKind;
|
||
use crossterm::event::MouseButton;
|
||
use crossterm::event::MouseEvent;
|
||
use crossterm::event::MouseEventKind;
|
||
use pretty_assertions::assert_eq;
|
||
|
||
async fn select_transcript(
|
||
app: &mut App,
|
||
tui: &mut tui::Tui,
|
||
app_server: &mut AppServerSession,
|
||
detailed: bool,
|
||
) -> Result<()> {
|
||
app.transcript_view = Default::default();
|
||
app.transcript_view
|
||
.set_presentation(detailed, HistoryRenderMode::Rich);
|
||
app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec![
|
||
"replaced cell".into(),
|
||
]))];
|
||
app.render_owned_transcript(tui, Size::new(/*width*/ 80, /*height*/ 12))?;
|
||
app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec![
|
||
"select this transcript".into(),
|
||
]))];
|
||
app.handle_tui_event(
|
||
tui,
|
||
app_server,
|
||
TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)),
|
||
)
|
||
.await?;
|
||
assert!(
|
||
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)
|
||
.content()
|
||
.iter()
|
||
.map(ratatui::buffer::Cell::symbol)
|
||
.collect::<String>()
|
||
.contains("select this transcript")
|
||
);
|
||
for _ in 0..6 {
|
||
app.handle_tui_event(
|
||
tui,
|
||
app_server,
|
||
TuiEvent::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT)),
|
||
)
|
||
.await?;
|
||
}
|
||
assert_eq!(
|
||
app.transcript_view
|
||
.selected_text(&app.transcript_cells)
|
||
.as_deref(),
|
||
Some("select")
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn configured_ctrl_space_submit_wins_over_transcript_selection() -> Result<()> {
|
||
for running in [false, true] {
|
||
let (mut app, mut events, mut operations) = make_test_app_with_channels().await;
|
||
let thread_id = ThreadId::new();
|
||
attach_thread(&mut app, thread_id);
|
||
if running {
|
||
app.chat_widget.handle_server_notification(
|
||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||
thread_id: thread_id.to_string(),
|
||
turn: Turn {
|
||
id: "turn".into(),
|
||
root_turn_id: None,
|
||
items_view: TurnItemsView::Full,
|
||
items: Vec::new(),
|
||
status: TurnStatus::InProgress,
|
||
error: None,
|
||
started_at: None,
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
},
|
||
}),
|
||
/*replay_kind*/ None,
|
||
);
|
||
}
|
||
let config = serde_json::from_value(serde_json::json!({
|
||
"composer": {"submit": ["ctrl-space", "f9"]}
|
||
}))?;
|
||
app.keymap = RuntimeKeymap::from_config(&config).expect("valid composer bindings");
|
||
app.local_settings.tui.keymap = config.clone();
|
||
app.chat_widget.apply_keymap_update(config, &app.keymap);
|
||
let mut app_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_cells = vec![Arc::new(PlainHistoryCell::new(vec!["transcript".into()]))];
|
||
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 12))?;
|
||
let text = if running { "steer once" } else { "send once" };
|
||
app.chat_widget.apply_external_edit(text.to_string());
|
||
while events.try_recv().is_ok() {}
|
||
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)),
|
||
)
|
||
.await?;
|
||
|
||
assert!(!app.transcript_view.has_active_interaction());
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
|
||
let items = std::iter::from_fn(|| operations.try_recv().ok())
|
||
.find_map(|op| match op {
|
||
Op::UserTurn { items, .. } => Some(items),
|
||
_ => None,
|
||
})
|
||
.expect("expected submitted user turn");
|
||
assert_eq!(
|
||
items,
|
||
vec![UserInput::Text {
|
||
text: text.into(),
|
||
text_elements: Vec::new(),
|
||
}]
|
||
);
|
||
app_server.shutdown().await?;
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn default_page_up_still_scrolls_the_transcript() -> Result<()> {
|
||
let mut app = crate::app::test_support::make_test_app().await;
|
||
let mut app_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_cells = vec![Arc::new(PlainHistoryCell::new(
|
||
(0..30).map(|row| format!("row {row}").into()).collect(),
|
||
))];
|
||
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 12))?;
|
||
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
TuiEvent::Key(KeyCode::PageUp.into()),
|
||
)
|
||
.await?;
|
||
|
||
assert!(!app.transcript_view.is_following());
|
||
app_server.shutdown().await?;
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn screen(tui: &tui::Tui) -> String {
|
||
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||
let rendered = buffer
|
||
.content()
|
||
.chunks(usize::from(buffer.area.width))
|
||
.map(|row| {
|
||
row.iter()
|
||
.map(ratatui::buffer::Cell::symbol)
|
||
.collect::<String>()
|
||
.trim_end()
|
||
.to_owned()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
normalize_snapshot_paths(rendered)
|
||
}
|
||
|
||
fn complete_plan_turn(app: &mut App) {
|
||
app.chat_widget
|
||
.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
|
||
app.chat_widget.set_collaboration_mask(
|
||
crate::collaboration_modes::plan_mask(app.model_catalog.as_ref()).unwrap(),
|
||
);
|
||
let thread_id = ThreadId::new().to_string();
|
||
let turn = Turn {
|
||
id: "plan-turn".into(),
|
||
root_turn_id: None,
|
||
items_view: TurnItemsView::Full,
|
||
items: Vec::new(),
|
||
status: TurnStatus::InProgress,
|
||
error: None,
|
||
started_at: None,
|
||
completed_at: None,
|
||
duration_ms: None,
|
||
};
|
||
for notification in [
|
||
ServerNotification::TurnStarted(TurnStartedNotification {
|
||
thread_id: thread_id.clone(),
|
||
turn: turn.clone(),
|
||
}),
|
||
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
||
thread_id.clone(),
|
||
turn.id.clone(),
|
||
ThreadItemPayload::Plan {
|
||
id: "plan".into(),
|
||
text: (1..=40)
|
||
.map(|row| format!("- Plan step {row:02}\n"))
|
||
.collect(),
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
)),
|
||
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
||
thread_id,
|
||
turn: Turn {
|
||
status: TurnStatus::Completed,
|
||
..turn
|
||
},
|
||
}),
|
||
] {
|
||
app.chat_widget
|
||
.handle_server_notification(notification, /*replay_kind*/ None);
|
||
}
|
||
assert!(app.chat_widget.has_active_modal());
|
||
}
|
||
|
||
fn pointer_event(kind: MouseEventKind, column: u16, row: u16) -> TuiEvent {
|
||
TuiEvent::Mouse(MouseEvent {
|
||
kind,
|
||
column,
|
||
row,
|
||
modifiers: KeyModifiers::NONE,
|
||
})
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn plan_menu_allows_transcript_wheel_scrolling_and_keeps_keyboard_ownership() -> Result<()> {
|
||
for close in [KeyCode::Esc, KeyCode::Enter] {
|
||
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
||
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)?;
|
||
let size = tui.terminal.size()?;
|
||
complete_plan_turn(&mut app);
|
||
while let Ok(event) = events.try_recv() {
|
||
if matches!(event, AppEvent::InsertHistoryCell(_)) {
|
||
app.handle_event(&mut tui, &mut server, event).await?;
|
||
}
|
||
}
|
||
let bottom = app.render_owned_transcript(&mut tui, size)?;
|
||
let latest = screen(&tui);
|
||
let menu = render_bottom_popup(&app.chat_widget, /*width*/ 80);
|
||
for kind in [
|
||
MouseEventKind::ScrollUp,
|
||
MouseEventKind::ScrollDown,
|
||
MouseEventKind::ScrollUp,
|
||
] {
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut server,
|
||
pointer_event(kind, /*column*/ 1, /*row*/ 1),
|
||
)
|
||
.await?;
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 80), menu);
|
||
if kind == MouseEventKind::ScrollDown {
|
||
assert_eq!(screen(&tui), latest);
|
||
assert!(app.transcript_view.is_following());
|
||
} else {
|
||
assert_ne!(screen(&tui), latest);
|
||
assert!(!app.transcript_view.is_following());
|
||
}
|
||
}
|
||
let scrolled = screen(&tui);
|
||
if close == KeyCode::Esc {
|
||
insta::assert_snapshot!("plan_prompt_scrolled", scrolled);
|
||
}
|
||
for (key, selected) in [(KeyCode::PageDown, "› 3."), (KeyCode::PageUp, "› 1.")] {
|
||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key.into()))
|
||
.await?;
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains(selected));
|
||
assert_eq!(
|
||
screen(&tui)
|
||
.lines()
|
||
.take(usize::from(bottom.y))
|
||
.collect::<Vec<_>>(),
|
||
scrolled
|
||
.lines()
|
||
.take(usize::from(bottom.y))
|
||
.collect::<Vec<_>>(),
|
||
);
|
||
}
|
||
while events.try_recv().is_ok() {}
|
||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(close.into()))
|
||
.await?;
|
||
assert!(!app.chat_widget.has_active_modal());
|
||
assert!(!app.transcript_view.is_following());
|
||
let mut follows = 0;
|
||
let mut submissions = Vec::new();
|
||
while let Ok(event) = events.try_recv() {
|
||
match event {
|
||
AppEvent::FollowTranscript => follows += 1,
|
||
AppEvent::SubmitUserMessageWithMode {
|
||
text,
|
||
collaboration_mode,
|
||
} => {
|
||
submissions.push((text, collaboration_mode.mode));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
let expected = if close == KeyCode::Enter {
|
||
(
|
||
1,
|
||
vec![("Implement the plan.".to_string(), Some(ModeKind::Default))],
|
||
)
|
||
} else {
|
||
(0, Vec::new())
|
||
};
|
||
assert_eq!((follows, submissions), expected);
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
server.shutdown().await?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn plan_menu_allows_transcript_selection_and_copy_but_respects_popup_bounds() -> Result<()> {
|
||
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
||
app.local_settings.tui.copy_on_select = codex_config::types::CopyOnSelect::Never;
|
||
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)?;
|
||
let size = tui.terminal.size()?;
|
||
complete_plan_turn(&mut app);
|
||
while let Ok(event) = events.try_recv() {
|
||
if matches!(event, AppEvent::InsertHistoryCell(_)) {
|
||
app.handle_event(&mut tui, &mut server, event).await?;
|
||
}
|
||
}
|
||
let bottom = app.render_owned_transcript(&mut tui, size)?;
|
||
let before = screen(&tui);
|
||
for (kind, column, row) in [
|
||
(MouseEventKind::ScrollUp, 1, bottom.y),
|
||
(MouseEventKind::ScrollDown, 1, bottom.y),
|
||
(MouseEventKind::ScrollUp, size.width, 1),
|
||
] {
|
||
app.handle_tui_event(&mut tui, &mut server, pointer_event(kind, column, row))
|
||
.await?;
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
assert_eq!(screen(&tui), before);
|
||
assert!(!app.transcript_view.has_active_interaction());
|
||
}
|
||
let (plan_row, plan_column) = before
|
||
.lines()
|
||
.enumerate()
|
||
.find_map(|(row, line)| {
|
||
line.find("Plan step")
|
||
.map(|column| (row as u16, column as u16))
|
||
})
|
||
.expect("visible plan step");
|
||
for event in [
|
||
pointer_event(
|
||
MouseEventKind::Down(MouseButton::Left),
|
||
plan_column,
|
||
plan_row,
|
||
),
|
||
TuiEvent::Draw,
|
||
pointer_event(
|
||
MouseEventKind::Drag(MouseButton::Left),
|
||
plan_column + 4,
|
||
plan_row,
|
||
),
|
||
TuiEvent::Draw,
|
||
pointer_event(
|
||
MouseEventKind::Up(MouseButton::Left),
|
||
plan_column + 4,
|
||
plan_row,
|
||
),
|
||
] {
|
||
app.handle_tui_event(&mut tui, &mut server, event).await?;
|
||
}
|
||
let selected = app
|
||
.transcript_view
|
||
.selected_text(&app.transcript_cells)
|
||
.expect("selected plan text");
|
||
assert!("Plan step".contains(&selected), "selected {selected:?}");
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut server,
|
||
TuiEvent::Key(KeyEvent::new(KeyCode::Insert, KeyModifiers::CONTROL)),
|
||
)
|
||
.await?;
|
||
assert!(tui.clipboard.is_busy());
|
||
assert!(app.chat_widget.has_active_modal());
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||
let selection = (0..size.width)
|
||
.map(|column| {
|
||
if buffer[(column, plan_row)]
|
||
.modifier
|
||
.contains(ratatui::style::Modifier::REVERSED)
|
||
{
|
||
'^'
|
||
} else {
|
||
'·'
|
||
}
|
||
})
|
||
.collect::<String>();
|
||
insta::assert_snapshot!(
|
||
"plan_prompt_selection",
|
||
format!(
|
||
"{selection}\n\n{}",
|
||
render_bottom_popup(&app.chat_widget, /*width*/ 80),
|
||
)
|
||
);
|
||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
||
.await?;
|
||
app.transcript_view.end_selection(&app.transcript_cells);
|
||
app.transcript_view.jump_to_latest();
|
||
app.chat_widget.apply_external_edit("/m".to_string());
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
let popup = screen(&tui);
|
||
let popup_row = popup
|
||
.lines()
|
||
.position(|line| line.contains("› /model"))
|
||
.unwrap() as u16;
|
||
assert!(!app.chat_widget.has_active_modal());
|
||
assert!(!app.chat_widget.no_modal_or_popup_active());
|
||
for kind in [MouseEventKind::ScrollUp, MouseEventKind::ScrollDown] {
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut server,
|
||
pointer_event(kind, /*column*/ 1, popup_row),
|
||
)
|
||
.await?;
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
assert_eq!(screen(&tui), popup);
|
||
assert!(app.transcript_view.is_following());
|
||
}
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn empty_enter_returns_to_latest_with_contextual_hints() -> Result<()> {
|
||
let (mut app, mut events, mut operations) = make_test_app_with_channels().await;
|
||
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 (width, detailed) in [(80, false), (40, true)] {
|
||
let config = serde_json::from_value(serde_json::json!({}))?;
|
||
app.keymap = RuntimeKeymap::from_config(&config).expect("valid default bindings");
|
||
app.local_settings.tui.keymap = config.clone();
|
||
app.chat_widget.apply_keymap_update(config, &app.keymap);
|
||
let size = Size::new(width, /*height*/ 12);
|
||
tui.terminal.resize(size)?;
|
||
app.transcript_view = Default::default();
|
||
app.transcript_view
|
||
.set_presentation(detailed, HistoryRenderMode::Rich);
|
||
app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(
|
||
(0..30).map(|row| format!("row {row}").into()).collect(),
|
||
))];
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
app.transcript_view
|
||
.scroll(&app.transcript_cells, /*rows*/ -10);
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
let cursor = tui.terminal.last_known_cursor_pos;
|
||
assert!(screen(&tui).contains("enter/esc latest"));
|
||
|
||
// Release/repeat events and modified Enter must not become a navigation command.
|
||
for key in [
|
||
KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Release),
|
||
KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat),
|
||
KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
|
||
] {
|
||
assert!(!app.handle_owned_transcript_event(
|
||
&mut tui,
|
||
&mut server,
|
||
&TuiEvent::Key(key)
|
||
)?);
|
||
assert!(!app.transcript_view.is_following());
|
||
}
|
||
|
||
app.chat_widget.apply_external_edit("draft".to_string());
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
assert!(screen(&tui).contains("esc latest"));
|
||
assert!(!screen(&tui).contains("enter/esc latest"));
|
||
app.chat_widget.apply_external_edit(String::new());
|
||
app.transcript_cells
|
||
.push(Arc::new(PlainHistoryCell::new(vec!["new output".into()])));
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
while events.try_recv().is_ok() {}
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut server,
|
||
TuiEvent::Key(KeyEvent::from(KeyCode::Enter)),
|
||
)
|
||
.await?;
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
assert!(app.transcript_view.is_following());
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
|
||
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
|
||
assert!(screen(&tui).contains("new output"));
|
||
assert!(!screen(&tui).contains("latest"));
|
||
assert!(events.try_recv().is_err());
|
||
assert!(operations.try_recv().is_err());
|
||
|
||
for config in [
|
||
serde_json::json!({"composer": {"submit": ["enter", "ctrl-space"]}}),
|
||
serde_json::json!({"global": {"submit": ["enter", "ctrl-space"]}}),
|
||
] {
|
||
let config = serde_json::from_value(config)?;
|
||
app.keymap = RuntimeKeymap::from_config(&config).expect("valid submit bindings");
|
||
app.local_settings.tui.keymap = config.clone();
|
||
app.chat_widget.apply_keymap_update(config, &app.keymap);
|
||
app.transcript_view
|
||
.scroll(&app.transcript_cells, /*rows*/ -10);
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
assert!(screen(&tui).contains("enter/esc latest"));
|
||
while events.try_recv().is_ok() {}
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut server,
|
||
TuiEvent::Key(KeyEvent::from(KeyCode::Enter)),
|
||
)
|
||
.await?;
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
assert!(app.transcript_view.is_following());
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
|
||
assert!(!screen(&tui).contains("latest"));
|
||
assert!(events.try_recv().is_err());
|
||
assert!(operations.try_recv().is_err());
|
||
}
|
||
|
||
assert!(!app.handle_owned_transcript_event(
|
||
&mut tui,
|
||
&mut server,
|
||
&TuiEvent::Key(KeyEvent::from(KeyCode::Enter))
|
||
)?);
|
||
}
|
||
server.shutdown().await?;
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn submitting_a_draft_from_history_queues_once_and_follows_immediately() -> Result<()> {
|
||
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
|
||
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)?;
|
||
let size = Size::new(/*width*/ 80, /*height*/ 12);
|
||
app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(
|
||
(0..30).map(|row| format!("row {row}").into()).collect(),
|
||
))];
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
app.transcript_view
|
||
.scroll(&app.transcript_cells, /*rows*/ -10);
|
||
app.chat_widget
|
||
.apply_external_edit("send this once".to_string());
|
||
while events.try_recv().is_ok() {}
|
||
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut server,
|
||
TuiEvent::Key(KeyEvent::from(KeyCode::Enter)),
|
||
)
|
||
.await?;
|
||
let mut follows = 0;
|
||
while let Ok(event) = events.try_recv() {
|
||
if matches!(event, AppEvent::FollowTranscript) {
|
||
follows += 1;
|
||
app.handle_event(&mut tui, &mut server, event).await?;
|
||
}
|
||
}
|
||
assert_eq!(follows, 1);
|
||
assert!(app.transcript_view.is_following());
|
||
assert_eq!(
|
||
app.chat_widget.queued_user_message_texts(),
|
||
vec!["send this once"]
|
||
);
|
||
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
|
||
server.shutdown().await?;
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn enter_preserves_search_backtrack_and_modal_ownership_while_scrolled() -> Result<()> {
|
||
let mut app = crate::app::test_support::make_test_app().await;
|
||
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)?;
|
||
let size = Size::new(/*width*/ 80, /*height*/ 12);
|
||
app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(
|
||
(0..30).map(|row| format!("row {row}").into()).collect(),
|
||
))];
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
app.transcript_view
|
||
.scroll(&app.transcript_cells, /*rows*/ -10);
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
let enter = TuiEvent::Key(KeyEvent::from(KeyCode::Enter));
|
||
app.transcript_view.begin_search();
|
||
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &enter)?);
|
||
assert!(app.transcript_view.is_search_editing());
|
||
assert!(!app.transcript_view.is_following());
|
||
app.handle_owned_transcript_event(
|
||
&mut tui,
|
||
&mut server,
|
||
&TuiEvent::Key(KeyEvent::from(KeyCode::Esc)),
|
||
)?;
|
||
|
||
// An empty selection anchor consumes Enter without accessing the host clipboard.
|
||
app.handle_owned_transcript_event(
|
||
&mut tui,
|
||
&mut server,
|
||
&TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)),
|
||
)?;
|
||
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &enter)?);
|
||
assert!(!app.transcript_view.is_following());
|
||
app.handle_owned_transcript_event(
|
||
&mut tui,
|
||
&mut server,
|
||
&TuiEvent::Key(KeyEvent::from(KeyCode::Esc)),
|
||
)?;
|
||
|
||
app.backtrack.primed = true;
|
||
assert!(!app.handle_owned_transcript_event(&mut tui, &mut server, &enter)?);
|
||
app.backtrack.primed = false;
|
||
app.backtrack.overlay_preview_active = true;
|
||
assert!(!app.handle_owned_transcript_event(&mut tui, &mut server, &enter)?);
|
||
app.backtrack.overlay_preview_active = false;
|
||
|
||
app.chat_widget
|
||
.set_remote_image_urls(vec!["https://example.com/image.png".into()]);
|
||
assert!(!app.handle_owned_transcript_event(&mut tui, &mut server, &enter)?);
|
||
app.chat_widget.set_remote_image_urls(Vec::new());
|
||
app.chat_widget.open_feature_enable_prompt(Feature::Collab);
|
||
assert!(!app.handle_owned_transcript_event(&mut tui, &mut server, &enter)?);
|
||
assert!(!app.transcript_view.is_following());
|
||
|
||
let outer = tui.terminal.size()?;
|
||
let area = Rect::new(
|
||
/*x*/ 48, /*y*/ 2, /*width*/ 26, /*height*/ 14,
|
||
);
|
||
let frames = tui.frame_requester();
|
||
let mut painted = Default::default();
|
||
tui.draw(outer.height, |frame| {
|
||
for cell in &mut frame.buffer.content {
|
||
cell.set_char('.');
|
||
}
|
||
let prepared = app.prepare_conversation(frame.area(), Some(area), &frames);
|
||
painted = app.paint_conversation(frame, &prepared);
|
||
app.paint_centered_dialog(frame);
|
||
assert!(frame.cursor_position.is_none());
|
||
})?;
|
||
app.finish_conversation(&mut tui, painted)?;
|
||
let dialog = screen(&tui);
|
||
let title = dialog
|
||
.lines()
|
||
.find(|line| line.contains("Enable subagents?"))
|
||
.expect("confirmation title");
|
||
assert!(title.find("Enable subagents?").unwrap() < usize::from(area.x));
|
||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into()))
|
||
.await?;
|
||
assert!(!app.chat_widget.has_active_modal());
|
||
assert!(!app.transcript_view.is_following());
|
||
|
||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
|
||
.await?;
|
||
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||
for pos in buffer.area.positions().filter(|pos| !area.contains(*pos)) {
|
||
assert_eq!(buffer[pos].symbol(), ".");
|
||
}
|
||
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
server.shutdown().await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn escape_returns_to_latest_without_changing_the_composer_draft() -> Result<()> {
|
||
let mut app = crate::app::test_support::make_test_app().await;
|
||
let mut app_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)?;
|
||
let size = Size::new(/*width*/ 80, /*height*/ 12);
|
||
app.chat_widget
|
||
.apply_external_edit("draft preserved".to_string());
|
||
app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(
|
||
(0..30).map(|row| format!("row {row}").into()).collect(),
|
||
))];
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
app.transcript_view
|
||
.scroll(&app.transcript_cells, /*rows*/ -10);
|
||
app.transcript_cells
|
||
.push(Arc::new(PlainHistoryCell::new(vec!["new output".into()])));
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
let before = screen(&tui);
|
||
assert!(before.contains("New activity · esc latest"));
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||
)
|
||
.await?;
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
assert!(app.transcript_view.is_following());
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
"draft preserved"
|
||
);
|
||
insta::assert_snapshot!(
|
||
"escape_returns_to_latest",
|
||
format!("before\n{before}\n\nafter\n{}", screen(&tui))
|
||
);
|
||
|
||
app.transcript_view
|
||
.scroll(&app.transcript_cells, /*rows*/ -10);
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
app.chat_widget.toggle_vim_mode_and_notify();
|
||
app.chat_widget
|
||
.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||
let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||
assert!(app.chat_widget.should_handle_vim_insert_escape(escape));
|
||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(escape))
|
||
.await?;
|
||
assert!(!app.chat_widget.should_handle_vim_insert_escape(escape));
|
||
assert!(!app.transcript_view.is_following());
|
||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(escape))
|
||
.await?;
|
||
assert!(app.transcript_view.is_following());
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
"draft preserved"
|
||
);
|
||
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
app.transcript_view
|
||
.scroll(&app.transcript_cells, /*rows*/ -10);
|
||
app.transcript_cells
|
||
.push(Arc::new(PlainHistoryCell::new(vec![
|
||
"visible after resize".into(),
|
||
])));
|
||
let large = Size::new(/*width*/ 80, /*height*/ 24);
|
||
tui.terminal.resize(large)?;
|
||
app.render_owned_transcript(&mut tui, large)?;
|
||
let resized = screen(&tui);
|
||
assert!(resized.contains("visible after resize"));
|
||
assert!(!resized.contains("New activity"));
|
||
app_server.shutdown().await?;
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn raw_multiline_paste_defers_rendering_and_submits_once() -> Result<()> {
|
||
let mut app = crate::app::test_support::make_test_app().await;
|
||
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.local_settings.tui.animations = false;
|
||
app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec!["Before paste".into()]))];
|
||
tokio::time::pause();
|
||
// The first character starts a possible burst; defer further renders until the draw.
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut server,
|
||
TuiEvent::Key(KeyCode::Char('D').into()),
|
||
)
|
||
.await?;
|
||
let before = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone();
|
||
app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec!["After paste".into()]))];
|
||
let rest = "ebug\t한국어\tASCII\t0123456789\n".repeat(80) + "end";
|
||
for ch in rest.chars() {
|
||
let code = match ch {
|
||
'\n' => KeyCode::Enter,
|
||
'\t' => KeyCode::Tab,
|
||
ch => KeyCode::Char(ch),
|
||
};
|
||
// Windows reports AltGr text as Ctrl+Alt, including with Shift held.
|
||
#[cfg(windows)]
|
||
let modifiers = match ch {
|
||
'한' => KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||
'국' => KeyModifiers::SHIFT | KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||
_ => KeyModifiers::NONE,
|
||
};
|
||
#[cfg(not(windows))]
|
||
let modifiers = KeyModifiers::NONE;
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut server,
|
||
TuiEvent::Key(KeyEvent::new(code, modifiers)),
|
||
)
|
||
.await?;
|
||
}
|
||
assert_eq!(
|
||
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
|
||
&before
|
||
);
|
||
assert!(app.chat_widget.queued_user_message_texts().is_empty());
|
||
let during_paste = screen(&tui);
|
||
// Wait out Windows' active-paste timeout and accidental-Enter suppression window.
|
||
tokio::time::advance(Duration::from_secs(/*secs*/ 1)).await;
|
||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
|
||
.await?;
|
||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
|
||
.await?;
|
||
assert!(screen(&tui).contains("After paste"));
|
||
insta::assert_snapshot!(
|
||
"raw_paste_burst_render",
|
||
format!(
|
||
"During paste:\n{during_paste}\n\nAfter flush:\n{}",
|
||
screen(&tui)
|
||
)
|
||
);
|
||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Enter.into()))
|
||
.await?;
|
||
assert_eq!(
|
||
app.chat_widget.queued_user_message_texts(),
|
||
vec![format!("D{rest}")]
|
||
);
|
||
tokio::time::resume();
|
||
server.shutdown().await?;
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn accepted_find_defers_paste_rendering_but_keeps_enter() -> Result<()> {
|
||
let mut app = crate::app::test_support::make_test_app().await;
|
||
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.local_settings.tui.animations = false;
|
||
app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec![
|
||
"Before paste needle".into(),
|
||
]))];
|
||
for event in [
|
||
TuiEvent::Key(KeyCode::F(3).into()),
|
||
TuiEvent::Paste("needle".to_string()),
|
||
TuiEvent::Draw,
|
||
TuiEvent::Key(KeyCode::Enter.into()),
|
||
] {
|
||
app.handle_tui_event(&mut tui, &mut server, event).await?;
|
||
}
|
||
assert!(!app.transcript_view.is_search_editing());
|
||
assert!(app.transcript_view.has_active_interaction());
|
||
tokio::time::pause();
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut server,
|
||
TuiEvent::Key(KeyCode::Char('D').into()),
|
||
)
|
||
.await?;
|
||
let before = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone();
|
||
app.transcript_cells = vec![Arc::new(PlainHistoryCell::new(vec![
|
||
"After paste needle".into(),
|
||
]))];
|
||
for ch in "ebug log ".repeat(80).chars() {
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut server,
|
||
TuiEvent::Key(KeyCode::Char(ch).into()),
|
||
)
|
||
.await?;
|
||
}
|
||
assert_eq!(
|
||
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
|
||
&before
|
||
);
|
||
// Enter still belongs to accepted Find, even in the middle of a paste burst.
|
||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Enter.into()))
|
||
.await?;
|
||
assert_ne!(
|
||
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
|
||
&before
|
||
);
|
||
assert!(app.chat_widget.queued_user_message_texts().is_empty());
|
||
tokio::time::advance(Duration::from_secs(/*secs*/ 1)).await;
|
||
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw)
|
||
.await?;
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
format!("D{}", "ebug log ".repeat(80))
|
||
);
|
||
tokio::time::resume();
|
||
server.shutdown().await?;
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn composer_paste_releases_selection_and_retains_normal_cursor_movement() -> Result<()> {
|
||
let mut app = crate::app::test_support::make_test_app().await;
|
||
let mut app_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)?;
|
||
let size = Size::new(/*width*/ 80, /*height*/ 12);
|
||
for detailed in [false, true] {
|
||
app.chat_widget.apply_external_edit("draft ".to_string());
|
||
select_transcript(&mut app, &mut tui, &mut app_server, detailed).await?;
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
TuiEvent::Paste("café\r\nbeta\rgamma".to_string()),
|
||
)
|
||
.await?;
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
"draft café\nbeta\ngamma"
|
||
);
|
||
assert!(!app.transcript_view.has_active_interaction());
|
||
assert!(!app.transcript_view.tick_selection(&app.transcript_cells));
|
||
assert!(!app.handle_owned_transcript_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
&TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
|
||
)?);
|
||
app.handle_tui_event(
|
||
&mut tui,
|
||
&mut app_server,
|
||
TuiEvent::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
|
||
)
|
||
.await?;
|
||
// A paste inserts immediately, avoiding the terminal's typing-burst timer in this fixture.
|
||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Paste("X".to_string()))
|
||
.await?;
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
"draft café\nbeta\ngammXa"
|
||
);
|
||
app.render_owned_transcript(&mut tui, size)?;
|
||
}
|
||
app_server.shutdown().await?;
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
Ok(())
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn modified_arrows_and_editor_chords_resume_composer_movement() -> Result<()> {
|
||
let mut app = crate::app::test_support::make_test_app().await;
|
||
let config = serde_json::from_value(serde_json::json!({
|
||
"editor": {"move_line_start": ["home", "ctrl-a", "ctrl-shift-left", "ctrl-x h"]}
|
||
}))?;
|
||
app.keymap = RuntimeKeymap::from_config(&config).expect("valid editor bindings");
|
||
app.chat_widget.apply_keymap_update(config, &app.keymap);
|
||
let mut app_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] {
|
||
for (keys, expected) in [
|
||
(
|
||
vec![KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL)],
|
||
"alpha beta Xgamma",
|
||
),
|
||
(
|
||
vec![KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)],
|
||
"alpha beta Xgamma",
|
||
),
|
||
(
|
||
vec![
|
||
KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL),
|
||
KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL),
|
||
],
|
||
"alpha beta gammaX",
|
||
),
|
||
(
|
||
vec![
|
||
KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
|
||
KeyEvent::new(KeyCode::Right, KeyModifiers::ALT),
|
||
],
|
||
"alpha beta gammaX",
|
||
),
|
||
(
|
||
vec![KeyEvent::new(
|
||
KeyCode::Left,
|
||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||
)],
|
||
"Xalpha beta gamma",
|
||
),
|
||
(
|
||
vec![
|
||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
|
||
],
|
||
"Xalpha beta gamma",
|
||
),
|
||
(
|
||
vec![
|
||
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
|
||
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
|
||
],
|
||
"aXlpha beta gamma",
|
||
),
|
||
] {
|
||
app.chat_widget
|
||
.apply_external_edit("alpha beta gamma".to_string());
|
||
select_transcript(&mut app, &mut tui, &mut app_server, detailed).await?;
|
||
for key in keys {
|
||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key))
|
||
.await?;
|
||
}
|
||
assert!(!app.transcript_view.has_active_interaction());
|
||
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Paste("X".to_string()))
|
||
.await?;
|
||
assert_eq!(
|
||
app.chat_widget.composer_text_with_pending(),
|
||
expected,
|
||
"detailed={detailed}"
|
||
);
|
||
}
|
||
}
|
||
app_server.shutdown().await?;
|
||
tui.set_owned_screen(/*owned*/ false)?;
|
||
Ok(())
|
||
}
|