#![cfg_attr(not(test), deny(unsafe_code))]
mod app;
mod cli;
mod demo;
mod external_editor;
mod forge_detect;
mod handler;
mod input;
mod mcp_bridge;
#[cfg(unix)]
mod mcp_socket;
mod output;
mod pr_list_app;
mod remote;
mod startup;
#[cfg(test)]
mod test_support;
mod text_edit;
mod theme;
mod ui;
mod update;
use std::fs::File;
use std::io::{self, Write};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crossterm::{
cursor::{Hide, Show},
event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind,
KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute,
terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
supports_keyboard_enhancement,
},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use app::{App, FocusedPanel, InputMode};
use clap::Parser;
use handler::{
handle_command_action, handle_command_palette_action, handle_comment_action,
handle_comment_template_picker_action, handle_commit_select_action,
handle_commit_selector_action, handle_confirm_action, handle_diff_action,
handle_file_list_action, handle_help_action, handle_mental_model_edit_action,
handle_reaction_picker_action, handle_review_submit_action, handle_search_action,
handle_visual_action,
};
use cli::Cli;
use input::{Action, map_key_to_action};
use tokio::sync::mpsc::Receiver;
use travelagent_core::error::TrvError;
use travelagent_core::live::{LiveEvent, LiveWatcherHandle, spawn_live_watcher};
const CTRL_C_EXIT_TIMEOUT: Duration = Duration::from_secs(2);
const MIN_WIDTH_FOR_FILE_LIST: u16 = 100;
const AUTOSAVE_DEBOUNCE_MS: u64 = 2000;
fn main() -> anyhow::Result<()> {
startup::install_panic_hook();
let mut cli_args = Cli::parse();
if let Some(shell) = cli_args.completions {
let mut cmd = <Cli as clap::CommandFactory>::command();
clap_complete::generate(shell, &mut cmd, "trv", &mut io::stdout());
return Ok(());
}
if cli_args.session_gc {
return run_session_gc_command(&cli_args);
}
#[cfg(unix)]
if let Some(target) = cli_args.attach.as_deref() {
return run_attach(target);
}
#[cfg(not(unix))]
if cli_args.attach.is_some() {
eprintln!("Error: --attach is only supported on Unix-like systems.");
std::process::exit(2);
}
let render_to_tty = cli_args.output_to_stdout || cli_args.mcp_alongside;
let keyboard_enhancement_supported = startup::keyboard_enhancement_supported_for(render_to_tty);
startup::apply_path_filter_implies_working_tree(&mut cli_args);
let mut startup_warnings = Vec::new();
let (mut config_outcome, theme) =
startup::load_global_config_and_theme(&cli_args, &mut startup_warnings);
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let runtime_handle = runtime.handle().clone();
let update_rx = if cli_args.no_update_check {
None
} else {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = update::check_for_updates();
let _ = tx.send(result); });
Some(rx)
};
if let Some(ref revset) = cli_args.tour {
cli_args.revisions = Some(revset.clone());
}
if cli_args.list {
match pr_list_app::run_picker(&theme, &config_outcome, &runtime_handle) {
Ok((pr_list_app::PickOutcome::Picked(item), _owner, _repo, _host, _forge_type)) => {
cli_args.pr = Some(item.number.to_string());
}
Ok((pr_list_app::PickOutcome::Cancelled, ..)) => {
return Ok(());
}
Err(e) => {
eprintln!("Error: {e}");
eprintln!(
"\nFailed to list PRs. Check your authentication token and network connectivity."
);
std::process::exit(1);
}
}
}
let mut resume_target: Option<startup::ResumeTarget> = None;
if let Some(ref token) = cli_args.resume {
match startup::resolve_resume_target(token) {
Ok(target) => {
if let Err(e) = std::env::set_current_dir(&target.repo_path) {
eprintln!(
"Error: couldn't enter session repo {}: {e}",
target.repo_path.display()
);
std::process::exit(1);
}
resume_target = Some(target);
}
Err(msg) => {
eprintln!("Error: {msg}");
std::process::exit(1);
}
}
}
let mut app = if let Some(target) = resume_target {
match App::new_resumed(
target.session,
theme,
config_outcome
.config
.as_ref()
.and_then(|cfg| cfg.comment_types.clone()),
cli_args.output_to_stdout,
runtime_handle.clone(),
) {
Ok(mut app) => {
app.supports_keyboard_enhancement = keyboard_enhancement_supported;
if let Some(message) = startup_warnings.first() {
app.set_warning(message.clone());
}
app
}
Err(e) => {
eprintln!("Error: {e}");
eprintln!("\nFailed to resume the saved session.");
std::process::exit(1);
}
}
} else if cli_args.demo {
match demo::create_demo_app(
theme,
config_outcome
.config
.as_ref()
.and_then(|cfg| cfg.comment_types.clone()),
cli_args.output_to_stdout,
runtime_handle.clone(),
) {
Ok(mut app) => {
app.supports_keyboard_enhancement = keyboard_enhancement_supported;
if let Some(message) = startup_warnings.first() {
app.set_warning(message.clone());
}
app
}
Err(e) => {
eprintln!("Error: {e}");
eprintln!("\nFailed to construct demo mode app.");
std::process::exit(1);
}
}
} else if let Some(ref pr_arg) = cli_args.pr {
match remote::create_remote_app(
&cli_args,
pr_arg,
theme,
&config_outcome,
runtime_handle.clone(),
) {
Ok(mut app) => {
app.supports_keyboard_enhancement = keyboard_enhancement_supported;
if let Some(message) = startup_warnings.first() {
app.set_warning(message.clone());
}
app
}
Err(e) => {
eprintln!("Error: {e}");
eprintln!(
"\nCheck your PR number/URL, authentication tokens, and network connectivity."
);
std::process::exit(1);
}
}
} else {
match App::new(
theme,
config_outcome
.config
.as_ref()
.and_then(|cfg| cfg.comment_types.clone()),
cli_args.output_to_stdout,
cli_args.revisions.as_deref(),
cli_args.working_tree,
cli_args.path_filter.as_deref(),
cli_args.file_path.as_deref(),
runtime_handle.clone(),
) {
Ok(mut app) => {
app.supports_keyboard_enhancement = keyboard_enhancement_supported;
if let Some(message) = startup_warnings.first() {
app.set_warning(message.clone());
}
app
}
Err(e) => {
eprintln!("Error: {e}");
if matches!(e, TrvError::NotARepository | TrvError::NoChanges) {
eprintln!(
"\ntravelagent needs a git, jujutsu, or mercurial repository with changes to review.\n\
\n\
Quick fixes:\n \
\u{2022} cd into a repo with uncommitted changes or recent commits, then run `trv`\n \
\u{2022} Try `trv --demo` to explore the TUI with mock data\n \
\u{2022} Review a PR by URL: `trv <github-or-gitlab-url>`\n\
\n\
See `trv --help` for more options."
);
} else {
eprintln!(
"\nMake sure you're in a git, jujutsu, or mercurial repository with commits or staged/unstaged changes."
);
}
std::process::exit(1);
}
}
};
if let Some(ref alias) = cli_args.alias {
app.set_session_alias(Some(alias));
}
startup::apply_repo_config_overrides(
&mut app,
&cli_args,
&mut config_outcome,
&mut startup_warnings,
);
startup::apply_blind_tests_config(&mut app, &cli_args, &mut startup_warnings);
startup::try_enter_spar_mode(&mut app, &cli_args, &mut startup_warnings);
enable_raw_mode()?;
let mut tty_output: Box<dyn Write> = if render_to_tty {
Box::new(File::options().write(true).open("/dev/tty")?)
} else {
Box::new(io::stdout())
};
execute!(tty_output, EnterAlternateScreen, EnableBracketedPaste)?;
if keyboard_enhancement_supported {
let _ = execute!(
tty_output,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
);
}
let backend = CrosstermBackend::new(tty_output);
let mut terminal = Terminal::new(backend)?;
if let Some(ref cfg) = config_outcome.config {
startup::apply_config_defaults_to_app(&mut app, cfg);
}
startup::apply_narrow_terminal_default(&mut app);
startup::seed_tour_plan_if_requested(&mut app, &cli_args);
let mcp_needed = cli_args.mcp_alongside || cfg!(unix) && cli_args.mcp_socket.is_some();
let mut mcp_hub = if mcp_needed {
Some(mcp_bridge::McpHub::start(&runtime_handle))
} else {
None
};
let mcp_rx = if cli_args.mcp_alongside {
let hub = mcp_hub
.as_ref()
.expect("mcp_hub built because --mcp-alongside is on");
Some(mcp_bridge::start_mcp_alongside(runtime_handle.clone(), hub))
} else {
None
};
#[cfg(unix)]
let (socket_tx, socket_rx_proto) = mpsc::channel::<mcp_bridge::McpCommand>();
#[cfg(unix)]
let mut socket_rx: Option<mpsc::Receiver<mcp_bridge::McpCommand>> = None;
#[cfg(unix)]
let mut socket_rx_pending: Option<mpsc::Receiver<mcp_bridge::McpCommand>> =
Some(socket_rx_proto);
#[cfg(unix)]
let mut socket_guard: Option<mcp_socket::SocketGuard> = None;
#[cfg(unix)]
if let Some(ref raw) = cli_args.mcp_socket {
let explicit = if raw.is_empty() {
None
} else {
Some(raw.as_str())
};
match mcp_socket::resolve_socket_path(explicit) {
Ok(path) => {
if let Some(dir) = mcp_socket::sessions_dir() {
mcp_socket::sweep_stale_sockets(&dir);
}
let hub = mcp_hub
.as_ref()
.expect("mcp_hub built because --mcp-socket is on");
match mcp_socket::spawn_mcp_socket_server(
path.clone(),
socket_tx.clone(),
runtime_handle.clone(),
hub,
) {
Ok(guard) => {
app.set_message(format!("MCP socket: {}", path.display()));
socket_rx = socket_rx_pending.take();
socket_guard = Some(guard);
app.mcp_listener.request_on();
}
Err(e) => {
app.set_warning(format!(
"Failed to bind MCP socket at {}: {e}",
path.display()
));
}
}
}
Err(e) => {
app.set_warning(format!("Failed to resolve socket path: {e}"));
}
}
}
#[cfg(not(unix))]
let socket_rx: Option<mpsc::Receiver<mcp_bridge::McpCommand>> = None;
let mut live_watcher: Option<LiveWatcherHandle> = None;
let mut live_rx: Option<Receiver<LiveEvent>> = None;
if cli_args.live {
if matches!(app.diff_source, app::DiffSource::Remote { .. }) {
app.set_warning("--live only supports local diffs; ignoring");
} else {
app.live.activate();
}
}
if cli_args.no_risk_colors {
app.risk_border_colors = false;
}
let mut pending_z = false;
let mut pending_shift_z = false;
let mut pending_d = false;
let mut pending_rbracket = false;
let mut pending_lbracket = false;
let mut pending_g = false;
let mut pending_ctrl_c: Option<Instant> = None;
let mut last_autosave: Option<Instant> = None;
let mut last_autosave_error: Option<String> = None;
let autosave_debounce = Duration::from_millis(AUTOSAVE_DEBOUNCE_MS);
let mut last_peer_count_refresh: Option<Instant> = None;
let peer_count_refresh_interval = Duration::from_secs(1);
loop {
match (app.live.active, live_watcher.is_some()) {
(true, false) => {
let root = app.vcs_info.root_path.clone();
let spawn_result = runtime_handle.block_on(async move { spawn_live_watcher(root) });
match spawn_result {
Ok((handle, rx)) => {
live_watcher = Some(handle);
live_rx = Some(rx);
}
Err(e) => {
app.set_error(format!("Failed to start live watcher: {e}"));
app.live.deactivate();
}
}
}
(false, true) => {
if let Some(handle) = live_watcher.take() {
handle.stop();
}
live_rx = None;
}
_ => {}
}
#[cfg(unix)]
match (app.mcp_listener.state(), socket_guard.is_some()) {
(app::ListenerState::On, false) => {
match mcp_socket::default_socket_path() {
Some(path) => {
let hub = mcp_hub
.get_or_insert_with(|| mcp_bridge::McpHub::start(&runtime_handle));
if let Some(dir) = mcp_socket::sessions_dir() {
mcp_socket::sweep_stale_sockets(&dir);
}
match mcp_socket::spawn_mcp_socket_server(
path.clone(),
socket_tx.clone(),
runtime_handle.clone(),
hub,
) {
Ok(guard) => {
socket_guard = Some(guard);
if socket_rx.is_none() {
socket_rx = socket_rx_pending.take();
}
app.set_message(format!(
"MCP listening · pid {}",
std::process::id()
));
}
Err(e) => {
app.set_error(format!("MCP listener failed: {e}"));
app.mcp_listener.force_off();
}
}
}
None => {
app.set_error("MCP listener failed: no usable socket directory");
app.mcp_listener.force_off();
}
}
}
(app::ListenerState::Draining, _) => {
if app.mcp_listener.just_entered_draining()
&& let Some(hub) = &mcp_hub
{
let _ = hub.notify_tx.try_send(app::McpNotify::Hangup {
deadline_ms: 5000,
reason: "user requested :mcp-off".into(),
});
}
let peers_empty = mcp_hub
.as_ref()
.map(|hub| runtime_handle.block_on(hub.registry.is_empty()))
.unwrap_or(true);
if app.mcp_listener.drain_expired(Instant::now()) || peers_empty {
socket_guard = None;
app.mcp_listener.force_off();
app.set_message("MCP listener: stopped");
}
}
_ => {}
}
if let Some(ref mut rx) = live_rx {
while let Ok(evt) = rx.try_recv() {
match evt {
LiveEvent::Rescan => {
if app.nav.input_mode != InputMode::Normal {
app.live.request_rescan();
} else {
match app.reload_diff_files() {
Ok(_) => {
app.live.mark_refreshed();
app.push_notify(app::McpNotify::FileChanged {
files: Vec::new(),
});
}
Err(e) => {
app.set_error(format!("Live rescan failed: {e}"));
}
}
}
}
LiveEvent::WatcherError(msg) => {
app.set_error(msg);
app.live.deactivate();
}
}
}
}
if app.nav.input_mode == InputMode::Normal && app.live.drain_rescan() {
match app.reload_diff_files() {
Ok(_) => {
app.live.mark_refreshed();
let files: Vec<String> = app
.diff_files
.iter()
.map(|f| f.display_path_lossy().to_string_lossy().to_string())
.collect();
app.push_notify(app::McpNotify::FileChanged { files });
}
Err(e) => {
app.set_error(format!("Live rescan failed: {e}"));
}
}
}
app.drain_forge_warnings();
app.tick_agent_action_timeout();
app.poll_forge_completion();
if app.dirty
&& last_autosave
.map(|t| t.elapsed() >= autosave_debounce)
.unwrap_or(true)
{
app.sync_tour_to_session();
match travelagent_core::persistence::save_session(app.engine.session()) {
Ok(_) => {
app.dirty = false;
last_autosave_error = None;
}
Err(e) => {
let msg = format!("Autosave failed: {e}");
if last_autosave_error.as_ref() != Some(&msg) {
app.set_error(msg.clone());
last_autosave_error = Some(msg);
}
}
}
last_autosave = Some(Instant::now());
}
terminal.draw(|frame| {
ui::render(frame, &mut app);
})?;
if let Some(ref rx) = mcp_rx {
while let Ok(cmd) = rx.try_recv() {
mcp_bridge::process_mcp_command(&mut app, cmd);
}
}
if let Some(ref rx) = socket_rx {
while let Ok(cmd) = rx.try_recv() {
mcp_bridge::process_mcp_command(&mut app, cmd);
}
}
if last_peer_count_refresh
.map(|t| t.elapsed() >= peer_count_refresh_interval)
.unwrap_or(true)
{
let count = mcp_hub
.as_ref()
.map(|hub| runtime_handle.block_on(hub.registry.len()))
.unwrap_or(0);
app.mcp_peer_count
.store(count, std::sync::atomic::Ordering::Relaxed);
last_peer_count_refresh = Some(Instant::now());
}
if let Some(commit_ids) = app.pending_tour_request.take() {
app.push_notify(app::McpNotify::TourRequest { commit_ids });
}
if let Some(ref hub) = mcp_hub {
while let Some(notify) = app.notify_queue.pop_front() {
if let Err(e) = hub.notify_tx.try_send(notify.clone()) {
use tokio::sync::mpsc::error::TrySendError;
match e {
TrySendError::Full(_) => {
app.notify_queue.push_front(notify);
break;
}
TrySendError::Closed(_) => {
app.notify_queue.clear();
break;
}
}
}
}
} else if !app.notify_queue.is_empty() {
app.notify_queue.clear();
}
if let Some(ref rx) = update_rx
&& let Ok(
update::UpdateCheckResult::UpdateAvailable(info)
| update::UpdateCheckResult::AheadOfRelease(info),
) = rx.try_recv()
{
app.update_info = Some(info);
}
if let Some(first_press) = pending_ctrl_c
&& first_press.elapsed() >= CTRL_C_EXIT_TIMEOUT
{
pending_ctrl_c = None;
app.message = None;
}
if event::poll(Duration::from_millis(100))? {
let event = event::read()?;
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if key.code == crossterm::event::KeyCode::Char('c')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
if app.nav.input_mode == InputMode::Comment {
app.exit_comment_mode();
}
if let Some(first_press) = pending_ctrl_c
&& first_press.elapsed() < CTRL_C_EXIT_TIMEOUT
{
app.should_quit = true;
continue;
}
pending_ctrl_c = Some(Instant::now());
app.set_message("Press Ctrl+C again to exit");
continue;
}
if pending_ctrl_c.is_some() {
pending_ctrl_c = None;
app.message = None;
}
if pending_z {
pending_z = false;
if key.code == crossterm::event::KeyCode::Char('z') {
handler::handle_diff_action(&mut app, Action::CenterOnCursor);
continue;
}
handler::handle_diff_action(&mut app, Action::ToggleFileCollapse);
}
if pending_g {
pending_g = false;
match key.code {
crossterm::event::KeyCode::Char('g') => {
handler::handle_diff_action(&mut app, Action::GoToTop);
continue;
}
crossterm::event::KeyCode::Char('f') => {
handler::handle_diff_action(&mut app, Action::OpenInEditor);
continue;
}
crossterm::event::KeyCode::Esc => {
continue;
}
crossterm::event::KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
continue;
}
_ => {
handler::handle_diff_action(&mut app, Action::GoToTop);
}
}
}
if pending_shift_z {
pending_shift_z = false;
match key.code {
crossterm::event::KeyCode::Char('Z') => {
app.sync_tour_to_session();
let _ = travelagent_core::persistence::save_session(
app.engine.session(),
);
app.dirty = false;
if app.engine.session().has_comments() {
handler::handle_export_and_quit(&mut app);
} else {
app.should_quit = true;
}
continue;
}
crossterm::event::KeyCode::Char('Q') => {
app.discard_on_exit = true;
app.should_quit = true;
continue;
}
_ => {} }
}
if pending_d {
pending_d = false;
if key.code == crossterm::event::KeyCode::Char('d') {
if !app.delete_comment_at_cursor() {
app.set_message("No comment at cursor");
}
continue;
}
}
if pending_rbracket {
pending_rbracket = false;
if key.code == crossterm::event::KeyCode::Char(']') {
if app.tour.plan.is_some() {
let _ = app.tour_next();
} else {
app.set_message("No active tour");
}
continue;
}
app.next_hunk();
continue;
}
if pending_lbracket {
pending_lbracket = false;
if key.code == crossterm::event::KeyCode::Char('[') {
if app.tour.plan.is_some() {
let _ = app.tour_prev();
} else {
app.set_message("No active tour");
}
continue;
}
app.prev_hunk();
continue;
}
let action = map_key_to_action(key, app.nav.input_mode);
match action {
Action::PendingZCommand => {
pending_z = true;
app.pending_count = None;
continue;
}
Action::PendingShiftZCommand => {
pending_shift_z = true;
app.pending_count = None;
continue;
}
Action::PendingDCommand => {
pending_d = true;
app.pending_count = None;
continue;
}
Action::PendingRBracketCommand => {
pending_rbracket = true;
app.pending_count = None;
continue;
}
Action::PendingLBracketCommand => {
pending_lbracket = true;
app.pending_count = None;
continue;
}
Action::PendingGCommand => {
pending_g = true;
app.pending_count = None;
continue;
}
_ => {}
}
if app.nav.input_mode == InputMode::Normal {
match action {
Action::Digit(d)
if matches!(
app.diff_source,
crate::app::DiffSource::Remote { .. }
) && app.pending_count.is_none()
&& crate::app::RemotePanel::from_digit(d, app.spar_mode)
.is_some() =>
{
let Some(panel) =
crate::app::RemotePanel::from_digit(d, app.spar_mode)
else {
unreachable!("guarded by `is_some()` match-guard above");
};
if let Some(r) = app.remote_mut() {
r.remote_panel = panel;
}
if panel == crate::app::RemotePanel::Sparring {
app.refresh_spec_statuses();
}
continue;
}
Action::Digit(0) if app.pending_count.is_none() => {
handler::handle_diff_action(&mut app, Action::GoToLineStart);
continue;
}
Action::Digit(d) => {
let n = app.pending_count.unwrap_or(0);
app.pending_count = Some(
(n.saturating_mul(10).saturating_add(d as usize)).min(999_999),
);
continue;
}
Action::GoToBottom if app.pending_count.is_some() => {
let count = app.pending_count.unwrap().max(1);
app.pending_count = None;
app.go_to_source_line(count as u32);
continue;
}
_ => {
app.pending_count = None;
}
}
}
if app.ai.show_panel && app.nav.input_mode == InputMode::Normal {
use crossterm::event::{KeyCode, KeyModifiers};
let handled = match (key.code, key.modifiers) {
(KeyCode::Char('a'), KeyModifiers::CONTROL)
| (KeyCode::Char('q') | KeyCode::Esc, KeyModifiers::NONE) => {
app.toggle_ai_summary();
true
}
(KeyCode::Char('j') | KeyCode::Down, KeyModifiers::NONE) => {
app.ai_summary_scroll_down(1);
true
}
(KeyCode::Char('k') | KeyCode::Up, KeyModifiers::NONE) => {
app.ai_summary_scroll_up(1);
true
}
(KeyCode::Char('d'), KeyModifiers::CONTROL)
| (KeyCode::PageDown, KeyModifiers::NONE) => {
app.ai_summary_scroll_down(10);
true
}
(KeyCode::Char('u'), KeyModifiers::CONTROL)
| (KeyCode::PageUp, KeyModifiers::NONE) => {
app.ai_summary_scroll_up(10);
true
}
(KeyCode::Char('g'), KeyModifiers::NONE) => {
app.ai.scroll = 0;
true
}
_ => false,
};
if handled {
continue;
}
}
if app.forge_modal_should_capture() {
use crossterm::event::{KeyCode, KeyModifiers};
match (key.code, key.modifiers) {
(KeyCode::Char('y'), KeyModifiers::NONE)
| (KeyCode::Char('Y'), KeyModifiers::NONE)
| (KeyCode::Enter, KeyModifiers::NONE) => {
app.approve_pending_agent_action();
continue;
}
(KeyCode::Char('n'), KeyModifiers::NONE)
| (KeyCode::Char('N'), KeyModifiers::NONE)
| (KeyCode::Esc, KeyModifiers::NONE) => {
app.reject_pending_agent_action();
continue;
}
_ => {}
}
}
match app.nav.input_mode {
InputMode::Help => handle_help_action(&mut app, action),
InputMode::Command => handle_command_action(&mut app, action),
InputMode::CommandPalette => {
handle_command_palette_action(&mut app, action);
}
InputMode::Search => handle_search_action(&mut app, action),
InputMode::Comment => handle_comment_action(&mut app, action),
InputMode::Confirm => handle_confirm_action(&mut app, action),
InputMode::CommitSelect => handle_commit_select_action(&mut app, action),
InputMode::VisualSelect => handle_visual_action(&mut app, action),
InputMode::ReviewSubmit => handle_review_submit_action(&mut app, action),
InputMode::ReactionPicker => {
handle_reaction_picker_action(&mut app, action);
}
InputMode::CommentTemplatePicker => {
handle_comment_template_picker_action(&mut app, action);
}
InputMode::MentalModelEdit => {
handle_mental_model_edit_action(&mut app, action);
}
InputMode::Normal => match app.nav.focused_panel {
FocusedPanel::FileList => handle_file_list_action(&mut app, action),
FocusedPanel::Diff => handle_diff_action(&mut app, action),
FocusedPanel::CommitSelector => {
handle_commit_selector_action(&mut app, action);
}
},
}
}
Event::Paste(text) => handler::handle_paste(&mut app, &text),
_ => {}
}
}
if app.pending_external_edit {
app.pending_external_edit = false;
match run_external_editor(&mut terminal, &app.comment.buffer) {
Ok(new_buffer) => {
app.comment.buffer = new_buffer;
app.comment.cursor = app.comment.buffer.len();
}
Err(e) => app.set_error(format!("External editor failed: {e}")),
}
}
if let Some((path, line)) = app.pending_open_file_editor.take() {
let root = app.vcs_info.root_path.clone();
if let Err(e) = run_external_editor_on_file(&mut terminal, &root, &path, line) {
app.set_error(format!("Failed to open file in editor: {e}"));
}
}
if app.should_quit {
if let Some(ref hub) = mcp_hub {
while let Some(notify) = app.notify_queue.pop_front() {
if hub.notify_tx.try_send(notify).is_err() {
break;
}
}
}
break;
}
}
if app.dirty && !app.discard_on_exit {
app.sync_tour_to_session();
let _ = travelagent_core::persistence::save_session(app.engine.session());
app.dirty = false;
}
if let Some(handle) = live_watcher.take() {
handle.stop();
}
drop(live_rx.take());
let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
DisableBracketedPaste,
LeaveAlternateScreen
)?;
let resume_hint_token =
(!cli_args.demo && !cli_args.output_to_stdout && !app.is_remote() && !app.discard_on_exit)
.then(|| {
app.session_alias()
.map(str::to_string)
.unwrap_or_else(|| app.engine.session().id.chars().take(8).collect::<String>())
});
if let Some(output) = app.pending_stdout_output {
print!("{output}");
}
if let Some(token) = resume_hint_token {
eprintln!("Resume this review: trv --resume (most recent)");
eprintln!(" or: trv --resume {token}");
}
if let Some(hub) = mcp_hub.take() {
hub.shutdown(Duration::from_millis(250));
}
runtime.shutdown_background();
Ok(())
}
#[derive(Debug)]
pub enum SparEntryOutcome {
Created(String),
Resumed(String),
FlagOnly(SparFlagOnlyReason),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SparFlagOnlyReason {
VcsUnsupported(String),
DetachedHead,
}
impl SparFlagOnlyReason {
#[must_use]
pub fn user_message(&self) -> String {
match self {
Self::VcsUnsupported(msg) => msg.clone(),
Self::DetachedHead => {
"could not derive branch name (detached HEAD and no PR number)".to_string()
}
}
}
}
pub fn spar_branch_name(app: &app::App) -> Option<String> {
if let Some(remote) = app.remote() {
return Some(format!("sparring-tests/{}", remote.pr_id.number));
}
app.vcs_info
.branch_name
.as_deref()
.map(|b| format!("sparring-tests/{b}"))
}
pub fn enter_spar_mode(app: &mut app::App) -> anyhow::Result<SparEntryOutcome> {
let Some(branch) = spar_branch_name(app) else {
return Ok(SparEntryOutcome::FlagOnly(SparFlagOnlyReason::DetachedHead));
};
macro_rules! try_vcs {
($call:expr) => {
match $call {
Ok(v) => v,
Err(travelagent_core::error::TrvError::UnsupportedOperation(msg)) => {
return Ok(SparEntryOutcome::FlagOnly(
SparFlagOnlyReason::VcsUnsupported(msg),
));
}
Err(e) => return Err(e.into()),
}
};
}
let dirty = try_vcs!(app.vcs.is_working_tree_dirty());
if dirty {
return Err(anyhow::anyhow!(
"working tree is dirty; commit or stash before entering sparring mode"
));
}
let exists = try_vcs!(app.vcs.branch_exists(&branch));
if !exists {
try_vcs!(app.vcs.create_branch(&branch));
}
try_vcs!(app.vcs.checkout_branch(&branch));
if exists {
Ok(SparEntryOutcome::Resumed(branch))
} else {
Ok(SparEntryOutcome::Created(branch))
}
}
#[cfg(unix)]
fn run_attach(target: &str) -> anyhow::Result<()> {
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
let path = if let Ok(pid) = target.parse::<i32>() {
let Some(base) = mcp_socket::sessions_dir() else {
eprintln!("Error: could not resolve default sessions dir. Pass a socket path instead.");
std::process::exit(2);
};
base.join(format!("{pid}.sock"))
} else {
match mcp_socket::resolve_socket_path(Some(target)) {
Ok(p) => p,
Err(e) => {
eprintln!("Error: invalid socket path: {e}");
std::process::exit(2);
}
}
};
let stream = match UnixStream::connect(&path) {
Ok(s) => s,
Err(e) => {
eprintln!(
"Error: could not connect to MCP socket {}: {e}",
path.display()
);
std::process::exit(2);
}
};
let stream_w = stream.try_clone()?;
let mut stream_r = stream;
let up = std::thread::spawn(move || {
let mut w = stream_w;
let mut stdin = io::stdin().lock();
let mut buf = [0u8; 8192];
loop {
match stdin.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if w.write_all(&buf[..n]).is_err() {
break;
}
}
Err(_) => break,
}
}
let _ = w.shutdown(std::net::Shutdown::Write);
});
let mut stdout = io::stdout().lock();
let mut buf = [0u8; 8192];
loop {
match stream_r.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if stdout.write_all(&buf[..n]).is_err() {
break;
}
let _ = stdout.flush();
}
Err(_) => break,
}
}
drop(up);
Ok(())
}
fn run_external_editor<B>(terminal: &mut Terminal<B>, buffer: &str) -> anyhow::Result<String>
where
B: ratatui::backend::Backend + std::io::Write,
<B as ratatui::backend::Backend>::Error: Send + Sync + 'static,
{
let _ = terminal.clear();
let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
let _ = disable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
DisableBracketedPaste,
LeaveAlternateScreen,
Show,
);
let result = external_editor::edit_text(buffer);
let _ = enable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableBracketedPaste,
Hide,
);
if matches!(supports_keyboard_enhancement(), Ok(true)) {
let _ = execute!(
terminal.backend_mut(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
);
}
let _ = terminal.clear();
let mut content = result?;
if content.ends_with('\n') {
content.pop();
if content.ends_with('\r') {
content.pop();
}
}
Ok(content)
}
fn run_external_editor_on_file<B>(
terminal: &mut Terminal<B>,
repo_root: &std::path::Path,
path: &std::path::Path,
line: u32,
) -> anyhow::Result<()>
where
B: ratatui::backend::Backend + std::io::Write,
<B as ratatui::backend::Backend>::Error: Send + Sync + 'static,
{
let abs_path = if path.is_absolute() {
path.to_path_buf()
} else {
repo_root.join(path)
};
let _ = terminal.clear();
let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
let _ = disable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
DisableBracketedPaste,
LeaveAlternateScreen,
Show,
);
let (editor, extra_args) = external_editor::resolve_editor(&|key| std::env::var(key).ok());
let spawn_result = std::process::Command::new(&editor)
.args(&extra_args)
.arg(format!("+{line}"))
.arg(&abs_path)
.status();
let _ = enable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableBracketedPaste,
Hide,
);
if matches!(supports_keyboard_enhancement(), Ok(true)) {
let _ = execute!(
terminal.backend_mut(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
);
}
let _ = terminal.clear();
match spawn_result {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(anyhow::anyhow!(
"editor '{editor}' exited with non-zero status: {status}"
)),
Err(e) => Err(anyhow::anyhow!("failed to spawn editor '{editor}': {e}")),
}
}
fn run_session_gc_command(cli_args: &Cli) -> anyhow::Result<()> {
use travelagent_core::config::SessionGcConfig;
let mut cfg = match travelagent_core::config::load_config() {
Ok(outcome) => {
for w in &outcome.warnings {
eprintln!("{w}");
}
outcome
.config
.map(|c| c.session_gc)
.unwrap_or_else(SessionGcConfig::default)
}
Err(e) => {
eprintln!("Warning: Failed to load config: {e}");
SessionGcConfig::default()
}
};
if let Some(v) = cli_args.gc_max_age_days {
cfg.max_age_days = v;
}
if let Some(v) = cli_args.gc_max_size_mb {
cfg.max_size_mb = v;
}
if let Some(v) = cli_args.gc_max_count {
cfg.max_count = v;
}
let report = travelagent_core::persistence::run_session_gc(&cfg, cli_args.gc_dry_run)?;
let mode = if cli_args.gc_dry_run {
"session GC (dry run)"
} else {
"session GC"
};
println!(
"{mode}: scanned {} file(s); removed {} (age {}, size {}, count {}); remaining {} file(s), {:.2} MB",
report.scanned,
report.total_removed(),
report.removed_age,
report.removed_size,
report.removed_count,
report.remaining_files,
report.remaining_bytes as f64 / (1024.0 * 1024.0),
);
Ok(())
}
#[cfg(test)]
mod spar_outcome_tests {
use super::*;
#[test]
fn flag_only_reason_vcs_unsupported_echoes_backend_message() {
let reason = SparFlagOnlyReason::VcsUnsupported(
"hg backend does not implement create_branch".to_string(),
);
assert_eq!(
reason.user_message(),
"hg backend does not implement create_branch"
);
}
#[test]
fn flag_only_reason_detached_head_has_specific_message() {
let msg = SparFlagOnlyReason::DetachedHead.user_message();
assert!(msg.contains("detached HEAD"), "{msg}");
assert!(msg.contains("PR number"), "{msg}");
}
}