Files
codex/codex-rs/tui/src/app/owned_transcript_tests.rs
T
Eric Traut 8b5ce2fd61 Add local image thumbnails to the TUI composer (#58261)
## What changed

- Show draft attachment thumbnails in reference order in inline and fullscreen views on supported terminals with local workspaces. Display a `+N` count for attachments that do not fit, and preserve editable image references when previews are unavailable or space is limited.
- Refresh previews when attached files change, apply image orientation metadata, and clear frame graphics before terminal handoffs, screen switches, and shutdown.
- Finish CLI subcommand dispatch before polling interactive TUI events.

## Testing

Add a regression test and snapshot covering thumbnail order, overflow, resizing, attachment deletion, file replacement, and fallback behavior in both inline and fullscreen views.

GitOrigin-RevId: 1ea7c89e089cecc390e145c1790b6ede8bad8d5e
2026-10-30 06:37:32 +00:00

2107 lines
83 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.
//! Owned transcript integration preserves composer spacing/input, prompt editing, and gestures.
use super::*;
use crate::app::tests::make_test_app_with_channels;
use crate::app_command::AppCommand;
use crate::app_event::ConsolidationScrollbackReflow;
use crate::chatwidget::tests::helpers::normalize_snapshot_paths;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::session_state::ThreadSessionState;
use crate::test_support::test_path_buf;
use codex_app_server_protocol::AskForApproval;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::CopyOnSelect;
use codex_protocol::models::PermissionProfile;
use crossterm::event::MouseButton::Left;
use crossterm::event::MouseButton::Right;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use crossterm::event::MouseEventKind::Down;
use crossterm::event::MouseEventKind::Drag;
use crossterm::event::MouseEventKind::Up;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
pub(in crate::app) fn user_cell(message: &str) -> Arc<dyn HistoryCell> {
Arc::new(UserHistoryCell {
presentation: Default::default(),
message: message.to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
})
}
pub(in crate::app) fn attach_thread(app: &mut App, thread_id: ThreadId) {
app.chat_widget.handle_thread_session(ThreadSessionState {
daybreak_enabled: false,
windows_sandbox_host: crate::app::WindowsSandboxHost::Local,
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/tmp/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
message_history: None,
network_proxy: None,
rollout_path: None,
});
}
pub(in crate::app) fn buffer_text(buffer: &Buffer) -> String {
buffer
.content()
.chunks(usize::from(buffer.area.width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
#[tokio::test]
async fn external_writer_escape_returns_to_command_center_without_editing() -> Result<()> {
for (detailed, scrolled, offline) in [
(false, false, false),
(false, true, false),
(true, false, false),
(false, false, true),
] {
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")];
app.app_server_target = AppServerTarget::Remote {
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?,
};
app.chat_widget.show_external_writer_thread();
if offline {
assert!(app.begin_reconnect());
}
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)?;
if detailed {
app.open_transcript_overlay(&mut tui);
}
if scrolled {
app.transcript_view
.jump_to_entry(&app.transcript_cells, /*index*/ 0);
}
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Esc.into()),
)
.await?;
assert!(app.chat_widget.has_active_view());
assert_eq!(
(
app.backtrack.primed,
app.backtrack.overlay_preview_active,
app.chat_widget.composer_text_with_pending(),
),
(false, false, String::new()),
);
if offline {
assert!(app.agents_overview.request_id.is_none());
assert!(app.reconnect.presentation == reconnect::ReconnectPresentation::Overview);
insta::assert_snapshot!(
"external_writer_escape_offline_command_center",
crate::chatwidget::tests::helpers::normalize_agent_center_snapshot(
crate::chatwidget::tests::helpers::render_bottom_popup(
&app.chat_widget,
/*width*/ 96,
)
)
);
} else if !detailed && !scrolled {
insta::assert_snapshot!(
"external_writer_escape_command_center",
crate::chatwidget::tests::helpers::normalize_agent_center_snapshot(
crate::chatwidget::tests::helpers::render_bottom_popup(
&app.chat_widget,
/*width*/ 96,
)
)
);
}
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
}
Ok(())
}
#[tokio::test]
async fn list_spacing_completion_preserves_the_scrolled_reader() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
app.transcript_cells = vec![Arc::new(history_cell::AgentMessageCell::new(
vec![
"• First item wraps onto".into(),
" a second row".into(),
"• b".into(),
"• c".into(),
],
/*is_first_line*/ true,
))];
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 26, /*height*/ 1,
);
let mut before = Buffer::empty(area);
app.transcript_view
.jump_to_entry(&app.transcript_cells, /*index*/ 0);
app.transcript_view
.render(area, &mut before, &app.transcript_cells);
app.transcript_view
.scroll(&app.transcript_cells, /*rows*/ 3);
app.transcript_view
.render(area, &mut before, &app.transcript_cells);
assert!(buffer_text(&before).contains("• c"));
app.handle_consolidate_agent_message(
&mut tui,
crate::history_cell::AgentMarkdownCell::new(
"- First item wraps onto a second row\n- b\n- c".into(),
&app.config.cwd,
),
ConsolidationScrollbackReflow::Required,
/*deferred_history_cell*/ None,
)?;
let mut after = Buffer::empty(area);
app.transcript_view
.render(area, &mut after, &app.transcript_cells);
assert_eq!(buffer_text(&after), buffer_text(&before));
app.transcript_view.jump_to_latest();
app.transcript_view
.render(area, &mut after, &app.transcript_cells);
assert_eq!(buffer_text(&after), buffer_text(&before));
Ok(())
}
#[tokio::test]
async fn older_page_loading_uses_the_status_row_without_moving_content_or_cursor() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.local_settings.tui.animations = false;
app.chat_widget.apply_external_edit("draft".to_string());
app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(
(1..=40)
.map(|row| format!("Transcript row {row:02}").into())
.collect(),
))];
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let mut snapshots = Vec::new();
for width in [80, 40, 28] {
let size = Size::new(width, /*height*/ 12);
tui.terminal.resize(size)?;
app.transcript_view.history = TranscriptHistoryState::Partial;
app.render_owned_transcript(&mut tui, size)?;
app.transcript_view
.scroll(&app.transcript_cells, /*rows*/ -3);
let bottom = app.render_owned_transcript(&mut tui, size)?;
let cursor = tui.terminal.last_known_cursor_pos;
let before =
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone();
for state in [
TranscriptHistoryState::LoadingOlder,
TranscriptHistoryState::LoadingBeginning,
TranscriptHistoryState::Failed,
TranscriptHistoryState::Complete,
] {
app.transcript_view.history = state;
let actual_bottom = app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
assert_eq!(
(actual_bottom, tui.terminal.last_known_cursor_pos),
(bottom, cursor)
);
let status_start = buffer.index_of(/*x*/ 0, size.height - 1);
assert_eq!(
&buffer.content()[..status_start],
&before.content()[..status_start]
);
if state == TranscriptHistoryState::Complete {
assert_eq!(
buffer_text(buffer).lines().last().unwrap().trim(),
"esc latest",
);
}
if state == TranscriptHistoryState::LoadingOlder {
assert_eq!(
buffer[(2, size.height - 1)].fg,
crate::style::accent_color()
);
}
let rendered = buffer_text(buffer);
let rendered = rendered.lines().last().unwrap();
snapshots.push(format!("{width} columns · {state:?}\n{rendered}"));
}
}
insta::assert_snapshot!("owned_history_loading_footer", snapshots.join("\n\n"));
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn recap_spacing_belongs_to_the_transcript_tail() -> Result<()> {
let mut snapshots = Vec::new();
for (width, height, next_action) in [
(80, 10, None),
(80, 12, Some("Review the changes.")),
(32, 12, Some("Review the changes.")),
(80, 7, Some("Review the changes.")),
(32, 6, Some("Review the changes.")),
] {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.local_settings.tui.show_tooltips = true;
app.local_settings.tui.animations = false;
app.transcript_cells = vec![
Arc::new(crate::history_cell::PlainHistoryCell::new(
(1..=20)
.map(|row| format!("History {row}").into())
.collect(),
)),
Arc::new(
crate::history_cell::ThreadRecapHistoryCell::new("The draft is ready.".into())
.with_next_action(next_action.map(str::to_owned)),
),
];
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let size = Size::new(width, height);
tui.terminal.resize(size)?;
let mut snapshot = |app: &mut App, label: &str| -> Result<()> {
app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
snapshots.push(format!(
"{width}x{height}, {label}\n{}",
buffer_text(buffer)
));
Ok(())
};
snapshot(&mut app, "Recap at tail")?;
app.transcript_cells
.push(Arc::new(crate::history_cell::AgentMessageCell::new(
vec!["Follow-up response.".into()],
/*is_first_line*/ true,
)));
snapshot(&mut app, "Message after recap")?;
app.transcript_cells.pop();
crate::chatwidget::tests::helpers::set_active_cell(
&mut app.chat_widget,
Box::new(crate::history_cell::AgentMessageCell::new(
vec!["Streaming response.".into()],
/*is_first_line*/ true,
)),
);
snapshot(&mut app, "Live message after recap")?;
tui.set_owned_screen(/*owned*/ false)?;
}
insta::assert_snapshot!(
"recap_tail_spacing",
normalize_snapshot_paths(snapshots.join("\n\n"))
);
Ok(())
}
#[tokio::test]
async fn owned_transcript_reserves_a_row_above_the_composer() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(
(1..=40)
.map(|row| format!("Transcript row {row:02}").into())
.collect(),
))];
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let mut snapshots = Vec::new();
for (label, width, height, draft) in [
("Latest", 80, 12, "draft"),
("Reading", 80, 12, "draft"),
(
"Resized multiline composer",
40,
12,
"draft\nsecond line\nthird line",
),
("Tiny terminal", 40, 5, "draft"),
("Detailed", 80, 12, "preserved draft"),
] {
let size = Size::new(width, height);
tui.terminal.resize(size)?;
app.chat_widget.apply_external_edit(draft.to_string());
if label == "Detailed" {
app.transcript_cells = vec![Arc::new(crate::history_cell::new_view_image_tool_call(
"detail-image".to_string(),
codex_utils_path_uri::LegacyAppPathString::from_string("assets/detail-image.png"),
))];
app.open_transcript_overlay(&mut tui);
assert!(app.overlay.is_none() && app.transcript_view.is_detailed());
}
if label == "Reading" {
app.transcript_view.handle_key(
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
&app.transcript_cells,
);
}
let bottom = app.render_owned_transcript(&mut tui, size)?;
if bottom.y == 0 {
assert_eq!(label, "Tiny terminal");
snapshots.push(format!(
"{label}\n{}",
normalize_snapshot_paths(buffer_text(
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)
))
));
continue;
}
let gap = Rect::new(/*x*/ 0, bottom.y, width, /*height*/ 1);
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let start = buffer.index_of(gap.x, gap.y);
// Both scenarios show the current tail, including the paused detailed view.
if matches!(label, "Latest" | "Detailed") {
assert_eq!(
&buffer.content()[start..start + usize::from(width)],
Buffer::empty(gap).content(),
"{label}",
);
} else {
assert!(
buffer_text(buffer)
.lines()
.nth(usize::from(gap.y))
.unwrap()
.contains("Back to bottom")
);
}
let rendered = normalize_snapshot_paths(buffer_text(buffer));
snapshots.push(format!("{label}\n{rendered}"));
assert!(
app.transcript_view
.handle_mouse(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(
crossterm::event::MouseButton::Left
),
column: gap.x,
row: gap.y,
modifiers: KeyModifiers::NONE,
},
&app.transcript_cells,
)
.is_none()
);
}
insta::assert_snapshot!("owned_transcript_composer_gap", snapshots.join("\n\n"));
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn owned_drag_stops_when_focus_or_input_ownership_is_lost() -> Result<()> {
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(
(0..40).map(|row| format!("row {row:02}").into()).collect(),
))];
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*/ 40, /*height*/ 12);
app.chat_widget
.apply_external_edit("retained draft 界".into());
app.chat_widget.handle_key_event(KeyCode::Left.into());
let draft = app.chat_widget.capture_thread_input_state();
for owner in ["focus", "resume", "overlay", "popup"] {
app.transcript_view = Default::default();
app.overlay = None;
app.render_owned_transcript(&mut tui, size)?;
app.transcript_view
.scroll(&app.transcript_cells, /*rows*/ -12);
app.render_owned_transcript(&mut tui, size)?;
for (kind, row) in [
(MouseEventKind::Down(MouseButton::Left), 2),
(MouseEventKind::Drag(MouseButton::Left), 0),
] {
app.handle_owned_transcript_event(
&mut tui,
&mut app_server,
&TuiEvent::Mouse(MouseEvent {
kind,
column: 3,
row,
modifiers: KeyModifiers::NONE,
}),
)?;
}
assert!(app.transcript_view.tick_selection(&app.transcript_cells));
let selected = app.transcript_view.selected_text(&app.transcript_cells);
let event = match owner {
"focus" => TuiEvent::FocusLost,
"resume" => TuiEvent::Resume,
"overlay" => {
app.overlay = Some(Overlay::new_static_with_lines(
vec!["overlay".into()],
"Overlay".to_string(),
app.keymap.pager.clone(),
));
TuiEvent::Draw
}
"popup" => {
app.chat_widget.open_feature_enable_prompt(Feature::Collab);
TuiEvent::Draw
}
_ => unreachable!(),
};
if matches!(event, TuiEvent::Resume) {
app.handle_tui_event(&mut tui, &mut app_server, event)
.await?;
} else {
app.handle_owned_transcript_event(&mut tui, &mut app_server, &event)?;
}
assert_eq!(app.chat_widget.capture_thread_input_state(), draft);
assert!(
!app.transcript_view.tick_selection(&app.transcript_cells),
"{owner}"
);
assert_eq!(
app.transcript_view.selected_text(&app.transcript_cells),
selected
);
}
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn owned_details_keep_the_composer_cursor_and_screen() -> 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")];
app.chat_widget
.apply_external_edit("preserved draft".to_string());
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
app.open_transcript_overlay(&mut tui);
let bottom_area =
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
let expected_cursor = app
.chat_widget
.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions {
command_popup_placement: crate::bottom_pane::CommandPopupPlacement::Overlay,
composer_gap: Some(&crate::bottom_pane::ComposerGap::default()),
..Default::default()
})
.cursor_pos(bottom_area)
.expect("composer cursor");
assert_eq!(
(
app.overlay.is_none(),
app.transcript_view.is_detailed(),
tui.is_owned_screen(),
tui.is_alt_screen_active()
),
(true, true, true, true),
);
assert_eq!(tui.terminal.last_known_cursor_pos, expected_cursor.into());
let rendered = normalize_snapshot_paths(buffer_text(
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
));
assert!(rendered.contains("First prompt"));
assert!(rendered.contains("Second prompt"));
assert!(rendered.contains("preserved draft"));
assert!(app.handle_owned_backtrack_event(
&mut tui,
&TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)),
)?);
assert_eq!(
(
app.transcript_view.is_detailed(),
tui.is_owned_screen(),
app.chat_widget.composer_text_with_pending()
),
(false, true, "preserved draft".to_string()),
);
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn owned_transcript_keeps_text_out_of_the_pet_columns() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(vec![
"x".repeat(/*n*/ 150).into(),
]))];
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let size = Size::new(/*width*/ 80, /*height*/ 24);
app.render_owned_transcript(&mut tui, size)?;
app.chat_widget
.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(
crate::pets::ImageProtocol::Kitty,
));
app.chat_widget
.install_test_ambient_pet_for_tests(/*animations_enabled*/ false);
let width = app.chat_widget.history_wrap_width(size.width);
assert!(width < size.width);
let bottom = app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
assert!(buffer_text(buffer).contains(&"x".repeat(/*n*/ 60)));
for y in 0..bottom.y {
for x in width..size.width {
assert_eq!(buffer[(x, y)].symbol(), " ");
}
}
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn owned_details_escape_interrupts_work_without_starting_backtrack() -> Result<()> {
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
attach_thread(&mut app, thread_id);
app.transcript_cells = vec![user_cell("historical prompt")];
app.chat_widget
.apply_external_edit("draft survives interrupt".to_string());
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.open_transcript_overlay(&mut tui);
app.chat_widget.handle_server_notification(
ServerNotification::TurnStarted(codex_app_server_protocol::TurnStartedNotification {
thread_id: thread_id.to_string(),
turn: codex_app_server_protocol::Turn {
id: "active-turn".to_string(),
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: Vec::new(),
status: codex_app_server_protocol::TurnStatus::InProgress,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
},
}),
/*replay_kind*/ None,
);
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
)
.await?;
let interrupts = std::iter::from_fn(|| events.try_recv().ok())
.filter(|event| matches!(event, AppEvent::CodexOp(AppCommand::Interrupt)))
.count();
assert_eq!(
(
interrupts,
app.backtrack.overlay_preview_active,
app.transcript_view.is_detailed(),
app.chat_widget.composer_text_with_pending(),
),
(1, false, true, "draft survives interrupt".to_string()),
);
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn owned_backtrack_keys_edit_the_selected_prompt_and_restore_compact_view() -> Result<()> {
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
attach_thread(&mut app, thread_id);
app.transcript_cells = vec![user_cell("first"), user_cell("second")];
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
app.open_transcript_overlay(&mut tui);
for (code, selected) in [(KeyCode::Esc, 1), (KeyCode::Left, 0), (KeyCode::Right, 1)] {
assert!(app.handle_owned_backtrack_event(
&mut tui,
&TuiEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
)?);
assert_eq!(
(
app.backtrack.overlay_preview_active,
app.backtrack.nth_user_message
),
(true, selected)
);
}
assert!(app.handle_owned_backtrack_event(
&mut tui,
&TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
)?);
let selection = std::iter::from_fn(|| events.try_recv().ok()).find_map(|event| match event {
AppEvent::RevertSessionForPromptEdit {
thread_id,
selected_cell,
prompt,
} => Some((
thread_id,
Arc::ptr_eq(&selected_cell, &app.transcript_cells[1]),
prompt,
)),
_ => None,
});
assert_eq!(
selection,
Some((
thread_id,
true,
crate::chatwidget::UserMessage::from("second")
))
);
assert_eq!(
(
app.overlay.is_none(),
app.transcript_view.is_detailed(),
app.backtrack.overlay_preview_active,
app.backtrack.base_id,
tui.is_owned_screen()
),
(true, false, false, None, true),
);
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn cancelling_owned_backtrack_returns_arrow_keys_to_the_composer() -> Result<()> {
for input in [
vec![TuiEvent::Paste("abc".to_string())],
['a', 'b', 'c']
.map(|c| TuiEvent::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)))
.into(),
] {
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 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.open_transcript_overlay(&mut tui);
let size = Size::new(/*width*/ 80, /*height*/ 12);
app.render_owned_transcript(&mut tui, size)?;
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Esc.into()),
)
.await?;
for event in input {
app.handle_tui_event(&mut tui, &mut app_server, event)
.await?;
}
// An ordinary key cancels preview before reaching the composer. Its arrow-key owner must end
// with the highlight, without requiring the user to toggle out of detailed presentation.
// Moving the caret also flushes buffered typing without depending on platform paste timers.
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
)
.await?;
assert_eq!(app.chat_widget.composer_text_with_pending(), "abc");
for event in [
TuiEvent::Paste("X".to_string()),
TuiEvent::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
TuiEvent::Paste("Y".to_string()),
] {
app.handle_tui_event(&mut tui, &mut app_server, event)
.await?;
}
assert_eq!(app.chat_widget.composer_text_with_pending(), "abXcY");
assert_eq!(
(
app.transcript_view.is_detailed(),
app.backtrack.primed,
app.backtrack.overlay_preview_active,
app.backtrack.base_id,
),
(true, false, false, None),
);
assert!(!app.handle_owned_backtrack_event(
&mut tui,
&TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
)?);
app.render_owned_transcript(&mut tui, size)?;
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
}
Ok(())
}
#[tokio::test]
async fn owned_search_and_selection_consume_input_before_composer_and_backtrack() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.transcript_cells = vec![user_cell("needle in history")];
app.chat_widget
.apply_external_edit("composer draft".to_string());
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.open_transcript_overlay(&mut tui);
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
for event in [
TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
TuiEvent::Paste("needle".to_string()),
] {
assert!(app.handle_owned_transcript_event(&mut tui, &mut app_server, &event)?);
}
app.handle_owned_transcript_event(&mut tui, &mut app_server, &TuiEvent::Draw)?;
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
assert!(
app.transcript_view
.search_footer(/*width*/ 80)
.expect("query footer")
.0
.to_string()
.starts_with("Find: needle")
);
let accept = TuiEvent::Key(KeyCode::Enter.into());
let escape = TuiEvent::Key(KeyCode::Esc.into());
assert!(app.handle_owned_transcript_event(&mut tui, &mut app_server, &accept)?);
assert!(!app.transcript_view.is_search_editing());
assert!(app.transcript_view.has_active_interaction());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"composer draft"
);
assert!(!app.backtrack.overlay_preview_active);
// Ghostty, iTerm2 and some tmux paths report held keys as Press, not Repeat.
for kind in [
crossterm::event::KeyEventKind::Press,
crossterm::event::KeyEventKind::Repeat,
] {
let repeat = KeyEvent {
kind,
..KeyCode::Enter.into()
};
assert!(app.transcript_view.owns_interaction_key(repeat));
assert!(app.handle_owned_transcript_event(
&mut tui,
&mut app_server,
&TuiEvent::Key(repeat)
)?);
}
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"composer draft"
);
// Once Find is accepted, modified Enter can still reach configured composer bindings.
for modifiers in [
KeyModifiers::SHIFT,
KeyModifiers::ALT,
KeyModifiers::CONTROL,
] {
let key = KeyEvent::new(KeyCode::Enter, modifiers);
assert!(!app.transcript_view.owns_interaction_key(key));
assert!(!app.handle_owned_transcript_event(
&mut tui,
&mut app_server,
&TuiEvent::Key(key)
)?);
}
// Start and extend a selection through the app, then inspect its copy action without touching
// the host clipboard. The app's existing clipboard handler is tested with an injected writer.
for key in [
KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Right, KeyModifiers::NONE),
] {
assert!(app.handle_owned_transcript_event(
&mut tui,
&mut app_server,
&TuiEvent::Key(key)
)?);
}
let copy_key = KeyEvent::new(KeyCode::Insert, KeyModifiers::CONTROL);
assert!(app.transcript_view.owns_interaction_key(copy_key));
let copy = app
.transcript_view
.handle_key(copy_key, &app.transcript_cells);
assert!(matches!(copy, Some(ViewAction::Copy(text)) if !text.is_empty()));
assert!(app.handle_owned_transcript_event(&mut tui, &mut app_server, &escape)?);
assert!(!app.backtrack.overlay_preview_active);
assert!(app.handle_owned_transcript_event(&mut tui, &mut app_server, &escape)?);
assert!(app.transcript_view.is_following());
assert!(!app.backtrack.overlay_preview_active);
assert!(!app.handle_owned_transcript_event(&mut tui, &mut app_server, &escape)?);
assert!(!app.backtrack.overlay_preview_active);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"composer draft"
);
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn keyboard_select_mode_preserves_the_draft_and_absorbs_enter_repeat() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.transcript_cells = vec![user_cell("select this transcript")];
app.chat_widget
.apply_external_edit("composer draft".to_string());
app.keymap.app.find_transcript = vec![crate::key_hint::KeyBinding::from_event(
KeyCode::Char('v').into(),
)];
app.keymap.pager.find = vec![crate::key_hint::KeyBinding::from_event(
KeyCode::Enter.into(),
)];
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.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
for key in [
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
KeyCode::Char('[').into(),
] {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key))
.await?;
}
assert!(app.transcript_view.is_select_mode());
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Char('v').into()),
)
.await?;
assert!(app.transcript_view.is_select_mode());
assert!(!app.transcript_view.is_search_editing());
for event in [
TuiEvent::Paste("ignored".to_string()),
TuiEvent::Key(KeyCode::Esc.into()),
] {
app.handle_tui_event(&mut tui, &mut app_server, event)
.await?;
}
assert!(app.transcript_view.is_select_mode());
assert!(app.backtrack.select_exit_key.is_none());
for (kind, exit_guard) in [
(KeyEventKind::Press, true),
(KeyEventKind::Repeat, false),
(KeyEventKind::Release, false),
] {
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new_with_kind(
KeyCode::Enter,
KeyModifiers::NONE,
kind,
)),
)
.await?;
assert_eq!(app.backtrack.select_exit_key.is_some(), exit_guard);
}
assert!(!app.transcript_view.is_select_mode());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"composer draft"
);
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn inline_select_mode_routes_find_and_returns_to_the_preserved_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("needle in history")];
app.chat_widget
.apply_external_edit("preserved inline draft".to_string());
app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({
"global": {"find_transcript": "f4"},
"pager": {"scroll_down": "f4"}
}))?)
.expect("pager motion takes precedence in select mode");
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()?;
app.open_transcript_overlay(&mut tui);
for key in [
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
KeyCode::Char('[').into(),
] {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key))
.await?;
}
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::F(4).into()),
)
.await?;
assert!(matches!(
&app.overlay,
Some(Overlay::Transcript(overlay))
if overlay.is_select_mode() && !overlay.is_search_editing()
));
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::F(3).into()),
)
.await?;
assert!(matches!(
&app.overlay,
Some(Overlay::Transcript(overlay)) if overlay.is_search_editing()
));
for key in [KeyCode::Esc, KeyCode::Char('q')] {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key.into()))
.await?;
}
assert!(app.overlay.is_none());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"preserved inline draft"
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn inline_transcript_search_draws_and_escape_precedes_backtrack() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.transcript_cells = vec![user_cell("needle in history")];
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()?;
app.open_transcript_overlay(&mut tui);
for event in [
TuiEvent::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)),
TuiEvent::Paste("needle".to_string()),
TuiEvent::Draw,
] {
app.handle_backtrack_overlay_event(&mut tui, &mut app_server, event)
.await?;
}
let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
&tui.terminal,
));
assert!(
rendered.contains("enter accept"),
"search advances on the inline overlay draw path"
);
app.handle_backtrack_overlay_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Enter.into()),
)
.await?;
assert!(
matches!(&app.overlay, Some(Overlay::Transcript(overlay)) if overlay.has_active_interaction() && !overlay.is_search_editing())
);
assert!(!app.backtrack.overlay_preview_active);
app.handle_backtrack_overlay_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
)
.await?;
assert!(!app.backtrack.overlay_preview_active);
app.handle_backtrack_overlay_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
)
.await?;
assert!(app.backtrack.overlay_preview_active);
app.close_transcript_overlay(&mut tui);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn find_owns_editor_chords_without_changing_the_composer_draft() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
app.transcript_cells = vec![user_cell("alpha beta")];
app.chat_widget
.apply_external_edit("draft remains intact".to_string());
app.keymap = RuntimeKeymap::from_config(&serde_json::from_value(serde_json::json!({
"global": {"find_transcript": "f4", "open_external_editor": "ctrl-g e"},
"editor": {"move_line_start": "ctrl-x h"},
"pager": {"find": "f4"}
}))?)
.expect("independent query bindings");
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()?;
for owned in [true, false] {
tui.set_owned_screen(owned)?;
if !owned {
app.open_transcript_overlay(&mut tui);
}
for event in [
TuiEvent::Key(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE)),
TuiEvent::Draw,
TuiEvent::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)),
TuiEvent::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
TuiEvent::Paste("alpha beta".to_string()),
// A global chord cannot own the next query character.
TuiEvent::Key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)),
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
// Configured editor chords use the same query TextArea.
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
TuiEvent::Key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)),
TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)),
TuiEvent::Key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)),
] {
let pasted = matches!(event, TuiEvent::Paste(_));
app.handle_tui_event(&mut tui, &mut app_server, event)
.await?;
if pasted {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw)
.await?;
let rendered = buffer_text(
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
);
assert!(
rendered.contains("enter accept"),
"Find must resume after pasting over selection"
);
}
if app.key_chord_matcher.is_pending() {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw)
.await?;
let rendered = buffer_text(
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
);
let cursor = tui.terminal.last_known_cursor_pos;
let query_row = rendered
.lines()
.nth(usize::from(cursor.y))
.expect("visible query caret");
assert_eq!(query_row.trim(), "Find: alpha betax");
assert!(rendered.contains("h move line start"));
insta::assert_snapshot!(
"owned_find_pending_chord_footer",
rendered
.lines()
.skip(usize::from(cursor.y))
.take(/*n*/ 2)
.map(str::trim)
.collect::<Vec<_>>()
.join("\n")
);
}
}
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw)
.await?;
let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
&tui.terminal,
));
assert!(
rendered
.lines()
.any(|line| line.trim() == "Find: apha betax")
);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"draft remains intact"
);
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
)
.await?;
assert!(!match &app.overlay {
Some(Overlay::Transcript(overlay)) => overlay.is_search_editing(),
_ => app.transcript_view.is_search_editing(),
});
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"draft remains intact"
);
app.close_transcript_overlay(&mut tui);
}
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn offline_find_closes_before_the_next_ctrl_c_quits() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
app.chat_widget
.apply_external_edit("offline draft".to_string());
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.open_transcript_overlay(&mut tui);
app.transcript_view.begin_search();
app.transcript_view.paste_search("needle");
app.reconnect.offline = true;
app.chat_widget.pause_for_disconnect();
app.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
&tui.terminal,
));
let cursor = tui.terminal.last_known_cursor_pos;
assert_eq!(
rendered.lines().nth(usize::from(cursor.y)).map(str::trim),
Some("Find: needle")
);
assert!(!rendered.contains("⌃c quit"));
let close = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(matches!(
app.handle_tui_event(&mut tui, &mut app_server, close)
.await?,
AppRunControl::Continue
));
assert!(!app.transcript_view.is_search_editing());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"offline draft"
);
assert!(app.transcript_view.is_detailed());
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)),
)
.await?;
assert!(!app.transcript_view.is_detailed());
let quit = TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(matches!(
app.handle_tui_event(&mut tui, &mut app_server, quit)
.await?,
AppRunControl::Exit(ExitReason::UserRequested)
));
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn offline_backtrack_keeps_the_preview_and_draft_without_reverting() -> Result<()> {
for owned in [true, false] {
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
attach_thread(&mut app, ThreadId::new());
app.transcript_cells = vec![user_cell("earlier prompt"), user_cell("historical prompt")];
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)?;
app.open_transcript_overlay(&mut tui);
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
)
.await?;
app.chat_widget
.apply_external_edit("offline draft".to_string());
app.reconnect.offline = true;
for key in [
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL),
] {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key))
.await?;
}
assert_eq!(app.backtrack.nth_user_message, 0);
assert!(if owned {
app.transcript_view.is_detailed()
} else {
matches!(&app.overlay, Some(Overlay::Transcript(overlay)) if overlay.is_detailed())
});
assert!(matches!(
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
)
.await?,
AppRunControl::Continue
));
assert_eq!(
(
app.backtrack.overlay_preview_active,
app.chat_widget.composer_text_with_pending()
),
(true, "offline draft".to_string())
);
assert!(
!std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::RevertSessionForPromptEdit { .. }))
);
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
)
.await?;
assert!(if owned {
app.transcript_view.is_search_editing()
} else {
matches!(&app.overlay, Some(Overlay::Transcript(overlay)) if overlay.is_search_editing())
});
for (key, preview_active, draft) in [
(KeyCode::Esc, true, "offline draft"),
(
if owned {
KeyCode::Backspace
} else {
KeyCode::Esc
},
false,
if owned {
"offline draf"
} else {
"offline draft"
},
),
] {
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key.into()))
.await?;
assert_eq!(app.backtrack.overlay_preview_active, preview_active);
assert_eq!(app.chat_widget.composer_text_with_pending(), draft);
}
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
}
Ok(())
}
#[tokio::test]
async fn slash_picker_overlays_history_without_moving_the_transcript_or_composer() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.chat_widget
.set_status_line(Some("gpt-test default · /tmp/project".into()));
app.local_settings.tui.animations = false;
app.chat_widget
.set_footer_hint_override(Some(vec![("model".to_string(), "high · fast".to_string())]));
app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(
(1..=40)
.map(|row| format!("Transcript row {row:02}: content behind the menu").into())
.collect(),
))];
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
for (width, height) in [(80, 14), (32, 14), (80, 7), (80, 5)] {
let size = Size::new(width, height);
tui.terminal.resize(size)?;
app.chat_widget.apply_external_edit(String::new());
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.handle_key_event(KeyCode::Esc.into());
let slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
assert!(!app.handle_owned_transcript_event(
&mut tui,
&mut app_server,
&TuiEvent::Key(slash),
)?);
tokio::time::pause();
for key in [slash, KeyCode::Char('m').into()] {
app.chat_widget.handle_key_event(key);
tokio::time::advance(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay())
.await;
app.chat_widget
.handle_paste_burst_tick(tui.frame_requester());
}
tokio::time::resume();
assert_eq!(app.chat_widget.composer_text_with_pending(), "/m");
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.render_owned_transcript(&mut tui, size)?;
app.transcript_view
.scroll(&app.transcript_cells, /*rows*/ -3);
let bottom = app.render_owned_transcript(&mut tui, size)?;
let cursor = tui.terminal.last_known_cursor_pos;
let before =
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone();
// Suggestions may cover the gap, but not the single-line composer's top padding.
let composer_y = cursor.y.saturating_sub(/*rhs*/ 1);
// Changing the token reopens the menu dismissed above.
app.chat_widget.apply_external_edit("/mo".to_string());
app.chat_widget.apply_external_edit("/m".to_string());
assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom);
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
let open =
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone();
let open_text = buffer_text(&open);
if composer_y > 0 {
let menu_y = open_text
.lines()
.position(|line| line.contains("› /model"))
.expect("available rows show command matches");
let prefix_len = menu_y * usize::from(width);
assert_eq!(
&open.content()[..prefix_len],
&before.content()[..prefix_len]
);
assert_ne!(open, before);
}
let composer_start = open.index_of(bottom.x, composer_y);
assert_eq!(
&open.content()[composer_start..],
&before.content()[composer_start..]
);
if (width, height) == (80, 14) {
insta::assert_snapshot!("slash_picker_overlays_history", open_text);
}
app.chat_widget.apply_external_edit("/mo".to_string());
assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom);
let filtered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
&tui.terminal,
));
assert_eq!(
filtered
.lines()
.take(usize::from(composer_y))
.any(|line| line.contains("› /model")),
composer_y > 0,
"a single command remains visible when the menu has room",
);
app.chat_widget.apply_external_edit("/m".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom);
assert_eq!(tui.terminal.last_known_cursor_pos, cursor);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom);
assert_eq!(
(
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
tui.terminal.last_known_cursor_pos
),
(&before, cursor),
);
}
tui.set_owned_screen(/*owned*/ false)?;
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn double_escape_browses_prompts_without_reverting_and_explains_editing() -> Result<()> {
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
app.keymap = RuntimeKeymap::from_config(&toml::from_str(
"[editor]\nmove_left=[]\nmove_line_start='left h'\n[global]\nopen_transcript='ctrl-x h'\n",
)?)
.unwrap();
attach_thread(&mut app, ThreadId::new());
app.transcript_cells = vec![
user_cell("first question"),
user_cell("second question"),
user_cell("third question"),
];
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*/ 16);
app.render_owned_transcript(&mut tui, size)?;
for code in [KeyCode::Esc, KeyCode::Esc] {
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyEvent::from(code)))
.await?;
}
assert!(app.backtrack.overlay_preview_active);
assert_eq!(app.backtrack.nth_user_message, 2);
for key in [
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
KeyCode::Char('h').into(),
] {
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))
.await?;
}
assert!(app.transcript_view.is_detailed());
assert_eq!(app.backtrack.nth_user_message, 2);
for key in [
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
KeyCode::Char('h').into(),
] {
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))
.await?;
}
assert!(!app.transcript_view.is_detailed());
for (code, index) in [(KeyCode::Left, 1), (KeyCode::Left, 0), (KeyCode::Right, 1)] {
app.render_owned_transcript(&mut tui, size)?;
app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(code.into()))
.await?;
assert!(!app.key_chord_matcher.is_pending());
assert_eq!(app.backtrack.nth_user_message, index);
}
assert!(
!std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::RevertSessionForPromptEdit { .. }))
);
app.keymap = RuntimeKeymap::defaults();
app.render_owned_transcript(&mut tui, size)?;
insta::assert_snapshot!(
"prompt_navigation",
normalize_snapshot_paths(buffer_text(
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)
))
);
app.close_transcript_overlay(&mut tui);
assert!(!app.backtrack.overlay_preview_active);
server.shutdown().await?;
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[path = "owned_transcript_browsing_tests.rs"]
mod browsing;
#[tokio::test]
async fn find_refreshes_live_details_before_searching_the_first_query() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
crate::chatwidget::tests::helpers::set_active_cell(
&mut app.chat_widget,
Box::new(crate::exec_cell::new_active_exec_command(
"live".into(),
vec!["printf visible\nprintf needle".into()],
Vec::new(),
codex_app_server_protocol::CommandExecutionSource::Agent,
/*interaction_input*/ None,
/*animations_enabled*/ false,
)),
);
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.render_owned_transcript(&mut tui, Size::new(/*width*/ 80, /*height*/ 24))?;
assert!(
!buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
&tui.terminal
))
.contains("needle")
);
for event in [
TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
TuiEvent::Paste("needle".into()),
TuiEvent::Draw,
] {
app.handle_tui_event(&mut tui, &mut app_server, event)
.await?;
}
let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
&tui.terminal,
));
assert!(rendered.contains("enter accept"), "{rendered}");
tui.set_owned_screen(/*owned*/ false)?;
app.open_transcript_overlay(&mut tui);
let Some(Overlay::Transcript(overlay)) = &mut app.overlay else {
panic!("expected transcript overlay");
};
overlay.set_presentation(/*detailed*/ false, HistoryRenderMode::Rich);
for event in [
TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)),
TuiEvent::Paste("needle".into()),
TuiEvent::Draw,
] {
app.handle_tui_event(&mut tui, &mut app_server, event)
.await?;
}
let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
&tui.terminal,
));
assert!(rendered.contains("printf needle"), "{rendered}");
insta::assert_snapshot!("compact_overlay_finds_hidden_live_text", rendered);
app_server.shutdown().await?;
Ok(())
}
fn mouse(kind: MouseEventKind, column: u16, row: u16) -> TuiEvent {
TuiEvent::Mouse(MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
})
}
fn row_containing(tui: &tui::Tui, text: &str) -> u16 {
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
buffer_text(buffer)
.lines()
.position(|row| row.contains(text))
.unwrap() as u16
}
#[tokio::test]
async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
app.local_settings.tui.copy_on_select = 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()?;
app.transcript_cells = vec![user_cell("transcript text")];
app.chat_widget.apply_external_edit("hello world".into());
app.render_owned_transcript(&mut tui, size)?;
let end = tui.terminal.last_known_cursor_pos;
let x = end.x - 11;
let y = end.y;
for event in [
mouse(Down(Left), x, y),
mouse(Drag(Left), x + 5, y),
mouse(Up(Left), x + 5, y),
] {
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &event)?);
}
assert!(!app.transcript_view.has_active_interaction());
app.start_right_click_paste(
&mut tui,
crossterm::event::MouseEvent {
kind: Down(Right),
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
},
);
assert!(!tui.clipboard.is_busy());
app.render_owned_transcript(&mut tui, size)?;
let cursor = tui.terminal.last_known_cursor_pos;
let draft = app.chat_widget.capture_thread_input_state();
for event in [
mouse(Up(Right), x + 2, y),
mouse(Down(Right), /*column*/ 0, y),
mouse(Down(Right), size.width, y),
mouse(Down(Right), x, y - 1),
mouse(Down(Right), x, y + 1),
] {
assert!(!app.handle_composer_copy_event(&mut tui, &event, |_, _| unreachable!()));
}
let copy_events = [
TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER)),
TuiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
TuiEvent::Key(KeyEvent::new(KeyCode::Insert, KeyModifiers::CONTROL)),
mouse(Down(Right), x + 2, y),
];
let mut selection_frames = Vec::new();
for (index, event) in copy_events.iter().enumerate() {
// Each confirmed copy clears the selection, so select again for the next gesture.
if index > 0 {
for event in [
mouse(Down(Left), x, y),
mouse(Drag(Left), x + 5, y),
mouse(Up(Left), x + 5, y),
] {
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &event)?);
}
}
for result in [
Err("clipboard unavailable".to_string()),
Ok(crate::clipboard_copy::CopyStatus::Unconfirmed),
Ok(crate::clipboard_copy::CopyStatus::Confirmed),
] {
assert!(app.handle_composer_copy_event(&mut tui, event, |_, text| {
assert_eq!(text, "hello");
result.clone()
}));
app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let rendered_draft = (x..x + 11)
.map(|column| buffer[(column, y)].symbol())
.collect::<String>();
let selection = (x..x + 11)
.map(|column| {
if buffer[(column, y)]
.modifier
.contains(ratatui::style::Modifier::REVERSED)
{
'^'
} else {
'·'
}
})
.collect::<String>();
let cleared = result == Ok(crate::clipboard_copy::CopyStatus::Confirmed);
assert_eq!(
(
app.chat_widget.capture_thread_input_state(),
tui.terminal.last_known_cursor_pos,
selection.as_str(),
),
(
draft.clone(),
cursor,
if cleared {
"···········"
} else {
"^^^^^······"
},
)
);
let gesture = match event {
TuiEvent::Mouse(_) => "right-click",
TuiEvent::Key(key) if key.code == KeyCode::Insert => "ctrl-insert",
TuiEvent::Key(key) if key.modifiers == KeyModifiers::SUPER => "cmd-c",
_ => "ctrl-c",
};
selection_frames.push(format!(
"{gesture} {result:?}\n{rendered_draft}\n{selection}"
));
}
}
assert_eq!(app.chat_widget.capture_thread_input_state(), draft);
app.render_owned_transcript(&mut tui, size)?;
assert!(row_containing(&tui, "Copied 5 chars to host clipboard") < y);
insta::assert_snapshot!(
"fullscreen_composer_right_click_copy",
format!(
"{}\n\n{}",
selection_frames.join("\n\n"),
normalize_snapshot_paths(buffer_text(
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)
))
)
);
app.chat_widget.handle_key_event(KeyCode::Char('x').into());
for event in &copy_events {
assert!(!app.handle_composer_copy_event(&mut tui, event, |_, _| unreachable!()));
}
assert_eq!(app.chat_widget.composer_text_with_pending(), "hellox world");
// A transcript drag that crosses into the composer remains a transcript selection.
let transcript_y = row_containing(&tui, "transcript text");
for event in [
mouse(Down(Left), /*column*/ 1, transcript_y),
mouse(Drag(Left), x + 3, y),
mouse(Up(Left), x + 3, y),
] {
app.handle_owned_transcript_event(&mut tui, &mut server, &event)?;
}
assert!(app.transcript_view.has_active_interaction());
for event in &copy_events {
assert!(!app.handle_composer_copy_event(&mut tui, event, |_, _| unreachable!()));
}
// A fresh composer click takes ownership away from the transcript selection.
app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?;
assert!(!app.transcript_view.has_active_interaction());
app.chat_widget.apply_external_edit("/".into());
assert!(!app.chat_widget.no_modal_or_popup_active());
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?);
app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Drag(Left), x + 1, y))?;
assert!(app.chat_widget.no_modal_or_popup_active());
app.chat_widget.open_feature_enable_prompt(Feature::Collab);
for event in &copy_events {
assert!(
!app.handle_composer_copy_event(&mut tui, event, |_, _| panic!(
"modals own copy input"
))
);
}
assert!(!app.handle_owned_transcript_event(&mut tui, &mut server, &mouse(Down(Left), x, y))?);
server.shutdown().await?;
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn inline_confirmation_preserves_compact_viewport_without_replaying_history() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
attach_thread(&mut app, ThreadId::new());
app.local_settings.tui.animations = false;
app.chat_widget
.apply_external_edit("preserved draft".to_string());
app.transcript_cells = vec![user_cell("Completed conversation behind the confirmation")];
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ false)?;
let size = Size::new(/*width*/ 80, /*height*/ 24);
app.show_replace_thread_goal_confirmation(
ThreadId::new(),
crate::goal_files::GoalDraft {
objective: "A replacement goal".to_string(),
..Default::default()
},
);
app.render_chat_widget_frame(&mut tui, size)?;
assert!(tui.terminal.viewport_area.height < size.height);
app.chat_widget.handle_key_event(KeyCode::Esc.into());
app.render_chat_widget_frame(&mut tui, size)?;
assert!(tui.terminal.viewport_area.height < size.height);
assert!(app.last_rendered_history_tail.is_none());
assert!(
buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(
&tui.terminal
))
.contains("preserved draft")
);
Ok(())
}
#[tokio::test]
async fn fullscreen_composer_scrolls_without_moving_caret_or_transcript() -> 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*/ 60, /*height*/ 24);
tui.terminal.resize(size)?;
app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(
(1..=40).map(|n| format!("History {n:02}").into()).collect(),
))];
let draft = (1..=35)
.map(|n| format!("Prompt {n:02} 界"))
.collect::<Vec<_>>()
.join("\n");
app.chat_widget.apply_external_edit(draft.clone());
let bottom = app.render_owned_transcript(&mut tui, size)?;
assert!(bottom.y > 0);
let caret = tui.terminal.last_known_cursor_pos;
let original = app.chat_widget.capture_thread_input_state();
let mut frames = Vec::new();
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
frames.push(format!("Following caret\n{}", buffer_text(buffer)));
let wheel = mouse(MouseEventKind::ScrollUp, caret.x, caret.y);
for _ in 0..3 {
assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &wheel)?);
}
assert_eq!(app.chat_widget.capture_thread_input_state(), original);
assert_eq!(app.render_owned_transcript(&mut tui, size)?, bottom);
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let rendered = buffer_text(buffer);
assert!(!rendered.contains("Prompt 35"), "{rendered}");
assert!(rendered.contains("History 40"), "{rendered}");
frames.push(format!("Wheel up\n{rendered}"));
// The new draft key is applied at the real caret even while that caret is offscreen.
app.chat_widget.handle_key_event(KeyCode::Left.into());
app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
assert!(buffer_text(buffer).contains("Prompt 35"));
frames.push(format!("Edit at caret\n{}", buffer_text(buffer)));
// Returning from an external editor restores the caret even for a same-length edit.
app.chat_widget.handle_key_event(KeyCode::Right.into());
app.render_owned_transcript(&mut tui, size)?;
let caret = tui.terminal.last_known_cursor_pos;
for _ in 0..3 {
assert!(app.handle_owned_transcript_event(
&mut tui,
&mut server,
&mouse(MouseEventKind::ScrollUp, caret.x, caret.y),
)?);
}
app.chat_widget
.apply_external_edit(draft.replace("Prompt", "Edited"));
app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
assert!(buffer_text(buffer).contains("Edited 35"));
// Pasting while browsing also resumes following, without inserting into a visible earlier row.
let caret = tui.terminal.last_known_cursor_pos;
for _ in 0..3 {
assert!(app.handle_owned_transcript_event(
&mut tui,
&mut server,
&mouse(MouseEventKind::ScrollUp, caret.x, caret.y),
)?);
}
app.chat_widget.handle_paste(" pasted here".to_string());
app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let rendered = buffer_text(buffer);
assert!(
rendered
.lines()
.any(|line| line.contains("Edited 35") && line.contains("pasted here")),
"{rendered}"
);
frames.push(format!("Paste at caret\n{rendered}"));
// A shorter screen still leaves a usable composer.
let small = Size::new(/*width*/ 40, /*height*/ 10);
tui.terminal.resize(small)?;
app.render_owned_transcript(&mut tui, small)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
frames.push(format!("Short screen\n{}", buffer_text(buffer)));
insta::assert_snapshot!(
"fullscreen_prompt_wheel",
normalize_snapshot_paths(frames.join("\n\n"))
);
server.shutdown().await?;
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn composer_thumbnails_follow_draft_deletion_and_resize() -> Result<()> {
use crate::custom_terminal::test_support::last_rendered_buffer;
use crate::pets::ImageProtocol;
use crate::pets::PetImageSupport;
use crate::pets::PetImageUnsupportedReason;
let dir = tempfile::tempdir()?;
let banner = dir.path().join("banner.png");
let portrait = dir.path().join("portrait.png");
let red = image::Rgba([255, 0, 0, 255]);
image::RgbaImage::from_pixel(/*width*/ 400, /*height*/ 1, red).save(&banner)?;
for owned in [false, true] {
image::RgbaImage::new(/*width*/ 4, /*height*/ 400).save(&portrait)?;
let mut app = crate::app::test_support::make_test_app().await;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(owned)?;
app.chat_widget
.set_pet_image_support_for_tests(PetImageSupport::Supported(ImageProtocol::Kitty));
app.chat_widget.attach_image(portrait.clone());
app.chat_widget.handle_key_event(KeyCode::Home.into());
app.chat_widget.attach_image(banner.clone());
app.chat_widget.handle_key_event(KeyCode::End.into());
app.chat_widget.attach_image(banner.clone());
let narrow = Size::new(/*width*/ 20, /*height*/ 24);
app.render_chat_widget_frame(&mut tui, narrow)?;
let placements = &tui.retained_images;
assert_eq!(placements.len(), 2);
let pixels = &placements[0].image.pixels;
// The whole panorama fits in a single pixel row inside the outlined tile.
assert_eq!(
pixels.pixels().filter(|pixel| **pixel == red).count(),
(pixels.width() - 2) as usize
);
let clear = &placements[1].image.pixels;
assert_eq!(
clear.get_pixel(clear.width() / 2, clear.height() / 2),
clear.get_pixel(/*x*/ 1, /*y*/ 1)
);
assert_ne!(
clear.get_pixel(/*x*/ 0, /*y*/ 0),
clear.get_pixel(/*x*/ 1, /*y*/ 1)
);
let buffer = last_rendered_buffer(&tui.terminal);
assert!(buffer_text(buffer).contains("+1"));
if owned {
// The terminal paints graphics after its text buffer. Mark their footprints
// for this snapshot of the strip and the first editable line.
let mut strip = buffer.clone();
for placement in placements {
for position in placement.area.positions() {
strip[position].set_symbol("░");
}
}
let snapshot = buffer_text(&strip)
.lines()
.skip(usize::from(placements[0].area.y - strip.area.y))
.take(6)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("composer_thumbnail_strip", snapshot);
}
let wide = Size::new(/*width*/ 60, /*height*/ 24);
app.render_chat_widget_frame(&mut tui, wide)?;
assert_eq!(tui.retained_images.len(), 3);
let portrait_image = tui.retained_images[1].image.clone();
app.chat_widget.handle_key_event(KeyCode::Home.into());
app.chat_widget.handle_key_event(KeyCode::Delete.into());
app.render_chat_widget_frame(&mut tui, wide)?;
assert_eq!(tui.retained_images.len(), 2);
assert_eq!(tui.retained_images[0].image.pixels, portrait_image.pixels);
// A draft submits the current file, even if it was replaced since attachment.
image::RgbaImage::from_pixel(/*width*/ 2, /*height*/ 2, red).save(&portrait)?;
app.render_chat_widget_frame(&mut tui, wide)?;
let updated = &tui.retained_images[0].image.pixels;
assert!(updated.pixels().any(|pixel| *pixel == red));
// Both insufficient height and an unsupported terminal leave the references editable.
for (height, support) in [
(5, PetImageSupport::Supported(ImageProtocol::Kitty)),
(
24,
PetImageSupport::Unsupported(PetImageUnsupportedReason::Terminal),
),
] {
app.chat_widget.set_pet_image_support_for_tests(support);
app.render_chat_widget_frame(&mut tui, Size::new(wide.width, height))?;
assert!(tui.retained_images.is_empty());
assert!(
buffer_text(last_rendered_buffer(&tui.terminal)).contains("[Image #1][Image #2]")
);
}
tui.set_owned_screen(/*owned*/ false)?;
}
Ok(())
}
#[tokio::test]
async fn fullscreen_composer_remote_images_leave_an_editable_prompt_row() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let size = Size::new(/*width*/ 60, /*height*/ 12);
tui.terminal.resize(size)?;
app.transcript_cells = vec![Arc::new(crate::history_cell::PlainHistoryCell::new(vec![
"History stays visible".into(),
]))];
app.chat_widget
.apply_external_edit("Edit this prompt".to_string());
app.chat_widget.set_remote_image_urls(
(1..=12)
.map(|n| format!("https://example.com/image-{n}.png"))
.collect(),
);
let bottom = app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let rendered = buffer_text(buffer);
assert!(bottom.y > 0);
assert!(rendered.contains("Edit this prompt"), "{rendered}");
let mut frames = vec![format!("Draft\n{rendered}")];
app.chat_widget.handle_key_event(KeyCode::Home.into());
for (key, label) in [
(KeyCode::Up, "[Image #12]"),
(KeyCode::Up, "[Image #11]"),
(KeyCode::Delete, "[Image #11]"),
(KeyCode::Down, "Edit this prompt"),
] {
app.chat_widget.handle_key_event(key.into());
app.render_owned_transcript(&mut tui, size)?;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let rendered = buffer_text(buffer);
assert!(rendered.contains(label), "{key:?}: {rendered}");
assert!(rendered.contains("Edit this prompt"), "{key:?}: {rendered}");
frames.push(format!("{key:?}\n{rendered}"));
}
assert_eq!(
app.chat_widget.remote_image_urls(),
(1..=12)
.filter(|n| *n != 11)
.map(|n| format!("https://example.com/image-{n}.png"))
.collect::<Vec<_>>()
);
insta::assert_snapshot!(
"fullscreen_prompt_remote_images",
normalize_snapshot_paths(frames.join("\n\n"))
);
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
#[tokio::test]
async fn footer_selection_defers_warning_clicks_and_preserves_the_draft() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
app.local_settings.tui.copy_on_select = CopyOnSelect::Never;
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.set_owned_screen(/*owned*/ true)?;
let size = tui.terminal.size()?;
app.chat_widget
.apply_external_edit("draft stays here".into());
let url = "https://docs.example.invalid/troubleshooting/mcp/oauth/sign-in/this-is-a-long-reference-for-terminal-copy";
let padding = "\n".repeat(8);
let message = format!("Example warning\n{url}\nNext line{padding}Last line");
app.insert_history_cell(&mut tui, Box::new(history_cell::new_warning_event(message)));
app.render_owned_transcript(&mut tui, size)?;
let draft = app.chat_widget.capture_thread_input_state();
let cursor = tui.terminal.last_known_cursor_pos;
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
let badge = buffer
.area
.positions()
.find(|position| buffer[*position].symbol() == "⚠")
.unwrap();
for event in [
mouse(Down(Left), badge.x, badge.y),
mouse(Drag(Left), badge.x + 11, badge.y),
mouse(Up(Left), badge.x + 11, badge.y),
] {
assert!(app.handle_rendered_selection_event(&mut tui, &event)?);
assert!(!app.chat_widget.keymap_contexts().is_warnings());
}
app.render_owned_transcript(&mut tui, size)?;
assert_eq!(
(
app.chat_widget.capture_thread_input_state(),
tui.terminal.last_known_cursor_pos
),
(draft, cursor)
);
{
let mut selection = app.chat_widget.rendered_selection.borrow_mut();
let crate::rendered_selection::RenderedSelection { view, cells, .. } = &mut *selection;
assert!(view.selected_text(cells).unwrap().contains("warning"));
}
insta::assert_snapshot!(
"fullscreen_footer_selection",
normalize_snapshot_paths(buffer_text(
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)
))
);
assert!(app.handle_rendered_selection_event(
&mut tui,
&TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
)?);
// Dismissal consumes the first Escape, and a stationary click still opens the warning viewer.
for event in [
mouse(Down(Left), badge.x + 2, badge.y),
mouse(Up(Left), badge.x + 2, badge.y),
] {
assert!(app.handle_rendered_selection_event(&mut tui, &event)?);
}
assert!(app.chat_widget.keymap_contexts().is_warnings());
app.render_owned_transcript(&mut tui, size)?;
let body = app.chat_widget.rendered_selection.borrow().areas[0];
assert!(
!app.handle_rendered_selection_event(&mut tui, &mouse(Down(Left), body.x, body.y - 1))?
);
let end_row = row_containing(&tui, "Next line");
for event in [
mouse(Down(Left), body.x, body.y + 1),
mouse(Drag(Left), body.x + 4, end_row),
mouse(Up(Left), body.x + 4, end_row),
] {
assert!(app.handle_rendered_selection_event(&mut tui, &event)?);
}
let mut selection = app.chat_widget.rendered_selection.borrow_mut();
let crate::rendered_selection::RenderedSelection { view, cells, .. } = &mut *selection;
assert_eq!(view.selected_text(cells), Some(format!("{url}\nNext")));
drop(selection);
let page = KeyCode::PageDown.into();
assert!(!app.handle_rendered_selection_event(&mut tui, &TuiEvent::Key(page))?);
app.chat_widget.handle_key_event(page);
app.render_owned_transcript(&mut tui, size)?;
assert!((body.y..body.bottom()).contains(&row_containing(&tui, "Last line")));
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}