pub use super::super::Session;
use super::super::TranscriptLine;
pub use crate::config::constants::ui;
pub use crate::core_tui::app::session::AppSession;
pub use crate::core_tui::app::types as app_types;
pub use crate::core_tui::session::terminal_capabilities;
pub use crate::core_tui::types::{InlineCommand, InlineEvent};
pub 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,
};
pub use crate::core_tui::widgets::TranscriptWidget;
pub use crate::ui::tui::session::message::RenderedTranscriptLink;
pub use anstyle::Color as AnsiColorEnum;
pub use anstyle::RgbColor;
pub use ratatui::crossterm::event::{
Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
#[cfg(target_os = "macos")]
pub use ratatui::crossterm::event::{KeyEventKind, ModifierKeyCode};
pub use ratatui::text::Text;
pub use ratatui::{
Terminal,
backend::TestBackend,
buffer::Buffer,
layout::Rect,
style::{Color, Modifier},
text::Line,
widgets::Widget,
};
pub use std::fs;
pub use std::path::PathBuf;
pub use std::sync::Arc;
pub use std::sync::{
LazyLock, Mutex,
atomic::{AtomicUsize, Ordering},
};
pub use std::time::Duration;
pub use tokio::sync::mpsc;
pub use tokio::sync::mpsc::UnboundedSender;
pub use vtcode_commons::ui_protocol::InlineMessageKind;
pub const VIEW_ROWS: u16 = 14;
pub const VIEW_WIDTH: u16 = 100;
pub const LINE_COUNT: usize = 10;
pub const LABEL_PREFIX: &str = "line";
pub const EXTRA_SEGMENT: &str = "\nextra-line";
pub static TRANSCRIPT_TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub static CLIPBOARD_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
pub static TERMINAL_ENV_TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
pub 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
}
pub fn make_segment(text: &str) -> InlineSegment {
InlineSegment {
text: text.to_string(),
style: Arc::new(InlineTextStyle::default()),
}
}
pub 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()
}
}
pub 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
}
pub 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
}
pub 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,
);
}
pub 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,
);
}
pub fn sample_local_agent_entry(kind: app_types::LocalAgentKind) -> LocalAgentEntry {
sample_local_agent_entry_with_id("agent-1", "rust-engineer", kind)
}
pub 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,
}
}
pub 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,
},
)),
});
}
pub 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(),
)
}
pub 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)));
}
pub 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))
);
}
pub 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(),
})),
});
}
pub 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,
});
}
pub 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)
}
pub 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()
}
pub 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()
}
pub 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()
}
pub 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))
}
pub 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()
}
pub 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)
}
pub fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.clone().into_owned())
.collect()
}
pub fn transcript_line(text: impl Into<String>) -> TranscriptLine {
TranscriptLine {
line: Line::from(text.into()),
explicit_links: Vec::new(),
}
}
pub fn text_content(text: &Text<'static>) -> String {
text.lines
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n")
}
pub fn vtcode_tui_workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
pub fn transcript_file_fixture_relative_path() -> &'static str {
"src/core_tui/session.rs"
}
pub fn transcript_file_fixture_absolute_path() -> String {
vtcode_tui_workspace_root()
.join(transcript_file_fixture_relative_path())
.display()
.to_string()
}
pub 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")]
pub fn open_file_click_modifiers() -> KeyModifiers {
KeyModifiers::SUPER
}
#[cfg(not(target_os = "macos"))]
pub fn open_file_click_modifiers() -> KeyModifiers {
KeyModifiers::CONTROL
}
#[cfg(target_os = "macos")]
pub fn command_modifier_press_event() -> KeyEvent {
KeyEvent::new_with_kind(
KeyCode::Modifier(ModifierKeyCode::LeftSuper),
KeyModifiers::SUPER,
KeyEventKind::Press,
)
}
#[cfg(target_os = "macos")]
pub fn command_modifier_release_event() -> KeyEvent {
KeyEvent::new_with_kind(
KeyCode::Modifier(ModifierKeyCode::LeftSuper),
KeyModifiers::SUPER,
KeyEventKind::Release,
)
}
#[cfg(target_os = "macos")]
pub fn meta_modifier_press_event() -> KeyEvent {
KeyEvent::new_with_kind(
KeyCode::Modifier(ModifierKeyCode::LeftMeta),
KeyModifiers::META,
KeyEventKind::Press,
)
}
pub 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,
}
}
pub 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()),
}
}
pub 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,
}],
})),
});
}
pub struct SessionWithFileLink {
pub session: Session,
pub path: String,
}
impl SessionWithFileLink {
pub fn new() -> Self {
let path = transcript_file_fixture_absolute_path();
let mut session = Session::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", path))],
);
let _ = visible_transcript(&mut session);
Self { session, path }
}
pub fn target_area(&self) -> Rect {
self.session
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.area
}
}
pub struct AppSessionWithFileLink {
pub session: AppSession,
pub path: String,
}
impl AppSessionWithFileLink {
pub fn new() -> Self {
let path = transcript_file_fixture_absolute_path();
let mut session = AppSession::new(InlineTheme::default(), None, VIEW_ROWS);
session.push_line(
InlineMessageKind::Agent,
vec![make_segment(&format!("Open {}", path))],
);
let _ = rendered_app_session_lines(&mut session, VIEW_ROWS);
Self { session, path }
}
pub fn target_area(&self) -> Rect {
self.session
.core
.transcript_file_link_targets
.first()
.expect("expected transcript file target")
.area
}
}
pub fn wizard_auth_transient(url: &str) -> app_types::TransientRequest {
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,
})
}
pub fn list_auth_overlay(url: &str) -> OverlayRequest {
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(),
})
}
pub fn set_busy_status(session: &mut Session) {
session.handle_command(InlineCommand::SetInputStatus {
left: Some("Running tool: unified_search".to_string()),
right: None,
});
}
pub fn set_app_session_busy_status(session: &mut AppSession) {
session.handle_command(app_types::InlineCommand::SetInputStatus {
left: Some("Running tool: unified_search".to_string()),
right: None,
});
}
pub fn set_queued_inputs(session: &mut Session, entries: Vec<String>) {
session.handle_command(InlineCommand::SetQueuedInputs { entries });
}
pub fn set_app_session_queued_inputs(session: &mut AppSession, entries: Vec<String>) {
session.handle_command(app_types::InlineCommand::SetQueuedInputs { entries });
}
pub fn assert_footer_contains(session: &mut Session, take_from_bottom: u16, needle: &str) {
let view = visible_transcript(session);
let footer: Vec<String> = view
.iter()
.rev()
.take(take_from_bottom as usize)
.cloned()
.collect();
assert!(
footer.iter().any(|line| line.contains(needle)),
"expected footer to contain: {needle}"
);
}