use super::*;
use crate::config::constants::ui;
use crate::core_tui::app::session::AppSession;
use crate::core_tui::app::types as app_types;
use crate::core_tui::types::{
InlineHeaderBadge, InlineHeaderStatusBadge, InlineHeaderStatusTone, InlineLinkTarget,
InlineListItem, InlineListSearchConfig, InlineListSelection, InlineSegment, InlineTextStyle,
InlineTheme, ListOverlayRequest, LocalAgentEntry, OverlayEvent, OverlayHotkey,
OverlayHotkeyAction, OverlayHotkeyKey, OverlayRequest, OverlaySubmission, WizardModalMode,
WizardOverlayRequest, WizardStep,
};
use crate::core_tui::widgets::TranscriptWidget;
use crate::ui::tui::session::message::RenderedTranscriptLink;
use crate::ui::tui::session::transcript_links::TranscriptLinkTarget;
use crate::ui::tui::style::ratatui_style_from_inline;
use ratatui::crossterm::event::{
Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
#[cfg(target_os = "macos")]
use ratatui::crossterm::event::{KeyEventKind, ModifierKeyCode};
use ratatui::{
Terminal,
backend::TestBackend,
buffer::Buffer,
layout::Rect,
style::{Color, Modifier},
text::{Line, Span},
};
use std::fs;
use std::path::PathBuf;
use std::sync::{
LazyLock, Mutex,
atomic::{AtomicUsize, Ordering},
};
use std::time::{Duration, Instant};
use tokio::sync::mpsc;
use tokio::sync::mpsc::UnboundedSender;
const VIEW_ROWS: u16 = 14;
const VIEW_WIDTH: u16 = 100;
const LINE_COUNT: usize = 10;
const LABEL_PREFIX: &str = "line";
const EXTRA_SEGMENT: &str = "\nextra-line";
static TRANSCRIPT_TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
static CLIPBOARD_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
static TERMINAL_ENV_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
fn with_terminal_env<R>(tmux: Option<&str>, term: Option<&str>, f: impl FnOnce() -> R) -> R {
let _guard = TERMINAL_ENV_TEST_LOCK
.lock()
.expect("terminal env test lock");
let original_tmux = std::env::var("TMUX").ok();
let original_term = std::env::var("TERM").ok();
terminal_capabilities::set_test_env_override("TMUX", tmux);
terminal_capabilities::set_test_env_override("TERM", term);
let result = f();
match original_tmux {
Some(value) => terminal_capabilities::set_test_env_override("TMUX", Some(&value)),
None => terminal_capabilities::clear_test_env_override("TMUX"),
}
match original_term {
Some(value) => terminal_capabilities::set_test_env_override("TERM", Some(&value)),
None => terminal_capabilities::clear_test_env_override("TERM"),
}
result
}
fn make_segment(text: &str) -> InlineSegment {
InlineSegment {
text: text.to_string(),
style: Arc::new(InlineTextStyle::default()),
}
}
fn themed_inline_colors() -> InlineTheme {
InlineTheme {
foreground: Some(AnsiColorEnum::Rgb(RgbColor(0xEE, 0xEE, 0xEE))),
tool_accent: Some(AnsiColorEnum::Rgb(RgbColor(0xBF, 0x45, 0x45))),
tool_body: Some(AnsiColorEnum::Rgb(RgbColor(0xAA, 0x88, 0x88))),
primary: Some(AnsiColorEnum::Rgb(RgbColor(0x88, 0x88, 0x88))),
secondary: Some(AnsiColorEnum::Rgb(RgbColor(0x77, 0x99, 0xAA))),
..Default::default()
}
}
fn session_with_input(input: &str, cursor: usize) -> Session {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS * 2);
session.set_input(input.to_string());
session.set_cursor(cursor);
session
}
fn app_session_with_input(input: &str, cursor: usize) -> AppSession {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
session.core.set_input(input.to_string());
session.core.set_cursor(cursor);
session
}
fn left_click_session(
session: &mut Session,
events: &UnboundedSender<InlineEvent>,
column: u16,
row: u16,
modifiers: KeyModifiers,
) {
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers,
}),
events,
None,
);
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column,
row,
modifiers,
}),
events,
None,
);
}
fn left_click_app_session(
session: &mut AppSession,
events: &UnboundedSender<app_types::InlineEvent>,
column: u16,
row: u16,
modifiers: KeyModifiers,
) {
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers,
}),
events,
None,
);
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column,
row,
modifiers,
}),
events,
None,
);
}
fn sample_local_agent_entry(kind: app_types::LocalAgentKind) -> LocalAgentEntry {
sample_local_agent_entry_with_id("agent-1", "rust-engineer", kind)
}
fn sample_local_agent_entry_with_id(
id: &str,
display_label: &str,
kind: app_types::LocalAgentKind,
) -> LocalAgentEntry {
LocalAgentEntry {
id: id.to_string(),
display_label: display_label.to_string(),
agent_name: display_label.to_string(),
color: Some("cyan".to_string()),
kind,
status: "running".to_string(),
summary: Some("Reviewing the workspace".to_string()),
preview: "assistant: reviewing the workspace".to_string(),
transcript_path: None,
}
}
fn load_app_file_palette(session: &mut AppSession, files: Vec<String>, workspace: PathBuf) {
session.handle_command(app_types::InlineCommand::ShowTransient {
request: Box::new(app_types::TransientRequest::FilePalette(
app_types::FilePaletteTransientRequest {
files,
workspace,
visible: None,
},
)),
});
}
fn session_with_slash_palette_commands() -> AppSession {
AppSession::new_with_logs(
InlineTheme::default(),
None,
VIEW_ROWS,
true,
None,
vec![
app_types::SlashCommandItem::new("new", "Start a new session"),
app_types::SlashCommandItem::new("review", "Review current diff"),
app_types::SlashCommandItem::new("doctor", "Run diagnostics"),
app_types::SlashCommandItem::new("command", "Run a terminal command"),
app_types::SlashCommandItem::new("files", "Browse files"),
],
"Agent TUI".to_string(),
)
}
fn enable_vim_normal_mode(session: &mut Session) {
session.vim_state.set_enabled(true);
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
}
fn enable_vim_normal_mode_app(session: &mut AppSession) {
session.core.vim_state.set_enabled(true);
assert!(
session
.core
.handle_vim_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
);
}
fn show_basic_list_overlay(session: &mut Session) {
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::List(ListOverlayRequest {
title: "Pick one".to_string(),
lines: vec!["Choose an option".to_string()],
footer_hint: None,
items: vec![
InlineListItem {
title: "Option A".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("a".to_string())),
search_value: None,
},
InlineListItem {
title: "Option B".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("b".to_string())),
search_value: None,
},
],
selected: Some(InlineListSelection::SlashCommand("a".to_string())),
search: None,
hotkeys: Vec::new(),
})),
});
}
fn show_diff_overlay(session: &mut AppSession, mode: app_types::DiffPreviewMode) {
session.show_diff_overlay(app_types::DiffOverlayRequest {
file_path: "src/main.rs".to_string(),
before: "fn old() {}\n".to_string(),
after: "fn new() {}\n".to_string(),
hunks: vec![app_types::DiffHunk {
old_start: 0,
new_start: 0,
old_lines: 1,
new_lines: 1,
display: "@@ -1 +1 @@".to_string(),
}],
current_hunk: 0,
mode,
});
}
#[test]
fn vim_mode_does_not_consume_control_shortcuts() {
let mut session = app_session_with_input("hello", 5);
enable_vim_normal_mode_app(&mut session);
let event = session.process_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert!(event.is_none());
assert!(session.history_picker_state.active);
}
#[test]
fn vim_dd_deletes_the_current_logical_line() {
let mut session = session_with_input("one\ntwo\nthree", 4);
enable_vim_normal_mode(&mut session);
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)));
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)));
assert_eq!(session.input_manager.content(), "one\nthree");
assert_eq!(session.cursor(), 4);
}
#[test]
fn vim_dot_repeats_last_delete_char_change() {
let mut session = session_with_input("abc", 0);
enable_vim_normal_mode(&mut session);
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)));
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)));
assert_eq!(session.input_manager.content(), "c");
}
#[test]
fn vim_dot_repeats_change_word_edits() {
let mut session = session_with_input("alpha beta", 0);
enable_vim_normal_mode(&mut session);
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)));
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)));
session.insert_paste_text("A");
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
session.set_cursor("A".len());
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)));
assert_eq!(session.input_manager.content(), "AA");
}
#[test]
fn vim_dot_repeats_change_line_edits() {
let mut session = session_with_input("one\ntwo", 0);
enable_vim_normal_mode(&mut session);
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)));
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)));
session.insert_paste_text("ONE");
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
session.set_cursor("ONE\n".len());
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)));
assert_eq!(session.input_manager.content(), "ONE\nONE");
}
#[test]
fn vim_dot_repeats_change_text_object_edits() {
let mut session = session_with_input("\"alpha\" \"beta\"", 1);
enable_vim_normal_mode(&mut session);
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)));
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)));
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('"'), KeyModifiers::NONE)));
session.insert_paste_text("A");
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
session.set_cursor("\"A\" \"".len());
assert!(session.handle_vim_key(&KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)));
assert_eq!(session.input_manager.content(), "\"A\" \"A\"");
}
#[test]
fn appearance_updates_do_not_reset_session_local_vim_mode() {
let mut session = session_with_input("hello", 5);
session.handle_command(InlineCommand::SetVimModeEnabled(true));
assert!(session.vim_state.enabled());
let mut appearance = session.appearance.clone();
appearance.vim_mode = false;
session.handle_command(InlineCommand::SetAppearance { appearance });
assert!(session.vim_state.enabled());
}
fn visible_transcript(session: &mut Session) -> Vec<String> {
let backend = TestBackend::new(VIEW_WIDTH, VIEW_ROWS);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render test session");
current_visible_transcript(session)
}
fn current_visible_transcript(session: &mut Session) -> Vec<String> {
let width = session.transcript_width;
let viewport = session.viewport_height();
let offset = session.transcript_view_top;
let lines = session.reflow_transcript_lines(width);
let start = offset.min(lines.len());
let mut visible: Vec<TranscriptLine> = lines
.into_iter()
.skip(start)
.take(viewport)
.map(|line| TranscriptLine {
line,
explicit_links: Vec::new(),
})
.collect();
let filler = viewport.saturating_sub(visible.len());
if filler > 0 {
visible.extend((0..filler).map(|_| TranscriptLine::default()));
}
if !session.queued_inputs.is_empty() {
session.overlay_queue_lines(&mut visible, width);
}
visible
.into_iter()
.map(|line| {
line.line
.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
fn rendered_session_lines(session: &mut Session, rows: u16) -> Vec<String> {
session.apply_view_rows(rows);
let backend = TestBackend::new(VIEW_WIDTH, rows);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
let completed = terminal
.draw(|frame| session.render(frame))
.expect("failed to render session");
let buffer = completed.buffer;
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string()))
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
fn rendered_transcript_widget_lines(session: &mut Session, width: u16, height: u16) -> Vec<String> {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
TranscriptWidget::new(session).render(area, &mut buf);
(0..area.height)
.map(|y| {
(0..area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
fn rendered_transcript_lines(session: &mut Session, rows: u16) -> (Rect, Vec<String>) {
session.apply_view_rows(rows);
let backend = TestBackend::new(VIEW_WIDTH, rows);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
let _ = terminal
.draw(|frame| session.render(frame))
.expect("failed to render session");
let area = session.transcript_area().expect("expected transcript area");
(area, current_visible_transcript(session))
}
fn rendered_app_session_lines(session: &mut AppSession, rows: u16) -> Vec<String> {
session.core.apply_view_rows(rows);
let backend = TestBackend::new(VIEW_WIDTH, rows);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
let completed = terminal
.draw(|frame| session.render(frame))
.expect("failed to render session");
let buffer = completed.buffer;
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.filter_map(|x| buffer.cell((x, y)).map(|cell| cell.symbol().to_string()))
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
fn is_horizontal_rule(line: &str) -> bool {
let glyph = ui::INLINE_BLOCK_HORIZONTAL
.chars()
.next()
.expect("horizontal rule glyph");
!line.is_empty() && line.chars().all(|ch| ch == glyph)
}
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect()
}
fn transcript_line(text: impl Into<String>) -> TranscriptLine {
TranscriptLine {
line: Line::from(text.into()),
explicit_links: Vec::new(),
}
}
fn text_content(text: &Text<'static>) -> String {
text.lines
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n")
}
fn vtcode_tui_workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn transcript_file_fixture_relative_path() -> &'static str {
"src/core_tui/session.rs"
}
fn transcript_file_fixture_absolute_path() -> String {
vtcode_tui_workspace_root()
.join(transcript_file_fixture_relative_path())
.display()
.to_string()
}
fn quoted_transcript_temp_file_path() -> PathBuf {
let unique = TRANSCRIPT_TEST_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"vtcode transcript quoted path {} {unique}.txt",
std::process::id()
))
}
#[cfg(target_os = "macos")]
fn open_file_click_modifiers() -> KeyModifiers {
KeyModifiers::SUPER
}
#[cfg(not(target_os = "macos"))]
fn open_file_click_modifiers() -> KeyModifiers {
KeyModifiers::CONTROL
}
#[cfg(target_os = "macos")]
fn command_modifier_press_event() -> KeyEvent {
KeyEvent::new_with_kind(
KeyCode::Modifier(ModifierKeyCode::LeftSuper),
KeyModifiers::SUPER,
KeyEventKind::Press,
)
}
#[cfg(target_os = "macos")]
fn command_modifier_release_event() -> KeyEvent {
KeyEvent::new_with_kind(
KeyCode::Modifier(ModifierKeyCode::LeftSuper),
KeyModifiers::SUPER,
KeyEventKind::Release,
)
}
#[cfg(target_os = "macos")]
fn meta_modifier_press_event() -> KeyEvent {
KeyEvent::new_with_kind(
KeyCode::Modifier(ModifierKeyCode::LeftMeta),
KeyModifiers::META,
KeyEventKind::Press,
)
}
#[test]
fn move_left_word_from_end_moves_to_word_start() {
let text = "hello world";
let mut session = session_with_input(text, text.len());
session.move_left_word();
assert_eq!(session.input_manager.cursor(), 6);
session.move_left_word();
assert_eq!(session.input_manager.cursor(), 0);
}
#[test]
fn transcript_relative_file_reference_is_underlined() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS * 2);
session.set_workspace_root(Some(vtcode_tui_workspace_root()));
let decorated = session.decorate_visible_transcript_links(
vec![transcript_line(format!(
"See {}",
transcript_file_fixture_relative_path()
))],
Rect::new(0, 0, 120, 1),
);
assert_eq!(session.transcript_file_link_targets.len(), 1);
let linked_span = decorated[0]
.spans
.iter()
.find(|span| {
span.content
.contains(transcript_file_fixture_relative_path())
})
.expect("expected linked span");
assert!(
linked_span
.style
.add_modifier
.contains(Modifier::UNDERLINED)
);
}
#[test]
fn hovered_transcript_file_reference_adds_hover_style() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS * 2);
session.set_workspace_root(Some(vtcode_tui_workspace_root()));
let line = transcript_line(format!("Open {}", transcript_file_fixture_relative_path()));
let area = Rect::new(0, 0, 120, 1);
let _ = session.decorate_visible_transcript_links(vec![line.clone()], area);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
assert!(session.update_transcript_file_link_hover(target.area.x, target.area.y));
let decorated = session.decorate_visible_transcript_links(vec![line], area);
let linked_span = decorated[0]
.spans
.iter()
.find(|span| {
span.content
.contains(transcript_file_fixture_relative_path())
})
.expect("expected hovered linked span");
assert!(
linked_span
.style
.add_modifier
.contains(Modifier::UNDERLINED)
);
assert!(linked_span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn mixed_transcript_file_references_are_all_underlined() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_workspace_root(Some(vtcode_tui_workspace_root()));
let temp_file = quoted_transcript_temp_file_path();
fs::write(&temp_file, "mixed-transcript-link").expect("write mixed transcript temp file");
let line = transcript_line(format!(
"Open {} and `{}`",
transcript_file_fixture_relative_path(),
temp_file.display()
));
let temp_file_display = temp_file.display().to_string();
let decorated = session.decorate_visible_transcript_links(vec![line], Rect::new(0, 0, 200, 1));
assert_eq!(session.transcript_file_link_targets.len(), 2);
assert!(decorated[0].spans.iter().any(|span| {
span.content
.contains(transcript_file_fixture_relative_path())
&& span.style.add_modifier.contains(Modifier::UNDERLINED)
}));
assert!(decorated[0].spans.iter().any(|span| {
span.content.contains(&temp_file_display)
&& span.style.add_modifier.contains(Modifier::UNDERLINED)
}));
let _ = fs::remove_file(&temp_file);
}
#[test]
fn plain_click_emits_open_file_event_for_absolute_transcript_path() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", absolute_path))],
);
let _ = visible_transcript(&mut session);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
KeyModifiers::NONE,
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == absolute_path
));
}
#[test]
fn repeated_plain_click_on_same_transcript_file_link_is_throttled() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", absolute_path))],
);
let _ = visible_transcript(&mut session);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: target.area.x,
row: target.area.y,
modifiers: KeyModifiers::NONE,
};
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(&mut session, &tx, click.column, click.row, click.modifiers);
left_click_session(&mut session, &tx, click.column, click.row, click.modifiers);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == absolute_path
));
assert!(rx.try_recv().is_err());
}
#[test]
fn repeated_plain_click_on_different_transcript_file_links_is_not_throttled() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
let temp_file = quoted_transcript_temp_file_path();
fs::write(&temp_file, "transcript-link-throttle").expect("write throttle transcript temp file");
let quoted_temp_path = format!("`{}`", temp_file.display());
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!(
"Open {} and {}",
absolute_path, quoted_temp_path
))],
);
let _ = visible_transcript(&mut session);
let first_target = session
.transcript_file_link_targets
.iter()
.find(|target| {
matches!(
&target.target,
TranscriptLinkTarget::File(path) if path.path().display().to_string() == absolute_path
)
})
.expect("expected first transcript file target")
.clone();
let second_target = session
.transcript_file_link_targets
.iter()
.find(|target| {
matches!(
&target.target,
TranscriptLinkTarget::File(path) if path.path() == temp_file.as_path()
)
})
.expect("expected second transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
first_target.area.x,
first_target.area.y,
KeyModifiers::NONE,
);
left_click_session(
&mut session,
&tx,
second_target.area.x,
second_target.area.y,
KeyModifiers::NONE,
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == absolute_path
));
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == temp_file.display().to_string()
));
let _ = fs::remove_file(&temp_file);
}
#[test]
fn double_click_emits_open_file_event_for_absolute_transcript_path() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", absolute_path))],
);
let _ = visible_transcript(&mut session);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: target.area.x,
row: target.area.y,
modifiers: KeyModifiers::NONE,
};
left_click_session(&mut session, &tx, click.column, click.row, click.modifiers);
left_click_session(&mut session, &tx, click.column, click.row, click.modifiers);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == absolute_path
));
assert!(rx.try_recv().is_err());
}
#[test]
fn modifier_click_emits_open_file_event_for_quoted_path_with_spaces() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let temp_file = quoted_transcript_temp_file_path();
fs::write(&temp_file, "transcript-link").expect("write quoted transcript temp file");
let quoted_path = format!("`{}`", temp_file.display());
let _ = session.decorate_visible_transcript_links(
vec![transcript_line(format!("Open {}", quoted_path))],
Rect::new(0, 0, 200, 1),
);
let target = session
.transcript_file_link_targets
.iter()
.find(|target| {
matches!(
&target.target,
TranscriptLinkTarget::File(path) if path.path() == temp_file.as_path()
)
})
.expect("expected quoted transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
KeyModifiers::NONE,
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == temp_file.display().to_string()
));
let _ = fs::remove_file(&temp_file);
}
#[test]
fn modifier_click_emits_open_url_event_for_explicit_transcript_link() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let url = "https://example.com/docs".to_string();
let _ = session.decorate_visible_transcript_links(
vec![TranscriptLine {
line: Line::from("Open docs"),
explicit_links: vec![RenderedTranscriptLink {
start: 5,
end: 9,
start_col: 5,
width: 4,
target: InlineLinkTarget::Url(url.clone()),
}],
}],
Rect::new(0, 0, 200, 1),
);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript url target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
KeyModifiers::NONE,
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenUrl(clicked)) if clicked == url
));
}
#[test]
fn modifier_click_emits_open_url_event_for_raw_transcript_url() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let url = "https://auth.openai.com/oauth/authorize?client_id=test".to_string();
session.push_line(InlineMessageKind::Agent, vec![make_segment(url.as_str())]);
let _ = rendered_session_lines(&mut session, VIEW_ROWS);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript url target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
open_file_click_modifiers(),
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenUrl(clicked)) if clicked == url
));
}
#[test]
fn wrapped_transcript_url_last_segment_is_underlined_and_clickable() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let url = format!(
"https://auth.openai.com/oauth/authorize?response_type=code&client_id=test&scope=openid%20profile%20email&state={}",
"abcdefghijklmnopqrstuvwxyz".repeat(12)
);
session.push_line(InlineMessageKind::Agent, vec![make_segment(url.as_str())]);
let transcript_lines = session.reflow_message_lines(0, 60);
let decorated =
session.decorate_visible_cached_transcript_links(transcript_lines, Rect::new(0, 0, 60, 8));
let targets = session
.transcript_file_link_targets
.iter()
.filter(|target| matches!(&target.target, TranscriptLinkTarget::Url(clicked) if clicked == &url))
.cloned()
.collect::<Vec<_>>();
assert!(
targets.len() >= 2,
"expected wrapped transcript url segments"
);
let target = targets
.iter()
.max_by_key(|target| (target.area.y, target.area.x))
.expect("expected wrapped transcript url target")
.clone();
assert!(
decorated[target.area.y as usize]
.spans
.iter()
.any(|span| span.style.add_modifier.contains(Modifier::UNDERLINED))
);
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
open_file_click_modifiers(),
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenUrl(clicked)) if clicked == url
));
}
#[test]
fn reflowed_tool_lines_include_detected_raw_links() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let url = "https://example.com/tool-output".to_string();
session.push_line(
InlineMessageKind::Tool,
vec![make_segment(&format!("open {url}"))],
);
let transcript_lines = session.reflow_message_lines(0, 80);
assert!(transcript_lines.iter().any(|line| {
line.explicit_links
.iter()
.any(|link| matches!(&link.target, InlineLinkTarget::Url(target) if target == &url))
}));
}
#[test]
fn modifier_click_emits_open_url_event_for_modal_auth_link_in_app_session() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
let url = "https://auth.openai.com/oauth/authorize?client_id=test".to_string();
session.show_transient(app_types::TransientRequest::Wizard(
app_types::WizardOverlayRequest {
title: "OpenAI manual callback".to_string(),
steps: vec![WizardStep {
title: "Callback".to_string(),
question: format!("Open this URL in your browser:\n\n{url}"),
items: vec![InlineListItem {
title: "Submit".to_string(),
subtitle: Some("Press Enter to continue.".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction("submit".to_string())),
search_value: None,
}],
completed: false,
answer: None,
allow_freeform: false,
freeform_label: None,
freeform_placeholder: None,
freeform_default: None,
}],
current_step: 0,
search: None,
mode: WizardModalMode::MultiStep,
},
));
let _ = rendered_app_session_lines(&mut session, 20);
let target = session
.core
.modal_link_targets()
.first()
.expect("expected modal url target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_app_session(
&mut session,
&tx,
target.area.x,
target.area.y,
open_file_click_modifiers(),
);
assert!(matches!(
rx.try_recv(),
Ok(app_types::InlineEvent::OpenUrl(clicked)) if clicked == url
));
}
#[test]
fn plain_click_emits_open_url_event_for_wrapped_wizard_modal_auth_link() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
let url = format!(
"https://auth.openai.com/oauth/authorize?response_type=code&client_id=test&scope=openid%20profile%20email&state={}",
"abcdefghijklmnopqrstuvwxyz".repeat(10)
);
session.show_transient(app_types::TransientRequest::Wizard(
app_types::WizardOverlayRequest {
title: "OpenAI manual callback".to_string(),
steps: vec![WizardStep {
title: "Callback".to_string(),
question: format!("Open this URL in your browser:\n\n{url}"),
items: vec![InlineListItem {
title: "Submit".to_string(),
subtitle: Some("Press Enter to continue.".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction("submit".to_string())),
search_value: None,
}],
completed: false,
answer: None,
allow_freeform: false,
freeform_label: None,
freeform_placeholder: None,
freeform_default: None,
}],
current_step: 0,
search: None,
mode: WizardModalMode::MultiStep,
},
));
let _ = rendered_app_session_lines(&mut session, 20);
let targets = session
.core
.modal_link_targets()
.iter()
.filter(|target| matches!(&target.target, TranscriptLinkTarget::Url(clicked) if clicked == &url))
.cloned()
.collect::<Vec<_>>();
assert!(
!targets.is_empty(),
"expected visible wizard modal url target"
);
let target = targets
.iter()
.max_by_key(|target| (target.area.y, target.area.x))
.expect("expected wrapped wizard modal url target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_app_session(
&mut session,
&tx,
target.area.x,
target.area.y,
KeyModifiers::NONE,
);
assert!(matches!(
rx.try_recv(),
Ok(app_types::InlineEvent::OpenUrl(clicked)) if clicked == url
));
}
#[test]
fn modal_auth_text_in_app_session_is_selectable_and_copied() {
let _guard = CLIPBOARD_TEST_LOCK
.lock()
.expect("clipboard test lock should not be poisoned");
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
let url = "https://auth.openai.com/oauth/authorize?client_id=test".to_string();
session.show_transient(app_types::TransientRequest::Wizard(
app_types::WizardOverlayRequest {
title: "OpenAI manual callback".to_string(),
steps: vec![WizardStep {
title: "Callback".to_string(),
question: format!("Copy this URL:\n\n{url}"),
items: vec![InlineListItem {
title: "Submit".to_string(),
subtitle: Some("Press Enter to continue.".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction("submit".to_string())),
search_value: None,
}],
completed: false,
answer: None,
allow_freeform: false,
freeform_label: None,
freeform_placeholder: None,
freeform_default: None,
}],
current_step: 0,
search: None,
mode: WizardModalMode::MultiStep,
},
));
let _ = rendered_app_session_lines(&mut session, 20);
let target = session
.core
.modal_link_targets()
.first()
.expect("expected modal url target")
.clone();
let (tx, _rx) = mpsc::unbounded_channel::<app_types::InlineEvent>();
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: target.area.x,
row: target.area.y,
modifiers: KeyModifiers::NONE,
}),
&tx,
None,
);
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: target.area.x + 5,
row: target.area.y,
modifiers: KeyModifiers::NONE,
}),
&tx,
None,
);
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: target.area.x + 5,
row: target.area.y,
modifiers: KeyModifiers::NONE,
}),
&tx,
None,
);
let backend = TestBackend::new(VIEW_WIDTH, 20);
let mut terminal = Terminal::new(backend).expect("create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("render modal selection");
let buffer = terminal.backend().buffer();
assert_eq!(
session
.core
.mouse_selection
.extract_text(buffer, buffer.area),
"https"
);
assert!(session.core.mouse_selection.has_selection);
assert!(!session.core.mouse_selection.needs_copy());
}
#[test]
fn plain_click_emits_open_url_event_for_standard_modal_auth_link() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let url = format!(
"https://auth.openai.com/oauth/authorize?client_id=test&state={}",
"abcdefghijklmnopqrstuvwxyz".repeat(10)
);
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::List(ListOverlayRequest {
title: format!("Authorize with {url}"),
lines: vec![format!("Open this URL in your browser:\n{url}")],
footer_hint: None,
items: vec![InlineListItem {
title: "Continue".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("continue".to_string())),
search_value: None,
}],
selected: Some(InlineListSelection::SlashCommand("continue".to_string())),
search: None,
hotkeys: Vec::new(),
})),
});
let _ = rendered_session_lines(&mut session, VIEW_ROWS);
let targets = session
.modal_link_targets()
.iter()
.filter(|target| matches!(&target.target, TranscriptLinkTarget::Url(clicked) if clicked == &url))
.cloned()
.collect::<Vec<_>>();
assert!(!targets.is_empty(), "expected visible modal url target");
let target = targets.last().expect("expected modal url target").clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
KeyModifiers::NONE,
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenUrl(clicked)) if clicked == url
));
}
#[test]
fn repeated_plain_click_on_same_modal_url_link_is_throttled() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let url = format!(
"https://auth.openai.com/oauth/authorize?client_id=test&state={}",
"abcdefghijklmnopqrstuvwxyz".repeat(10)
);
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::List(ListOverlayRequest {
title: format!("Authorize with {url}"),
lines: vec![format!("Open this URL in your browser:\n{url}")],
footer_hint: None,
items: vec![InlineListItem {
title: "Continue".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("continue".to_string())),
search_value: None,
}],
selected: Some(InlineListSelection::SlashCommand("continue".to_string())),
search: None,
hotkeys: Vec::new(),
})),
});
let _ = rendered_session_lines(&mut session, VIEW_ROWS);
let target = session
.modal_link_targets()
.iter()
.find(|target| matches!(&target.target, TranscriptLinkTarget::Url(clicked) if clicked == &url))
.expect("expected modal url target")
.clone();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: target.area.x,
row: target.area.y,
modifiers: KeyModifiers::NONE,
};
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(&mut session, &tx, click.column, click.row, click.modifiers);
left_click_session(&mut session, &tx, click.column, click.row, click.modifiers);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenUrl(clicked)) if clicked == url
));
assert!(rx.try_recv().is_err());
}
#[test]
fn double_click_emits_open_url_event_for_standard_modal_auth_link() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let url = format!(
"https://auth.openai.com/oauth/authorize?client_id=test&state={}",
"abcdefghijklmnopqrstuvwxyz".repeat(10)
);
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::List(ListOverlayRequest {
title: format!("Authorize with {url}"),
lines: vec![format!("Open this URL in your browser:\n{url}")],
footer_hint: None,
items: vec![InlineListItem {
title: "Continue".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("continue".to_string())),
search_value: None,
}],
selected: Some(InlineListSelection::SlashCommand("continue".to_string())),
search: None,
hotkeys: Vec::new(),
})),
});
let _ = rendered_session_lines(&mut session, VIEW_ROWS);
let target = session
.modal_link_targets()
.iter()
.find(|target| matches!(&target.target, TranscriptLinkTarget::Url(clicked) if clicked == &url))
.expect("expected modal url target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: target.area.x,
row: target.area.y,
modifiers: KeyModifiers::NONE,
};
left_click_session(&mut session, &tx, click.column, click.row, click.modifiers);
left_click_session(&mut session, &tx, click.column, click.row, click.modifiers);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenUrl(clicked)) if clicked == url
));
assert!(rx.try_recv().is_err());
}
#[test]
fn modifier_click_emits_open_file_event_for_standard_modal_file_link_with_location() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
let file_target = format!("{absolute_path}#L12C4");
let canonical_target = format!("{absolute_path}:12:4");
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::List(ListOverlayRequest {
title: "Open source file".to_string(),
lines: vec![format!("Review this file:\n{file_target}")],
footer_hint: None,
items: vec![InlineListItem {
title: "Continue".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("continue".to_string())),
search_value: None,
}],
selected: Some(InlineListSelection::SlashCommand("continue".to_string())),
search: None,
hotkeys: Vec::new(),
})),
});
let _ = rendered_session_lines(&mut session, VIEW_ROWS);
let target = session
.modal_link_targets()
.iter()
.find(|target| {
matches!(
&target.target,
TranscriptLinkTarget::File(path)
if path.path().display().to_string() == absolute_path
&& path.location_suffix() == Some(":12:4")
)
})
.expect("expected modal file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
open_file_click_modifiers(),
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == canonical_target
));
}
#[test]
fn modifier_click_emits_open_file_event_for_explicit_transcript_file_link() {
let mut session = Session::new(themed_inline_colors(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
let _ = session.decorate_visible_transcript_links(
vec![TranscriptLine {
line: Line::from("Open file"),
explicit_links: vec![RenderedTranscriptLink {
start: 5,
end: 9,
start_col: 5,
width: 4,
target: InlineLinkTarget::Url(absolute_path.clone()),
}],
}],
Rect::new(0, 0, 200, 1),
);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
open_file_click_modifiers(),
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == absolute_path
));
}
#[test]
fn explicit_transcript_file_link_uses_theme_accent_color() {
let mut session = Session::new(themed_inline_colors(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
let decorated = session.decorate_visible_transcript_links(
vec![TranscriptLine {
line: Line::from("Open file"),
explicit_links: vec![RenderedTranscriptLink {
start: 5,
end: 9,
start_col: 5,
width: 4,
target: InlineLinkTarget::Url(absolute_path),
}],
}],
Rect::new(0, 0, 200, 1),
);
let linked_span = decorated[0]
.spans
.iter()
.find(|span| span.content == "file")
.expect("expected explicit linked span");
assert_eq!(
linked_span.style.fg,
themed_inline_colors()
.tool_accent
.map(ratatui_color_from_ansi)
);
assert!(
linked_span
.style
.add_modifier
.contains(Modifier::UNDERLINED)
);
}
#[test]
fn meta_click_emits_open_file_event_for_transcript_path() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", absolute_path))],
);
let _ = visible_transcript(&mut session);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
KeyModifiers::META,
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == absolute_path
));
}
#[cfg(target_os = "macos")]
#[test]
fn ctrl_click_does_not_emit_open_file_event_on_macos() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", absolute_path))],
);
let _ = visible_transcript(&mut session);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: target.area.x,
row: target.area.y,
modifiers: KeyModifiers::CONTROL,
}),
&tx,
None,
);
assert!(rx.try_recv().is_err());
assert_eq!(session.mouse_drag_target, MouseDragTarget::None);
assert!(!session.mouse_selection.is_selecting);
assert!(!session.mouse_selection.has_selection);
}
#[cfg(target_os = "macos")]
#[test]
fn command_key_press_then_plain_click_emits_open_file_event_on_macos() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", absolute_path))],
);
let _ = visible_transcript(&mut session);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
session.handle_event(
CrosstermEvent::Key(command_modifier_press_event()),
&tx,
None,
);
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
KeyModifiers::NONE,
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == absolute_path
));
session.handle_event(
CrosstermEvent::Key(command_modifier_release_event()),
&tx,
None,
);
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
KeyModifiers::NONE,
);
assert!(rx.try_recv().is_err());
}
#[cfg(target_os = "macos")]
#[test]
fn meta_key_press_then_plain_click_emits_open_file_event_on_macos() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", absolute_path))],
);
let _ = visible_transcript(&mut session);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
session.handle_event(CrosstermEvent::Key(meta_modifier_press_event()), &tx, None);
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
KeyModifiers::NONE,
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == absolute_path
));
}
#[test]
fn app_session_modifier_click_emits_open_file_event_for_transcript_path() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", absolute_path))],
);
let _ = rendered_app_session_lines(&mut session, VIEW_ROWS);
let target = session
.core
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_app_session(
&mut session,
&tx,
target.area.x,
target.area.y,
open_file_click_modifiers(),
);
assert!(matches!(
rx.try_recv(),
Ok(app_types::InlineEvent::OpenFileInEditor(path)) if path == absolute_path
));
assert_eq!(session.core.mouse_drag_target, MouseDragTarget::None);
assert!(!session.core.mouse_selection.is_selecting);
assert!(!session.core.mouse_selection.has_selection);
}
#[test]
fn app_session_double_click_emits_open_file_event_for_transcript_path() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", absolute_path))],
);
let _ = rendered_app_session_lines(&mut session, VIEW_ROWS);
let target = session
.core
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: target.area.x,
row: target.area.y,
modifiers: KeyModifiers::NONE,
};
left_click_app_session(&mut session, &tx, click.column, click.row, click.modifiers);
left_click_app_session(&mut session, &tx, click.column, click.row, click.modifiers);
assert!(matches!(
rx.try_recv(),
Ok(app_types::InlineEvent::OpenFileInEditor(path)) if path == absolute_path
));
assert!(rx.try_recv().is_err());
}
#[test]
fn app_session_double_click_emits_open_url_event_for_modal_auth_link() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
let url = "https://auth.openai.com/oauth/authorize?client_id=test".to_string();
session.show_transient(app_types::TransientRequest::Wizard(
app_types::WizardOverlayRequest {
title: "OpenAI manual callback".to_string(),
steps: vec![WizardStep {
title: "Callback".to_string(),
question: format!("Open this URL in your browser:\n\n{url}"),
items: vec![InlineListItem {
title: "Submit".to_string(),
subtitle: Some("Press Enter to continue.".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::ConfigAction("submit".to_string())),
search_value: None,
}],
completed: false,
answer: None,
allow_freeform: false,
freeform_label: None,
freeform_placeholder: None,
freeform_default: None,
}],
current_step: 0,
search: None,
mode: WizardModalMode::MultiStep,
},
));
let _ = rendered_app_session_lines(&mut session, 20);
let target = session
.core
.modal_link_targets()
.first()
.expect("expected modal url target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: target.area.x,
row: target.area.y,
modifiers: KeyModifiers::NONE,
};
left_click_app_session(&mut session, &tx, click.column, click.row, click.modifiers);
left_click_app_session(&mut session, &tx, click.column, click.row, click.modifiers);
assert!(matches!(
rx.try_recv(),
Ok(app_types::InlineEvent::OpenUrl(clicked)) if clicked == url
));
assert!(rx.try_recv().is_err());
}
#[cfg(target_os = "macos")]
#[test]
fn app_session_command_key_press_then_plain_click_emits_open_file_event_on_macos() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", absolute_path))],
);
let _ = rendered_app_session_lines(&mut session, VIEW_ROWS);
let target = session
.core
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
session.handle_event(
CrosstermEvent::Key(command_modifier_press_event()),
&tx,
None,
);
left_click_app_session(
&mut session,
&tx,
target.area.x,
target.area.y,
KeyModifiers::NONE,
);
assert!(matches!(
rx.try_recv(),
Ok(app_types::InlineEvent::OpenFileInEditor(path)) if path == absolute_path
));
}
#[cfg(target_os = "macos")]
#[test]
fn app_session_ctrl_click_on_link_is_consumed_without_selection() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", absolute_path))],
);
let _ = rendered_app_session_lines(&mut session, VIEW_ROWS);
let target = session
.core
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
let (tx, mut rx) = mpsc::unbounded_channel();
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: target.area.x,
row: target.area.y,
modifiers: KeyModifiers::CONTROL,
}),
&tx,
None,
);
assert!(rx.try_recv().is_err());
assert_eq!(session.core.mouse_drag_target, MouseDragTarget::None);
assert!(!session.core.mouse_selection.is_selecting);
assert!(!session.core.mouse_selection.has_selection);
}
#[cfg(unix)]
#[test]
fn double_click_selects_transcript_word_and_copies_it() {
use crate::core_tui::session::mouse_selection::{
clipboard_command_override, set_clipboard_command_override,
};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
let _guard = CLIPBOARD_TEST_LOCK
.lock()
.expect("clipboard test lock should not be poisoned");
let temp_dir = std::env::temp_dir().join(format!(
"vtcode-clipboard-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after UNIX_EPOCH")
.as_nanos()
));
fs::create_dir_all(&temp_dir).expect("create temp dir for clipboard script");
struct TempDirGuard(PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
let _temp_guard = TempDirGuard(temp_dir.clone());
let clipboard_file = temp_dir.join("clipboard.txt");
let script_name = if cfg!(target_os = "macos") {
"pbcopy"
} else {
"xclip"
};
let script_path = temp_dir.join(script_name);
fs::write(
&script_path,
format!("#!/bin/sh\ncat > '{}'\n", clipboard_file.display()),
)
.expect("write fake clipboard command");
let mut permissions = fs::metadata(&script_path)
.expect("read fake clipboard metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions).expect("make fake clipboard executable");
struct ClipboardCommandGuard(Option<PathBuf>);
impl Drop for ClipboardCommandGuard {
fn drop(&mut self) {
set_clipboard_command_override(self.0.clone());
}
}
let _path_guard = ClipboardCommandGuard(clipboard_command_override());
set_clipboard_command_override(Some(script_path.clone()));
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(InlineMessageKind::Agent, vec![make_segment("hello world")]);
let (transcript_area, rendered) = rendered_transcript_lines(&mut session, VIEW_ROWS * 2);
let row = rendered
.iter()
.position(|line| line.contains("hello world"))
.expect("expected hello world to be rendered");
let column = rendered[row]
.find("hello")
.expect("expected hello word in rendered line") as u16
+ transcript_area.x
+ 1;
let row = transcript_area.y + row as u16;
let (tx, _rx) = mpsc::unbounded_channel();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
};
let release = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
};
session.handle_event(CrosstermEvent::Mouse(click), &tx, None);
session.handle_event(CrosstermEvent::Mouse(release), &tx, None);
session.handle_event(CrosstermEvent::Mouse(click), &tx, None);
session.handle_event(CrosstermEvent::Mouse(release), &tx, None);
let mut buffer = Buffer::empty(Rect::new(0, 0, VIEW_WIDTH, VIEW_ROWS * 2));
for (dy, line) in rendered.iter().enumerate() {
for (dx, ch) in line.chars().enumerate() {
buffer[(transcript_area.x + dx as u16, transcript_area.y + dy as u16)]
.set_symbol(&ch.to_string());
}
}
let selected = session.mouse_selection.extract_text(&buffer, buffer.area);
assert_eq!(selected, "hello");
assert!(session.mouse_selection.has_selection);
assert!(session.mouse_selection.needs_copy());
session.copy_text_to_clipboard(&selected);
session.mouse_selection.mark_copied();
assert!(!session.mouse_selection.needs_copy());
let clipboard_contents =
fs::read_to_string(&clipboard_file).expect("read copied transcript text");
assert_eq!(clipboard_contents, "hello");
let rendered_status = session
.render_input_status_line(VIEW_WIDTH)
.expect("input status line")
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(
rendered_status.contains("Copied to clipboard"),
"transcript copy should surface a temporary confirmation"
);
}
#[cfg(unix)]
#[test]
fn selecting_input_text_auto_copies_and_keeps_selection() {
use crate::core_tui::session::mouse_selection::{
clipboard_command_override, set_clipboard_command_override,
};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
let _guard = CLIPBOARD_TEST_LOCK
.lock()
.expect("clipboard test lock should not be poisoned");
let temp_dir = std::env::temp_dir().join(format!(
"vtcode-clipboard-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after UNIX_EPOCH")
.as_nanos()
));
fs::create_dir_all(&temp_dir).expect("create temp dir for clipboard script");
struct TempDirGuard(PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
let _temp_guard = TempDirGuard(temp_dir.clone());
let clipboard_file = temp_dir.join("clipboard.txt");
let script_name = if cfg!(target_os = "macos") {
"pbcopy"
} else {
"xclip"
};
let script_path = temp_dir.join(script_name);
fs::write(
&script_path,
format!("#!/bin/sh\ncat > '{}'\n", clipboard_file.display()),
)
.expect("write fake clipboard command");
let mut permissions = fs::metadata(&script_path)
.expect("read fake clipboard metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions).expect("make fake clipboard executable");
struct ClipboardCommandGuard(Option<PathBuf>);
impl Drop for ClipboardCommandGuard {
fn drop(&mut self) {
set_clipboard_command_override(self.0.clone());
}
}
let _path_guard = ClipboardCommandGuard(clipboard_command_override());
set_clipboard_command_override(Some(script_path.clone()));
let mut session = app_session_with_input("hello world", "hello world".len());
for _ in 0..5 {
let result = session.process_key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT));
assert!(result.is_none());
}
assert_eq!(
session.core.input_manager.selection_range(),
Some(("hello world".len() - 5, "hello world".len()))
);
let rendered = rendered_app_session_lines(&mut session, VIEW_ROWS);
assert!(
rendered
.iter()
.any(|line| line.contains("Copied to clipboard")),
"input copy should surface a temporary confirmation"
);
let clipboard_contents = fs::read_to_string(&clipboard_file).expect("read copied input text");
assert_eq!(clipboard_contents, "world");
assert_eq!(
session.core.input_manager.selection_range(),
Some(("hello world".len() - 5, "hello world".len()))
);
let result = session.process_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(result.is_none());
assert_eq!(
session.core.input_manager.selection_range(),
Some(("hello world".len() - 5, "hello world".len()))
);
let clipboard_contents = fs::read_to_string(&clipboard_file).expect("read copied input text");
assert_eq!(clipboard_contents, "world");
}
#[cfg(unix)]
#[test]
fn scroll_between_clicks_clears_double_click_history() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(InlineMessageKind::Agent, vec![make_segment("hello world")]);
let (transcript_area, rendered) = rendered_transcript_lines(&mut session, VIEW_ROWS * 2);
let row = rendered
.iter()
.position(|line| line.contains("hello world"))
.expect("expected hello world to be rendered");
let column = rendered[row]
.find("hello")
.expect("expected hello word in rendered line") as u16
+ transcript_area.x
+ 1;
let row = transcript_area.y + row as u16;
let (tx, _rx) = mpsc::unbounded_channel();
let click = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
};
let release = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
};
let scroll = MouseEvent {
kind: MouseEventKind::ScrollDown,
column,
row,
modifiers: KeyModifiers::NONE,
};
session.handle_event(CrosstermEvent::Mouse(click), &tx, None);
session.handle_event(CrosstermEvent::Mouse(release), &tx, None);
session.handle_event(CrosstermEvent::Mouse(scroll), &tx, None);
session.handle_event(CrosstermEvent::Mouse(click), &tx, None);
session.handle_event(CrosstermEvent::Mouse(release), &tx, None);
assert!(!session.mouse_selection.has_selection);
}
#[test]
fn path_with_line_col_suffix_resolves_correctly() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
let path_with_loc = format!("{}:42:10", absolute_path);
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Error at {}", path_with_loc))],
);
let decorated = session.decorate_visible_transcript_links(
vec![transcript_line(format!("Error at {}", path_with_loc))],
Rect::new(0, 0, 200, 1),
);
assert!(!session.transcript_file_link_targets.is_empty());
let target = session.transcript_file_link_targets[0].clone();
assert!(matches!(
&target.target,
TranscriptLinkTarget::File(path)
if path.path().display().to_string() == absolute_path
&& path.location_suffix() == Some(":42:10")
));
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
open_file_click_modifiers(),
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == path_with_loc
));
assert!(
decorated[0]
.spans
.iter()
.any(|span| { span.style.add_modifier.contains(Modifier::UNDERLINED) })
);
}
#[test]
fn path_with_paren_location_resolves_correctly() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
let path_with_loc = format!("{}(10,5)", absolute_path);
let _ = session.decorate_visible_transcript_links(
vec![transcript_line(format!("Error at {}", path_with_loc))],
Rect::new(0, 0, 200, 1),
);
assert!(!session.transcript_file_link_targets.is_empty());
assert!(matches!(
&session.transcript_file_link_targets[0].target,
TranscriptLinkTarget::File(path)
if path.path().display().to_string() == absolute_path
&& path.location_suffix() == Some(":10:5")
));
}
#[test]
fn path_with_hash_location_resolves_and_opens_with_canonical_suffix() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let absolute_path = transcript_file_fixture_absolute_path();
let hash_path = format!("{absolute_path}#L12C4");
let _ = session.decorate_visible_transcript_links(
vec![transcript_line(format!("Error at {}", hash_path))],
Rect::new(0, 0, 200, 1),
);
let target = session
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.clone();
assert!(matches!(
&target.target,
TranscriptLinkTarget::File(path)
if path.path().display().to_string() == absolute_path
&& path.location_suffix() == Some(":12:4")
));
let (tx, mut rx) = mpsc::unbounded_channel();
left_click_session(
&mut session,
&tx,
target.area.x,
target.area.y,
open_file_click_modifiers(),
);
assert!(matches!(
rx.try_recv(),
Ok(InlineEvent::OpenFileInEditor(path)) if path == format!("{absolute_path}:12:4")
));
}
#[test]
fn abbreviation_tokens_are_not_detected_as_paths() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let _ = session.decorate_visible_transcript_links(
vec![transcript_line("e.g. this or i.e. that")],
Rect::new(0, 0, 200, 1),
);
assert!(session.transcript_file_link_targets.is_empty());
}
#[test]
fn move_left_word_skips_trailing_whitespace() {
let text = "hello world";
let mut session = session_with_input(text, text.len());
session.move_left_word();
assert_eq!(session.input_manager.cursor(), 7);
}
#[test]
fn file_palette_insertion_uses_at_alias_in_input() {
let mut session = app_session_with_input("check @mai", "check @mai".len());
session.insert_file_reference("src/main.rs");
assert_eq!(session.core.input_manager.content(), "check @src/main.rs ");
assert_eq!(session.core.cursor(), "check @src/main.rs ".len());
}
#[test]
fn set_input_command_activates_file_palette_for_at_query() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
load_app_file_palette(
&mut session,
vec!["src/main.rs".to_string()],
PathBuf::from("."),
);
assert!(!session.file_palette_active);
session.handle_command(app_types::InlineCommand::SetInput("@src".to_string()));
assert!(session.file_palette_active);
}
#[test]
fn arrow_keys_navigate_input_history() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("first message".to_string());
let submit_first = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(submit_first, Some(InlineEvent::Submit(value)) if value == "first message"));
session.set_input("second".to_string());
let submit_second = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(submit_second, Some(InlineEvent::Submit(value)) if value == "second"));
assert_eq!(session.input_manager.history().len(), 2);
assert!(session.input_manager.content().is_empty());
let up_latest = session.process_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(up_latest, Some(InlineEvent::HistoryPrevious)));
assert_eq!(session.input_manager.content(), "second");
let up_previous = session.process_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(up_previous, Some(InlineEvent::HistoryPrevious)));
assert_eq!(session.input_manager.content(), "first message");
let down_forward = session.process_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(matches!(down_forward, Some(InlineEvent::HistoryNext)));
assert!(session.input_manager.content().is_empty());
assert!(session.input_manager.history_index().is_none());
let down_restore = session.process_key(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
assert!(down_restore.is_none());
assert!(session.input_manager.content().is_empty());
assert!(session.input_manager.history_index().is_none());
}
#[test]
fn down_opens_local_agents_drawer_when_input_is_empty() {
let mut session = app_session_with_input("", 0);
session.handle_command(app_types::InlineCommand::SetLocalAgents {
entries: vec![sample_local_agent_entry(
app_types::LocalAgentKind::Delegated,
)],
});
session.close_transient();
let event = session.process_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(event.is_none());
assert!(session.local_agents_visible());
}
#[test]
fn new_local_agent_auto_opens_drawer() {
let mut session = app_session_with_input("", 0);
session.handle_command(app_types::InlineCommand::SetLocalAgents {
entries: vec![sample_local_agent_entry(
app_types::LocalAgentKind::Delegated,
)],
});
assert!(session.local_agents_visible());
}
#[test]
fn local_agents_drawer_hides_input_while_open() {
let mut session = app_session_with_input("draft command", "draft command".len());
session.handle_command(app_types::InlineCommand::SetLocalAgents {
entries: vec![sample_local_agent_entry(
app_types::LocalAgentKind::Delegated,
)],
});
assert!(session.local_agents_visible());
assert!(!session.core.input_enabled());
assert!(
!session
.core
.build_input_widget_data(VIEW_WIDTH, 1)
.cursor_should_be_visible
);
let lines = rendered_app_session_lines(&mut session, 20);
assert!(session.core.input_area().is_none());
assert!(session.core.bottom_panel_area().is_some());
assert!(
lines.iter().any(|line| line.contains("Local Agents")),
"drawer should still render"
);
assert!(
!lines.iter().any(|line| line.contains("draft command")),
"hidden composer should not render draft text"
);
}
#[test]
fn closing_local_agents_drawer_restores_input_and_draft() {
let mut session = app_session_with_input("draft command", "draft command".len());
session.handle_command(app_types::InlineCommand::SetLocalAgents {
entries: vec![sample_local_agent_entry(
app_types::LocalAgentKind::Delegated,
)],
});
let _ = rendered_app_session_lines(&mut session, 20);
let event = session.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(event.is_none());
assert!(!session.local_agents_visible());
assert!(session.core.input_enabled());
assert!(
session
.core
.build_input_widget_data(VIEW_WIDTH, 1)
.cursor_should_be_visible
);
assert_eq!(session.core.input_manager.content(), "draft command");
let lines = rendered_app_session_lines(&mut session, 20);
assert!(session.core.input_area().is_some());
assert!(
lines.iter().any(|line| line.contains("draft command")),
"composer should re-render its preserved draft"
);
}
#[test]
fn local_agents_drawer_navigation_works_with_existing_draft() {
let mut session = app_session_with_input("draft command", "draft command".len());
session.handle_command(app_types::InlineCommand::SetLocalAgents {
entries: vec![
sample_local_agent_entry_with_id(
"agent-1",
"rust-engineer",
app_types::LocalAgentKind::Delegated,
),
sample_local_agent_entry_with_id(
"agent-2",
"qa-reviewer",
app_types::LocalAgentKind::Delegated,
),
],
});
let down = session.process_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(down.is_none());
let enter = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
enter,
Some(app_types::InlineEvent::Submit(value)) if value == "/agent inspect agent-2"
));
assert_eq!(session.core.input_manager.content(), "draft command");
}
#[test]
fn new_background_local_agent_does_not_auto_open_drawer() {
let mut session = app_session_with_input("", 0);
session.handle_command(app_types::InlineCommand::SetLocalAgents {
entries: vec![sample_local_agent_entry(
app_types::LocalAgentKind::Background,
)],
});
assert!(!session.local_agents_visible());
}
#[test]
fn down_keeps_history_navigation_when_history_is_active() {
let mut session = app_session_with_input("", 0);
session.handle_command(app_types::InlineCommand::SetLocalAgents {
entries: vec![sample_local_agent_entry(
app_types::LocalAgentKind::Delegated,
)],
});
session.close_transient();
session.core.set_input("first".to_string());
let first = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(first, Some(app_types::InlineEvent::Submit(value)) if value == "first"));
session.core.set_input("second".to_string());
let second = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(second, Some(app_types::InlineEvent::Submit(value)) if value == "second"));
let up = session.process_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(up, Some(app_types::InlineEvent::HistoryPrevious)));
let down = session.process_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(matches!(down, Some(app_types::InlineEvent::HistoryNext)));
assert!(!session.local_agents_visible());
}
#[test]
fn empty_local_agents_drawer_stays_open_after_last_entry_is_removed() {
let mut session = app_session_with_input("", 0);
session.handle_command(app_types::InlineCommand::SetLocalAgents {
entries: vec![sample_local_agent_entry(
app_types::LocalAgentKind::Delegated,
)],
});
session.handle_command(app_types::InlineCommand::SetLocalAgents { entries: vec![] });
assert!(session.local_agents_visible());
let lines = rendered_app_session_lines(&mut session, 20);
assert!(
lines
.iter()
.any(|line| line.contains("No local agents yet")),
"drawer should remain visible and show the empty state"
);
}
#[test]
fn alt_s_remains_subprocesses_entrypoint() {
let mut session = app_session_with_input("", 0);
let event = session.process_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::ALT));
assert!(matches!(
event,
Some(app_types::InlineEvent::Submit(value)) if value == "/subprocesses"
));
}
#[test]
fn header_suggestions_include_subagent_shortcuts() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.local_agents = vec![sample_local_agent_entry(
app_types::LocalAgentKind::Delegated,
)];
let line = session
.header_suggestions_line()
.expect("header suggestions line");
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(rendered.contains("Alt+S"));
assert!(rendered.contains("Ctrl+B"));
}
#[test]
fn header_suggestions_hide_subagent_shortcuts_without_agents() {
let session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let line = session
.header_suggestions_line()
.expect("header suggestions line");
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(!rendered.contains("Alt+S"));
assert!(!rendered.contains("Ctrl+B"));
}
#[test]
fn header_suggestions_hide_subagent_shortcuts_with_background_only() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.local_agents = vec![sample_local_agent_entry(
app_types::LocalAgentKind::Background,
)];
let line = session
.header_suggestions_line()
.expect("header suggestions line");
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(!rendered.contains("Alt+S"));
assert!(!rendered.contains("Ctrl+B"));
}
#[test]
fn empty_input_status_shows_subagent_shortcuts() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.local_agents = vec![sample_local_agent_entry(
app_types::LocalAgentKind::Delegated,
)];
let line = session
.render_input_status_line(VIEW_WIDTH)
.expect("input status line");
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(rendered.contains("Alt+S"));
assert!(rendered.contains("Ctrl+B"));
}
#[test]
fn empty_input_status_hides_subagent_shortcuts_without_agents() {
let session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let rendered = session
.render_input_status_line(VIEW_WIDTH)
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.unwrap_or_default();
assert!(!rendered.contains("Alt+S"));
assert!(!rendered.contains("Ctrl+B"));
}
#[test]
fn empty_input_status_hides_subagent_shortcuts_with_background_only() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.local_agents = vec![sample_local_agent_entry(
app_types::LocalAgentKind::Background,
)];
let rendered = session
.render_input_status_line(VIEW_WIDTH)
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.unwrap_or_default();
assert!(!rendered.contains("Alt+S"));
assert!(!rendered.contains("Ctrl+B"));
}
#[test]
fn copy_notification_renders_in_input_status_line() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.show_copy_notification();
let rendered = session
.render_input_status_line(VIEW_WIDTH)
.expect("input status line")
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(rendered.contains("Copied to clipboard"));
}
#[test]
fn copy_notification_expires_after_five_seconds() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.show_copy_notification();
session.copy_notification_until = Some(Instant::now() - Duration::from_secs(1));
session.handle_tick();
let rendered = session
.render_input_status_line(VIEW_WIDTH)
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.unwrap_or_default();
assert!(!rendered.contains("Copied to clipboard"));
}
#[test]
fn cursor_visible_while_scrolling() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let initial = session.build_input_widget_data(VIEW_WIDTH, 1);
assert!(initial.cursor_should_be_visible);
session.scroll_line_down();
let during_scroll = session.build_input_widget_data(VIEW_WIDTH, 1);
assert!(during_scroll.cursor_should_be_visible);
assert!(session.use_steady_cursor());
session.scroll_cursor_steady_until = Some(Instant::now() - Duration::from_millis(1));
session.handle_tick();
assert!(!session.use_steady_cursor());
let after_scroll = session.build_input_widget_data(VIEW_WIDTH, 1);
assert!(after_scroll.cursor_should_be_visible);
}
#[test]
fn cursor_steady_during_shimmer() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let initial = session.build_input_widget_data(VIEW_WIDTH, 1);
assert!(initial.cursor_should_be_visible);
assert!(!session.use_steady_cursor());
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Running command: test".to_string()),
right: None,
});
let during_shimmer = session.build_input_widget_data(VIEW_WIDTH, 1);
assert!(during_shimmer.cursor_should_be_visible);
assert!(session.use_steady_cursor());
session.handle_command(InlineCommand::SetInputStatus {
left: None,
right: None,
});
assert!(!session.use_steady_cursor());
let after_shimmer = session.build_input_widget_data(VIEW_WIDTH, 1);
assert!(after_shimmer.cursor_should_be_visible);
}
#[test]
fn cursor_fake_during_status_shimmer() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let initial = session.build_input_widget_data(VIEW_WIDTH, 1);
assert!(initial.cursor_should_be_visible);
assert!(!initial.use_fake_cursor);
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Loading (Esc, Ctrl+C, or /stop to stop)".to_string()),
right: None,
});
let during_shimmer = session.build_input_widget_data(VIEW_WIDTH, 1);
assert!(during_shimmer.cursor_should_be_visible);
assert!(during_shimmer.use_fake_cursor);
assert!(session.use_steady_cursor());
session.handle_command(InlineCommand::SetInputStatus {
left: None,
right: None,
});
let after_shimmer = session.build_input_widget_data(VIEW_WIDTH, 1);
assert!(after_shimmer.cursor_should_be_visible);
assert!(!after_shimmer.use_fake_cursor);
}
#[test]
fn shift_enter_inserts_newline() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.input_manager.set_content("queued".to_string());
let result = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
assert!(result.is_none());
assert_eq!(session.input_manager.content(), "queued\n");
assert_eq!(
session.input_manager.cursor(),
session.input_manager.content().len()
);
}
#[test]
fn paste_preserves_all_newlines() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let pasted = (0..15)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
let (tx, _rx) = mpsc::unbounded_channel();
session.handle_event(CrosstermEvent::Paste(pasted.clone()), &tx, None);
assert_eq!(session.input_manager.content(), pasted);
}
#[test]
fn pasted_message_displays_full_content() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let line_total = ui::INLINE_PASTE_COLLAPSE_LINE_THRESHOLD + 1;
let pasted_lines: Vec<String> = (1..=line_total).map(|idx| format!("paste-{idx}")).collect();
let pasted_text = pasted_lines.join("\n");
session.append_pasted_message(
InlineMessageKind::User,
pasted_text.clone(),
pasted_lines.len(),
);
let user_line = session
.lines
.iter()
.find(|line| line.kind == InlineMessageKind::User)
.expect("user line should exist");
let combined: String = user_line
.segments
.iter()
.map(|segment| segment.text.as_str())
.collect();
assert!(combined.contains("paste-1"));
assert!(session.collapsed_pastes.is_empty());
}
#[test]
fn pasted_message_collapses_large_json_for_tool() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let mut json = String::from("{\n");
let line_total = ui::INLINE_JSON_COLLAPSE_LINE_THRESHOLD + 5;
for idx in 0..line_total {
json.push_str(&format!(" \"key{idx}\": \"value{idx}\",\n"));
}
json.push_str(" \"end\": true\n}");
let line_count = json.lines().count();
session.append_pasted_message(InlineMessageKind::Tool, json.clone(), line_count);
assert_eq!(session.collapsed_pastes.len(), 1);
let collapsed_index = session.collapsed_pastes[0].line_index;
let preview_line = session
.lines
.get(collapsed_index)
.expect("collapsed line exists");
let preview_text: String = preview_line
.segments
.iter()
.map(|segment| segment.text.as_str())
.collect();
assert!(preview_text.contains("showing last"));
assert!(preview_text.contains("\"end\": true"));
assert!(session.expand_collapsed_paste_at_line_index(collapsed_index));
assert!(session.collapsed_pastes.is_empty());
let expanded_line = session
.lines
.get(collapsed_index)
.expect("expanded line exists");
let expanded_text: String = expanded_line
.segments
.iter()
.map(|segment| segment.text.as_str())
.collect();
assert!(expanded_text.contains("\"key0\": \"value0\""));
assert!(expanded_text.contains("\"end\": true"));
}
#[test]
fn collapsed_paste_review_reflow_includes_detected_raw_links() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let url = "https://example.com/collapsed-review".to_string();
let mut json = String::from("{\n");
for idx in 0..ui::INLINE_JSON_COLLAPSE_LINE_THRESHOLD {
json.push_str(&format!(" \"key{idx}\": \"value{idx}\",\n"));
}
json.push_str(&format!(" \"link\": \"{url}\",\n"));
json.push_str(" \"end\": true\n}");
session.append_pasted_message(InlineMessageKind::Tool, json.clone(), json.lines().count());
let collapsed_index = session.collapsed_pastes[0].line_index;
let review_lines = session.reflow_message_lines_for_review(collapsed_index, 80);
assert!(review_lines.iter().any(|line| {
line.explicit_links
.iter()
.any(|link| matches!(&link.target, InlineLinkTarget::Url(target) if target == &url))
}));
}
#[test]
fn input_compact_preview_for_large_paste() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let line_total = ui::INLINE_PASTE_COLLAPSE_LINE_THRESHOLD + 1;
let pasted_lines: Vec<String> = (1..=line_total).map(|idx| format!("line-{idx}")).collect();
let pasted_text = pasted_lines.join("\n");
session.insert_paste_text(&pasted_text);
let data = session.build_input_widget_data(VIEW_WIDTH, VIEW_ROWS);
let rendered = text_content(&data.text);
assert!(rendered.contains("[Pasted Content"));
}
#[test]
fn input_compact_preview_for_image_path() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let image_path = "/tmp/Screenshot 2026-02-06 at 3.39.48 PM.png";
session.insert_paste_text(image_path);
let data = session.build_input_widget_data(VIEW_WIDTH, VIEW_ROWS);
let rendered = text_content(&data.text);
assert!(rendered.contains("[Image:"));
assert!(rendered.contains("Screenshot 2026-02-06"));
}
#[test]
fn input_compact_preview_for_quoted_image_path() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let image_path = "\"/tmp/Screenshot 2026-02-06 at 3.39.48 PM.png\"";
session.insert_paste_text(image_path);
let data = session.build_input_widget_data(VIEW_WIDTH, VIEW_ROWS);
let rendered = text_content(&data.text);
assert!(rendered.contains("[Image:"));
assert!(rendered.contains("Screenshot 2026-02-06"));
}
#[test]
fn input_compact_preview_for_image_path_with_text() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let input = "/tmp/Screenshot 2026-02-06 at 3.39.48 PM.png can you see";
session.insert_paste_text(input);
let data = session.build_input_widget_data(VIEW_WIDTH, VIEW_ROWS);
let rendered = text_content(&data.text);
assert!(rendered.contains("[Image:"));
assert!(rendered.contains("Screenshot 2026-02-06"));
assert!(rendered.contains("can you see"));
}
#[test]
fn bang_prefix_input_shows_shell_mode_status_hint() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("!echo hello".to_string());
let spans = session
.build_input_status_widget_data(VIEW_WIDTH)
.expect("expected shell mode status hint");
let rendered: String = spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect();
assert!(rendered.contains("Shell mode (!):"));
}
#[test]
fn bang_prefix_input_enables_shell_mode_border_title() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input(" !ls -la".to_string());
assert_eq!(session.shell_mode_border_title(), Some(" ! Shell mode "));
}
#[test]
fn bang_prefix_input_uses_zero_padding_for_visible_input_area() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("!echo hello".to_string());
assert_eq!(
session.input_block_padding(),
ratatui::widgets::Padding::new(0, 0, 0, 0)
);
}
#[test]
fn non_bang_input_has_no_shell_mode_border_title() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("run ls -la".to_string());
assert_eq!(session.shell_mode_border_title(), None);
}
#[test]
fn active_subagent_input_border_adds_extra_height() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.subagent_badges = vec![InlineHeaderBadge {
text: "rust-engineer".to_string(),
style: InlineTextStyle {
color: Some(AnsiColorEnum::Rgb(RgbColor(0xFF, 0xFF, 0xFF))),
bg_color: Some(AnsiColorEnum::Rgb(RgbColor(0x4F, 0x8F, 0xD8))),
..InlineTextStyle::default()
},
full_background: true,
}];
assert_eq!(session.input_block_extra_height(), 2);
}
#[test]
fn non_bang_input_uses_default_padding() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("run ls -la".to_string());
assert_eq!(
session.input_block_padding(),
ratatui::widgets::Padding::new(
ui::INLINE_INPUT_PADDING_HORIZONTAL,
ui::INLINE_INPUT_PADDING_HORIZONTAL,
ui::INLINE_INPUT_PADDING_VERTICAL,
ui::INLINE_INPUT_PADDING_VERTICAL,
)
);
}
#[test]
fn idle_enter_submits_immediately() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("queued".to_string());
let event = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(event, Some(InlineEvent::Submit(value)) if value == "queued"));
}
#[test]
fn control_enter_submits_current_draft_immediately() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("process now".to_string());
let event = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(matches!(event, Some(InlineEvent::Submit(value)) if value == "process now"));
}
#[test]
fn idle_control_enter_with_empty_input_processes_latest_queued_message() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_queued_input("first queued".to_string());
session.push_queued_input("latest queued".to_string());
let event = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(matches!(event, Some(InlineEvent::ProcessLatestQueued)));
}
#[test]
fn control_l_submits_clear_command() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let event = session.process_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
assert!(matches!(event, Some(InlineEvent::Submit(value)) if value == "/clear"));
}
#[test]
fn control_slash_toggles_inline_list_visibility() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
assert!(session.inline_lists_visible());
let _ = session.process_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
assert!(!session.inline_lists_visible());
let _ = session.process_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
assert!(session.inline_lists_visible());
}
#[test]
fn control_i_toggles_inline_list_visibility() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
assert!(session.inline_lists_visible());
let _ = session.process_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL));
assert!(!session.inline_lists_visible());
let _ = session.process_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL));
assert!(session.inline_lists_visible());
}
#[test]
fn tab_queues_submission() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("queued".to_string());
let queued = session.process_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(queued, Some(InlineEvent::QueueSubmit(value)) if value == "queued"));
}
#[test]
fn busy_escape_interrupts_then_exits() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Running command: test".to_string()),
right: None,
});
let first = session.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(first, Some(InlineEvent::Interrupt)));
let second = session.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(second, Some(InlineEvent::Exit)));
}
#[test]
fn busy_stop_command_interrupts_immediately() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(app_types::InlineCommand::SetInputStatus {
left: Some("Running tool: unified_search".to_string()),
right: None,
});
session.core.set_input("/stop".to_string());
let event = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(event, Some(app_types::InlineEvent::Interrupt)));
}
#[test]
fn busy_pause_command_emits_pause_event() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(app_types::InlineCommand::SetInputStatus {
left: Some("Running tool: unified_search".to_string()),
right: None,
});
session.core.set_input("/pause".to_string());
let event = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(event, Some(app_types::InlineEvent::Pause)));
}
#[test]
fn busy_resume_command_emits_resume_event() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(app_types::InlineCommand::SetInputStatus {
left: Some("Running tool: unified_search".to_string()),
right: None,
});
session.core.set_input("/resume".to_string());
let event = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(event, Some(app_types::InlineEvent::Resume)));
}
#[test]
fn busy_plain_enter_queues_submission() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Running tool: unified_search".to_string()),
right: None,
});
session.set_input("keep searching in docs/".to_string());
let event = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(event, Some(InlineEvent::QueueSubmit(value)) if value == "keep searching in docs/")
);
}
#[test]
fn busy_control_enter_steers_active_run() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Running tool: unified_search".to_string()),
right: None,
});
session.set_input("keep searching in docs/".to_string());
let event = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(matches!(event, Some(InlineEvent::Steer(value)) if value == "keep searching in docs/"));
}
#[test]
fn busy_tab_still_queues_submission() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Running tool: unified_search".to_string()),
right: None,
});
session.set_input("queue this next".to_string());
let event = session.process_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(event, Some(InlineEvent::QueueSubmit(value)) if value == "queue this next"));
}
#[test]
fn busy_slash_palette_stop_interrupts_immediately() {
let mut session = session_with_slash_palette_commands();
session.handle_command(app_types::InlineCommand::SetInputStatus {
left: Some("Running command: cargo test".to_string()),
right: None,
});
for key in [
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
] {
let event = session.process_key(key);
assert!(event.is_none());
}
let event = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(event, Some(app_types::InlineEvent::Interrupt)));
}
#[test]
fn double_escape_submits_rewind_when_idle() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let _ = session.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let second = session.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(second, Some(InlineEvent::Submit(value)) if value == "/rewind"));
}
#[test]
fn slash_palette_enter_submits_immediate_command() {
let mut session = session_with_slash_palette_commands();
for key in [
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
] {
let event = session.process_key(key);
assert!(event.is_none());
}
let submit = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(submit, Some(app_types::InlineEvent::Submit(value)) if value.trim() == "/new")
);
}
#[test]
fn slash_palette_enter_submits_review_immediately() {
let mut session = session_with_slash_palette_commands();
for key in [
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE),
] {
let event = session.process_key(key);
assert!(event.is_none());
}
let submit = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
matches!(submit, Some(app_types::InlineEvent::Submit(value)) if value.trim() == "/review")
);
}
#[test]
fn slash_palette_hides_entries_for_unmatched_keyword() {
let mut session = session_with_slash_palette_commands();
let _ = session.process_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert!(
!session.slash_palette.suggestions().is_empty(),
"slash palette should show entries after typing '/'"
);
for key in [
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE),
] {
let event = session.process_key(key);
assert!(event.is_none());
}
assert!(
session.slash_palette.suggestions().is_empty(),
"slash palette should hide entries for unmatched /zzzz"
);
}
#[test]
fn slash_trigger_auto_shows_inline_lists() {
let mut session = session_with_slash_palette_commands();
let _ = session.process_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
assert!(!session.inline_lists_visible());
let _ = session.process_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert!(session.inline_lists_visible());
}
#[test]
fn history_picker_trigger_auto_shows_inline_lists() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
let _ = session.process_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
assert!(!session.inline_lists_visible());
let _ = session.process_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert!(session.inline_lists_visible());
assert!(session.history_picker_state.active);
}
#[test]
fn slash_palette_keeps_base_input_and_cursor_active() {
let mut session = session_with_slash_palette_commands();
let _ = session.process_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert!(session.core.input_enabled());
assert!(
session
.core
.build_input_widget_data(VIEW_WIDTH, 1)
.cursor_should_be_visible
);
}
#[test]
fn history_picker_restores_base_input_and_draft_on_cancel() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
session.core.set_input("draft command".to_string());
let _ = session.process_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert!(session.history_picker_state.active);
assert!(!session.core.input_enabled());
assert!(
!session
.core
.build_input_widget_data(VIEW_WIDTH, 1)
.cursor_should_be_visible
);
let _ = session.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!session.history_picker_state.active);
assert!(session.core.input_enabled());
assert!(
session
.core
.build_input_widget_data(VIEW_WIDTH, 1)
.cursor_should_be_visible
);
assert_eq!(session.core.input_manager.content(), "draft command");
}
#[test]
fn slash_panel_renders_search_field_above_results() {
let mut session = session_with_slash_palette_commands();
for key in [
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
] {
let _ = session.process_key(key);
}
let lines = rendered_app_session_lines(&mut session, 20);
let search_index = lines
.iter()
.position(|line| line.contains("Search commands: [re"))
.expect("search commands field should render");
let item_index = lines
.iter()
.position(|line| line.contains("/review"))
.expect("slash result should render");
assert!(search_index < item_index);
}
#[test]
fn slash_palette_uses_full_width_header_background_and_divider() {
let theme = InlineTheme {
foreground: Some(AnsiColorEnum::Rgb(RgbColor(0xEE, 0xEE, 0xEE))),
background: Some(AnsiColorEnum::Rgb(RgbColor(0x2B, 0x2D, 0x33))),
primary: Some(AnsiColorEnum::Rgb(RgbColor(0x88, 0x99, 0xFF))),
..InlineTheme::default()
};
let mut session = AppSession::new_with_logs(
theme,
None,
20,
true,
None,
vec![
app_types::SlashCommandItem::new("new", "Start a new session"),
app_types::SlashCommandItem::new("review", "Review current diff"),
],
"Agent TUI".to_string(),
);
for key in [
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
] {
let _ = session.process_key(key);
}
let backend = TestBackend::new(VIEW_WIDTH, 20);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render slash palette");
let lines = rendered_app_session_lines(&mut session, 20);
let title_row = lines
.iter()
.position(|line| line.contains("Slash Commands"))
.expect("slash title row");
let divider_row_index = lines
.iter()
.position(|line| is_horizontal_rule(line))
.expect("slash divider row");
let panel_area = session.core.bottom_panel_area().expect("panel area");
let buffer = terminal.backend().buffer();
let title_left = buffer
.cell((panel_area.x, title_row as u16))
.expect("title left cell");
let title_right = buffer
.cell((
panel_area.x + panel_area.width.saturating_sub(1),
title_row as u16,
))
.expect("title right cell");
let divider_row = (0..panel_area.width)
.filter_map(|x| buffer.cell((panel_area.x + x, divider_row_index as u16)))
.map(|cell| cell.symbol().to_string())
.collect::<String>()
.trim_end()
.to_string();
assert_eq!(title_left.style().bg, Some(Color::Rgb(0x2B, 0x2D, 0x33)));
assert_eq!(title_right.style().bg, Some(Color::Rgb(0x2B, 0x2D, 0x33)));
assert_eq!(
divider_row,
ui::INLINE_BLOCK_HORIZONTAL.repeat(panel_area.width as usize)
);
}
#[test]
fn slash_panel_height_stays_fixed_for_short_results() {
let mut short_session = session_with_slash_palette_commands();
for key in [
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE),
] {
let _ = short_session.process_key(key);
}
let _ = rendered_app_session_lines(&mut short_session, 20);
let short_height = short_session
.core
.bottom_panel_area()
.expect("short slash panel area")
.height;
let mut full_session = session_with_slash_palette_commands();
let _ = full_session.process_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
let _ = rendered_app_session_lines(&mut full_session, 20);
let full_height = full_session
.core
.bottom_panel_area()
.expect("full slash panel area")
.height;
assert_eq!(
short_height, full_height,
"slash panel height should stay fixed regardless of result count"
);
}
#[test]
fn history_picker_renders_search_field_above_results() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
session.core.set_input("cargo test".to_string());
let _ = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
session.core.set_input("git status".to_string());
let _ = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let _ = session.process_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
let _ = session.process_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
let _ = session.process_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
let lines = rendered_app_session_lines(&mut session, 20);
let search_index = lines
.iter()
.position(|line| line.contains("Search history: [gi"))
.expect("search history field should render");
let item_index = lines
.iter()
.rposition(|line| line.contains("git status"))
.expect("history match should render");
assert!(search_index < item_index);
}
#[test]
fn mouse_wheel_navigates_slash_palette_instead_of_transcript() {
let mut session = session_with_slash_palette_commands();
let _ = session.process_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
let _ = rendered_app_session_lines(&mut session, 20);
let selected_before = session.slash_palette.selected_index();
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let panel_area = session.core.bottom_panel_area().expect("panel area");
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: panel_area.x,
row: panel_area.y,
modifiers: KeyModifiers::NONE,
}),
&event_tx,
None,
);
assert_ne!(session.slash_palette.selected_index(), selected_before);
assert_eq!(session.core.transcript_view_top, 0);
}
#[test]
fn mouse_wheel_navigates_modal_list_instead_of_transcript() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
show_basic_list_overlay(&mut session);
let _ = rendered_session_lines(&mut session, 20);
let selected_before = session
.modal_state()
.and_then(|modal| modal.list.as_ref())
.and_then(|list| list.current_selection());
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let modal_area = session.modal_list_area.expect("modal list area");
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: modal_area.x,
row: modal_area.y,
modifiers: KeyModifiers::NONE,
}),
&event_tx,
None,
);
let selected_after = session
.modal_state()
.and_then(|modal| modal.list.as_ref())
.and_then(|list| list.current_selection());
assert_ne!(selected_after, selected_before);
assert_eq!(session.transcript_view_top, 0);
}
#[test]
fn clicking_selected_slash_row_applies_command() {
let mut session = session_with_slash_palette_commands();
for key in [
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE),
] {
let _ = session.process_key(key);
}
let _ = rendered_app_session_lines(&mut session, 20);
let panel_area = session.core.bottom_panel_area().expect("panel area");
let (event_tx, _event_rx) = mpsc::unbounded_channel();
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: panel_area.x,
row: panel_area.y + 4,
modifiers: KeyModifiers::NONE,
}),
&event_tx,
None,
);
assert_eq!(session.core.input_manager.content(), "/review ");
assert!(session.slash_palette.suggestions().is_empty());
}
#[test]
fn clicking_selected_file_palette_row_inserts_reference() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
let workspace = vtcode_tui_workspace_root();
load_app_file_palette(
&mut session,
vec![
workspace
.join("src/core_tui/session.rs")
.display()
.to_string(),
],
workspace.clone(),
);
session.handle_command(app_types::InlineCommand::SetInput("@".to_string()));
let _ = rendered_app_session_lines(&mut session, 20);
let panel_area = session.core.bottom_panel_area().expect("panel area");
let (event_tx, _event_rx) = mpsc::unbounded_channel();
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: panel_area.x,
row: panel_area.y + 5,
modifiers: KeyModifiers::NONE,
}),
&event_tx,
None,
);
assert_eq!(
session.core.input_manager.content(),
"@src/core_tui/session.rs "
);
assert!(!session.file_palette_active);
}
#[test]
fn clicking_selected_history_row_accepts_entry() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
session.task_panel_lines = vec!["Active task".to_string()];
session.set_task_panel_visible(true);
session.core.set_input("cargo test".to_string());
let _ = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
session.core.set_input("git status".to_string());
let _ = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let _ = session.process_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
let _ = rendered_app_session_lines(&mut session, 20);
let expected = session
.history_picker_state
.selected_match()
.map(|item| item.content.clone())
.expect("selected history entry");
let panel_area = session.core.bottom_panel_area().expect("panel area");
let (event_tx, _event_rx) = mpsc::unbounded_channel();
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: panel_area.x,
row: panel_area.y + 3,
modifiers: KeyModifiers::NONE,
}),
&event_tx,
None,
);
assert!(!session.history_picker_state.active);
assert_eq!(session.core.input_manager.content(), expected);
let lines = rendered_app_session_lines(&mut session, 20);
assert!(
lines.iter().any(|line| line.contains("Active task")),
"task panel should resume after the history picker closes"
);
}
#[test]
fn clicking_input_moves_cursor_to_clicked_position() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("hello world".to_string());
session.set_cursor(session.input_manager.content().len());
let _ = rendered_session_lines(&mut session, 20);
let input_area = session.input_area.expect("input area");
let (event_tx, _event_rx) = mpsc::unbounded_channel();
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: input_area.x + 5,
row: input_area.y,
modifiers: KeyModifiers::NONE,
}),
&event_tx,
None,
);
assert_eq!(session.input_manager.cursor(), 5);
}
#[test]
fn clicking_input_does_not_start_transcript_selection() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("hello world".to_string());
let _ = rendered_session_lines(&mut session, 20);
let input_area = session.input_area.expect("input area");
let (event_tx, _event_rx) = mpsc::unbounded_channel();
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: input_area.x + 3,
row: input_area.y,
modifiers: KeyModifiers::NONE,
}),
&event_tx,
None,
);
assert_eq!(session.mouse_drag_target, MouseDragTarget::Input);
assert!(!session.mouse_selection.is_selecting);
assert!(!session.mouse_selection.has_selection);
}
#[test]
fn dragging_in_input_creates_input_selection_without_transcript_selection() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("hello world".to_string());
session.set_cursor(0);
let _ = rendered_session_lines(&mut session, 20);
let input_area = session.input_area.expect("input area");
let (event_tx, _event_rx) = mpsc::unbounded_channel();
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: input_area.x + 1,
row: input_area.y,
modifiers: KeyModifiers::NONE,
}),
&event_tx,
None,
);
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: input_area.x + 6,
row: input_area.y,
modifiers: KeyModifiers::NONE,
}),
&event_tx,
None,
);
session.handle_event(
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: input_area.x + 6,
row: input_area.y,
modifiers: KeyModifiers::NONE,
}),
&event_tx,
None,
);
assert_eq!(session.input_manager.cursor(), 6);
assert_eq!(session.input_manager.selection_range(), Some((1, 6)));
assert_eq!(session.mouse_drag_target, MouseDragTarget::None);
assert!(!session.mouse_selection.is_selecting);
assert!(!session.mouse_selection.has_selection);
}
#[test]
fn shift_left_selects_input_range() {
let mut session = session_with_input("hello world", "hello world".len());
let result = session.process_key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT));
assert!(result.is_none());
assert_eq!(
session.input_manager.selection_range(),
Some(("hello worl".len(), "hello world".len()))
);
}
#[test]
fn typing_replaces_selected_input_range() {
let mut session = session_with_input("hello world", "hello world".len());
let _ = session.process_key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT));
let _ = session.process_key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT));
let result = session.process_key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE));
assert!(result.is_none());
assert_eq!(session.input_manager.content(), "hello wor!");
assert_eq!(session.cursor(), "hello wor!".len());
assert_eq!(session.input_manager.selection_range(), None);
}
#[test]
fn backspace_deletes_selected_input_range() {
let mut session = session_with_input("hello world", "hello world".len());
let _ = session.process_key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT));
let _ = session.process_key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT));
let result = session.process_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(result.is_none());
assert_eq!(session.input_manager.content(), "hello wor");
assert_eq!(session.cursor(), "hello wor".len());
assert_eq!(session.input_manager.selection_range(), None);
}
#[test]
fn file_palette_renders_search_field_above_results() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
load_app_file_palette(
&mut session,
vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
PathBuf::from("."),
);
session.handle_command(app_types::InlineCommand::SetInput("@src".to_string()));
let lines = rendered_app_session_lines(&mut session, 20);
let search_index = lines
.iter()
.position(|line| line.contains("Search files: [src"))
.expect("search files field should render");
let item_index = lines
.iter()
.position(|line| line.contains("src/main.rs"))
.expect("file result should render");
assert!(search_index < item_index);
}
#[test]
fn file_palette_uses_full_width_header_background_and_divider() {
let theme = InlineTheme {
foreground: Some(AnsiColorEnum::Rgb(RgbColor(0xEE, 0xEE, 0xEE))),
background: Some(AnsiColorEnum::Rgb(RgbColor(0x2B, 0x2D, 0x33))),
primary: Some(AnsiColorEnum::Rgb(RgbColor(0x88, 0x99, 0xFF))),
..InlineTheme::default()
};
let mut session = AppSession::new(theme, None, VIEW_ROWS);
load_app_file_palette(
&mut session,
vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
PathBuf::from("."),
);
session.handle_command(app_types::InlineCommand::SetInput("@src".to_string()));
let backend = TestBackend::new(VIEW_WIDTH, 20);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render file palette");
let lines = rendered_app_session_lines(&mut session, 20);
let title_row = lines
.iter()
.position(|line| line.contains("Files"))
.expect("file title row");
let divider_row_index = lines
.iter()
.position(|line| is_horizontal_rule(line))
.expect("file divider row");
let panel_area = session.core.bottom_panel_area().expect("panel area");
let buffer = terminal.backend().buffer();
let title_left = buffer
.cell((panel_area.x, title_row as u16))
.expect("title left cell");
let title_right = buffer
.cell((
panel_area.x + panel_area.width.saturating_sub(1),
title_row as u16,
))
.expect("title right cell");
let divider_row = (0..panel_area.width)
.filter_map(|x| buffer.cell((panel_area.x + x, divider_row_index as u16)))
.map(|cell| cell.symbol().to_string())
.collect::<String>()
.trim_end()
.to_string();
assert_eq!(title_left.style().bg, Some(Color::Rgb(0x2B, 0x2D, 0x33)));
assert_eq!(title_right.style().bg, Some(Color::Rgb(0x2B, 0x2D, 0x33)));
assert_eq!(
divider_row,
ui::INLINE_BLOCK_HORIZONTAL.repeat(panel_area.width as usize)
);
}
#[test]
fn file_palette_trigger_auto_shows_inline_lists() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
load_app_file_palette(
&mut session,
vec!["src/main.rs".to_string()],
PathBuf::from("."),
);
let _ = session.process_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
assert!(!session.inline_lists_visible());
session.handle_command(app_types::InlineCommand::SetInput("@src".to_string()));
assert!(session.inline_lists_visible());
assert!(session.file_palette_active);
}
#[test]
fn file_palette_keeps_base_input_and_cursor_active() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
load_app_file_palette(
&mut session,
vec!["src/main.rs".to_string()],
PathBuf::from("."),
);
session.handle_command(app_types::InlineCommand::SetInput("@src".to_string()));
assert!(session.file_palette_visible());
assert!(session.core.input_enabled());
assert!(
session
.core
.build_input_widget_data(VIEW_WIDTH, 1)
.cursor_should_be_visible
);
}
#[test]
fn alt_up_edits_latest_queued_input() {
with_terminal_env(None, Some("xterm-256color"), || {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::SetQueuedInputs {
entries: vec!["first".to_string(), "second".to_string()],
});
let event = session.process_key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
assert!(matches!(event, Some(InlineEvent::EditQueue)));
assert_eq!(session.input_manager.content(), "second");
});
}
#[test]
fn shift_left_edits_latest_queued_input_in_tmux() {
with_terminal_env(
Some("/tmp/tmux-1000/default,123,0"),
Some("tmux-256color"),
|| {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::SetQueuedInputs {
entries: vec!["first".to_string(), "second".to_string()],
});
let event = session.process_key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT));
assert!(matches!(event, Some(InlineEvent::EditQueue)));
assert_eq!(session.input_manager.content(), "second");
},
);
}
#[test]
fn app_session_shift_left_edits_latest_queued_input_in_tmux() {
with_terminal_env(
Some("/tmp/tmux-1000/default,123,0"),
Some("tmux-256color"),
|| {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(app_types::InlineCommand::SetQueuedInputs {
entries: vec!["first".to_string(), "second".to_string()],
});
let event = session.process_key(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT));
assert!(matches!(event, Some(app_types::InlineEvent::EditQueue)));
assert_eq!(session.core.input_manager.content(), "second");
},
);
}
#[test]
fn consecutive_duplicate_submissions_not_stored_twice() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("repeat".to_string());
let first = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(first, Some(InlineEvent::Submit(value)) if value == "repeat"));
session.set_input("repeat".to_string());
let second = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(second, Some(InlineEvent::Submit(value)) if value == "repeat"));
assert_eq!(session.input_manager.history().len(), 1);
}
#[test]
fn alt_arrow_left_moves_cursor_by_word() {
let text = "hello world";
let mut session = session_with_input(text, text.len());
let event = KeyEvent::new(KeyCode::Left, KeyModifiers::ALT);
session.process_key(event);
assert_eq!(session.cursor(), 6);
}
#[test]
fn alt_b_moves_cursor_by_word() {
let text = "hello world";
let mut session = session_with_input(text, text.len());
let event = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT);
session.process_key(event);
assert_eq!(session.cursor(), 6);
}
#[test]
fn move_right_word_advances_to_word_boundaries() {
let text = "hello world";
let mut session = session_with_input(text, 0);
session.move_right_word();
assert_eq!(session.cursor(), 5);
session.move_right_word();
assert_eq!(session.cursor(), 7);
session.move_right_word();
assert_eq!(session.cursor(), text.len());
}
#[test]
fn move_right_word_from_whitespace_moves_to_next_word_start() {
let text = "hello world";
let mut session = session_with_input(text, 5);
session.move_right_word();
assert_eq!(session.cursor(), 7);
}
#[test]
fn super_arrow_right_moves_cursor_to_end() {
let text = "hello world";
let mut session = session_with_input(text, 0);
let event = KeyEvent::new(KeyCode::Right, KeyModifiers::SUPER);
let result = session.process_key(event);
assert_eq!(session.cursor(), text.len());
assert!(!matches!(result, Some(InlineEvent::LaunchEditor)));
}
#[test]
fn super_a_moves_cursor_to_start() {
let text = "hello world";
let mut session = session_with_input(text, text.len());
let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
session.process_key(event);
assert_eq!(session.cursor(), 0);
}
#[test]
fn super_e_moves_cursor_to_end() {
let text = "hello world";
let mut session = session_with_input(text, 0);
let event = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::SUPER);
let result = session.process_key(event);
assert!(result.is_none());
assert_eq!(session.cursor(), text.len());
}
#[test]
fn control_e_launches_editor() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let event = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL);
let result = session.process_key(event);
assert!(matches!(result, Some(InlineEvent::LaunchEditor)));
}
#[test]
fn control_a_moves_cursor_to_start() {
let text = "hello world";
let mut session = session_with_input(text, text.len());
let result = session.process_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert!(result.is_none());
assert_eq!(session.cursor(), 0);
}
#[test]
fn control_e_moves_cursor_to_end_when_input_has_content() {
let text = "hello world";
let mut session = session_with_input(text, 0);
let result = session.process_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert!(result.is_none());
assert_eq!(session.cursor(), text.len());
}
#[test]
fn control_w_deletes_previous_word() {
let mut session = session_with_input("hello world", "hello world".len());
let result = session.process_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert!(result.is_none());
assert_eq!(session.input_manager.content(), "hello ");
assert_eq!(session.cursor(), "hello ".len());
}
#[test]
fn control_u_deletes_to_start_of_line() {
let mut session = session_with_input("hello world", 5);
let result = session.process_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
assert!(result.is_none());
assert_eq!(session.input_manager.content(), " world");
assert_eq!(session.cursor(), 0);
}
#[test]
fn control_k_deletes_to_end_of_line() {
let mut session = session_with_input("hello world", 5);
let result = session.process_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
assert!(result.is_none());
assert_eq!(session.input_manager.content(), "hello");
assert_eq!(session.cursor(), 5);
}
#[test]
fn control_alt_e_does_not_launch_editor() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let event = KeyEvent::new(
KeyCode::Char('e'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
);
let result = session.process_key(event);
assert!(!matches!(result, Some(InlineEvent::LaunchEditor)));
}
#[test]
fn control_g_launches_editor_from_plan_confirmation_modal() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.input_status_right = Some("model | 25% context".to_string());
let plan = app_types::PlanContent::from_markdown(
"Test Plan".to_string(),
"## Plan of Work\n- Step 1",
Some(".vtcode/plans/test-plan.md".to_string()),
);
show_plan_confirmation_overlay(&mut session, plan);
let event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL);
let result = session.process_key(event);
assert!(matches!(
result,
Some(InlineEvent::Overlay(OverlayEvent::Submitted(
OverlaySubmission::Hotkey(OverlayHotkeyAction::LaunchEditor)
)))
));
assert!(session.modal_state().is_none());
}
#[test]
fn plan_confirmation_modal_matches_four_way_gate_copy() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let plan = app_types::PlanContent::from_markdown(
"Test Plan".to_string(),
"## Implementation Plan\n1. Step",
Some(".vtcode/plans/test-plan.md".to_string()),
);
show_plan_confirmation_overlay(&mut session, plan);
let modal = session
.modal_state()
.expect("plan confirmation modal should be present");
assert_eq!(modal.title, "Ready to code?");
assert_eq!(
modal.lines.first().map(String::as_str),
Some("A plan is ready to execute. Would you like to proceed?")
);
let list = modal
.list
.as_ref()
.expect("plan confirmation should include list options");
assert_eq!(list.items.len(), 3);
assert_eq!(list.items[0].title, "Yes, auto-accept edits");
assert_eq!(
list.items[0].subtitle.as_deref(),
Some("Execute with auto-approval.")
);
assert_eq!(list.items[0].badge.as_deref(), Some("Recommended"));
assert_eq!(list.items[1].title, "Yes, manually approve edits");
assert_eq!(
list.items[1].subtitle.as_deref(),
Some("Keep context and confirm each edit before applying.")
);
assert_eq!(list.items[2].title, "Type feedback to revise the plan");
assert_eq!(
list.items[2].subtitle.as_deref(),
Some("Return to plan mode and refine the plan.")
);
}
#[test]
fn control_super_e_does_not_launch_editor() {
let text = "hello world";
let mut session = session_with_input(text, 0);
let event = KeyEvent::new(
KeyCode::Char('e'),
KeyModifiers::CONTROL | KeyModifiers::SUPER,
);
let result = session.process_key(event);
assert!(!matches!(result, Some(InlineEvent::LaunchEditor)));
}
#[test]
fn diff_overlay_defaults_to_edit_approval_mode() {
let preview = app_types::DiffPreviewState::new(
"src/main.rs".to_string(),
"before".to_string(),
"after".to_string(),
Vec::new(),
);
assert_eq!(preview.mode, app_types::DiffPreviewMode::EditApproval);
}
#[test]
fn diff_overlay_edit_approval_keys_remain_unchanged() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
show_diff_overlay(&mut session, app_types::DiffPreviewMode::EditApproval);
let apply = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
apply,
Some(app_types::InlineEvent::Transient(
app_types::TransientEvent::Submitted(app_types::TransientSubmission::DiffApply)
))
));
show_diff_overlay(&mut session, app_types::DiffPreviewMode::EditApproval);
let reload = session.process_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
assert!(reload.is_none());
assert!(session.diff_preview_state().is_some());
let reject = session.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(
reject,
Some(app_types::InlineEvent::Transient(
app_types::TransientEvent::Submitted(app_types::TransientSubmission::DiffReject)
))
));
}
#[test]
fn diff_overlay_conflict_mode_maps_enter_reload_and_escape() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
show_diff_overlay(&mut session, app_types::DiffPreviewMode::FileConflict);
let proceed = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
proceed,
Some(app_types::InlineEvent::Transient(
app_types::TransientEvent::Submitted(app_types::TransientSubmission::DiffProceed)
))
));
show_diff_overlay(&mut session, app_types::DiffPreviewMode::FileConflict);
let reload = session.process_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
assert!(matches!(
reload,
Some(app_types::InlineEvent::Transient(
app_types::TransientEvent::Submitted(app_types::TransientSubmission::DiffReload)
))
));
show_diff_overlay(&mut session, app_types::DiffPreviewMode::FileConflict);
let abort = session.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(
abort,
Some(app_types::InlineEvent::Transient(
app_types::TransientEvent::Submitted(app_types::TransientSubmission::DiffAbort)
))
));
}
#[test]
fn diff_overlay_conflict_mode_ignores_trust_shortcuts() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
show_diff_overlay(&mut session, app_types::DiffPreviewMode::FileConflict);
let event = session.process_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert!(event.is_none());
assert!(matches!(
session.diff_preview_state().map(|preview| preview.mode),
Some(app_types::DiffPreviewMode::FileConflict)
));
}
#[test]
fn diff_overlay_readonly_review_maps_enter_and_escape_to_back() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
show_diff_overlay(&mut session, app_types::DiffPreviewMode::ReadonlyReview);
let enter = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
enter,
Some(app_types::InlineEvent::Transient(
app_types::TransientEvent::Submitted(app_types::TransientSubmission::DiffAbort)
))
));
show_diff_overlay(&mut session, app_types::DiffPreviewMode::ReadonlyReview);
let escape = session.process_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(
escape,
Some(app_types::InlineEvent::Transient(
app_types::TransientEvent::Submitted(app_types::TransientSubmission::DiffAbort)
))
));
}
#[test]
fn diff_overlay_readonly_review_ignores_reload_shortcut() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
show_diff_overlay(&mut session, app_types::DiffPreviewMode::ReadonlyReview);
let reload = session.process_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
assert!(reload.is_none());
assert!(matches!(
session.diff_preview_state().map(|preview| preview.mode),
Some(app_types::DiffPreviewMode::ReadonlyReview)
));
}
#[test]
fn diff_preview_suspends_task_panel_and_restores_it_on_close() {
let mut session = AppSession::new(InlineTheme::default(), None, 30);
session.task_panel_lines = vec!["Queued task".to_string()];
session.set_task_panel_visible(true);
let backend = TestBackend::new(VIEW_WIDTH, 30);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render task panel");
assert!(session.core.bottom_panel_area().is_some());
show_diff_overlay(&mut session, app_types::DiffPreviewMode::ReadonlyReview);
assert!(!session.core.input_enabled());
assert!(
!session
.core
.build_input_widget_data(VIEW_WIDTH, 1)
.cursor_should_be_visible
);
terminal
.draw(|frame| session.render(frame))
.expect("failed to render diff preview");
assert!(
session.core.bottom_panel_area().is_none(),
"floating diff preview should hide the lower bottom panel"
);
session.close_diff_overlay();
assert!(session.core.input_enabled());
assert!(
session
.core
.build_input_widget_data(VIEW_WIDTH, 1)
.cursor_should_be_visible
);
let lines = rendered_app_session_lines(&mut session, 30);
assert!(
lines.iter().any(|line| line.contains("Queued task")),
"task panel should resume after closing diff preview"
);
}
#[test]
fn arrow_keys_never_launch_editor() {
let text = "hello world";
let mut session = session_with_input(text, 0);
for modifiers in [
KeyModifiers::empty(),
KeyModifiers::CONTROL,
KeyModifiers::SHIFT,
KeyModifiers::ALT,
KeyModifiers::SUPER,
KeyModifiers::META,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
KeyModifiers::CONTROL | KeyModifiers::SUPER,
] {
let event = KeyEvent::new(KeyCode::Right, modifiers);
let result = session.process_key(event);
assert!(
!matches!(result, Some(InlineEvent::LaunchEditor)),
"Right arrow with modifiers {:?} should not launch editor",
modifiers
);
}
for key_code in [KeyCode::Left, KeyCode::Up, KeyCode::Down] {
let event = KeyEvent::new(key_code, KeyModifiers::SUPER);
let result = session.process_key(event);
assert!(
!matches!(result, Some(InlineEvent::LaunchEditor)),
"{:?} with SUPER should not launch editor",
key_code
);
}
}
#[test]
fn question_mark_opens_help_overlay_when_input_is_empty() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let result = session.process_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert!(result.is_none());
let modal = session.modal_state().expect("help modal should open");
assert_eq!(modal.title, "Keyboard Shortcuts");
assert!(
modal
.lines
.iter()
.any(|line| line.contains("Ctrl+A / Ctrl+E"))
);
}
#[test]
fn question_mark_inserts_character_when_input_has_content() {
let mut session = session_with_input("why", 3);
let result = session.process_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert!(result.is_none());
assert_eq!(session.input_manager.content(), "why?");
assert_eq!(session.cursor(), 4);
assert!(session.modal_state().is_none());
}
fn request_user_input_step(question_id: &str, label: &str) -> WizardStep {
WizardStep {
title: format!("Question {question_id}"),
question: format!("Select {question_id}"),
items: vec![InlineListItem {
title: label.to_string(),
subtitle: Some("Option".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::RequestUserInputAnswer {
question_id: question_id.to_string(),
selected: vec![label.to_string()],
other: None,
}),
search_value: Some(label.to_string()),
}],
completed: false,
answer: None,
allow_freeform: true,
freeform_label: None,
freeform_placeholder: None,
freeform_default: None,
}
}
fn request_user_input_custom_step(question_id: &str, label: &str, default: &str) -> WizardStep {
WizardStep {
title: format!("Question {question_id}"),
question: format!("Enter {question_id}"),
items: vec![InlineListItem {
title: label.to_string(),
subtitle: Some("Input".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::RequestUserInputAnswer {
question_id: question_id.to_string(),
selected: vec![],
other: Some(String::new()),
}),
search_value: Some(label.to_string()),
}],
completed: false,
answer: None,
allow_freeform: true,
freeform_label: Some(label.to_string()),
freeform_placeholder: Some(default.to_string()),
freeform_default: Some(default.to_string()),
}
}
fn show_plan_confirmation_overlay(session: &mut Session, plan: app_types::PlanContent) {
let mut lines: Vec<String> = plan
.raw_content
.lines()
.map(|line| line.to_string())
.collect();
if lines.is_empty() && !plan.summary.is_empty() {
lines.push(plan.summary.clone());
}
lines.insert(
0,
"A plan is ready to execute. Would you like to proceed?".to_string(),
);
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::List(ListOverlayRequest {
title: "Ready to code?".to_string(),
lines,
footer_hint: plan
.file_path
.as_ref()
.map(|path| format!("ctrl-g to edit in VS Code · {path}")),
items: vec![
InlineListItem {
title: "Yes, auto-accept edits".to_string(),
subtitle: Some("Execute with auto-approval.".to_string()),
badge: Some("Recommended".to_string()),
indent: 0,
selection: Some(InlineListSelection::PlanApprovalAutoAccept),
search_value: None,
},
InlineListItem {
title: "Yes, manually approve edits".to_string(),
subtitle: Some(
"Keep context and confirm each edit before applying.".to_string(),
),
badge: None,
indent: 0,
selection: Some(InlineListSelection::PlanApprovalExecute),
search_value: None,
},
InlineListItem {
title: "Type feedback to revise the plan".to_string(),
subtitle: Some("Return to plan mode and refine the plan.".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::PlanApprovalEditPlan),
search_value: None,
},
],
selected: Some(InlineListSelection::PlanApprovalAutoAccept),
search: None,
hotkeys: vec![OverlayHotkey {
key: OverlayHotkeyKey::CtrlChar('g'),
action: OverlayHotkeyAction::LaunchEditor,
}],
})),
});
}
#[test]
fn show_list_modal_renders_as_floating_transient_without_bottom_panel() {
let mut session = AppSession::new(InlineTheme::default(), None, 30);
let item = InlineListItem {
title: "Option A".to_string(),
subtitle: Some("Select this option".to_string()),
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("a".to_string())),
search_value: Some("Option A".to_string()),
};
session.handle_command(app_types::InlineCommand::ShowTransient {
request: Box::new(app_types::TransientRequest::List(
app_types::ListOverlayRequest {
title: "Pick one".to_string(),
lines: vec!["Choose an option".to_string()],
footer_hint: None,
items: vec![item],
selected: None,
search: Some(InlineListSearchConfig {
label: "Search models".to_string(),
placeholder: Some("provider, name, id".to_string()),
}),
hotkeys: Vec::new(),
},
)),
});
let backend = TestBackend::new(VIEW_WIDTH, 30);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render list modal");
assert!(
session.has_active_overlay(),
"floating list modal should remain active after rendering"
);
assert!(
session.core.bottom_panel_area().is_none(),
"floating list modal should not render in the bottom panel"
);
}
#[test]
fn show_list_modal_uses_bottom_half_of_terminal() {
let mut session = AppSession::new(InlineTheme::default(), None, 30);
let item = InlineListItem {
title: "Option A".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("a".to_string())),
search_value: Some("Option A".to_string()),
};
session.handle_command(app_types::InlineCommand::ShowTransient {
request: Box::new(app_types::TransientRequest::List(
app_types::ListOverlayRequest {
title: "Pick one".to_string(),
lines: vec!["Choose an option".to_string()],
footer_hint: None,
items: vec![item],
selected: None,
search: None,
hotkeys: Vec::new(),
},
)),
});
let lines = rendered_app_session_lines(&mut session, 30);
assert!(
lines.get(15).is_some_and(|line| line.contains("Pick one")),
"floating modal title should start at the halfway row"
);
let modal_area = session.core.modal_list_area().expect("modal list area");
assert!(
modal_area.y >= 17,
"floating modal list should render below the title chrome, got y={}",
modal_area.y
);
}
#[test]
fn titled_floating_modal_renders_matching_title_and_divider_chrome() {
let mut session = AppSession::new(InlineTheme::default(), None, 30);
let item = InlineListItem {
title: "Option A".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("a".to_string())),
search_value: Some("Option A".to_string()),
};
session.handle_command(app_types::InlineCommand::ShowTransient {
request: Box::new(app_types::TransientRequest::List(
app_types::ListOverlayRequest {
title: "Pick one".to_string(),
lines: vec!["Choose an option".to_string()],
footer_hint: None,
items: vec![item],
selected: None,
search: None,
hotkeys: Vec::new(),
},
)),
});
let backend = TestBackend::new(VIEW_WIDTH, 30);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render titled floating modal");
let buffer = terminal.backend().buffer();
let title_cell = buffer.cell((0, 15)).expect("title cell");
let top_divider_cell = buffer.cell((0, 16)).expect("top divider cell");
let bottom_divider_cell = buffer.cell((0, 29)).expect("bottom divider cell");
assert_eq!(title_cell.symbol(), "P");
assert_eq!(top_divider_cell.symbol(), ui::INLINE_BLOCK_HORIZONTAL);
assert_eq!(bottom_divider_cell.symbol(), ui::INLINE_BLOCK_HORIZONTAL);
assert_ne!(
title_cell.style().bg,
Some(Color::Indexed(ui::SAFE_ANSI_BRIGHT_CYAN))
);
assert_eq!(
top_divider_cell.style().fg,
Some(Color::Indexed(ui::SAFE_ANSI_BRIGHT_CYAN))
);
assert_eq!(
bottom_divider_cell.style().fg,
Some(Color::Indexed(ui::SAFE_ANSI_BRIGHT_CYAN))
);
}
#[test]
fn floating_modal_clears_stale_buffer_content_before_painting() {
let theme = InlineTheme {
foreground: Some(AnsiColorEnum::Rgb(RgbColor(0x22, 0x22, 0x22))),
background: Some(AnsiColorEnum::Rgb(RgbColor(0xF5, 0xF5, 0xF0))),
primary: Some(AnsiColorEnum::Rgb(RgbColor(0x7A, 0x8F, 0xFF))),
..InlineTheme::default()
};
let mut session = AppSession::new(theme, None, 30);
session.handle_command(app_types::InlineCommand::ShowTransient {
request: Box::new(app_types::TransientRequest::List(
app_types::ListOverlayRequest {
title: "Theme".to_string(),
lines: vec!["Choose a theme".to_string()],
footer_hint: None,
items: vec![InlineListItem {
title: "Clapre".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("theme".to_string())),
search_value: Some("Clapre".to_string()),
}],
selected: None,
search: None,
hotkeys: Vec::new(),
},
)),
});
let backend = TestBackend::new(VIEW_WIDTH, 30);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| {
let filler = (0..30)
.map(|_| Line::from("X".repeat(VIEW_WIDTH as usize)))
.collect::<Vec<_>>();
frame.render_widget(ratatui::widgets::Paragraph::new(filler), frame.area());
})
.expect("failed to prefill terminal buffer");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render modal over stale buffer");
let buffer = terminal.backend().buffer();
let title_tail_cell = buffer
.cell((VIEW_WIDTH.saturating_sub(1), 15))
.expect("title tail cell");
let body_blank_cell = buffer.cell((10, 25)).expect("body blank cell");
assert_eq!(title_tail_cell.symbol(), " ");
assert_eq!(body_blank_cell.symbol(), " ");
assert_eq!(
title_tail_cell.style().bg,
Some(Color::Rgb(0xF5, 0xF5, 0xF0))
);
assert_eq!(
body_blank_cell.style().bg,
Some(Color::Rgb(0xF5, 0xF5, 0xF0))
);
}
#[test]
fn selected_modal_row_uses_full_width_accent_background() {
let theme = InlineTheme {
foreground: Some(AnsiColorEnum::Rgb(RgbColor(0xEE, 0xEE, 0xEE))),
primary: Some(AnsiColorEnum::Rgb(RgbColor(0x12, 0x34, 0x56))),
..InlineTheme::default()
};
let mut session = AppSession::new(theme, None, 30);
let selection = InlineListSelection::SlashCommand("a".to_string());
session.handle_command(app_types::InlineCommand::ShowTransient {
request: Box::new(app_types::TransientRequest::List(
app_types::ListOverlayRequest {
title: "Pick one".to_string(),
lines: vec!["Choose an option".to_string()],
footer_hint: None,
items: vec![InlineListItem {
title: "Option A".to_string(),
subtitle: None,
badge: Some("Active".to_string()),
indent: 0,
selection: Some(selection.clone()),
search_value: Some("Option A".to_string()),
}],
selected: Some(selection),
search: None,
hotkeys: Vec::new(),
},
)),
});
let backend = TestBackend::new(VIEW_WIDTH, 30);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render selected modal row");
let modal_area = session.core.modal_list_area().expect("modal list area");
let far_right = terminal
.backend()
.buffer()
.cell((
modal_area.x + modal_area.width.saturating_sub(1),
modal_area.y,
))
.expect("selected row far-right cell");
assert_eq!(far_right.style().bg, Some(Color::Rgb(0x12, 0x34, 0x56)));
let badge_cell = terminal
.backend()
.buffer()
.cell((modal_area.x, modal_area.y))
.expect("selected row badge cell");
assert_eq!(badge_cell.style().bg, Some(Color::Rgb(0x12, 0x34, 0x56)));
let title_cell = terminal
.backend()
.buffer()
.cell((modal_area.x + 10, modal_area.y))
.expect("selected row title cell");
assert_eq!(title_cell.style().bg, Some(Color::Rgb(0x12, 0x34, 0x56)));
}
#[test]
fn modal_section_header_uses_foreground_contrast_on_light_theme() {
let theme = InlineTheme {
foreground: Some(AnsiColorEnum::Rgb(RgbColor(0x22, 0x22, 0x22))),
background: Some(AnsiColorEnum::Rgb(RgbColor(0xF5, 0xF5, 0xF0))),
primary: Some(AnsiColorEnum::Rgb(RgbColor(0x7A, 0x8F, 0xFF))),
..InlineTheme::default()
};
let mut session = AppSession::new(theme, None, 30);
session.handle_command(app_types::InlineCommand::ShowTransient {
request: Box::new(app_types::TransientRequest::List(
app_types::ListOverlayRequest {
title: "Theme".to_string(),
lines: vec!["Choose a theme".to_string()],
footer_hint: None,
items: vec![
InlineListItem {
title: "Built-in themes".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: None,
search_value: Some("Built-in themes".to_string()),
},
InlineListItem {
title: "Clapre".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("theme".to_string())),
search_value: Some("Clapre".to_string()),
},
],
selected: None,
search: None,
hotkeys: Vec::new(),
},
)),
});
let backend = TestBackend::new(VIEW_WIDTH, 30);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render modal section header");
let lines = rendered_app_session_lines(&mut session, 30);
let title_row = lines
.iter()
.position(|line| line.trim() == "Theme")
.expect("title row");
let modal_area = session.core.modal_list_area().expect("modal list area");
let header_cell = terminal
.backend()
.buffer()
.cell((modal_area.x + 1, modal_area.y))
.expect("section header cell");
let title_cell = terminal
.backend()
.buffer()
.cell((modal_area.x, title_row as u16))
.expect("title cell");
assert_eq!(title_cell.symbol(), "T");
assert_eq!(title_cell.style().bg, Some(Color::Rgb(0xF5, 0xF5, 0xF0)));
assert_eq!(header_cell.symbol(), "B");
assert_eq!(header_cell.style().fg, Some(Color::Rgb(0x7A, 0x8F, 0xFF)));
assert_eq!(header_cell.style().bg, Some(Color::Rgb(0xF5, 0xF5, 0xF0)));
assert!(header_cell.style().add_modifier.contains(Modifier::BOLD));
}
#[test]
fn untitled_floating_modal_skips_title_chrome_rows() {
let mut session = AppSession::new(InlineTheme::default(), None, 30);
let item = InlineListItem {
title: "Option A".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("a".to_string())),
search_value: Some("Option A".to_string()),
};
session.handle_command(app_types::InlineCommand::ShowTransient {
request: Box::new(app_types::TransientRequest::List(
app_types::ListOverlayRequest {
title: String::new(),
lines: vec!["Choose an option".to_string()],
footer_hint: None,
items: vec![item],
selected: None,
search: None,
hotkeys: Vec::new(),
},
)),
});
let lines = rendered_app_session_lines(&mut session, 30);
assert!(
lines
.get(15)
.is_some_and(|line| line.contains("Choose an option")),
"untitled modal body should begin at the floating modal origin"
);
assert!(
(15..30).all(|row| !lines.get(row).is_some_and(|line| is_horizontal_rule(line))),
"untitled modal should not render title chrome divider rows"
);
let modal_area = session.core.modal_list_area().expect("modal list area");
assert_eq!(modal_area.y, 16);
}
#[test]
fn closing_top_transient_restores_previous_bottom_panel() {
let mut session = AppSession::new(InlineTheme::default(), None, 30);
session.set_task_panel_visible(true);
let backend = TestBackend::new(VIEW_WIDTH, 30);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render task panel");
assert!(
session.core.bottom_panel_area().is_some(),
"task panel should occupy the bottom panel when visible"
);
session.handle_command(app_types::InlineCommand::ShowTransient {
request: Box::new(app_types::TransientRequest::List(
app_types::ListOverlayRequest {
title: "Pick one".to_string(),
lines: vec!["Choose an option".to_string()],
footer_hint: None,
items: vec![InlineListItem {
title: "Option A".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("a".to_string())),
search_value: None,
}],
selected: None,
search: None,
hotkeys: Vec::new(),
},
)),
});
terminal
.draw(|frame| session.render(frame))
.expect("failed to render stacked transients");
assert!(
session.core.bottom_panel_area().is_none(),
"floating transient should hide the lower bottom panel while it is on top"
);
session.close_transient();
terminal
.draw(|frame| session.render(frame))
.expect("failed to render restored bottom panel");
assert!(
session.core.bottom_panel_area().is_some(),
"closing the top transient should restore the previous bottom panel"
);
}
#[test]
fn list_modal_keeps_last_selection_when_items_append() {
let mut session = Session::new(InlineTheme::default(), None, 30);
let make_item = |title: &str, cmd: &str| InlineListItem {
title: title.to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand(cmd.to_string())),
search_value: None,
};
let selected = InlineListSelection::SlashCommand("second".to_string());
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::List(ListOverlayRequest {
title: "Pick".to_string(),
lines: vec!["Choose".to_string()],
footer_hint: None,
items: vec![make_item("First", "first"), make_item("Second", "second")],
selected: Some(selected.clone()),
search: None,
hotkeys: Vec::new(),
})),
});
session.handle_command(InlineCommand::CloseOverlay);
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::List(ListOverlayRequest {
title: "Pick".to_string(),
lines: vec!["Choose".to_string()],
footer_hint: None,
items: vec![
make_item("First", "first"),
make_item("Second", "second"),
make_item("Third", "third"),
],
selected: Some(selected),
search: None,
hotkeys: Vec::new(),
})),
});
let selection = session
.modal_state()
.and_then(|modal| modal.list.as_ref())
.and_then(|list| list.current_selection());
assert_eq!(
selection,
Some(InlineListSelection::SlashCommand("third".to_string()))
);
}
#[test]
fn render_always_reserves_input_status_row() {
let mut session = Session::new(InlineTheme::default(), None, 30);
let input_width = VIEW_WIDTH.saturating_sub(2);
let base_input_height =
Session::input_block_height_for_lines(session.desired_input_lines(input_width));
let backend = TestBackend::new(VIEW_WIDTH, 30);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render session");
assert!(
session.input_height >= base_input_height + ui::INLINE_INPUT_STATUS_HEIGHT,
"input should always reserve persistent status row"
);
}
#[test]
fn wizard_multistep_submit_keeps_modal_open_until_last_step() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let steps = vec![
request_user_input_step("q1", "Scope"),
request_user_input_step("q2", "Priority"),
];
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::Wizard(WizardOverlayRequest {
title: "Questions".to_string(),
steps,
current_step: 0,
search: None,
mode: WizardModalMode::MultiStep,
})),
});
assert!(session.wizard_overlay().is_some());
let first_submit = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(first_submit.is_none());
assert!(
session.wizard_overlay().is_some(),
"wizard should remain open after intermediate step completion"
);
assert_eq!(
session.wizard_overlay().map(|wizard| wizard.current_step),
Some(1)
);
let final_submit = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
final_submit,
Some(InlineEvent::Overlay(OverlayEvent::Submitted(
OverlaySubmission::Wizard(selections)
))) if selections.len() == 2
));
assert!(
session.wizard_overlay().is_none(),
"wizard should close after final submission"
);
}
#[test]
fn wizard_multistep_defaulted_enter_advances_and_returns_default_answer() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let steps = vec![
request_user_input_custom_step("q1", "Cadence", "10m"),
request_user_input_step("q2", "Priority"),
];
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::Wizard(WizardOverlayRequest {
title: "Questions".to_string(),
steps,
current_step: 0,
search: None,
mode: WizardModalMode::MultiStep,
})),
});
assert!(session.wizard_overlay().is_some());
let first_submit = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(first_submit.is_none());
assert!(session.wizard_overlay().is_some());
assert_eq!(
session.wizard_overlay().map(|wizard| wizard.current_step),
Some(1)
);
let final_submit = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match final_submit {
Some(InlineEvent::Overlay(OverlayEvent::Submitted(OverlaySubmission::Wizard(
selections,
)))) => {
assert_eq!(selections.len(), 2);
match &selections[0] {
InlineListSelection::RequestUserInputAnswer { other, .. } => {
assert_eq!(other.as_deref(), Some("10m"));
}
other => panic!("unexpected first selection: {:?}", other),
}
}
other => panic!("Expected final wizard submission, got {:?}", other),
}
assert!(session.wizard_overlay().is_none());
}
#[test]
fn wizard_search_paste_updates_filter_in_session_handle_event() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let (tx, _rx) = mpsc::unbounded_channel();
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::Wizard(WizardOverlayRequest {
title: "Question".to_string(),
steps: vec![WizardStep {
title: "Choose".to_string(),
question: "Pick one".to_string(),
items: vec![
InlineListItem {
title: "Scope".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("scope".to_string())),
search_value: Some("scope".to_string()),
},
InlineListItem {
title: "Priority".to_string(),
subtitle: None,
badge: None,
indent: 0,
selection: Some(InlineListSelection::SlashCommand("priority".to_string())),
search_value: Some("priority".to_string()),
},
],
completed: false,
answer: None,
allow_freeform: false,
freeform_label: None,
freeform_placeholder: None,
freeform_default: None,
}],
current_step: 0,
search: Some(InlineListSearchConfig {
label: "Filter".to_string(),
placeholder: None,
}),
mode: WizardModalMode::MultiStep,
})),
});
session.handle_event(CrosstermEvent::Paste("prio".to_string()), &tx, None);
let wizard = session.wizard_overlay().expect("wizard should stay open");
assert_eq!(
wizard.search.as_ref().map(|search| search.query.as_str()),
Some("prio")
);
assert_eq!(wizard.steps[0].list.visible_indices, vec![1]);
}
#[test]
fn wizard_tabbed_submit_closes_modal_immediately() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let steps = vec![request_user_input_step("q1", "Single choice")];
session.handle_command(InlineCommand::ShowOverlay {
request: Box::new(OverlayRequest::Wizard(WizardOverlayRequest {
title: "Question".to_string(),
steps,
current_step: 0,
search: None,
mode: WizardModalMode::TabbedList,
})),
});
assert!(session.wizard_overlay().is_some());
let submit = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
submit,
Some(InlineEvent::Overlay(OverlayEvent::Submitted(
OverlaySubmission::Wizard(selections)
))) if selections.len() == 1
));
assert!(session.wizard_overlay().is_none());
}
#[test]
fn streaming_new_lines_preserves_scrolled_view() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
for index in 1..=LINE_COUNT {
let label = format!("{LABEL_PREFIX}-{index}");
session.push_line(InlineMessageKind::Agent, vec![make_segment(label.as_str())]);
}
session.scroll_page_up();
let before = visible_transcript(&mut session);
let before_offset = session.scroll_offset();
session.append_inline(InlineMessageKind::Agent, make_segment(EXTRA_SEGMENT));
let after = visible_transcript(&mut session);
assert_eq!(before.len(), after.len());
assert_eq!(
session.scroll_offset(),
before_offset,
"streaming should preserve manual scroll offset"
);
}
#[test]
fn streaming_segments_render_incrementally() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(InlineMessageKind::Agent, vec![make_segment("")]);
session.append_inline(InlineMessageKind::Agent, make_segment("Hello"));
let first = visible_transcript(&mut session);
assert!(first.iter().any(|line| line.contains("Hello")));
session.append_inline(InlineMessageKind::Agent, make_segment(" world"));
let second = visible_transcript(&mut session);
assert!(second.iter().any(|line| line.contains("Hello world")));
}
#[test]
fn page_up_reveals_prior_lines_until_buffer_start() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
for index in 1..=LINE_COUNT {
let label = format!("{LABEL_PREFIX}-{index}");
session.push_line(InlineMessageKind::Agent, vec![make_segment(label.as_str())]);
}
let bottom_view = visible_transcript(&mut session);
let start_offset = session.scroll_offset();
for _ in 0..(LINE_COUNT * 2) {
session.scroll_page_up();
if session.scroll_offset() > start_offset {
break;
}
}
let scrolled_view = visible_transcript(&mut session);
assert!(session.scroll_offset() > start_offset);
assert_ne!(bottom_view, scrolled_view);
}
#[test]
fn resizing_viewport_clamps_scroll_offset() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
for index in 1..=(LINE_COUNT * 5) {
let label = format!("{LABEL_PREFIX}-{index}");
session.push_line(InlineMessageKind::Agent, vec![make_segment(label.as_str())]);
}
visible_transcript(&mut session);
for _ in 0..(LINE_COUNT * 2) {
session.scroll_page_up();
if session.scroll_offset() > 0 {
break;
}
}
assert!(session.scroll_offset() > 0);
let scrolled_offset = session.scroll_offset();
session.force_view_rows(
(LINE_COUNT as u16)
+ ui::INLINE_HEADER_HEIGHT
+ Session::input_block_height_for_lines(1)
+ 2,
);
let max_offset = session.current_max_scroll_offset();
assert!(session.scroll_offset() <= scrolled_offset);
assert!(session.scroll_offset() <= max_offset);
}
#[test]
fn scroll_end_displays_full_final_paragraph() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let total = LINE_COUNT * 5;
for index in 1..=total {
let label = format!("{LABEL_PREFIX}-{index}");
let text = format!("{label}\n{label}-continued");
session.push_line(InlineMessageKind::Agent, vec![make_segment(text.as_str())]);
}
visible_transcript(&mut session);
for _ in 0..total {
session.scroll_page_up();
if session.scroll_offset() == session.current_max_scroll_offset() {
break;
}
}
assert!(session.scroll_offset() > 0);
for _ in 0..total {
session.scroll_page_down();
if session.scroll_offset() == 0 {
break;
}
}
assert_eq!(session.scroll_offset(), 0);
let view = visible_transcript(&mut session);
let expected_tail = format!("{LABEL_PREFIX}-{total}-continued");
assert!(
view.iter().any(|line| line.contains(&expected_tail)),
"expected final paragraph tail `{expected_tail}` to appear, got {view:?}"
);
}
#[test]
fn user_messages_render_with_dividers() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(InlineMessageKind::User, vec![make_segment("Hi")]);
let width = 10;
let lines = session.reflow_transcript_lines(width);
assert!(
lines.iter().any(|line| line_text(line).contains("Hi")),
"expected user message to remain visible in transcript"
);
}
#[test]
fn header_shows_safe_badge_for_tools_policy_trust() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.workspace_trust = format!("{}tools policy", ui::HEADER_TRUST_PREFIX);
session.input_manager.set_content("test".to_string());
session
.input_manager
.set_cursor(session.input_manager.content().len());
let lines = session.header_lines();
assert_eq!(lines.len(), 1);
let line_text: String = lines[0]
.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect();
assert!(line_text.contains("Safe"));
}
#[test]
fn header_shows_full_auto_trust_badge_for_full_auto_trust() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.workspace_trust = format!("{}full auto", ui::HEADER_TRUST_PREFIX);
session.input_manager.set_content("test".to_string());
session
.input_manager
.set_cursor(session.input_manager.content().len());
let lines = session.header_lines();
assert_eq!(lines.len(), 1);
let line_text: String = lines[0]
.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect();
assert!(line_text.contains("Full-auto"));
}
#[test]
fn header_shows_auto_badge() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.autonomous_mode = true;
session.header_context.workspace_trust = format!("{}tools policy", ui::HEADER_TRUST_PREFIX);
session.input_manager.set_content("test".to_string());
session
.input_manager
.set_cursor(session.input_manager.content().len());
let lines = session.header_lines();
assert_eq!(lines.len(), 1);
let line_text: String = lines[0]
.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect();
assert!(line_text.contains("Auto"));
assert!(line_text.contains("Safe"));
}
#[test]
fn header_shows_pr_review_status_badge() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.pr_review = Some(InlineHeaderStatusBadge {
text: "PR: outdated".to_string(),
tone: InlineHeaderStatusTone::Warning,
});
let line = session.header_meta_line();
let badge_span = line
.spans
.iter()
.find(|span| span.content.as_ref() == "PR: outdated")
.expect("pr review badge span");
assert_eq!(badge_span.style.fg, Some(Color::Yellow));
assert!(badge_span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn header_shows_persistent_memory_status_badge() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.persistent_memory = Some(InlineHeaderStatusBadge {
text: "Memory: cleanup".to_string(),
tone: InlineHeaderStatusTone::Warning,
});
let line = session.header_meta_line();
let badge_span = line
.spans
.iter()
.find(|span| span.content.as_ref() == "Memory: cleanup")
.expect("persistent memory badge span");
assert_eq!(badge_span.style.fg, Some(Color::Yellow));
assert!(badge_span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn header_shows_active_subagent_badge_with_full_background() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.subagent_badges = vec![InlineHeaderBadge {
text: "rust-engineer".to_string(),
style: InlineTextStyle {
color: Some(AnsiColorEnum::Rgb(RgbColor(0xFF, 0xFF, 0xFF))),
bg_color: Some(AnsiColorEnum::Rgb(RgbColor(0x4F, 0x8F, 0xD8))),
..InlineTextStyle::default()
},
full_background: true,
}];
let line = session.header_meta_line();
let badge_span = line
.spans
.iter()
.find(|span| span.content.as_ref() == " rust-engineer ")
.expect("subagent badge span");
assert_eq!(badge_span.style.fg, Some(Color::Rgb(0xFF, 0xFF, 0xFF)));
assert_eq!(badge_span.style.bg, Some(Color::Rgb(0x4F, 0x8F, 0xD8)));
assert!(badge_span.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn input_block_shows_active_subagent_title_with_badge_style() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("review current code".to_string());
session.header_context.subagent_badges = vec![InlineHeaderBadge {
text: "rust-engineer".to_string(),
style: InlineTextStyle {
color: Some(AnsiColorEnum::Rgb(RgbColor(0xFF, 0xFF, 0xFF))),
bg_color: Some(AnsiColorEnum::Rgb(RgbColor(0x4F, 0x8F, 0xD8))),
..InlineTextStyle::default()
},
full_background: true,
}];
let title = session
.active_subagent_input_title()
.expect("active subagent input title");
let span = title.spans.first().expect("title span");
assert_eq!(span.content.as_ref(), " rust-engineer ");
assert_eq!(span.style.fg, Some(Color::Rgb(0xFF, 0xFF, 0xFF)));
assert_eq!(span.style.bg, Some(Color::Rgb(0x4F, 0x8F, 0xD8)));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
assert_eq!(session.input_block_extra_height(), 2);
}
#[test]
fn header_meta_line_excludes_editor_context() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.editor_context =
Some("File: src/main.rs · Rust · Sel 120-148".to_string());
let line = session.header_meta_line();
let summary = line_text(&line);
assert!(!summary.contains("File: src/main.rs"));
}
#[test]
fn header_title_line_shows_model_context_window() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.provider = format!("{}Anthropic", ui::HEADER_PROVIDER_PREFIX);
session.header_context.model = format!("{}claude-sonnet-4-6", ui::HEADER_MODEL_PREFIX);
session.header_context.context_window_size = Some(1_000_000);
let summary = line_text(&session.header_title_line());
assert!(summary.contains("claude-sonnet-4-6 (1M)"));
session.header_context.model = format!("{}claude-haiku-4-5", ui::HEADER_MODEL_PREFIX);
session.header_context.context_window_size = Some(200_000);
let summary = line_text(&session.header_title_line());
assert!(summary.contains("claude-haiku-4-5 (200K)"));
}
#[test]
fn header_highlights_collapse_to_single_line() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.highlights = vec![
InlineHeaderHighlight {
title: "Keyboard Shortcuts".to_string(),
lines: vec![
"/help Show help".to_string(),
"Enter Submit message".to_string(),
],
},
InlineHeaderHighlight {
title: "Usage Tips".to_string(),
lines: vec!["- Keep tasks focused".to_string()],
},
];
session.input_manager.set_content("notes".to_string());
session
.input_manager
.set_cursor(session.input_manager.content().len());
let lines = session.header_lines();
assert_eq!(lines.len(), 1);
let summary: String = lines[0]
.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect();
assert!(summary.contains("Keyboard Shortcuts"));
assert!(summary.contains("/help Show help"));
assert!(summary.contains("(+1 more)"));
assert!(!summary.contains("Enter Submit message"));
assert!(summary.contains("Usage Tips"));
assert!(summary.contains("Keep tasks focused"));
}
#[test]
fn header_highlight_summary_truncates_long_entries() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let limit = ui::HEADER_HIGHLIGHT_PREVIEW_MAX_CHARS;
let long_entry = "A".repeat(limit + 5);
session.header_context.highlights = vec![InlineHeaderHighlight {
title: "Details".to_string(),
lines: vec![long_entry.clone()],
}];
session.input_manager.set_content("notes".to_string());
session
.input_manager
.set_cursor(session.input_manager.content().len());
let lines = session.header_lines();
assert_eq!(lines.len(), 1);
let summary: String = lines[0]
.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect();
let expected_preview = format!(
"{}{}",
"A".repeat(limit.saturating_sub(1)),
ui::INLINE_PREVIEW_ELLIPSIS
);
assert!(summary.contains("Details"));
assert!(summary.contains(&expected_preview));
assert!(!summary.contains(&long_entry));
}
#[test]
fn header_highlight_summary_hides_truncated_command_segments() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.highlights = vec![InlineHeaderHighlight {
title: String::new(),
lines: vec![
" - /{command}".to_string(),
" - /help Show slash command help".to_string(),
" - Enter Submit message".to_string(),
" - Escape Cancel input".to_string(),
],
}];
session.input_manager.set_content("notes".to_string());
session
.input_manager
.set_cursor(session.input_manager.content().len());
let lines = session.header_lines();
assert_eq!(lines.len(), 1);
let summary: String = lines[0]
.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect();
assert!(summary.contains("/{command}"));
assert!(summary.contains("(+3 more)"));
assert!(!summary.contains("Escape"));
assert!(!summary.contains(ui::INLINE_PREVIEW_ELLIPSIS));
}
#[test]
fn header_suggestions_do_not_show_memory_shortcut_when_enabled() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.persistent_memory = Some(InlineHeaderStatusBadge {
text: "Memory: auto".to_string(),
tone: InlineHeaderStatusTone::Ready,
});
let line = session
.header_suggestions_line()
.expect("header suggestions line");
let summary = line_text(&line);
assert!(!summary.contains("/memory"));
}
#[test]
fn header_height_expands_when_wrapping_required() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.header_context.provider = format!(
"{}Example Provider With Extended Label",
ui::HEADER_PROVIDER_PREFIX
);
session.header_context.model = format!(
"{}ExampleModelIdentifierWithDetail",
ui::HEADER_MODEL_PREFIX
);
session.header_context.reasoning = format!("{}medium", ui::HEADER_REASONING_PREFIX);
session.header_context.mode = ui::HEADER_MODE_AUTO.to_string();
session.header_context.workspace_trust = format!("{}full auto", ui::HEADER_TRUST_PREFIX);
session.header_context.tools = format!(
"{}allow 11 · prompt 7 · deny 0 · extras extras extras",
ui::HEADER_TOOLS_PREFIX
);
session.header_context.mcp = format!("{}enabled", ui::HEADER_MCP_PREFIX);
session.header_context.highlights = vec![InlineHeaderHighlight {
title: "Tips".to_string(),
lines: vec![
"- Use /prompt:quick-start for boilerplate".to_string(),
"- Keep responses focused".to_string(),
],
}];
session.input_manager.set_content("notes".to_string());
session
.input_manager
.set_cursor(session.input_manager.content().len());
let wide = session.header_height_for_width(120);
let narrow = session.header_height_for_width(40);
assert!(
narrow >= wide,
"expected narrower width to require at least as many header rows"
);
assert!(
wide >= ui::INLINE_HEADER_HEIGHT && narrow >= ui::INLINE_HEADER_HEIGHT,
"expected header rows to meet minimum height"
);
}
#[test]
fn agent_messages_include_left_padding() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(
"Hello, here is the information you requested. This is an example of a standard agent message.",
)],
);
let lines = session.reflow_transcript_lines(32);
let content_lines: Vec<String> = lines
.iter()
.map(line_text)
.filter(|text| !text.trim().is_empty())
.collect();
assert!(
content_lines.len() >= 2,
"expected wrapped agent lines to be visible"
);
let first_line = &content_lines[0];
let second_line = &content_lines[1];
let expected_prefix = format!(
"{}{}",
ui::INLINE_AGENT_QUOTE_PREFIX,
ui::INLINE_AGENT_MESSAGE_LEFT_PADDING
);
let continuation_prefix = " ".repeat(expected_prefix.chars().count());
assert!(
first_line.starts_with(&expected_prefix),
"agent message should include left padding",
);
assert!(
second_line.starts_with(&continuation_prefix),
"agent message continuation should align with content padding",
);
assert!(
!second_line.starts_with(&expected_prefix),
"agent message continuation should not repeat bullet prefix",
);
assert!(
!first_line.contains('│'),
"agent message should not render a left border",
);
}
#[test]
fn wrap_line_splits_double_width_graphemes() {
let session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let style = session.default_style();
let line = Line::from(vec![Span::styled(
"你好世界".to_string(),
ratatui_style_from_inline(&style, None),
)]);
let wrapped = session.wrap_line(line, 4);
let rendered: Vec<String> = wrapped.iter().map(line_text).collect();
assert_eq!(rendered, vec!["你好".to_string(), "世界".to_string()]);
}
#[test]
fn wrap_line_keeps_explicit_blank_rows() {
let session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let style = session.default_style();
let line = Line::from(vec![Span::styled(
"top\n\nbottom".to_string(),
ratatui_style_from_inline(&style, None),
)]);
let wrapped = session.wrap_line(line, 40);
let rendered: Vec<String> = wrapped.iter().map(line_text).collect();
assert_eq!(
rendered,
vec!["top".to_string(), String::new(), "bottom".to_string()]
);
}
#[test]
fn wrap_line_prefers_word_boundaries_for_plain_text() {
let session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let style = session.default_style();
let line = Line::from(vec![Span::styled(
"alpha beta gamma".to_string(),
ratatui_style_from_inline(&style, None),
)]);
let wrapped = session.wrap_line(line, 7);
let rendered: Vec<String> = wrapped.iter().map(line_text).collect();
assert_eq!(
rendered,
vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()]
);
}
#[test]
fn wrap_line_keeps_words_intact_across_same_style_stream_chunks() {
let session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let style = ratatui_style_from_inline(&session.default_style(), None);
let line = Line::from(vec![
Span::styled("alpha be".to_string(), style),
Span::styled("ta gamma".to_string(), style),
]);
let wrapped = session.wrap_line(line, 7);
let rendered: Vec<String> = wrapped.iter().map(line_text).collect();
assert_eq!(
rendered,
vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()]
);
}
#[test]
fn wrap_line_keeps_list_continuation_aligned() {
let session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let style = session.default_style();
let line = Line::from(vec![Span::styled(
"• alpha beta gamma".to_string(),
ratatui_style_from_inline(&style, None),
)]);
let wrapped = session.wrap_line(line, 8);
let rendered: Vec<String> = wrapped.iter().map(line_text).collect();
assert_eq!(
rendered,
vec![
"• alpha".to_string(),
" beta".to_string(),
" gamma".to_string()
]
);
}
#[test]
fn wrap_line_preserves_characters_wider_than_viewport() {
let session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let style = session.default_style();
let line = Line::from(vec![Span::styled(
"你".to_string(),
ratatui_style_from_inline(&style, None),
)]);
let wrapped = session.wrap_line(line, 1);
let rendered: Vec<String> = wrapped.iter().map(line_text).collect();
assert_eq!(rendered, vec!["你".to_string()]);
}
#[test]
fn wrap_line_discards_carriage_return_before_newline() {
let session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
let style = session.default_style();
let line = Line::from(vec![Span::styled(
"foo\r\nbar".to_string(),
ratatui_style_from_inline(&style, None),
)]);
let wrapped = session.wrap_line(line, 80);
let rendered: Vec<String> = wrapped.iter().map(line_text).collect();
assert_eq!(rendered, vec!["foo".to_string(), "bar".to_string()]);
}
#[test]
fn tool_code_fence_markers_are_skipped() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.append_inline(
InlineMessageKind::Tool,
InlineSegment {
text: "```rust\nfn demo() {}\n```".to_string(),
style: Arc::new(InlineTextStyle::default()),
},
);
let tool_lines: Vec<&MessageLine> = session
.lines
.iter()
.filter(|line| line.kind == InlineMessageKind::Tool)
.collect();
assert_eq!(tool_lines.len(), 1);
let Some(first_line) = tool_lines.first() else {
panic!("Expected at least one tool line");
};
assert_eq!(first_line.segments.len(), 1);
let Some(first_segment) = first_line.segments.first() else {
panic!("Expected at least one segment");
};
assert_eq!(first_segment.text.as_str(), "```rust\nfn demo() {}\n```");
assert!(!session.in_tool_code_fence);
}
#[test]
fn pty_block_omits_placeholder_when_empty() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(InlineMessageKind::Pty, Vec::new());
let lines = session.reflow_pty_lines(0, 80);
assert!(lines.is_empty());
}
#[test]
fn pty_block_hides_until_output_available() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(InlineMessageKind::Pty, Vec::new());
assert!(session.reflow_pty_lines(0, 80).is_empty());
session.push_line(
InlineMessageKind::Pty,
vec![InlineSegment {
text: "first output".to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
);
assert!(
session.reflow_pty_lines(0, 80).is_empty(),
"placeholder PTY line should remain hidden",
);
let rendered = session.reflow_pty_lines(1, 80);
assert!(rendered.iter().any(|line| !line.line.spans.is_empty()));
}
#[test]
fn pty_block_skips_status_only_sequence() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(InlineMessageKind::Pty, Vec::new());
session.push_line(InlineMessageKind::Pty, Vec::new());
assert!(session.reflow_pty_lines(0, 80).is_empty());
assert!(session.reflow_pty_lines(1, 80).is_empty());
}
#[test]
fn pty_wrapped_lines_keep_hanging_left_padding() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Pty,
vec![InlineSegment {
text: " └ this PTY output line wraps on narrow widths".to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
);
let rendered = session.reflow_pty_lines(0, 18);
assert!(
rendered.len() >= 2,
"expected wrapped PTY output, got {} line(s)",
rendered.len()
);
let first = line_text(&rendered[0].line);
let second = line_text(&rendered[1].line);
assert!(first.starts_with(" └ "), "first line was: {first:?}");
assert!(
second.starts_with(" "),
"wrapped line should keep hanging indent, got: {second:?}"
);
}
#[test]
fn pty_wrapped_lines_do_not_exceed_viewport_width() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Pty,
vec![InlineSegment {
text: " └ this PTY output line wraps on narrow widths".to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
);
let width = 18usize;
let rendered = session.reflow_pty_lines(0, width as u16);
for line in rendered {
let line_width: usize = line.line.spans.iter().map(|span| span.width()).sum();
assert!(
line_width <= width,
"wrapped PTY line exceeded viewport width: {line_width} > {width}",
);
}
}
#[test]
fn tool_diff_numbered_lines_keep_hanging_indent_when_wrapped() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Tool,
vec![InlineSegment {
text:
"459 + let digits_len = digits.chars().take_while(|c| c.is_ascii_digit()).count();"
.to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
);
let rendered = session.reflow_transcript_lines(40);
assert!(
rendered.len() >= 2,
"expected wrapped tool diff output, got {} line(s)",
rendered.len()
);
let first = line_text(&rendered[0]);
let second = line_text(&rendered[1]);
assert!(
first.contains("459 + "),
"first line should include diff gutter: {first:?}"
);
assert!(
second.starts_with(" "),
"wrapped line should keep hanging indent after tool prefix, got: {second:?}"
);
}
#[test]
fn agent_numbered_code_lines_keep_hanging_indent_when_wrapped() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Agent,
vec![
InlineSegment {
text: " 12 ".to_string(),
style: Arc::new(InlineTextStyle {
effects: anstyle::Effects::DIMMED,
..InlineTextStyle::default()
}),
},
make_segment(
"fn wrapped_diff_continuation_prefix(line_text: &str) -> Option<String> {",
),
],
);
let rendered = session.reflow_transcript_lines(36);
let content_lines: Vec<String> = rendered
.iter()
.map(line_text)
.filter(|text| !text.trim().is_empty())
.collect();
assert!(
content_lines.len() >= 2,
"expected wrapped code line, got: {content_lines:?}"
);
let first = &content_lines[0];
let second = &content_lines[1];
let agent_indent = " ".repeat(
format!(
"{}{}",
ui::INLINE_AGENT_QUOTE_PREFIX,
ui::INLINE_AGENT_MESSAGE_LEFT_PADDING
)
.chars()
.count(),
);
let expected_prefix = format!("{agent_indent}{}", " ".repeat(" 12 ".chars().count()));
assert!(
first.contains("12 fn wrapped_diff"),
"first line was: {first:?}"
);
assert!(
second.starts_with(&expected_prefix),
"wrapped code continuation should keep gutter indent, got: {second:?}"
);
}
#[test]
fn agent_omitted_code_lines_keep_hanging_indent_when_wrapped() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Agent,
vec![InlineSegment {
text: "21-421 … [+400 lines omitted; use read_file with offset/limit (1-indexed line numbers) for full content]".to_string(),
style: Arc::new(InlineTextStyle {
effects: anstyle::Effects::DIMMED,
..InlineTextStyle::default()
}),
}],
);
let rendered = session.reflow_transcript_lines(52);
let content_lines: Vec<String> = rendered
.iter()
.map(line_text)
.filter(|text| !text.trim().is_empty())
.collect();
assert!(
content_lines.len() >= 2,
"expected wrapped omitted line, got: {content_lines:?}"
);
let first = &content_lines[0];
let second = &content_lines[1];
let agent_indent = " ".repeat(
format!(
"{}{}",
ui::INLINE_AGENT_QUOTE_PREFIX,
ui::INLINE_AGENT_MESSAGE_LEFT_PADDING
)
.chars()
.count(),
);
let expected_prefix = format!("{agent_indent}{}", " ".repeat("21-421 ".chars().count()));
assert!(
first.contains("21-421 … [+400 lines omitted"),
"first line was: {first:?}"
);
assert!(
second.starts_with(&expected_prefix),
"wrapped omitted-line continuation should keep gutter indent, got: {second:?}"
);
}
#[test]
fn pty_lines_use_subdued_foreground() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Pty,
vec![InlineSegment {
text: "plain pty output".to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
);
let rendered = session.reflow_pty_lines(0, 80);
let body_span = rendered
.iter()
.flat_map(|line| line.line.spans.iter())
.find(|span| span.content.contains("plain pty output"))
.expect("expected PTY body span");
assert!(
body_span.style.fg.is_some() || body_span.style.add_modifier.contains(Modifier::DIM),
"PTY body span should apply non-default visual styling"
);
}
#[test]
fn assistant_text_is_brighter_than_pty_output() {
let agent_fg = Color::Rgb(0xEE, 0xEE, 0xEE);
let pty_fg = Color::Rgb(0x7A, 0x7A, 0x7A);
let theme = InlineTheme {
foreground: Some(AnsiColorEnum::Rgb(RgbColor(0xEE, 0xEE, 0xEE))),
pty_body: Some(AnsiColorEnum::Rgb(RgbColor(0x7A, 0x7A, 0x7A))),
..Default::default()
};
let mut session = Session::new(theme, None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Agent,
vec![InlineSegment {
text: "assistant reply".to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
);
session.push_line(
InlineMessageKind::Pty,
vec![InlineSegment {
text: "pty output".to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
);
let agent_spans = session.render_message_spans(0);
let agent_body = agent_spans
.iter()
.find(|span| span.content.contains("assistant reply"))
.expect("expected assistant body span");
assert_eq!(agent_body.style.fg, Some(agent_fg));
let pty_rendered = session.reflow_pty_lines(1, 80);
let pty_body = pty_rendered
.iter()
.flat_map(|line| line.line.spans.iter())
.find(|span| span.content.contains("pty output"))
.expect("expected PTY body span");
assert_eq!(pty_body.style.fg, Some(pty_fg));
assert!(pty_body.style.add_modifier.contains(Modifier::DIM));
assert_ne!(agent_body.style.fg, pty_body.style.fg);
}
#[test]
fn transcript_shows_content_when_viewport_smaller_than_padding() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
for index in 0..10 {
let label = format!("{LABEL_PREFIX}-{index}");
session.push_line(InlineMessageKind::Agent, vec![make_segment(label.as_str())]);
}
let minimal_view_rows = ui::INLINE_HEADER_HEIGHT + Session::input_block_height_for_lines(1) + 1;
session.force_view_rows(minimal_view_rows);
let view = visible_transcript(&mut session);
assert!(
view.iter()
.any(|line| line.contains(&format!("{LABEL_PREFIX}-9"))),
"expected most recent transcript line to remain visible even when viewport is small"
);
}
#[test]
fn pty_scroll_preserves_order() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
for index in 0..200 {
let label = format!("{LABEL_PREFIX}-{index}");
session.push_line(
InlineMessageKind::Pty,
vec![InlineSegment {
text: label,
style: Arc::new(InlineTextStyle::default()),
}],
);
}
let bottom_view = visible_transcript(&mut session);
assert!(
bottom_view
.iter()
.any(|line| line.contains(&format!("{LABEL_PREFIX}-199"))),
"bottom view should include latest PTY line"
);
for _ in 0..200 {
session.scroll_page_up();
if session.scroll_manager.offset() == session.current_max_scroll_offset() {
break;
}
}
let top_view = visible_transcript(&mut session);
assert!(
(0..=5).any(|index| top_view
.iter()
.any(|line| line.contains(&format!("{LABEL_PREFIX}-{index}")))),
"top view should include earliest PTY lines"
);
assert!(
top_view
.iter()
.all(|line| !line.contains(&format!("{LABEL_PREFIX}-199"))),
"top view should not include latest PTY line"
);
}
#[test]
fn agent_label_uses_accent_color_without_border() {
let accent = AnsiColorEnum::Rgb(RgbColor(0x12, 0x34, 0x56));
let theme = InlineTheme {
primary: Some(accent),
..Default::default()
};
let mut session = Session::new(theme, None, VIEW_ROWS);
session.labels.agent = Some("Agent".to_string());
let mut segment = make_segment("Response");
segment.style = Arc::new(InlineTextStyle {
color: Some(accent),
..InlineTextStyle::default()
});
session.push_line(InlineMessageKind::Agent, vec![segment]);
let index = session
.lines
.len()
.checked_sub(1)
.expect("agent message should be available");
let spans = session.render_message_spans(index);
assert!(!spans.is_empty());
let prefix_span = &spans[0];
assert_eq!(
prefix_span.content.clone().into_owned(),
ui::INLINE_AGENT_QUOTE_PREFIX
);
let label_index = spans
.iter()
.position(|span| span.content.clone().into_owned() == "Agent")
.expect("agent label span should be present");
let label_span = &spans[label_index];
assert_eq!(label_span.style.fg, Some(Color::Rgb(0x12, 0x34, 0x56)));
let padding_span = spans
.get(label_index + 1)
.expect("agent label should be followed by padding");
assert_eq!(
padding_span.content.clone().into_owned(),
ui::INLINE_AGENT_MESSAGE_LEFT_PADDING
);
assert!(
!spans
.iter()
.any(|span| span.content.clone().into_owned().contains('│')),
"agent prefix should not render a left border",
);
assert!(
!spans
.iter()
.any(|span| span.content.clone().into_owned().contains('✦')),
"agent prefix should not include decorative symbols",
);
}
#[test]
fn timeline_hidden_keeps_navigation_unselected() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(InlineMessageKind::Agent, vec![make_segment("Response")]);
let backend = TestBackend::new(VIEW_WIDTH, VIEW_ROWS);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render session with hidden timeline");
assert!(session.navigation_state.selected().is_none());
}
#[test]
fn queued_inputs_overlay_bottom_rows() {
with_terminal_env(None, Some("xterm-256color"), || {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::SetQueuedInputs {
entries: vec![
"first queued message".to_string(),
"second queued message".to_string(),
"third queued message".to_string(),
],
});
let view = visible_transcript(&mut session);
let footer: Vec<String> = view.iter().rev().take(10).cloned().collect();
assert!(
footer
.iter()
.any(|line| line.contains("↳ third queued message")),
"latest queued message should render first"
);
assert!(
footer
.iter()
.any(|line| line.contains("↳ second queued message")),
"second-latest queued message should render second"
);
let hint = if cfg!(target_os = "macos") {
"⌥ + ↑ edit"
} else {
"Alt + ↑ edit"
};
assert!(
footer.iter().any(|line| line.contains(hint)),
"hint line should show how to edit queue"
);
});
}
#[test]
fn queued_inputs_overlay_shows_shift_left_hint_in_tmux() {
with_terminal_env(
Some("/tmp/tmux-1000/default,123,0"),
Some("tmux-256color"),
|| {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::SetQueuedInputs {
entries: vec![
"first queued message".to_string(),
"second queued message".to_string(),
],
});
let view = visible_transcript(&mut session);
let footer: Vec<String> = view.iter().rev().take(10).cloned().collect();
let hint = if cfg!(target_os = "macos") {
"⇧ + ← edit"
} else {
"Shift + ← edit"
};
assert!(
footer.iter().any(|line| line.contains(hint)),
"hint line should show the tmux-safe queue edit binding"
);
},
);
}
#[test]
fn running_activity_not_overlaid_above_queue_lines() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::SetQueuedInputs {
entries: vec![
"first queued message".to_string(),
"second queued message".to_string(),
],
});
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Running command: test".to_string()),
right: None,
});
let mut visible = vec![TranscriptLine::default(); 6];
session.overlay_queue_lines(&mut visible, VIEW_WIDTH);
let rendered: Vec<String> = visible.iter().map(|line| line_text(&line.line)).collect();
assert!(
!rendered
.iter()
.any(|line| line.contains("Running command: test")),
"running status should not be overlaid in transcript"
);
assert!(
rendered
.iter()
.any(|line| line.contains("↳ second queued message")),
"latest queued message should remain visible"
);
assert!(
rendered
.iter()
.any(|line| line.contains("↳ first queued message")),
"older queued message should remain visible"
);
}
#[test]
fn running_activity_not_overlaid_without_queue() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Running tool: grep".to_string()),
right: None,
});
let mut visible = vec![TranscriptLine::default(); 3];
session.overlay_queue_lines(&mut visible, VIEW_WIDTH);
let rendered: Vec<String> = visible.iter().map(|line| line_text(&line.line)).collect();
assert!(
!rendered
.iter()
.any(|line| line.contains("Running tool: grep")),
"running status should render only in bottom input status row"
);
}
#[test]
fn active_file_operation_indicator_renders_spinner_frame() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Info,
vec![make_segment("❋ Editing vtcode.toml...")],
);
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Running tool: edit_file".to_string()),
right: None,
});
let rendered = rendered_transcript_widget_lines(&mut session, VIEW_WIDTH, VIEW_ROWS);
assert!(
rendered
.iter()
.any(|line| line.contains("⠋ Editing vtcode.toml...")),
"active file operation indicator should show a spinner frame"
);
assert!(
!rendered
.iter()
.any(|line| line.contains("❋ Editing vtcode.toml...")),
"spinner should replace the static file operation marker while active"
);
}
#[test]
fn non_file_tool_status_keeps_static_file_operation_indicator() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Info,
vec![make_segment("❋ Editing vtcode.toml...")],
);
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Running tool: unified_search".to_string()),
right: None,
});
let rendered = rendered_transcript_widget_lines(&mut session, VIEW_WIDTH, VIEW_ROWS);
assert!(
rendered
.iter()
.any(|line| line.contains("❋ Editing vtcode.toml...")),
"non-file tool activity should not animate stale file operation indicators"
);
}
#[test]
fn pty_busy_state_does_not_overlay_transcript_status() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.active_pty_sessions = Some(Arc::new(AtomicUsize::new(1)));
let mut visible = vec![TranscriptLine::default(); 2];
session.overlay_queue_lines(&mut visible, VIEW_WIDTH);
let rendered: Vec<String> = visible.iter().map(|line| line_text(&line.line)).collect();
assert!(
!rendered.iter().any(|line| line.contains("Running...")),
"busy PTY state should not inject transcript status overlay"
);
}
#[test]
fn apply_suggested_prompt_replaces_empty_input() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.apply_suggested_prompt("Review the latest diff.".to_string());
assert_eq!(session.input_manager.content(), "Review the latest diff.");
assert!(session.suggested_prompt_state.active);
}
#[test]
fn apply_suggested_prompt_appends_to_existing_input() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("Initial draft".to_string());
session.apply_suggested_prompt("Review the latest diff.".to_string());
assert_eq!(
session.input_manager.content(),
"Initial draft\n\nReview the latest diff."
);
assert!(session.suggested_prompt_state.active);
}
#[test]
fn suggested_prompt_state_clears_after_manual_edit() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.apply_suggested_prompt("Review the latest diff.".to_string());
session.insert_char('!');
assert!(!session.suggested_prompt_state.active);
}
#[test]
fn alt_p_requests_inline_prompt_suggestion() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("Review the current".to_string());
let event = session.process_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::ALT));
assert!(matches!(
event,
Some(InlineEvent::RequestInlinePromptSuggestion(ref value))
if value == "Review the current"
));
}
#[test]
fn tab_accepts_visible_inline_prompt_suggestion() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("Review the current".to_string());
session.set_inline_prompt_suggestion("Review the current diff".to_string(), true);
let event = session.process_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(event.is_none());
assert_eq!(session.input_manager.content(), "Review the current diff");
assert!(session.inline_prompt_suggestion.suggestion.is_none());
}
#[test]
fn tab_accepts_inline_prompt_suggestion_with_trailing_space_prefix() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("Review the current diff ".to_string());
session
.set_inline_prompt_suggestion("Review the current diff and summarize it".to_string(), true);
let event = session.process_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(event.is_none());
assert_eq!(
session.input_manager.content(),
"Review the current diff and summarize it"
);
assert!(session.inline_prompt_suggestion.suggestion.is_none());
}
#[test]
fn tab_queues_when_no_inline_prompt_suggestion_is_visible() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("Review the current diff".to_string());
let event = session.process_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(matches!(
event,
Some(InlineEvent::QueueSubmit(ref value)) if value == "Review the current diff"
));
}
#[test]
fn inline_prompt_suggestion_clears_after_cursor_movement() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_input("Review the current".to_string());
session.set_inline_prompt_suggestion("Review the current diff".to_string(), false);
let event = session.process_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
assert!(event.is_none());
assert!(session.inline_prompt_suggestion.suggestion.is_none());
}
#[test]
fn empty_enter_with_active_pty_opens_jobs() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.active_pty_sessions = Some(Arc::new(AtomicUsize::new(1)));
let event = session.process_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(event, Some(InlineEvent::Submit(ref value)) if value == "/jobs"));
}
#[test]
fn task_panel_visibility_is_independent_from_logs() {
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
session.set_task_panel_visible(true);
let initial_task_panel = session.show_task_panel;
let initial_logs = session.core.show_logs;
session.core.toggle_logs();
assert_eq!(session.show_task_panel, initial_task_panel);
assert_ne!(session.core.show_logs, initial_logs);
}
#[test]
fn timeline_visible_selects_latest_item() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(InlineMessageKind::Agent, vec![make_segment("First")]);
session.push_line(InlineMessageKind::Agent, vec![make_segment("Second")]);
let backend = TestBackend::new(VIEW_WIDTH, VIEW_ROWS);
let mut terminal = Terminal::new(backend).expect("failed to create test terminal");
terminal
.draw(|frame| session.render(frame))
.expect("failed to render session with timeline");
assert!(session.navigation_state.selected().is_none());
}
#[test]
fn tool_detail_renders_with_border_and_body_style() {
let theme = themed_inline_colors();
let mut session = Session::new(theme, None, VIEW_ROWS);
let detail_style = InlineTextStyle::default().italic();
session.push_line(
InlineMessageKind::Tool,
vec![InlineSegment {
text: " result line".to_string(),
style: Arc::new(detail_style),
}],
);
let index = session
.lines
.len()
.checked_sub(1)
.expect("tool detail line should exist");
let spans = session.render_message_spans(index);
assert_eq!(spans.len(), 1);
let body_span = &spans[0];
assert!(body_span.style.add_modifier.contains(Modifier::ITALIC));
assert_eq!(body_span.content.clone().into_owned(), " result line");
}
#[test]
fn top_level_task_tree_tail_line_is_dimmed_in_tool_blocks() {
let theme = themed_inline_colors();
let mut session = Session::new(theme, None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Tool,
vec![InlineSegment {
text: "└ Report actions taken, blockers, and required user input".to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
);
let index = session
.lines
.len()
.checked_sub(1)
.expect("tool detail line should exist");
let transcript_lines = session.reflow_message_lines(index, 100);
let task_span = transcript_lines
.iter()
.flat_map(|line| line.line.spans.iter())
.find(|span| span.content.contains("Report actions taken"))
.expect("expected task span");
assert!(
task_span.style.add_modifier.contains(Modifier::DIM),
"top-level task rows should render dimmed"
);
}
#[test]
fn streaming_state_starts_false() {
let session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
assert!(!session.is_streaming_final_answer);
}
#[test]
fn streaming_state_set_on_agent_append_line() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
assert!(!session.is_streaming_final_answer);
session.handle_command(InlineCommand::AppendLine {
kind: InlineMessageKind::Agent,
segments: vec![InlineSegment {
text: "Hello".to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
});
assert!(session.is_streaming_final_answer);
}
#[test]
fn streaming_state_set_on_agent_inline() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
assert!(!session.is_streaming_final_answer);
session.handle_command(InlineCommand::Inline {
kind: InlineMessageKind::Agent,
segment: InlineSegment {
text: "Hello".to_string(),
style: Arc::new(InlineTextStyle::default()),
},
});
assert!(session.is_streaming_final_answer);
}
#[test]
fn streaming_state_cleared_on_turn_completion() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::AppendLine {
kind: InlineMessageKind::Agent,
segments: vec![InlineSegment {
text: "Hello".to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
});
assert!(session.is_streaming_final_answer);
session.handle_command(InlineCommand::SetInputStatus {
left: None,
right: None,
});
assert!(!session.is_streaming_final_answer);
}
#[test]
fn streaming_state_not_cleared_on_status_update_with_content() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::AppendLine {
kind: InlineMessageKind::Agent,
segments: vec![InlineSegment {
text: "Hello".to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
});
assert!(session.is_streaming_final_answer);
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Working...".to_string()),
right: None,
});
assert!(session.is_streaming_final_answer);
}
#[test]
fn non_agent_messages_dont_trigger_streaming_state() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::AppendLine {
kind: InlineMessageKind::User,
segments: vec![InlineSegment {
text: "Hello".to_string(),
style: Arc::new(InlineTextStyle::default()),
}],
});
assert!(!session.is_streaming_final_answer);
}
#[test]
fn empty_agent_segments_dont_trigger_streaming_state() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.handle_command(InlineCommand::AppendLine {
kind: InlineMessageKind::Agent,
segments: vec![],
});
assert!(!session.is_streaming_final_answer);
}
#[test]
fn streaming_state_set_on_agent_append_pasted_message() {
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
assert!(!session.is_streaming_final_answer);
session.handle_command(InlineCommand::AppendPastedMessage {
kind: InlineMessageKind::Agent,
text: "Hello".to_string(),
line_count: 1,
});
assert!(session.is_streaming_final_answer);
}