pub mod mental_model;
pub mod palette;
pub mod review_modes;
pub mod sparring_panel;
pub use mental_model::{handle_mental_model_edit_action, open_mental_model_modal};
pub use palette::{handle_command_palette_action, handle_comment_template_picker_action};
pub use sparring_panel::handle_sparring_panel_action;
use crate::app::{
self, App, DiffSource, ExpandDirection, FileTreeItem, FocusedPanel, GapCursorHit, InputMode,
RemotePanel,
};
use crate::input::Action;
use crate::output::{append_tour_stops, append_tour_triage, generate_export_content};
use crate::text_edit::{
delete_char_before, delete_word_before, next_char_boundary, prev_char_boundary,
};
use crate::ui::reaction_picker::REACTIONS;
use travelagent_core::forge::{PrState, ReactionTarget, ReviewVerdict};
use travelagent_core::persistence::save_session;
fn open_browser(url: &str) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(url)
.spawn()
.map(|_| ())
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(url)
.spawn()
.map(|_| ())
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/c", "start", "", url])
.spawn()
.map(|_| ())
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"unsupported platform",
))
}
}
pub(crate) fn reanchor_selected_orphan(app: &mut App) {
let Some((line, side)) = app.get_line_at_cursor() else {
if app.selected_orphan_at_cursor().is_some() {
app.set_message("Move cursor to target diff line, then press A to re-anchor");
} else {
app.set_message("Move cursor to a diff line to re-anchor an orphan");
}
return;
};
let Some(current_path) = app.current_file_path().cloned() else {
app.set_message("No file under cursor");
return;
};
let selection = app.live.last_selected_orphan.clone();
let (target_path, orphan_idx) = if let Some((path, idx)) = selection {
(path, idx)
} else {
let review = match app.engine.session().files.get(¤t_path) {
Some(r) => r,
None => {
app.set_message("No orphans on this file");
return;
}
};
match review.orphaned_comments.len() {
0 => {
app.set_message("No orphaned comments on this file");
return;
}
1 => (current_path, 0),
n => {
app.set_message(format!(
"{n} orphans on this file; cursor to one in the Orphaned section first, then A"
));
return;
}
}
};
let exists = app
.engine
.session()
.files
.get(&target_path)
.is_some_and(|r| r.orphaned_comments.get(orphan_idx).is_some());
if !exists {
app.live.clear_orphan();
app.set_error("Selected orphan no longer exists");
return;
}
if app.reanchor_orphan(&target_path, orphan_idx, line, side) {
app.live.clear_orphan();
app.set_message(format!("Re-anchored orphan to line {line}"));
} else {
app.set_error("Failed to re-anchor orphan");
}
}
fn handle_export(app: &mut App) {
match generate_export_content(
app.engine.session(),
&app.diff_source,
&app.comment.types,
app.export_legend,
) {
Ok(mut content) => {
if let Some(ref tour) = app.tour.plan {
append_tour_stops(&mut content, tour, &app.tour.comment_meta, &app.tour.triage);
}
append_tour_triage(&mut content, &app.tour.comment_meta, &app.tour.triage);
if app.spar_mode {
crate::output::markdown::append_sparring_summary(
&mut content,
app.engine.session(),
);
}
let completion_ts = chrono::Utc::now();
app.engine.session_mut().last_review_submitted_at = Some(completion_ts);
let head_sha = app
.remote()
.and_then(|r| r.pr_metadata.as_ref().map(|m| m.head_sha.clone()));
if let Some(sha) = head_sha {
app.engine.session_mut().last_review_sha = Some(sha);
}
app.dirty = true;
app.push_notify(app::McpNotify::ReviewSubmitted {
verdict: None,
at: completion_ts.to_rfc3339(),
});
if app.output_to_stdout {
app.pending_stdout_output = Some(content);
app.should_quit = true;
} else {
match crate::output::markdown::copy_text_to_clipboard(&content) {
Ok(msg) => app.set_message(msg),
Err(e) => app.set_warning(format!("{e}")),
}
}
}
Err(e) => app.set_warning(format!("{e}")),
}
}
pub fn handle_export_and_quit(app: &mut App) {
handle_export(app);
app.should_quit = true;
}
fn comment_line_start(buffer: &str, cursor: usize) -> usize {
let cursor = cursor.min(buffer.len());
match buffer[..cursor].rfind('\n') {
Some(pos) => pos + 1,
None => 0,
}
}
fn comment_line_end(buffer: &str, cursor: usize) -> usize {
let cursor = cursor.min(buffer.len());
match buffer[cursor..].find('\n') {
Some(pos) => cursor + pos,
None => buffer.len(),
}
}
fn comment_word_left(buffer: &str, cursor: usize) -> usize {
let cursor = cursor.min(buffer.len());
if cursor == 0 {
return 0;
}
let before = &buffer[..cursor];
let mut idx = 0;
let mut found_word = false;
for (pos, ch) in before.char_indices().rev() {
if !ch.is_whitespace() {
idx = pos;
found_word = true;
break;
}
}
if !found_word {
return 0;
}
for (pos, ch) in before[..idx].char_indices().rev() {
if ch.is_whitespace() {
return pos + ch.len_utf8();
}
idx = pos;
}
idx
}
fn comment_word_right(buffer: &str, cursor: usize) -> usize {
let cursor = cursor.min(buffer.len());
if cursor >= buffer.len() {
return buffer.len();
}
let mut chars = buffer[cursor..].char_indices();
if let Some((_, ch)) = chars.next()
&& ch.is_whitespace()
{
for (pos, ch) in buffer[cursor..].char_indices() {
if !ch.is_whitespace() {
return cursor + pos;
}
}
return buffer.len();
}
let mut word_end = buffer.len();
for (pos, ch) in buffer[cursor..].char_indices() {
if ch.is_whitespace() {
word_end = cursor + pos;
break;
}
}
if word_end >= buffer.len() {
return buffer.len();
}
for (pos, ch) in buffer[word_end..].char_indices() {
if !ch.is_whitespace() {
return word_end + pos;
}
}
buffer.len()
}
pub fn handle_help_action(app: &mut App, action: Action) {
match action {
Action::CursorDown(n) => app.help_scroll_down(n),
Action::CursorUp(n) => app.help_scroll_up(n),
Action::HalfPageDown => app.help_scroll_down(app.help_state.viewport_height / 2),
Action::HalfPageUp => app.help_scroll_up(app.help_state.viewport_height / 2),
Action::PageDown => app.help_scroll_down(app.help_state.viewport_height),
Action::PageUp => app.help_scroll_up(app.help_state.viewport_height),
Action::GoToTop => app.help_scroll_to_top(),
Action::GoToBottom => app.help_scroll_to_bottom(),
Action::ToggleHelp => app.toggle_help(),
Action::Quit => app.should_quit = true,
_ => {}
}
}
pub fn handle_command_action(app: &mut App, action: Action) {
match action {
Action::InsertChar(c) => app.palette.push_char(c),
Action::DeleteChar => {
app.palette.pop_char();
}
Action::ExitMode => app.exit_command_mode(),
Action::SubmitInput => {
let cmd = app.palette.buffer().trim().to_string();
match cmd.as_str() {
"q" | "quit" => {
if app.dirty {
app.set_error("No write since last change (add ! to override)");
} else {
app.should_quit = true;
}
}
"q!" | "quit!" => {
app.discard_on_exit = true;
app.should_quit = true;
}
"w" | "write" => {
app.sync_tour_to_session();
match save_session(app.engine.session()) {
Ok(path) => {
app.dirty = false;
app.set_message(format!("Saved to {}", path.display()));
}
Err(e) => app.set_error(format!("Save failed: {e}")),
}
}
"x" | "wq" => {
app.sync_tour_to_session();
match save_session(app.engine.session()) {
Ok(_) => {
app.dirty = false;
if app.engine.session().has_comments() {
if app.output_to_stdout {
handle_export(app);
return;
}
app.exit_command_mode();
app.enter_confirm_mode(app::ConfirmAction::CopyAndQuit);
return;
}
app.should_quit = true;
}
Err(e) => app.set_error(format!("Save failed: {e}")),
}
}
"e" | "reload" | "refresh" => {
if matches!(app.diff_source, DiffSource::Remote { .. }) {
let host = app.forge_host_label();
match app.refresh_remote() {
Ok(()) => app.set_message(format!("Refreshed from {host}")),
Err(e) => app.set_error(format!("Refresh failed: {e}")),
}
} else {
match app.reload_diff_files() {
Ok(count) => app.set_message(format!("Reloaded {count} files")),
Err(e) => app.set_error(format!("Reload failed: {e}")),
}
}
}
"live" => {
if app.live.active {
app.set_message("Live mode already active");
} else if matches!(app.diff_source, DiffSource::Remote { .. }) {
app.set_warning("Live mode only supports local diffs");
} else {
app.live.activate();
app.set_message("Live mode: on");
}
}
"live!" => {
if app.live.active {
app.live.deactivate();
app.set_message("Live mode: off");
} else {
app.set_message("Live mode not active");
}
}
"mcp-on" => {
if cfg!(not(unix)) {
app.set_warning("MCP socket transport not supported on Windows");
} else if app.mcp_listener.is_on() {
app.set_message("MCP listener already active");
} else if app.mcp_listener.is_draining() {
app.set_message("MCP listener: still draining, try again shortly");
} else {
app.mcp_listener.request_on();
app.set_message("MCP listener: starting…");
}
}
"mcp-off" => {
if app.mcp_listener.is_on() {
app.mcp_listener.request_off();
app.set_message("MCP listener: draining (5s)");
} else if app.mcp_listener.is_draining() {
app.set_message("MCP listener: already draining");
} else {
app.set_message("MCP listener: not active");
}
}
"mcp-status" => {
use crate::app::ListenerState;
let label = match app.mcp_listener.state() {
ListenerState::Off => "off",
ListenerState::On => "on",
ListenerState::Draining => "draining",
};
app.set_message(format!("MCP listener: {label}"));
}
"reanchor" => {
reanchor_selected_orphan(app);
}
"unblind" => review_modes::handle_unblind(app),
"blind" => review_modes::handle_blind(app),
"reload-review-config" => review_modes::handle_reload_review_config(app),
"spar" => review_modes::handle_spar(app),
"unspar" => review_modes::handle_unspar(app),
"spec" => review_modes::handle_spec(app),
"specs" => review_modes::handle_specs(app),
"clip" | "export" => handle_export(app),
"clear" => app.clear_all_comments(),
"version" => {
app.set_message(format!("trv v{}", env!("CARGO_PKG_VERSION")));
}
"errors" => {
if !app.recall_next_error() {
app.set_message("No recent errors");
}
}
"update" => match crate::update::check_for_updates() {
crate::update::UpdateCheckResult::UpdateAvailable(info) => {
app.set_message(format!(
"Update available: v{} -> v{}",
info.current_version, info.latest_version
));
}
crate::update::UpdateCheckResult::UpToDate(info) => {
app.set_message(format!("trv v{} is up to date", info.current_version));
}
crate::update::UpdateCheckResult::AheadOfRelease(info) => {
app.set_message(format!(
"You're from the future! v{} > v{}",
info.current_version, info.latest_version
));
}
crate::update::UpdateCheckResult::Failed(err) => {
app.set_warning(format!("Update check failed: {err}"));
}
},
"set wrap" => app.set_diff_wrap(true),
"set wrap!" => app.toggle_diff_wrap(),
"set tabstop" => {
let tw = app.tab_width;
app.set_message(format!("Tab width: {tw}"));
}
"set tour" => {
match app.tour_get_threshold() {
Some(threshold) => {
let preset =
travelagent_core::model::TourAggressiveness::from_threshold(
threshold,
)
.preset_id();
let n = threshold.as_u8();
app.set_message(format!("Tour threshold: {n} ({preset})"));
}
None => app.set_warning("No active tour"),
}
}
cmd if cmd.starts_with("set tour=") => {
let suffix = cmd.trim_start_matches("set tour=");
match travelagent_core::model::TourAggressiveness::parse(suffix) {
Some(agg) => match app.tour_set_aggressiveness(agg) {
Ok(n) => {
let threshold = agg.threshold().as_u8();
let preset = agg.preset_id();
app.set_message(format!(
"Tour threshold: {threshold} ({preset}), {n} stops"
));
}
Err(e) => app.set_warning(format!("Failed to retarget tour: {e}")),
},
None => app.set_warning(format!(
"Invalid tour preset '{suffix}'; expected cautious | balanced | aggressive | 0..=5"
)),
}
}
cmd if cmd.starts_with("set tabstop=") => {
let suffix = cmd.trim_start_matches("set tabstop=");
match suffix.parse::<usize>() {
Ok(n) if (1..=16).contains(&n) => {
app.tab_width = n;
app.set_message(format!("Tab width: {n}"));
}
Ok(_) => {
app.set_warning(
"tabstop must be a positive integer (1..=16)".to_string(),
);
}
Err(_) => {
app.set_warning(
"tabstop must be a positive integer (1..=16)".to_string(),
);
}
}
}
"set commits" => {
app.inline_selector.visible = true;
app.set_message("Commit selector: visible");
}
"set nocommits" => {
app.inline_selector.visible = false;
if app.nav.focused_panel == FocusedPanel::CommitSelector {
app.nav.focused_panel = FocusedPanel::Diff;
}
app.set_message("Commit selector: hidden");
}
"set commits!" => {
app.inline_selector.visible = !app.inline_selector.visible;
if !app.inline_selector.visible
&& app.nav.focused_panel == FocusedPanel::CommitSelector
{
app.nav.focused_panel = FocusedPanel::Diff;
}
let status = if app.inline_selector.visible {
"visible"
} else {
"hidden"
};
app.set_message(format!("Commit selector: {status}"));
}
"diff" => app.toggle_diff_view_mode(),
"commits" => {
if let Err(e) = app.enter_commit_select_mode() {
app.set_error(format!("Failed to load commits: {e}"));
} else {
return;
}
}
"tour" => {
if app.mcp_peer_count() == 0 {
app.set_error("`:tour` needs a connected agent (none attached)");
} else {
let commit_ids: Vec<String> = match app.commit_select.selection_range {
Some((start, end)) => (start..=end)
.filter_map(|i| app.commit_select.list.get(i))
.map(|c| c.id.clone())
.collect(),
None => app
.commit_select
.list
.iter()
.map(|c| c.id.clone())
.collect(),
};
let count = commit_ids.len();
app.pending_tour_request = Some(commit_ids.clone());
app.pending_tour_request_poll = Some(commit_ids);
app.set_message(format!("Tour requested ({count} commits)"));
if app.nav.command_origin == InputMode::CommitSelect {
app.palette.clear();
app.nav.input_mode = InputMode::CommitSelect;
return;
}
}
}
"edit" => {
if app.nav.input_mode == InputMode::Comment {
app.pending_external_edit = true;
} else {
app.set_warning("Use Ctrl+E in comment mode");
}
}
_ => app.set_message(format!("Unknown command: {cmd}")),
}
app.exit_command_mode();
}
Action::Quit => app.should_quit = true,
_ => {}
}
}
pub fn handle_search_action(app: &mut App, action: Action) {
match action {
Action::InsertChar(c) => app.search_buffer.push(c),
Action::DeleteChar => {
app.search_buffer.pop();
}
Action::DeleteWord if !app.search_buffer.is_empty() => {
while app
.search_buffer
.chars()
.last()
.is_some_and(char::is_whitespace)
{
app.search_buffer.pop();
}
while app
.search_buffer
.chars()
.last()
.is_some_and(|c| !c.is_whitespace())
{
app.search_buffer.pop();
}
}
Action::ClearLine => {
app.search_buffer.clear();
}
Action::ExitMode => app.exit_search_mode(),
Action::SubmitInput => {
app.search_in_diff_from_cursor();
app.exit_search_mode();
}
Action::Quit => app.should_quit = true,
_ => {}
}
}
pub(crate) fn build_suggestion_template(anchor_content: &str) -> (String, usize) {
let template = format!("```suggestion\n{anchor_content}\n```\n");
let cursor_offset = "```suggestion\n".len() + anchor_content.len();
(template, cursor_offset)
}
fn suggestion_anchor_content(app: &App) -> String {
if let Some((range, side)) = app.comment.line_range
&& let Some(content) = app.line_content_range(range.start, range.end, side)
{
return content;
}
if let Some((line, side)) = app.comment.line
&& let Some(content) = app.line_content_at(line, side)
{
return content;
}
app.current_line_content().unwrap_or_default()
}
pub fn handle_comment_action(app: &mut App, action: Action) {
match action {
Action::InsertChar(c) => {
app.comment.buffer.insert(app.comment.cursor, c);
app.comment.cursor += c.len_utf8();
}
Action::DeleteChar => {
app.comment.cursor = delete_char_before(&mut app.comment.buffer, app.comment.cursor);
}
Action::ExitMode => {
if let Some(r) = app.remote_mut() {
r.replying_to_thread = None;
}
app.exit_comment_mode();
}
Action::SubmitInput => {
#[allow(clippy::collapsible_match)]
if !try_submit_reply(app) {
app.save_comment();
}
}
Action::InsertSuggestion => {
let anchor = suggestion_anchor_content(app);
let (template, cursor_offset) = build_suggestion_template(&anchor);
app.comment.buffer.insert_str(app.comment.cursor, &template);
app.comment.cursor += cursor_offset;
}
Action::OpenExternalEditor => {
app.pending_external_edit = true;
}
Action::OpenCommentTemplatePicker => {
app.enter_comment_template_picker();
}
Action::CycleCommentType => app.cycle_comment_type(),
Action::CycleCommentTypeReverse => app.cycle_comment_type_reverse(),
Action::TextCursorLeft => {
app.comment.cursor = prev_char_boundary(&app.comment.buffer, app.comment.cursor);
}
Action::TextCursorRight => {
app.comment.cursor = next_char_boundary(&app.comment.buffer, app.comment.cursor);
}
Action::TextCursorLineStart => {
app.comment.cursor = comment_line_start(&app.comment.buffer, app.comment.cursor);
}
Action::TextCursorLineEnd => {
app.comment.cursor = comment_line_end(&app.comment.buffer, app.comment.cursor);
}
Action::TextCursorWordLeft => {
app.comment.cursor = comment_word_left(&app.comment.buffer, app.comment.cursor);
}
Action::TextCursorWordRight => {
app.comment.cursor = comment_word_right(&app.comment.buffer, app.comment.cursor);
}
Action::DeleteWord => {
app.comment.cursor = delete_word_before(&mut app.comment.buffer, app.comment.cursor);
}
Action::ClearLine => {
app.comment.buffer.clear();
app.comment.cursor = 0;
}
Action::Quit => app.should_quit = true,
_ => {}
}
}
pub fn handle_confirm_action(app: &mut App, action: Action) {
match action {
Action::ConfirmYes => match app.pending_confirm {
Some(app::ConfirmAction::CopyAndQuit) => {
match generate_export_content(
app.engine.session(),
&app.diff_source,
&app.comment.types,
app.export_legend,
) {
Ok(mut content) => {
if let Some(ref tour) = app.tour.plan {
append_tour_stops(
&mut content,
tour,
&app.tour.comment_meta,
&app.tour.triage,
);
}
append_tour_triage(&mut content, &app.tour.comment_meta, &app.tour.triage);
if app.output_to_stdout {
app.pending_stdout_output = Some(content);
} else {
match crate::output::markdown::copy_text_to_clipboard(&content) {
Ok(msg) => app.set_message(msg),
Err(e) => app.set_warning(format!("{e}")),
}
}
}
Err(e) => app.set_warning(format!("{e}")),
}
app.exit_confirm_mode();
app.should_quit = true;
}
Some(app::ConfirmAction::Merge) => {
if !app.forge_required("merge PR") {
app.exit_confirm_mode();
return;
}
app.set_message("Merging PR...");
match app.merge_remote_auto() {
Ok(Some(method)) => {
app.set_message(format!("PR merged successfully ({method})!"));
if let Some(r) = app.remote_mut()
&& let Some(ref mut meta) = r.pr_metadata
{
meta.state = PrState::Merged;
}
}
Ok(None) => app.set_message("Not in remote mode"),
Err(e) => app.set_error(format!("Failed to merge: {e}")),
}
app.exit_confirm_mode();
}
None => {
app.exit_confirm_mode();
}
},
Action::ConfirmNo => {
let is_merge = matches!(app.pending_confirm, Some(app::ConfirmAction::Merge));
app.exit_confirm_mode();
if !is_merge {
app.should_quit = true;
}
}
Action::Quit => app.should_quit = true,
_ => {}
}
}
pub fn handle_commit_select_action(app: &mut App, action: Action) {
if !matches!(action, Action::Quit) {
app.quit_warned = false;
}
match action {
Action::CommitSelectUp => app.commit_select_up(),
Action::CommitSelectDown => app.commit_select_down(),
Action::ToggleCommitSelect => {
if app.is_on_expand_row() {
if let Err(e) = app.expand_commit() {
app.set_error(format!("Failed to load commits: {e}"));
}
} else {
app.toggle_commit_selection();
}
}
Action::ConfirmCommitSelect => {
if app.is_on_expand_row() {
if let Err(e) = app.expand_commit() {
app.set_error(format!("Failed to load commits: {e}"));
}
} else if let Err(e) = app.confirm_commit_selection() {
app.set_error(format!("Failed to load commits: {e}"));
}
}
Action::EnterCommandMode => app.enter_command_mode(),
Action::ExitMode => {
if let Err(e) = app.exit_commit_select_mode() {
app.set_error(format!("Failed to reload changes: {e}"));
}
}
Action::Quit => {
if app.confirm_on_quit && app.dirty && !app.quit_warned {
app.set_warning("Unsaved changes. Press q again to quit, or :w to save.");
app.quit_warned = true;
} else {
app.should_quit = true;
}
}
_ => {}
}
}
pub fn handle_commit_selector_action(app: &mut App, action: Action) {
match action {
Action::CursorDown(_) => app.commit_select_down(),
Action::CursorUp(_) => app.commit_select_up(),
Action::ToggleExpand | Action::ToggleCommitSelect | Action::SelectFile => {
app.toggle_commit_selection();
if let Err(e) = app.reload_inline_selection() {
app.set_error(format!("Failed to load diff: {e}"));
}
}
Action::ExitMode => {
app.nav.focused_panel = FocusedPanel::Diff;
}
_ => handle_shared_normal_action(app, action),
}
}
pub fn handle_visual_action(app: &mut App, action: Action) {
if !matches!(action, Action::Quit) {
app.quit_warned = false;
}
match action {
Action::CursorDown(n) => {
app.cursor_down(n);
if let Some((_, anchor_side)) = app.comment.visual_anchor
&& let Some((_, current_side)) = app.get_line_at_cursor()
&& anchor_side != current_side
{
app.set_warning("Cannot select across old/new sides");
}
}
Action::CursorUp(n) => {
app.cursor_up(n);
if let Some((_, anchor_side)) = app.comment.visual_anchor
&& let Some((_, current_side)) = app.get_line_at_cursor()
&& anchor_side != current_side
{
app.set_warning("Cannot select across old/new sides");
}
}
Action::AddRangeComment => {
if app.get_visual_selection().is_some() {
app.enter_comment_from_visual();
} else {
app.set_warning("Invalid selection - cannot span old and new lines");
app.exit_visual_mode();
}
}
Action::ExitMode => app.exit_visual_mode(),
Action::Quit => {
if app.confirm_on_quit && app.dirty && !app.quit_warned {
app.set_warning("Unsaved changes. Press q again to quit, or :w to save.");
app.quit_warned = true;
} else {
app.should_quit = true;
}
}
_ => {}
}
}
pub fn handle_review_submit_action(app: &mut App, action: Action) {
match action {
Action::CursorUp(_) if !app.remote().is_some_and(|r| r.review_body_editing) => {
if let Some(r) = app.remote_mut() {
r.review_verdict_cursor = r.review_verdict_cursor.saturating_sub(1);
}
}
Action::CursorDown(_) => {
let max_verdict = if app.supports_request_changes() { 2 } else { 1 };
if let Some(r) = app.remote_mut()
&& !r.review_body_editing
&& r.review_verdict_cursor < max_verdict
{
r.review_verdict_cursor += 1;
}
}
Action::SelectFile => {
if let Some(r) = app.remote_mut() {
r.review_body_editing = true;
}
}
Action::SubmitInput => {
let Some(r) = app.remote() else {
app.nav.input_mode = InputMode::Normal;
return;
};
let verdict = match r.review_verdict_cursor {
0 => ReviewVerdict::Comment,
1 => ReviewVerdict::Approve,
_ => ReviewVerdict::RequestChanges,
};
if !app.forge_required("submit review") {
app.nav.input_mode = InputMode::Normal;
return;
}
let body = app
.remote()
.map(|r| r.review_body.clone())
.unwrap_or_default();
app.set_message("Submitting review...");
match app.submit_remote_review(verdict, &body) {
Ok(true) => {
let label = match verdict {
ReviewVerdict::Comment => "Comment",
ReviewVerdict::Approve => "Approve",
ReviewVerdict::RequestChanges => "Request Changes",
};
let completion_ts = chrono::Utc::now();
app.engine.session_mut().last_review_submitted_at = Some(completion_ts);
let head_sha = app
.remote()
.and_then(|r| r.pr_metadata.as_ref().map(|m| m.head_sha.clone()));
if let Some(sha) = head_sha {
app.engine.session_mut().last_review_sha = Some(sha);
}
app.dirty = true;
app.push_notify(app::McpNotify::ReviewSubmitted {
verdict: Some(label.to_string()),
at: completion_ts.to_rfc3339(),
});
app.set_message(format!("Review submitted: {label}"));
app.nav.input_mode = InputMode::Normal;
}
Ok(false) => app.set_message("Not in remote mode"),
Err(e) => app.set_error(format!("Failed to submit review: {e}")),
}
}
Action::ExitMode => {
if let Some(r) = app.remote_mut()
&& r.review_body_editing
{
r.review_body_editing = false; } else {
app.nav.input_mode = InputMode::Normal; }
}
Action::InsertChar(c) if app.remote().is_some_and(|r| r.review_body_editing) => {
if let Some(r) = app.remote_mut() {
r.review_body.push(c);
}
}
Action::DeleteChar if app.remote().is_some_and(|r| r.review_body_editing) => {
if let Some(r) = app.remote_mut() {
r.review_body.pop();
}
}
_ => {}
}
}
pub fn handle_file_list_action(app: &mut App, action: Action) {
match action {
Action::CursorDown(n) => app.file_list_down(n),
Action::CursorUp(n) => app.file_list_up(n),
Action::ScrollLeft(n) => app.file_list_state.scroll_left(n),
Action::ScrollRight(n) => app.file_list_state.scroll_right(n),
Action::SelectFile | Action::ToggleExpand => {
if let Some(item) = app.get_selected_tree_item() {
match item {
FileTreeItem::Directory { path, .. } => app.toggle_directory(&path),
FileTreeItem::File { file_idx, .. } => {
app.jump_to_file(file_idx);
app.nav.focused_panel = FocusedPanel::Diff;
}
}
}
}
Action::ToggleReviewed => {
if let Some(FileTreeItem::File { file_idx, .. }) = app.get_selected_tree_item() {
app.toggle_reviewed_for_file_idx(file_idx, false);
} else {
app.set_warning("Select a file to toggle reviewed");
}
}
_ => handle_shared_normal_action(app, action),
}
}
fn handle_toggle_resolve_thread(app: &mut App) {
let thread_id = if let Some(id) = crate::ui::conversation_panel::selected_thread_id(app) {
id
} else {
app.set_message("No thread selected");
return;
};
let root_id = crate::ui::conversation_panel::selected_root_comment_id(app);
let currently_resolved = root_id
.and_then(|id| {
app.remote().and_then(|r| {
crate::ui::conversation_panel::thread_is_resolved(&r.review_threads, id)
})
})
.unwrap_or(false);
if !app.forge_required("toggle thread resolution") {
return;
}
let Some(r) = app.remote() else {
app.set_message("Not in remote PR mode");
return;
};
let forge = match r.forge.as_ref() {
Some(f) => f,
None => {
app.set_message("Not in remote PR mode");
return;
}
};
let pr_id = r.pr_id.clone();
let rt = &app.runtime_handle;
let result = if currently_resolved {
rt.block_on(forge.unresolve_thread(&thread_id))
} else {
rt.block_on(forge.resolve_thread(&thread_id))
};
match result {
Ok(()) => {
let msg = if currently_resolved {
"Thread unresolved"
} else {
"Thread resolved"
};
match rt.block_on(forge.get_review_threads(&pr_id)) {
Ok(threads) => {
if let Some(r) = app.remote_mut() {
r.review_threads = threads;
}
}
Err(e) => {
app.set_warning(format!("{msg}, but failed to refresh threads: {e}"));
return;
}
}
app.set_message(msg);
}
Err(e) => app.set_error(format!("Failed to toggle thread: {e}")),
}
}
fn handle_conversation_reply(app: &mut App) {
let thread_id = if let Some(id) = crate::ui::conversation_panel::selected_thread_id(app) {
id
} else {
app.set_message("No thread selected");
return;
};
if let Some(r) = app.remote_mut() {
r.replying_to_thread = Some(thread_id);
}
app.enter_comment_mode(false, None);
}
fn try_submit_reply(app: &mut App) -> bool {
let thread_id = match app.remote().and_then(|r| r.replying_to_thread.clone()) {
Some(id) => id,
None => return false,
};
if app.comment.buffer.trim().is_empty() {
app.set_message("Reply cannot be empty");
return true;
}
let body = app.comment.buffer.trim().to_string();
if !app.forge_required("post reply") {
if let Some(r) = app.remote_mut() {
r.replying_to_thread = None;
}
app.exit_comment_mode();
return true;
}
let Some(r) = app.remote() else {
app.set_warning("Not in remote PR mode");
app.exit_comment_mode();
return true;
};
if r.forge.is_none() {
app.set_warning("Not in remote PR mode");
if let Some(r) = app.remote_mut() {
r.replying_to_thread = None;
}
app.exit_comment_mode();
return true;
}
let pr_id = r.pr_id.clone();
let rt = &app.runtime_handle;
let post_result = rt.block_on(
r.forge
.as_ref()
.expect("forge presence checked above")
.post_reply(&pr_id, &thread_id, &body),
);
match post_result {
Ok(reply) => {
let refresh = rt.block_on(
r.forge
.as_ref()
.expect("forge presence checked above")
.get_review_threads(&pr_id),
);
if let Some(r) = app.remote_mut() {
r.remote_comments.push(reply);
}
match refresh {
Ok(threads) => {
if let Some(r) = app.remote_mut() {
r.review_threads = threads;
}
}
Err(e) => {
app.set_warning(format!("Reply posted, failed to refresh threads: {e}"));
}
}
app.set_message("Reply posted");
}
Err(e) => app.set_error(format!("Failed to post reply: {e}")),
}
if let Some(r) = app.remote_mut() {
r.replying_to_thread = None;
}
app.exit_comment_mode();
true
}
fn handle_open_reaction_picker(app: &mut App) {
if app.nav.input_mode != InputMode::Normal {
return;
}
if !matches!(app.diff_source, DiffSource::Remote { .. })
|| app.remote().map(|r| r.remote_panel) != Some(RemotePanel::Conversation)
{
return;
}
match crate::ui::conversation_panel::selected_thread_id(app) {
Some(thread_id) => app.enter_reaction_picker(thread_id),
None => app.set_warning("No thread selected"),
}
}
fn submit_reaction(app: &mut App, idx: usize) {
let entry = if let Some(entry) = REACTIONS.get(idx) {
entry
} else {
app.exit_reaction_picker();
return;
};
let content = entry.0;
let thread_id = if let Some(id) = app
.remote()
.and_then(|r| r.reaction_picker_target_thread.clone())
{
id
} else {
app.set_warning("No thread selected");
app.exit_reaction_picker();
return;
};
if !app
.remote()
.is_some_and(|r| r.review_threads.iter().any(|t| t.id == thread_id))
{
app.set_warning("Thread no longer exists");
app.exit_reaction_picker();
return;
}
if !app.forge_required("add reaction") {
app.exit_reaction_picker();
return;
}
let Some(r) = app.remote() else {
app.set_warning("Not in remote PR mode");
app.exit_reaction_picker();
return;
};
let forge = match r.forge.as_ref() {
Some(f) => f,
None => {
app.set_warning("Not in remote PR mode");
app.exit_reaction_picker();
return;
}
};
let rt = &app.runtime_handle;
let target = ReactionTarget::Review(thread_id);
match rt.block_on(forge.add_reaction(&target, content)) {
Ok(()) => app.set_message("Reaction added"),
Err(e) => app.set_error(format!("Failed to add reaction: {e}")),
}
app.exit_reaction_picker();
}
pub fn handle_reaction_picker_action(app: &mut App, action: Action) {
let len = REACTIONS.len();
match action {
Action::ReactionPickerCancel => app.exit_reaction_picker(),
Action::ReactionPickerCursorLeft => {
app.reaction_picker_cursor = (app.reaction_picker_cursor + len - 1) % len;
}
Action::ReactionPickerCursorRight => {
app.reaction_picker_cursor = (app.reaction_picker_cursor + 1) % len;
}
Action::ReactionPickerSelect => {
let idx = app.reaction_picker_cursor;
submit_reaction(app, idx);
}
Action::ReactionPickerSelectAt(idx) if idx < len => {
app.reaction_picker_cursor = idx;
submit_reaction(app, idx);
}
_ => {}
}
}
fn handle_remote_panel_action(app: &mut App, action: Action) {
let Some(panel) = app.remote().map(|r| r.remote_panel) else {
return;
};
let viewport_half = app.diff_state.viewport_height / 2;
let page = app.diff_state.viewport_height.max(1);
match panel {
RemotePanel::Description => match action {
Action::CursorDown(n) => {
if let Some(r) = app.remote_mut() {
r.description_scroll += n;
}
}
Action::CursorUp(n) => {
if let Some(r) = app.remote_mut() {
r.description_scroll = r.description_scroll.saturating_sub(n);
}
}
Action::HalfPageDown => {
if let Some(r) = app.remote_mut() {
r.description_scroll += viewport_half;
}
}
Action::HalfPageUp => {
if let Some(r) = app.remote_mut() {
r.description_scroll = r.description_scroll.saturating_sub(viewport_half);
}
}
Action::PageDown => {
if let Some(r) = app.remote_mut() {
r.description_scroll += page;
}
}
Action::PageUp => {
if let Some(r) = app.remote_mut() {
r.description_scroll = r.description_scroll.saturating_sub(page);
}
}
Action::GoToTop => {
if let Some(r) = app.remote_mut() {
r.description_scroll = 0;
}
}
Action::GoToBottom => {
if let Some(r) = app.remote_mut() {
r.description_scroll = usize::MAX; }
}
_ => handle_shared_normal_action(app, action),
},
RemotePanel::Conversation => match action {
Action::CursorDown(n) => {
let tops = crate::ui::conversation_panel::top_level_thread_indices(app);
if let Some(r) = app.remote_mut()
&& !tops.is_empty()
{
let max = tops.len() - 1;
r.conversation_cursor = (r.conversation_cursor + n).min(max);
}
}
Action::CursorUp(n) => {
if let Some(r) = app.remote_mut() {
r.conversation_cursor = r.conversation_cursor.saturating_sub(n);
}
}
Action::HalfPageDown => {
if let Some(r) = app.remote_mut() {
r.conversation_scroll += viewport_half;
}
}
Action::HalfPageUp => {
if let Some(r) = app.remote_mut() {
r.conversation_scroll = r.conversation_scroll.saturating_sub(viewport_half);
}
}
Action::PageDown => {
if let Some(r) = app.remote_mut() {
r.conversation_scroll += page;
}
}
Action::PageUp => {
if let Some(r) = app.remote_mut() {
r.conversation_scroll = r.conversation_scroll.saturating_sub(page);
}
}
Action::GoToTop => {
if let Some(r) = app.remote_mut() {
r.conversation_cursor = 0;
r.conversation_scroll = 0;
}
}
Action::GoToBottom => {
let tops = crate::ui::conversation_panel::top_level_thread_indices(app);
if let Some(r) = app.remote_mut() {
if !tops.is_empty() {
r.conversation_cursor = tops.len() - 1;
}
r.conversation_scroll = usize::MAX; }
}
Action::ToggleResolveThread => handle_toggle_resolve_thread(app),
Action::ToggleReviewed => handle_conversation_reply(app),
_ => handle_shared_normal_action(app, action),
},
RemotePanel::Commits => match action {
Action::CursorDown(n) => {
if let Some(r) = app.remote_mut() {
let max = r.pr_commits.len().saturating_sub(1);
r.pr_commits_cursor = (r.pr_commits_cursor + n).min(max);
}
}
Action::CursorUp(n) => {
if let Some(r) = app.remote_mut() {
r.pr_commits_cursor = r.pr_commits_cursor.saturating_sub(n);
}
}
Action::SelectFile | Action::SelectFileFull => {
if let Some(r) = app.remote_mut() {
r.remote_panel = RemotePanel::Files;
}
}
_ => handle_shared_normal_action(app, action),
},
RemotePanel::Files => {} RemotePanel::Sparring => handle_sparring_panel_action(app, action),
}
}
pub fn handle_diff_action(app: &mut App, action: Action) {
if matches!(app.diff_source, DiffSource::Remote { .. })
&& app.remote().map(|r| r.remote_panel) != Some(RemotePanel::Files)
{
handle_remote_panel_action(app, action);
return;
}
match action {
Action::CursorDown(n) => app.cursor_down(n),
Action::CursorUp(n) => app.cursor_up(n),
Action::ScrollLeft(n) => app.scroll_left(n),
Action::ScrollRight(n) => app.scroll_right(n),
Action::SelectFile => {
if let Some(hit) = app.get_gap_at_cursor() {
match hit {
GapCursorHit::Expander(gap_id, dir) => {
let limit = if dir == ExpandDirection::Both {
None
} else {
Some(20)
};
if let Err(e) = app.expand_gap(gap_id, dir, limit) {
app.set_error(format!("Failed to expand: {e}"));
}
}
GapCursorHit::HiddenLines(gap_id) => {
if let Err(e) = app.expand_gap(gap_id, ExpandDirection::Both, None) {
app.set_error(format!("Failed to expand: {e}"));
}
}
GapCursorHit::ExpandedContent(gap_id) => {
app.collapse_gap(gap_id);
}
}
}
}
Action::SelectFileFull => {
if let Some(hit) = app.get_gap_at_cursor() {
match hit {
GapCursorHit::Expander(gap_id, _) | GapCursorHit::HiddenLines(gap_id) => {
if let Err(e) = app.expand_gap(gap_id, ExpandDirection::Both, None) {
app.set_error(format!("Failed to expand: {e}"));
}
}
GapCursorHit::ExpandedContent(gap_id) => {
app.collapse_gap(gap_id);
}
}
}
}
_ => handle_shared_normal_action(app, action),
}
}
pub(super) fn handle_shared_normal_action(app: &mut App, action: Action) {
if !matches!(action, Action::Quit) {
app.quit_warned = false;
}
match action {
Action::Quit => {
if app.confirm_on_quit && app.dirty && !app.quit_warned {
app.set_warning("Unsaved changes. Press q again to quit, or :w to save.");
app.quit_warned = true;
} else {
app.should_quit = true;
}
}
Action::HalfPageDown => app.scroll_down(app.diff_state.viewport_height / 2),
Action::HalfPageUp => app.scroll_up(app.diff_state.viewport_height / 2),
Action::PageDown => app.scroll_down(app.diff_state.viewport_height),
Action::PageUp => app.scroll_up(app.diff_state.viewport_height),
Action::GoToTop => app.jump_to_file(0),
Action::GoToBottom => app.jump_to_bottom(),
Action::NextFile => app.next_file(),
Action::PrevFile => app.prev_file(),
Action::GoToLineStart => {
app.diff_state.scroll_x = 0;
}
Action::GoToLineEnd if !app.diff_state.wrap_lines => {
let max_scroll_x = app
.diff_state
.max_content_width
.saturating_sub(app.diff_state.viewport_width);
app.diff_state.scroll_x = max_scroll_x;
}
Action::WordForward => app.next_hunk(),
Action::WordBackward => app.prev_hunk(),
Action::CursorToTopOfScreen => {
app.diff_state.cursor_line = app.diff_state.scroll_offset;
app.update_current_file_from_cursor();
}
Action::CursorToBottomOfScreen => {
let visible = if app.diff_state.visible_line_count > 0 {
app.diff_state.visible_line_count
} else {
app.diff_state.viewport_height.max(1)
};
let total = app.line_annotations.len();
let target = app
.diff_state
.scroll_offset
.saturating_add(visible.saturating_sub(1))
.min(total.saturating_sub(1));
app.diff_state.cursor_line = target;
app.update_current_file_from_cursor();
}
Action::CenterOnCursor => app.center_cursor(),
Action::ScrollViewUp => {
app.diff_state.scroll_offset = app.diff_state.scroll_offset.saturating_sub(1);
}
Action::ScrollViewDown => {
let max_scroll = app.max_scroll_offset();
app.diff_state.scroll_offset = app
.diff_state
.scroll_offset
.saturating_add(1)
.min(max_scroll);
}
Action::ToggleReviewed => app.toggle_reviewed(),
Action::ToggleFileCollapse => app.toggle_file_collapse(),
Action::ToggleFocus => {
app.nav.focused_panel = next_focus(
app.nav.focused_panel,
app.has_inline_commit_selector(),
app.ui_layout.show_file_list,
);
}
Action::ToggleFocusReverse => {
app.nav.focused_panel = prev_focus(
app.nav.focused_panel,
app.has_inline_commit_selector(),
app.ui_layout.show_file_list,
);
}
Action::OpenInBrowser => {
if let Some(url) = app.get_browser_url() {
if let Err(e) = open_browser(&url) {
app.set_error(format!("Failed to open browser: {e}"));
}
} else {
app.expand_all_dirs();
app.set_message("All directories expanded");
}
}
Action::OpenInEditor => {
match crate::app::resolve_cursor_to_path_line(
&app.line_annotations,
app.diff_state.cursor_line,
&app.diff_files,
) {
Some((path, line)) => {
app.pending_open_file_editor = Some((path, line));
}
None => {
app.set_message(
"Move cursor to a diff line to open the source file in $EDITOR",
);
}
}
}
Action::CollapseAll => {
app.collapse_all_dirs();
app.set_message("All directories collapsed");
}
Action::ToggleHelp => app.toggle_help(),
Action::ToggleAiSummary => app.toggle_ai_summary(),
Action::ToggleViewportPin => app.toggle_viewport_pin(),
Action::JumpToAgentGhost => {
app.jump_to_agent_ghost();
}
Action::EnterCommandMode => app.enter_command_mode(),
Action::EnterSearchMode => app.enter_search_mode(),
Action::AddLineComment => {
let line = app.get_line_at_cursor();
if line.is_some() {
app.enter_comment_mode(false, line);
} else {
app.set_message("Move cursor to a diff line to add a line comment");
}
}
Action::ReviewComment => app.enter_review_comment_mode(),
Action::ToggleFileList => app.toggle_file_list(),
Action::ShrinkFileList => app.shrink_file_list(),
Action::GrowFileList => app.grow_file_list(),
Action::TourGranularityCoarser => {
if app.tour.plan.is_some() {
app.tour_set_granularity_hint(app::GranularityHint::Coarser);
} else {
app.set_message("No active tour");
}
}
Action::TourGranularityFiner => {
if app.tour.plan.is_some() {
app.tour_set_granularity_hint(app::GranularityHint::Finer);
} else {
app.set_message("No active tour");
}
}
Action::EditComment if !app.enter_edit_mode() => {
app.set_message("No comment at cursor");
}
Action::ExportToClipboard => handle_export(app),
Action::SearchNext => {
app.search_next_in_diff();
}
Action::SearchPrev => {
app.search_prev_in_diff();
}
Action::EnterVisualMode => {
if let Some((line, side)) = app.get_line_at_cursor() {
app.enter_visual_mode(line, side);
} else {
app.set_message("Move cursor to a diff line to start visual selection");
}
}
Action::CycleCommitNext if app.has_inline_commit_selector() => {
app.cycle_commit_next();
if let Err(e) = app.reload_inline_selection() {
app.set_error(format!("Failed to load diff: {e}"));
}
}
Action::CycleCommitPrev if app.has_inline_commit_selector() => {
app.cycle_commit_prev();
if let Err(e) = app.reload_inline_selection() {
app.set_error(format!("Failed to load diff: {e}"));
}
}
Action::SubmitReview => {
if app.has_forge() {
app.nav.input_mode = InputMode::ReviewSubmit;
if let Some(r) = app.remote_mut() {
r.review_verdict_cursor = 0;
r.review_body.clear();
r.review_body_editing = false;
}
} else {
app.set_message("Not in remote PR mode");
}
}
Action::ReanchorOrphan => {
reanchor_selected_orphan(app);
}
Action::MentalModelOpen => {
open_mental_model_modal(app);
}
Action::OpenReactionPicker => {
handle_open_reaction_picker(app);
}
Action::MergePR => {
if app.has_forge() {
let meta_snapshot = app
.remote()
.and_then(|r| r.pr_metadata.as_ref().map(|m| (m.state, m.is_draft)));
if let Some((state, is_draft)) = meta_snapshot {
if state != PrState::Open {
app.set_warning(format!("PR is already {}", state.display()));
} else if is_draft {
app.set_warning("Cannot merge a draft PR".to_string());
} else {
app.enter_confirm_mode(app::ConfirmAction::Merge);
}
} else {
app.set_warning("No PR metadata available".to_string());
}
} else {
app.set_message("Not in remote PR mode");
}
}
Action::ReturnToCommits => handle_return_to_commits(app),
_ => {}
}
}
fn handle_return_to_commits(app: &mut App) {
if app.nav.focused_panel != FocusedPanel::Diff {
return;
}
if matches!(app.diff_source, DiffSource::Remote { .. }) {
if let Some(r) = app.remote_mut() {
r.remote_panel = RemotePanel::Commits;
}
} else if app.has_inline_commit_selector() {
app.nav.focused_panel = FocusedPanel::CommitSelector;
} else if let Err(e) = app.enter_commit_select_mode() {
app.set_error(format!("Failed to load commits: {e}"));
}
}
fn next_focus(current: FocusedPanel, has_selector: bool, show_file_list: bool) -> FocusedPanel {
let ordered = focus_order(has_selector, show_file_list);
cycle(&ordered, current, 1)
}
fn prev_focus(current: FocusedPanel, has_selector: bool, show_file_list: bool) -> FocusedPanel {
let ordered = focus_order(has_selector, show_file_list);
cycle(&ordered, current, -1)
}
fn focus_order(has_selector: bool, show_file_list: bool) -> Vec<FocusedPanel> {
let mut panels = Vec::with_capacity(3);
if show_file_list {
panels.push(FocusedPanel::FileList);
}
panels.push(FocusedPanel::Diff);
if has_selector {
panels.push(FocusedPanel::CommitSelector);
}
panels
}
fn cycle(order: &[FocusedPanel], current: FocusedPanel, step: isize) -> FocusedPanel {
if order.is_empty() {
return current;
}
let idx = order.iter().position(|p| *p == current).unwrap_or(0);
let len = order.len() as isize;
let next = ((idx as isize + step).rem_euclid(len)) as usize;
order[next]
}
pub fn handle_paste(app: &mut App, text: &str) {
match app.nav.input_mode {
InputMode::Comment => {
let raw_cursor = app.comment.cursor.min(app.comment.buffer.len());
let cursor = if app.comment.buffer.is_char_boundary(raw_cursor) {
raw_cursor
} else {
crate::text_edit::prev_char_boundary(&app.comment.buffer, raw_cursor)
};
app.comment.buffer.insert_str(cursor, text);
app.comment.cursor = cursor + text.len();
}
InputMode::Command | InputMode::CommandPalette if app.palette.push_str(text) => {
app.set_warning("Paste truncated (palette is for short commands, not buffers)");
}
InputMode::Command | InputMode::CommandPalette => {}
InputMode::Search => {
app.search_buffer.push_str(text);
}
InputMode::ReviewSubmit if app.remote().is_some_and(|r| r.review_body_editing) => {
if let Some(r) = app.remote_mut() {
r.review_body.push_str(text);
}
}
InputMode::MentalModelEdit => {
let limit = app.mental_model_byte_limit;
let focused = app.mental_model_edit.focused;
let buf = &mut app.mental_model_edit.drafts[focused];
let remaining = limit.saturating_sub(buf.len());
if remaining == 0 {
return;
}
let mut cut = text.len().min(remaining);
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
buf.push_str(&text[..cut]);
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{InputMode, MessageType};
use crate::test_support::cwd_lock;
use crate::theme::Theme;
use tempfile::TempDir;
fn build_test_app() -> (App, TempDir) {
let _lock = cwd_lock();
let dir = TempDir::new().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
std::fs::write(dir.path().join("test.txt"), "hello\n").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let theme = Theme::dark();
let app = App::new(
theme,
None,
false,
None,
true,
None,
None,
crate::test_support::runtime_handle(),
)
.unwrap();
std::env::set_current_dir(original_dir).unwrap();
(app, dir)
}
#[test]
fn command_quit_sets_should_quit_when_not_dirty() {
let (mut app, _dir) = build_test_app();
app.enter_command_mode();
app.palette.set_buffer("q".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(app.should_quit);
}
#[test]
fn command_quit_warns_when_dirty() {
let (mut app, _dir) = build_test_app();
app.dirty = true;
app.enter_command_mode();
app.palette.set_buffer("q".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(!app.should_quit);
assert!(app.message.is_some());
assert_eq!(
app.message.as_ref().unwrap().message_type,
MessageType::Error
);
}
#[test]
fn command_force_quit_quits_even_when_dirty() {
let (mut app, _dir) = build_test_app();
app.dirty = true;
app.enter_command_mode();
app.palette.set_buffer("q!".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(app.should_quit);
}
#[test]
fn command_version_sets_info_message() {
let (mut app, _dir) = build_test_app();
app.enter_command_mode();
app.palette.set_buffer("version".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let msg = app.message.as_ref().expect("message should be set");
assert!(msg.content.starts_with("trv v"));
assert_eq!(msg.message_type, MessageType::Info);
}
#[test]
fn command_set_wrap_enables_wrapping() {
let (mut app, _dir) = build_test_app();
app.diff_state.wrap_lines = false;
app.enter_command_mode();
app.palette.set_buffer("set wrap".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(app.diff_state.wrap_lines);
}
#[test]
fn command_set_wrap_bang_toggles_wrapping() {
let (mut app, _dir) = build_test_app();
assert!(app.diff_state.wrap_lines);
app.enter_command_mode();
app.palette.set_buffer("set wrap!".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(!app.diff_state.wrap_lines);
}
#[test]
fn set_tabstop_with_value_updates_app_state() {
let (mut app, _dir) = build_test_app();
app.enter_command_mode();
app.palette.set_buffer("set tabstop=8".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert_eq!(app.tab_width, 8);
let msg = app.message.as_ref().expect("info message");
assert_eq!(msg.message_type, MessageType::Info);
assert!(msg.content.contains('8'));
}
#[test]
fn set_tabstop_rejects_non_numeric() {
let (mut app, _dir) = build_test_app();
let before = app.tab_width;
app.enter_command_mode();
app.palette.set_buffer("set tabstop=abc".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert_eq!(app.tab_width, before);
let msg = app.message.as_ref().expect("warning message");
assert_eq!(msg.message_type, MessageType::Warning);
assert!(msg.content.to_lowercase().contains("tabstop"));
}
#[test]
fn set_tabstop_rejects_out_of_range() {
let (mut app, _dir) = build_test_app();
let before = app.tab_width;
app.enter_command_mode();
app.palette.set_buffer("set tabstop=0".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert_eq!(app.tab_width, before, "tabstop=0 must be rejected");
assert_eq!(
app.message.as_ref().unwrap().message_type,
MessageType::Warning
);
let (mut app, _dir) = build_test_app();
let before = app.tab_width;
app.enter_command_mode();
app.palette.set_buffer("set tabstop=17".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert_eq!(app.tab_width, before, "tabstop=17 must be rejected");
assert_eq!(
app.message.as_ref().unwrap().message_type,
MessageType::Warning
);
}
fn build_tour_test_app() -> (App, TempDir) {
let _lock = cwd_lock();
let dir = TempDir::new().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let first = repo
.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
std::fs::write(dir.path().join("file.txt"), "hello\n").unwrap();
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new("file.txt")).unwrap();
index.write().unwrap();
let second_tree_id = index.write_tree().unwrap();
let second_tree = repo.find_tree(second_tree_id).unwrap();
let parent = repo.find_commit(first).unwrap();
let second_sha = repo
.commit(
Some("HEAD"),
&sig,
&sig,
"add file",
&second_tree,
&[&parent],
)
.unwrap()
.to_string();
std::fs::write(dir.path().join("untracked.txt"), "scratch\n").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let theme = Theme::dark();
let mut app = App::new(
theme,
None,
false,
None,
true,
None,
None,
crate::test_support::runtime_handle(),
)
.unwrap();
std::env::set_current_dir(original_dir).unwrap();
app.tour.plan = Some(travelagent_core::model::TourState::new(vec![
travelagent_core::model::TourStop {
commit_ids: vec![second_sha],
summary: "test stop".to_string(),
risk: travelagent_core::risk::RiskScore::MIN,
},
]));
(app, dir)
}
#[test]
fn set_tour_cautious_updates_threshold() {
let (mut app, _dir) = build_tour_test_app();
app.enter_command_mode();
app.palette.set_buffer("set tour=cautious".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let tour = app.tour.plan.as_ref().expect("tour still active");
assert_eq!(tour.threshold.as_u8(), 1);
let msg = app.message.as_ref().expect("info message");
assert_eq!(msg.message_type, MessageType::Info);
assert!(msg.content.contains("cautious"), "got: {}", msg.content);
}
#[test]
fn set_tour_balanced_updates_threshold() {
let (mut app, _dir) = build_tour_test_app();
app.enter_command_mode();
app.palette.set_buffer("set tour=balanced".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let tour = app.tour.plan.as_ref().expect("tour still active");
assert_eq!(tour.threshold.as_u8(), 3);
let msg = app.message.as_ref().expect("info message");
assert_eq!(msg.message_type, MessageType::Info);
assert!(msg.content.contains("balanced"));
}
#[test]
fn set_tour_aggressive_updates_threshold() {
let (mut app, _dir) = build_tour_test_app();
app.enter_command_mode();
app.palette.set_buffer("set tour=aggressive".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let tour = app.tour.plan.as_ref().expect("tour still active");
assert_eq!(tour.threshold.as_u8(), 5);
let msg = app.message.as_ref().expect("info message");
assert_eq!(msg.message_type, MessageType::Info);
assert!(msg.content.contains("aggressive"));
}
#[test]
fn set_tour_numeric_in_range_updates_threshold() {
let (mut app, _dir) = build_tour_test_app();
app.enter_command_mode();
app.palette.set_buffer("set tour=2".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let tour = app.tour.plan.as_ref().expect("tour still active");
assert_eq!(tour.threshold.as_u8(), 2);
let msg = app.message.as_ref().expect("info message");
assert_eq!(msg.message_type, MessageType::Info);
}
#[test]
fn set_tour_numeric_out_of_range_warns() {
let (mut app, _dir) = build_tour_test_app();
let before = app.tour.plan.as_ref().unwrap().threshold.as_u8();
app.enter_command_mode();
app.palette.set_buffer("set tour=9".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let after = app.tour.plan.as_ref().unwrap().threshold.as_u8();
assert_eq!(before, after, "threshold must not change on invalid preset");
let msg = app.message.as_ref().expect("warning set");
assert_eq!(msg.message_type, MessageType::Warning);
assert!(msg.content.to_lowercase().contains("invalid"));
}
#[test]
fn set_tour_invalid_preset_warns() {
let (mut app, _dir) = build_tour_test_app();
let before = app.tour.plan.as_ref().unwrap().threshold.as_u8();
app.enter_command_mode();
app.palette.set_buffer("set tour=bogus".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let after = app.tour.plan.as_ref().unwrap().threshold.as_u8();
assert_eq!(before, after, "threshold must not change on invalid preset");
let msg = app.message.as_ref().expect("warning set");
assert_eq!(msg.message_type, MessageType::Warning);
assert!(msg.content.to_lowercase().contains("invalid"));
}
#[test]
fn set_tour_without_value_reports_current() {
let (mut app, _dir) = build_tour_test_app();
app.enter_command_mode();
app.palette.set_buffer("set tour".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let msg = app.message.as_ref().expect("info message");
assert_eq!(msg.message_type, MessageType::Info);
assert!(
msg.content.to_lowercase().contains("threshold"),
"expected threshold report, got: {}",
msg.content
);
}
#[test]
fn set_tour_without_active_tour_warns() {
let (mut app, _dir) = build_test_app();
app.enter_command_mode();
app.palette.set_buffer("set tour".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let msg = app.message.as_ref().expect("warning set");
assert_eq!(msg.message_type, MessageType::Warning);
}
#[test]
fn set_tabstop_with_no_value_reports_current() {
let (mut app, _dir) = build_test_app();
app.tab_width = 7;
app.enter_command_mode();
app.palette.set_buffer("set tabstop".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert_eq!(app.tab_width, 7);
let msg = app.message.as_ref().expect("message set");
assert_eq!(msg.message_type, MessageType::Info);
assert!(msg.content.contains('7'));
}
#[test]
fn export_updates_last_review_submitted_at() {
let (mut app, _dir) = build_test_app();
app.engine.session_mut().last_review_submitted_at = None;
app.engine
.session_mut()
.review_comments
.push(travelagent_core::model::Comment::new(
"note".to_string(),
travelagent_core::model::CommentType::Note,
None,
));
app.output_to_stdout = true;
let before = chrono::Utc::now();
handle_export(&mut app);
let stamped = app
.engine
.session()
.last_review_submitted_at
.expect("session-level timestamp must be set");
assert!(
stamped >= before - chrono::Duration::seconds(1),
"timestamp must be near 'now'"
);
}
#[test]
fn command_unknown_shows_message() {
let (mut app, _dir) = build_test_app();
app.enter_command_mode();
app.palette.set_buffer("nonexistent".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let msg = app.message.as_ref().expect("message should be set");
assert!(msg.content.contains("Unknown command: nonexistent"));
}
#[test]
fn command_insert_char_appends_to_buffer() {
let (mut app, _dir) = build_test_app();
app.enter_command_mode();
handle_command_action(&mut app, Action::InsertChar('a'));
handle_command_action(&mut app, Action::InsertChar('b'));
assert_eq!(app.palette.buffer(), "ab");
}
#[test]
fn command_delete_char_pops_from_buffer() {
let (mut app, _dir) = build_test_app();
app.enter_command_mode();
app.palette.set_buffer("abc".to_string());
handle_command_action(&mut app, Action::DeleteChar);
assert_eq!(app.palette.buffer(), "ab");
}
#[test]
fn search_insert_char_appends_to_buffer() {
let (mut app, _dir) = build_test_app();
app.enter_search_mode();
handle_search_action(&mut app, Action::InsertChar('f'));
handle_search_action(&mut app, Action::InsertChar('o'));
assert_eq!(app.search_buffer, "fo");
}
#[test]
fn search_delete_char_pops_from_buffer() {
let (mut app, _dir) = build_test_app();
app.enter_search_mode();
app.search_buffer = "foo".to_string();
handle_search_action(&mut app, Action::DeleteChar);
assert_eq!(app.search_buffer, "fo");
}
#[test]
fn search_clear_line_empties_buffer() {
let (mut app, _dir) = build_test_app();
app.enter_search_mode();
app.search_buffer = "something".to_string();
handle_search_action(&mut app, Action::ClearLine);
assert!(app.search_buffer.is_empty());
}
#[test]
fn search_submit_exits_search_mode() {
let (mut app, _dir) = build_test_app();
app.enter_search_mode();
app.search_buffer = "pattern".to_string();
handle_search_action(&mut app, Action::SubmitInput);
assert_eq!(app.nav.input_mode, InputMode::Normal);
}
#[test]
fn search_exit_mode_returns_to_normal() {
let (mut app, _dir) = build_test_app();
app.enter_search_mode();
handle_search_action(&mut app, Action::ExitMode);
assert_eq!(app.nav.input_mode, InputMode::Normal);
}
#[test]
fn comment_insert_char_at_cursor() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Comment;
app.comment.buffer = "hllo".to_string();
app.comment.cursor = 1;
handle_comment_action(&mut app, Action::InsertChar('e'));
assert_eq!(app.comment.buffer, "hello");
assert_eq!(app.comment.cursor, 2);
}
#[test]
fn comment_delete_char_removes_before_cursor() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Comment;
app.comment.buffer = "hello".to_string();
app.comment.cursor = 5;
handle_comment_action(&mut app, Action::DeleteChar);
assert_eq!(app.comment.buffer, "hell");
}
#[test]
fn comment_cycle_type_changes_comment_type() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Comment;
let initial_type = app.comment.comment_type.id().to_string();
handle_comment_action(&mut app, Action::CycleCommentType);
let new_type = app.comment.comment_type.id().to_string();
assert_ne!(initial_type, new_type);
}
#[test]
fn comment_clear_line_empties_buffer_and_resets_cursor() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Comment;
app.comment.buffer = "some text".to_string();
app.comment.cursor = 5;
handle_comment_action(&mut app, Action::ClearLine);
assert!(app.comment.buffer.is_empty());
assert_eq!(app.comment.cursor, 0);
}
#[test]
fn comment_exit_mode_returns_to_normal() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Comment;
handle_comment_action(&mut app, Action::ExitMode);
assert_eq!(app.nav.input_mode, InputMode::Normal);
}
#[test]
fn export_stdout_without_comments_sets_warning() {
let (mut app, _dir) = build_test_app();
app.output_to_stdout = true;
handle_export(&mut app);
assert!(app.message.is_some());
assert_eq!(
app.message.as_ref().unwrap().message_type,
MessageType::Warning
);
assert!(app.pending_stdout_output.is_none());
}
#[test]
fn export_and_quit_sets_should_quit() {
let (mut app, _dir) = build_test_app();
app.output_to_stdout = true;
handle_export_and_quit(&mut app);
assert!(app.should_quit);
}
#[test]
fn comment_line_start_finds_start_of_line() {
let buf = "first\nsecond\nthird";
assert_eq!(comment_line_start(buf, 8), 6);
assert_eq!(comment_line_start(buf, 0), 0);
assert_eq!(comment_line_start(buf, 14), 13);
}
#[test]
fn comment_line_end_finds_end_of_line() {
let buf = "first\nsecond\nthird";
assert_eq!(comment_line_end(buf, 0), 5);
assert_eq!(comment_line_end(buf, 8), 12);
assert_eq!(comment_line_end(buf, 14), 18);
}
#[test]
fn comment_word_left_navigates_words() {
let buf = "hello world foo";
assert_eq!(comment_word_left(buf, 15), 12);
assert_eq!(comment_word_left(buf, 12), 6);
assert_eq!(comment_word_left(buf, 6), 0);
assert_eq!(comment_word_left(buf, 0), 0);
}
#[test]
fn comment_word_right_navigates_words() {
let buf = "hello world foo";
assert_eq!(comment_word_right(buf, 0), 6);
assert_eq!(comment_word_right(buf, 6), 12);
assert_eq!(comment_word_right(buf, 12), 15);
}
#[test]
fn confirm_no_sets_should_quit() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Confirm;
handle_confirm_action(&mut app, Action::ConfirmNo);
assert!(app.should_quit);
}
#[test]
fn help_quit_sets_should_quit() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Help;
handle_help_action(&mut app, Action::Quit);
assert!(app.should_quit);
}
fn make_remote_app() -> (App, TempDir) {
let (_local_app, dir) = build_test_app();
let app = App::new_remote(
Theme::dark(),
None,
false,
Vec::new(),
"Test PR".to_string(),
1,
"owner",
"repo",
crate::test_support::runtime_handle(),
None,
travelagent_core::forge::PrId {
owner: "owner".to_string(),
repo: "repo".to_string(),
number: 1,
},
)
.expect("new_remote in test harness");
let mut app = app;
app.diff_state.viewport_height = 20;
(app, dir)
}
fn make_test_commit(id: &str) -> travelagent_core::vcs::CommitInfo {
travelagent_core::vcs::CommitInfo {
id: id.to_string(),
short_id: id[..7.min(id.len())].to_string(),
branch_name: None,
summary: format!("Commit {id}"),
body: None,
author: "test".to_string(),
time: chrono::Utc::now(),
}
}
#[test]
fn remote_description_scroll_down_respects_count() {
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Description;
r.description_scroll = 0;
}
handle_diff_action(&mut app, Action::CursorDown(5));
assert_eq!(app.remote().unwrap().description_scroll, 5);
}
#[test]
fn remote_description_scroll_up_respects_count() {
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Description;
r.description_scroll = 10;
}
handle_diff_action(&mut app, Action::CursorUp(3));
assert_eq!(app.remote().unwrap().description_scroll, 7);
}
#[test]
fn remote_description_page_down() {
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Description;
r.description_scroll = 0;
}
handle_diff_action(&mut app, Action::PageDown);
assert_eq!(app.remote().unwrap().description_scroll, 20);
}
#[test]
fn remote_description_go_to_bottom() {
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Description;
r.description_scroll = 0;
}
handle_diff_action(&mut app, Action::GoToBottom);
assert_eq!(app.remote().unwrap().description_scroll, usize::MAX);
}
#[test]
fn remote_conversation_page_still_scrolls() {
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Conversation;
r.conversation_scroll = 0;
}
handle_diff_action(&mut app, Action::PageDown);
assert_eq!(app.remote().unwrap().conversation_scroll, 20);
handle_diff_action(&mut app, Action::HalfPageUp);
assert_eq!(app.remote().unwrap().conversation_scroll, 10);
}
#[test]
fn remote_commits_cursor_navigation() {
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Commits;
r.pr_commits = vec![
make_test_commit("aaa1111"),
make_test_commit("bbb2222"),
make_test_commit("ccc3333"),
];
r.pr_commits_cursor = 0;
}
handle_diff_action(&mut app, Action::CursorDown(2));
assert_eq!(app.remote().unwrap().pr_commits_cursor, 2);
handle_diff_action(&mut app, Action::CursorDown(5));
assert_eq!(app.remote().unwrap().pr_commits_cursor, 2);
handle_diff_action(&mut app, Action::CursorUp(1));
assert_eq!(app.remote().unwrap().pr_commits_cursor, 1);
}
#[test]
fn remote_commits_select_switches_to_files() {
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Commits;
r.pr_commits = vec![make_test_commit("aaa1111")];
r.pr_commits_cursor = 0;
}
handle_diff_action(&mut app, Action::SelectFile);
assert_eq!(app.remote().unwrap().remote_panel, RemotePanel::Files);
}
#[test]
fn build_suggestion_template_wraps_single_line() {
let (template, cursor) = build_suggestion_template("let x = 1;");
assert_eq!(template, "```suggestion\nlet x = 1;\n```\n");
assert_eq!(cursor, "```suggestion\n".len() + "let x = 1;".len());
assert_eq!(&template[cursor..=cursor], "\n");
}
#[test]
fn build_suggestion_template_wraps_multi_line_visual_selection() {
let anchor = "a\nb\nc";
let (template, cursor) = build_suggestion_template(anchor);
assert_eq!(template, "```suggestion\na\nb\nc\n```\n");
assert_eq!(cursor, "```suggestion\n".len() + anchor.len());
}
#[test]
fn build_suggestion_template_handles_empty_anchor() {
let (template, cursor) = build_suggestion_template("");
assert_eq!(template, "```suggestion\n\n```\n");
assert_eq!(cursor, "```suggestion\n".len());
}
#[test]
fn insert_suggestion_action_inserts_at_cursor_when_no_anchor() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Comment;
app.comment.buffer.clear();
app.comment.cursor = 0;
handle_comment_action(&mut app, Action::InsertSuggestion);
assert!(app.comment.buffer.starts_with("```suggestion\n"));
assert!(app.comment.buffer.ends_with("```\n"));
let expected_cursor = "```suggestion\n".len();
assert_eq!(app.comment.cursor, expected_cursor);
}
#[test]
fn refresh_command_without_remote_forge_reloads_local() {
let (mut app, _dir) = build_test_app();
app.enter_command_mode();
app.palette.set_buffer("refresh".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let msg = app.message.as_ref().expect("message should be set");
assert_eq!(msg.message_type, MessageType::Info);
assert!(msg.content.contains("Reloaded"));
}
#[test]
fn live_command_turns_live_mode_on() {
let (mut app, _dir) = build_test_app();
assert!(!app.live.active, "precondition: live mode starts off");
app.enter_command_mode();
app.palette.set_buffer("live".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(app.live.active, ":live must turn live mode on");
let msg = app.message.as_ref().expect("info message set");
assert_eq!(msg.message_type, MessageType::Info);
assert!(msg.content.to_lowercase().contains("live"));
}
#[test]
fn live_command_when_already_on_is_noop_with_message() {
let (mut app, _dir) = build_test_app();
app.live.active = true;
app.enter_command_mode();
app.palette.set_buffer("live".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(app.live.active, "live mode must stay on");
let msg = app.message.as_ref().expect("message set");
assert!(msg.content.to_lowercase().contains("already"));
}
#[test]
fn live_bang_command_turns_live_mode_off() {
let (mut app, _dir) = build_test_app();
app.live.active = true;
app.live.last_refresh_at = Some(chrono::Local::now());
app.enter_command_mode();
app.palette.set_buffer("live!".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(!app.live.active, ":live! must turn live mode off");
assert!(
app.live.last_refresh_at.is_none(),
"refresh timestamp must be cleared"
);
let msg = app.message.as_ref().expect("info message set");
assert_eq!(msg.message_type, MessageType::Info);
}
#[test]
fn live_bang_command_when_already_off_is_noop_with_message() {
let (mut app, _dir) = build_test_app();
assert!(!app.live.active);
app.enter_command_mode();
app.palette.set_buffer("live!".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(!app.live.active);
let msg = app.message.as_ref().expect("message set");
assert!(msg.content.to_lowercase().contains("not active"));
}
#[test]
fn live_command_in_remote_mode_warns_and_stays_off() {
let (mut app, _dir) = make_remote_app();
app.enter_command_mode();
app.palette.set_buffer("live".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(!app.live.active, "remote mode must reject live toggle");
let msg = app.message.as_ref().expect("warning set");
assert_eq!(msg.message_type, MessageType::Warning);
}
#[test]
fn refresh_remote_without_forge_is_noop_ok() {
let (mut app, _dir) = make_remote_app();
let before = app.remote().unwrap().pr_commits.len();
assert!(app.refresh_remote().is_ok());
assert_eq!(app.remote().unwrap().pr_commits.len(), before);
}
#[test]
fn zero_key_maps_to_line_start() {
let (mut app, _dir) = build_test_app();
app.diff_state.wrap_lines = false;
app.diff_state.scroll_x = 42;
handle_diff_action(&mut app, Action::GoToLineStart);
assert_eq!(app.diff_state.scroll_x, 0);
}
#[test]
fn dollar_key_maps_to_line_end() {
let (mut app, _dir) = build_test_app();
app.diff_state.wrap_lines = false;
app.diff_state.max_content_width = 120;
app.diff_state.viewport_width = 40;
app.diff_state.scroll_x = 0;
handle_diff_action(&mut app, Action::GoToLineEnd);
assert_eq!(app.diff_state.scroll_x, 80);
}
#[test]
fn shift_h_m_l_map_to_cursor_vertical() {
let (mut app, _dir) = build_test_app();
app.line_annotations = vec![
crate::app::AnnotatedLine::FileHeader { file_idx: 0 },
crate::app::AnnotatedLine::HunkHeader {
file_idx: 0,
hunk_idx: 0,
},
crate::app::AnnotatedLine::DiffLine {
file_idx: 0,
hunk_idx: 0,
line_idx: 0,
old_lineno: None,
new_lineno: Some(1),
},
crate::app::AnnotatedLine::DiffLine {
file_idx: 0,
hunk_idx: 0,
line_idx: 1,
old_lineno: None,
new_lineno: Some(2),
},
crate::app::AnnotatedLine::DiffLine {
file_idx: 0,
hunk_idx: 0,
line_idx: 2,
old_lineno: None,
new_lineno: Some(3),
},
];
app.diff_state.viewport_height = 5;
app.diff_state.visible_line_count = 5;
app.diff_state.scroll_offset = 0;
app.diff_state.cursor_line = 3;
handle_diff_action(&mut app, Action::CursorToTopOfScreen);
assert_eq!(app.diff_state.cursor_line, 0);
handle_diff_action(&mut app, Action::CursorToBottomOfScreen);
assert_eq!(app.diff_state.cursor_line, 4);
}
#[test]
fn w_and_b_navigate_hunks() {
let (mut app, _dir) = build_test_app();
let before = app.diff_state.cursor_line;
handle_diff_action(&mut app, Action::WordForward);
let _after_fwd = app.diff_state.cursor_line;
handle_diff_action(&mut app, Action::WordBackward);
assert!(app.diff_state.cursor_line <= app.line_annotations.len());
let _ = before;
}
#[test]
fn zz_centers_on_cursor() {
let (mut app, _dir) = build_test_app();
let max_scroll = app.max_scroll_offset();
app.diff_state.viewport_height = 10;
app.diff_state.cursor_line = max_scroll.saturating_add(50);
app.diff_state.scroll_offset = 0;
handle_diff_action(&mut app, Action::CenterOnCursor);
assert_eq!(
app.diff_state.scroll_offset, max_scroll,
"centering must clamp to max_scroll"
);
}
#[test]
fn scroll_view_up_down_do_not_move_cursor() {
let (mut app, _dir) = build_test_app();
app.diff_state.viewport_height = 10;
app.diff_state.visible_line_count = 10;
let cursor_before = 2;
app.diff_state.cursor_line = cursor_before;
app.diff_state.scroll_offset = 1;
handle_diff_action(&mut app, Action::ScrollViewUp);
assert_eq!(app.diff_state.scroll_offset, 0, "scroll moved up by one");
assert_eq!(
app.diff_state.cursor_line, cursor_before,
"cursor must not move"
);
handle_diff_action(&mut app, Action::ScrollViewDown);
assert_eq!(
app.diff_state.cursor_line, cursor_before,
"cursor must not move"
);
}
#[test]
fn tab_cycles_through_only_visible_panels() {
let a = next_focus(FocusedPanel::Diff, true, false);
assert_eq!(a, FocusedPanel::CommitSelector);
let b = next_focus(a, true, false);
assert_eq!(b, FocusedPanel::Diff);
}
#[test]
fn tab_visits_file_list_when_shown() {
let a = next_focus(FocusedPanel::FileList, false, true);
assert_eq!(a, FocusedPanel::Diff);
let b = next_focus(a, false, true);
assert_eq!(b, FocusedPanel::FileList);
}
#[test]
fn tab_full_three_panel_cycle() {
let a = next_focus(FocusedPanel::FileList, true, true);
assert_eq!(a, FocusedPanel::Diff);
let b = next_focus(a, true, true);
assert_eq!(b, FocusedPanel::CommitSelector);
let c = next_focus(b, true, true);
assert_eq!(c, FocusedPanel::FileList);
}
#[test]
fn shift_tab_reverses_cycle() {
assert_eq!(
prev_focus(FocusedPanel::Diff, true, false),
FocusedPanel::CommitSelector
);
assert_eq!(
prev_focus(FocusedPanel::CommitSelector, true, true),
FocusedPanel::Diff
);
}
#[test]
fn tab_stays_on_diff_when_only_diff_visible() {
assert_eq!(
next_focus(FocusedPanel::Diff, false, false),
FocusedPanel::Diff
);
}
mod reaction_picker_tests {
use super::*;
use async_trait::async_trait;
use chrono::Utc;
use std::sync::{Arc, Mutex};
use travelagent_core::error::Result as CoreResult;
use travelagent_core::forge::{
ForgeComments, ForgeMerge, ForgeReactions, ForgeRead, ForgeReview, ForgeType,
MergeMethod, NewComment, NewReview, Permissions, PrId, PrListFilter, PrListItem,
PrMetadata, ReactionContent, ReactionTarget, RemoteComment, ReviewThread, User,
};
use travelagent_core::model::DiffFile;
use travelagent_core::vcs::CommitInfo;
struct RecordingForge {
calls: Arc<Mutex<Vec<(ReactionTarget, ReactionContent)>>>,
}
#[async_trait]
impl ForgeRead for RecordingForge {
fn forge_type(&self) -> ForgeType {
ForgeType::GitHub
}
async fn get_pr(&self, _id: &PrId) -> CoreResult<PrMetadata> {
unimplemented!()
}
async fn get_pr_commits(&self, _id: &PrId) -> CoreResult<Vec<CommitInfo>> {
unimplemented!()
}
async fn get_pr_files(&self, _id: &PrId) -> CoreResult<Vec<DiffFile>> {
unimplemented!()
}
async fn get_commit_diff(
&self,
_id: &PrId,
_commit_sha: &str,
) -> CoreResult<Vec<DiffFile>> {
unimplemented!()
}
async fn list_prs(
&self,
_owner: &str,
_repo: &str,
_filter: &PrListFilter,
) -> CoreResult<Vec<PrListItem>> {
unimplemented!()
}
async fn current_user(&self) -> CoreResult<User> {
unimplemented!()
}
async fn check_permissions(&self, _id: &PrId) -> CoreResult<Permissions> {
unimplemented!()
}
}
#[async_trait]
impl ForgeComments for RecordingForge {
async fn get_comments(&self, _id: &PrId) -> CoreResult<Vec<RemoteComment>> {
unimplemented!()
}
async fn get_review_threads(&self, _id: &PrId) -> CoreResult<Vec<ReviewThread>> {
unimplemented!()
}
async fn post_comment(
&self,
_id: &PrId,
_comment: NewComment,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn post_reply(
&self,
_id: &PrId,
_thread_id: &str,
_body: &str,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn edit_comment(
&self,
_id: &PrId,
_comment_id: u64,
_body: &str,
) -> CoreResult<RemoteComment> {
unimplemented!()
}
async fn delete_comment(&self, _id: &PrId, _comment_id: u64) -> CoreResult<()> {
unimplemented!()
}
async fn resolve_thread(&self, _thread_id: &str) -> CoreResult<()> {
unimplemented!()
}
async fn unresolve_thread(&self, _thread_id: &str) -> CoreResult<()> {
unimplemented!()
}
}
#[async_trait]
impl ForgeReview for RecordingForge {
async fn submit_review(&self, _id: &PrId, _review: NewReview) -> CoreResult<()> {
unimplemented!()
}
}
#[async_trait]
impl ForgeMerge for RecordingForge {
async fn merge(&self, _id: &PrId, _method: MergeMethod) -> CoreResult<()> {
unimplemented!()
}
async fn close(&self, _id: &PrId) -> CoreResult<()> {
unimplemented!()
}
async fn reopen(&self, _id: &PrId) -> CoreResult<()> {
unimplemented!()
}
}
#[async_trait]
impl ForgeReactions for RecordingForge {
async fn add_reaction(
&self,
target: &ReactionTarget,
content: ReactionContent,
) -> CoreResult<()> {
self.calls.lock().unwrap().push((target.clone(), content));
Ok(())
}
async fn remove_reaction(
&self,
_target: &ReactionTarget,
_content: ReactionContent,
) -> CoreResult<()> {
unimplemented!()
}
}
fn top_comment(id: u64) -> RemoteComment {
RemoteComment {
id,
author: "alice".to_string(),
body: "hi".to_string(),
path: None,
line: None,
side: None,
created_at: Utc::now(),
in_reply_to: None,
}
}
fn thread(id: &str, root: u64) -> ReviewThread {
ReviewThread {
id: id.to_string(),
is_resolved: false,
root_comment_id: root,
}
}
fn wire_forge(app: &mut App) -> Arc<Mutex<Vec<(ReactionTarget, ReactionContent)>>> {
let calls = Arc::new(Mutex::new(Vec::new()));
if let Some(r) = app.remote_mut() {
r.forge = Some(Arc::new(RecordingForge {
calls: calls.clone(),
}));
}
calls
}
#[test]
fn open_reaction_picker_requires_conversation_panel_and_selection() {
let (mut app, _dir) = build_test_app();
assert!(app.remote().is_none());
handle_open_reaction_picker(&mut app);
assert_eq!(app.nav.input_mode, InputMode::Normal);
assert!(
app.message.is_none(),
"off-panel `e` should be silent, got {:?}",
app.message
);
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Conversation;
r.remote_comments.clear();
}
handle_open_reaction_picker(&mut app);
assert_eq!(app.nav.input_mode, InputMode::Normal);
let msg = app.message.as_ref().expect("warning set");
assert_eq!(msg.message_type, MessageType::Warning);
assert!(
msg.content.contains("No thread selected"),
"expected 'No thread selected' warning, got {:?}",
msg.content
);
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Conversation;
r.remote_comments = vec![top_comment(42)];
r.review_threads = vec![thread("t-42", 42)];
r.conversation_cursor = 0;
}
handle_open_reaction_picker(&mut app);
assert_eq!(app.nav.input_mode, InputMode::ReactionPicker);
assert_eq!(
app.remote()
.unwrap()
.reaction_picker_target_thread
.as_deref(),
Some("t-42")
);
assert_eq!(app.reaction_picker_cursor, 0);
}
#[test]
fn reaction_picker_select_submits_to_forge() {
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Conversation;
r.remote_comments = vec![top_comment(7)];
r.review_threads = vec![thread("thread-7", 7)];
r.conversation_cursor = 0;
}
let calls = wire_forge(&mut app);
handle_open_reaction_picker(&mut app);
assert_eq!(app.nav.input_mode, InputMode::ReactionPicker);
handle_reaction_picker_action(&mut app, Action::ReactionPickerCursorRight);
handle_reaction_picker_action(&mut app, Action::ReactionPickerCursorRight);
assert_eq!(app.reaction_picker_cursor, 2);
handle_reaction_picker_action(&mut app, Action::ReactionPickerSelect);
assert_eq!(app.nav.input_mode, InputMode::Normal);
let msg = app.message.as_ref().expect("message set");
assert_eq!(msg.content, "Reaction added");
assert_eq!(msg.message_type, MessageType::Info);
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
let (target, content) = &calls[0];
assert_eq!(*target, ReactionTarget::Review("thread-7".to_string()));
assert_eq!(*content, ReactionContent::Laugh);
}
#[test]
fn reaction_picker_cursor_wraps() {
let (mut app, _dir) = make_remote_app();
app.reaction_picker_cursor = 0;
app.nav.input_mode = InputMode::ReactionPicker;
handle_reaction_picker_action(&mut app, Action::ReactionPickerCursorLeft);
assert_eq!(app.reaction_picker_cursor, 7);
handle_reaction_picker_action(&mut app, Action::ReactionPickerCursorRight);
assert_eq!(app.reaction_picker_cursor, 0);
}
#[test]
fn reaction_picker_cancel_closes_picker() {
let (mut app, _dir) = make_remote_app();
app.enter_reaction_picker("thread-x".to_string());
handle_reaction_picker_action(&mut app, Action::ReactionPickerCancel);
assert_eq!(app.nav.input_mode, InputMode::Normal);
assert!(
app.remote()
.unwrap()
.reaction_picker_target_thread
.is_none()
);
}
#[test]
fn reaction_picker_aborts_when_thread_disappears() {
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Conversation;
r.remote_comments = vec![top_comment(3)];
r.review_threads = vec![thread("thread-3", 3)];
r.conversation_cursor = 0;
}
let calls = wire_forge(&mut app);
handle_open_reaction_picker(&mut app);
assert_eq!(app.nav.input_mode, InputMode::ReactionPicker);
app.remote_mut().unwrap().review_threads.clear();
handle_reaction_picker_action(&mut app, Action::ReactionPickerSelect);
assert_eq!(app.nav.input_mode, InputMode::Normal);
assert!(
app.remote()
.unwrap()
.reaction_picker_target_thread
.is_none()
);
let msg = app.message.as_ref().expect("warning set");
assert_eq!(msg.message_type, MessageType::Warning);
assert!(msg.content.contains("Thread no longer exists"));
assert_eq!(calls.lock().unwrap().len(), 0);
}
#[test]
fn reaction_picker_select_at_submits_exact_index() {
let (mut app, _dir) = make_remote_app();
{
let r = app.remote_mut().unwrap();
r.remote_panel = RemotePanel::Conversation;
r.remote_comments = vec![top_comment(9)];
r.review_threads = vec![thread("thread-9", 9)];
r.conversation_cursor = 0;
}
let calls = wire_forge(&mut app);
handle_open_reaction_picker(&mut app);
handle_reaction_picker_action(&mut app, Action::ReactionPickerSelectAt(5));
assert_eq!(app.nav.input_mode, InputMode::Normal);
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].1, ReactionContent::Heart);
}
}
#[test]
fn mental_model_save_with_all_empty_drafts_collapses_to_none() {
let (mut app, _dir) = build_test_app();
open_mental_model_modal(&mut app);
assert_eq!(app.nav.input_mode, InputMode::MentalModelEdit);
handle_mental_model_edit_action(&mut app, Action::MentalModelSave);
assert_eq!(app.nav.input_mode, InputMode::Normal);
assert!(app.engine.session().mental_model.is_none());
}
#[test]
fn mental_model_save_preserves_created_at_on_edit() {
use travelagent_core::model::MentalModel;
let (mut app, _dir) = build_test_app();
let seeded_created = chrono::Utc::now() - chrono::Duration::hours(1);
app.engine.session_mut().mental_model = Some(MentalModel {
should_do: "first".to_string(),
shouldnt_do: String::new(),
could_go_wrong: String::new(),
assumptions: String::new(),
created_at: seeded_created,
updated_at: seeded_created,
});
open_mental_model_modal(&mut app);
handle_mental_model_edit_action(&mut app, Action::MentalModelInsertChar('!'));
handle_mental_model_edit_action(&mut app, Action::MentalModelSave);
let mm = app
.engine
.session()
.mental_model
.as_ref()
.expect("model should be present");
assert_eq!(mm.should_do, "first!");
assert_eq!(mm.created_at, seeded_created);
assert!(mm.updated_at > seeded_created);
}
#[test]
fn mental_model_insert_char_respects_byte_limit() {
let (mut app, _dir) = build_test_app();
app.mental_model_byte_limit = 3;
open_mental_model_modal(&mut app);
for _ in 0..10 {
handle_mental_model_edit_action(&mut app, Action::MentalModelInsertChar('x'));
}
assert_eq!(app.mental_model_edit.drafts[0].len(), 3);
}
#[test]
fn paste_into_mental_model_respects_byte_limit_and_utf8() {
let (mut app, _dir) = build_test_app();
app.mental_model_byte_limit = 4;
open_mental_model_modal(&mut app);
handle_paste(&mut app, "ééé");
assert_eq!(
app.mental_model_edit.drafts[0], "éé",
"paste clamp must stop at a UTF-8 boundary, not split a codepoint"
);
}
#[test]
fn paste_into_mental_model_skips_when_buffer_full() {
let (mut app, _dir) = build_test_app();
app.mental_model_byte_limit = 3;
open_mental_model_modal(&mut app);
app.mental_model_edit.drafts[0] = "abc".to_string();
handle_paste(&mut app, "xyz");
assert_eq!(
app.mental_model_edit.drafts[0], "abc",
"zero remaining must be a no-op, not clobber the buffer"
);
}
#[test]
fn paste_into_command_buffer_appends() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Command;
app.palette.set_buffer("q".to_string());
handle_paste(&mut app, "uit");
assert_eq!(app.palette.buffer(), "quit");
}
#[test]
fn paste_into_search_buffer_appends() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Search;
app.search_buffer = "foo".into();
handle_paste(&mut app, "bar");
assert_eq!(app.search_buffer, "foobar");
}
#[test]
fn paste_into_comment_respects_char_boundary() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Comment;
app.comment.buffer = "héllo".to_string(); app.comment.cursor = 2;
handle_paste(&mut app, "X");
assert_eq!(app.comment.buffer, "hXéllo");
assert_eq!(app.comment.cursor, 2, "cursor advances by len of paste");
}
#[test]
fn paste_in_normal_mode_is_a_no_op() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::Normal;
let snapshot = app.palette.buffer().to_string();
handle_paste(&mut app, "anything");
assert_eq!(
app.palette.buffer(),
snapshot,
"Normal mode has no buffer; paste must fall through silently"
);
}
fn seed_diff_files(app: &mut App, paths: &[&str]) {
use travelagent_core::model::{DiffFile, FileStatus};
app.diff_files = paths
.iter()
.map(|p| DiffFile {
old_path: None,
new_path: Some(std::path::PathBuf::from(p)),
status: FileStatus::Modified,
hunks: Vec::new(),
is_binary: false,
is_too_large: false,
is_commit_message: false,
})
.collect();
}
#[test]
fn apply_blind_filter_hides_matching_paths() {
let (mut app, _dir) = build_test_app();
seed_diff_files(
&mut app,
&[
"src/main.rs",
"tests/integration.rs",
"src/foo_test.rs",
"src/bar.rs",
],
);
app.blind_mode = true;
app.blind_patterns = vec!["tests/**".to_string(), "*_test.*".to_string()];
let hidden = app.apply_blind_filter();
assert_eq!(hidden, 2);
let kept: Vec<String> = app
.diff_files
.iter()
.map(|f| f.display_path_lossy().to_string_lossy().to_string())
.collect();
assert_eq!(kept, vec!["src/main.rs", "src/bar.rs"]);
}
#[test]
fn reload_diff_files_preserves_blind_filter() {
let (mut app, dir) = build_test_app();
std::fs::write(dir.path().join("foo_test.rs"), "fn a(){}\n").unwrap();
app.blind_mode = true;
app.blind_patterns = vec!["*_test.*".to_string(), "test.txt".to_string()];
seed_diff_files(&mut app, &["src/main.rs", "foo_test.rs", "test.txt"]);
let hidden = app.apply_blind_filter();
assert_eq!(hidden, 2);
assert_eq!(app.diff_files.len(), 1);
let _ = app.reload_diff_files();
for f in &app.diff_files {
let p = f.display_path_lossy().to_string_lossy().to_string();
assert!(
!p.contains("_test.") && !p.contains("test.txt"),
"reload leaked blind-hidden path: {p}"
);
}
}
#[test]
fn apply_blind_filter_is_noop_when_off() {
let (mut app, _dir) = build_test_app();
seed_diff_files(&mut app, &["src/main.rs", "tests/a.rs"]);
app.blind_mode = false;
app.blind_patterns = vec!["tests/**".to_string()];
let hidden = app.apply_blind_filter();
assert_eq!(hidden, 0);
assert_eq!(app.diff_files.len(), 2);
}
#[test]
fn command_blind_warns_when_patterns_empty() {
let (mut app, _dir) = build_test_app();
app.blind_patterns.clear();
app.blind_mode = false;
app.enter_command_mode();
app.palette.set_buffer("blind".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(!app.blind_mode);
let msg = app.message.as_ref().expect("warning set");
assert_eq!(msg.message_type, MessageType::Warning);
assert!(msg.content.contains("hidden_from_reviewer"));
}
#[test]
fn command_blind_flips_mode_on_when_patterns_present() {
let (mut app, _dir) = build_test_app();
seed_diff_files(&mut app, &["src/main.rs", "tests/a.rs"]);
app.blind_mode = false;
app.blind_patterns = vec!["tests/**".to_string()];
app.enter_command_mode();
app.palette.set_buffer("blind".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(app.blind_mode);
assert_eq!(app.diff_files.len(), 1);
let msg = app.message.as_ref().expect("info set");
assert_eq!(msg.message_type, MessageType::Info);
assert!(msg.content.contains("hiding 1 file"));
}
#[test]
fn command_unblind_is_noop_when_already_off() {
let (mut app, _dir) = build_test_app();
app.blind_mode = false;
app.enter_command_mode();
app.palette.set_buffer("unblind".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(!app.blind_mode);
let msg = app.message.as_ref().expect("info set");
assert!(msg.content.contains("already off"));
}
#[test]
fn command_reload_review_config_updates_blind_patterns() {
use std::fs;
let (mut app, dir) = build_test_app();
let trv_dir = dir.path().join(".travelagent");
fs::create_dir_all(&trv_dir).unwrap();
fs::write(
trv_dir.join("review.toml"),
r#"hidden_from_reviewer = ["tests/**"]"#,
)
.unwrap();
app.vcs_info.root_path = dir.path().to_path_buf();
app.blind_patterns = Vec::new();
app.blind_mode = false;
app.enter_command_mode();
app.palette.set_buffer("reload-review-config".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert_eq!(app.blind_patterns, vec!["tests/**".to_string()]);
let msg = app.message.as_ref().expect("info set");
assert_eq!(msg.message_type, MessageType::Info);
assert!(
msg.content.contains("reloaded"),
"expected reload confirmation: {}",
msg.content
);
}
#[test]
fn command_reload_review_config_warns_without_repo_root() {
let (mut app, _dir) = build_test_app();
app.vcs_info.root_path = std::path::PathBuf::new();
app.enter_command_mode();
app.palette.set_buffer("reload-review-config".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let msg = app.message.as_ref().expect("warning set");
assert_eq!(msg.message_type, MessageType::Warning);
assert!(
msg.content.contains("no effect"),
"expected no-effect warning: {}",
msg.content
);
}
fn commit_test_txt(repo_dir: &std::path::Path) {
let repo = git2::Repository::open(repo_dir).unwrap();
let mut index = repo.index().unwrap();
index.add_path(std::path::Path::new("test.txt")).unwrap();
let tree_id = index.write_tree().unwrap();
index.write().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "add test.txt", &tree, &[&head])
.unwrap();
}
#[test]
fn enter_spar_mode_refuses_when_working_tree_dirty() {
let (mut app, _dir) = build_test_app();
let result = crate::enter_spar_mode(&mut app);
assert!(
result.is_err(),
"dirty tree must bubble up an error, not flip flag-only"
);
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("dirty"),
"error must name the dirty-tree condition: {err_msg}"
);
}
#[test]
fn enter_spar_mode_creates_branch_on_first_entry() {
let (mut app, dir) = build_test_app();
commit_test_txt(dir.path());
let expected = crate::spar_branch_name(&app).expect("branch name");
let outcome = crate::enter_spar_mode(&mut app).expect("clean tree succeeds");
match outcome {
crate::SparEntryOutcome::Created(b) => assert_eq!(b, expected),
other => panic!("expected Created, got {other:?}"),
}
assert!(
!app.vcs
.is_working_tree_dirty()
.expect("dirty check post-checkout"),
"post-checkout tree must still be clean"
);
}
#[test]
fn enter_spar_mode_resumes_existing_sparring_branch() {
let (mut app, dir) = build_test_app();
commit_test_txt(dir.path());
let first = crate::enter_spar_mode(&mut app).expect("first entry");
assert!(matches!(first, crate::SparEntryOutcome::Created(_)));
let second = crate::enter_spar_mode(&mut app).expect("second entry");
assert!(
matches!(second, crate::SparEntryOutcome::Resumed(_)),
"second entry must return Resumed, got {second:?}"
);
}
#[test]
fn command_spar_refuses_on_dirty_tree() {
let (mut app, _dir) = build_test_app();
app.spar_mode = false;
app.enter_command_mode();
app.palette.set_buffer("spar".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(
!app.spar_mode,
"spar mode must not flip on for a dirty tree"
);
let msg = app.message.as_ref().expect("error set");
assert_eq!(msg.message_type, MessageType::Error);
}
#[test]
fn command_spar_is_noop_when_already_on() {
let (mut app, _dir) = build_test_app();
app.spar_mode = true;
app.enter_command_mode();
app.palette.set_buffer("spar".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(app.spar_mode);
let msg = app.message.as_ref().expect("info set");
assert!(msg.content.contains("already on"));
}
#[test]
fn command_unspar_flips_scaffold_flag_off() {
let (mut app, _dir) = build_test_app();
app.spar_mode = true;
app.enter_command_mode();
app.palette.set_buffer("unspar".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert!(!app.spar_mode);
}
#[test]
fn command_spec_warns_when_spar_mode_off() {
use travelagent_core::model::CommentType;
let (mut app, _dir) = build_test_app();
app.spar_mode = false;
app.comment.comment_type = CommentType::Note;
app.enter_command_mode();
app.palette.set_buffer("spec".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert_eq!(app.comment.comment_type, CommentType::Note);
let msg = app.message.as_ref().expect("warning set");
assert_eq!(msg.message_type, MessageType::Warning);
assert!(msg.content.contains("Sparring"));
}
#[test]
fn command_spec_sets_comment_type_when_spar_mode_on() {
use travelagent_core::model::CommentType;
let (mut app, _dir) = build_test_app();
app.spar_mode = true;
app.comment.comment_type = CommentType::Note;
app.enter_command_mode();
app.palette.set_buffer("spec".to_string());
handle_command_action(&mut app, Action::SubmitInput);
assert_eq!(app.comment.comment_type, CommentType::Spec);
let msg = app.message.as_ref().expect("info set");
assert_eq!(msg.message_type, MessageType::Info);
}
#[test]
fn command_specs_reports_zero_when_empty() {
let (mut app, _dir) = build_test_app();
app.enter_command_mode();
app.palette.set_buffer("specs".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let msg = app.message.as_ref().expect("info set");
assert_eq!(msg.message_type, MessageType::Info);
assert!(msg.content.contains("No specs yet"));
}
#[test]
fn command_specs_counts_across_scopes() {
use travelagent_core::model::{Comment, CommentType};
let (mut app, _dir) = build_test_app();
let session = app.engine.session_mut();
session
.review_comments
.push(Comment::new("a".into(), CommentType::Spec, None));
session
.review_comments
.push(Comment::new("b".into(), CommentType::Note, None));
session
.review_comments
.push(Comment::new("c".into(), CommentType::Spec, None));
app.enter_command_mode();
app.palette.set_buffer("specs".to_string());
handle_command_action(&mut app, Action::SubmitInput);
let msg = app.message.as_ref().expect("info set");
assert!(msg.content.starts_with("2 specs"), "got: {}", msg.content);
}
fn push_review_spec(app: &mut App, body: &str) -> String {
use travelagent_core::model::{Comment, CommentType};
let c = Comment::new(body.to_string(), CommentType::Spec, None);
let id = c.id.clone();
app.engine.session_mut().review_comments.push(c);
id
}
#[test]
fn sparring_accept_marks_spec_resolved_and_shrinks_counts() {
let (mut app, _dir) = build_test_app();
app.spar_mode = true;
let _id = push_review_spec(&mut app, "spec one");
let _id2 = push_review_spec(&mut app, "spec two");
assert_eq!(app.engine.session().spec_count(), 2);
app.sparring_cursor = 0;
handle_sparring_panel_action(&mut app, Action::SparringAccept);
assert_eq!(app.engine.session().spec_count(), 1);
assert_eq!(app.engine.session().review_comments.len(), 2);
let resolved_count = app
.engine
.session()
.review_comments
.iter()
.filter(|c| c.resolved)
.count();
assert_eq!(resolved_count, 1);
assert!(app.dirty);
}
#[test]
fn sparring_drop_deletes_spec_comment() {
let (mut app, _dir) = build_test_app();
app.spar_mode = true;
let _id = push_review_spec(&mut app, "spec one");
let _id2 = push_review_spec(&mut app, "spec two");
assert_eq!(app.engine.session().review_comments.len(), 2);
app.sparring_cursor = 0;
handle_sparring_panel_action(&mut app, Action::PendingDCommand);
assert_eq!(app.engine.session().review_comments.len(), 1);
assert_eq!(app.engine.session().spec_count(), 1);
}
#[test]
fn sparring_reshape_is_non_destructive_and_sets_message() {
let (mut app, _dir) = build_test_app();
app.spar_mode = true;
let _id = push_review_spec(&mut app, "spec one");
app.sparring_cursor = 0;
handle_sparring_panel_action(&mut app, Action::ToggleReviewed);
assert_eq!(app.engine.session().review_comments.len(), 1);
assert_eq!(app.engine.session().spec_count(), 1);
let msg = app.message.as_ref().expect("reshape message set");
assert!(msg.content.contains("Reshape requested"));
}
#[test]
fn sparring_cursor_clamps_after_accept_at_end() {
let (mut app, _dir) = build_test_app();
app.spar_mode = true;
push_review_spec(&mut app, "first");
push_review_spec(&mut app, "second");
app.sparring_cursor = 1;
handle_sparring_panel_action(&mut app, Action::SparringAccept);
assert_eq!(app.sparring_cursor, 0);
}
#[test]
fn sparring_cursor_nav_respects_bounds() {
let (mut app, _dir) = build_test_app();
app.spar_mode = true;
push_review_spec(&mut app, "a");
push_review_spec(&mut app, "b");
push_review_spec(&mut app, "c");
app.sparring_cursor = 0;
handle_sparring_panel_action(&mut app, Action::CursorDown(5));
assert_eq!(app.sparring_cursor, 2);
handle_sparring_panel_action(&mut app, Action::CursorUp(10));
assert_eq!(app.sparring_cursor, 0);
}
#[test]
fn mental_model_cancel_discards_drafts_without_touching_session() {
use travelagent_core::model::MentalModel;
let (mut app, _dir) = build_test_app();
let before = MentalModel {
should_do: "keep".to_string(),
shouldnt_do: "me".to_string(),
could_go_wrong: String::new(),
assumptions: String::new(),
created_at: chrono::Utc::now() - chrono::Duration::minutes(5),
updated_at: chrono::Utc::now() - chrono::Duration::minutes(5),
};
app.engine.session_mut().mental_model = Some(before.clone());
open_mental_model_modal(&mut app);
handle_mental_model_edit_action(&mut app, Action::MentalModelInsertChar('Z'));
handle_mental_model_edit_action(&mut app, Action::MentalModelCancel);
assert_eq!(app.nav.input_mode, InputMode::Normal);
let after = app.engine.session().mental_model.as_ref().unwrap();
assert_eq!(after.should_do, "keep");
assert_eq!(after.shouldnt_do, "me");
assert!(app.mental_model_edit.drafts.iter().all(|s| s.is_empty()));
}
#[test]
fn visual_action_exit_mode_returns_to_normal_and_clears_anchor() {
use travelagent_core::model::LineSide;
let (mut app, _dir) = build_test_app();
app.enter_visual_mode(3, LineSide::New);
assert_eq!(app.nav.input_mode, InputMode::VisualSelect);
assert!(app.comment.visual_anchor.is_some());
handle_visual_action(&mut app, Action::ExitMode);
assert_eq!(app.nav.input_mode, InputMode::Normal);
assert!(app.comment.visual_anchor.is_none());
}
#[test]
fn visual_action_quit_warns_when_dirty_and_arms_quit_warned() {
use travelagent_core::model::LineSide;
let (mut app, _dir) = build_test_app();
app.enter_visual_mode(2, LineSide::New);
app.dirty = true;
app.confirm_on_quit = true;
assert!(!app.quit_warned);
handle_visual_action(&mut app, Action::Quit);
assert!(!app.should_quit, "first Quit must NOT quit when dirty");
assert!(app.quit_warned, "first Quit must arm quit_warned");
let msg = app.message.as_ref().expect("warning set");
assert_eq!(msg.message_type, MessageType::Warning);
assert!(msg.content.contains("Unsaved changes"));
handle_visual_action(&mut app, Action::Quit);
assert!(app.should_quit);
}
#[test]
fn commit_select_action_up_decrements_cursor() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::CommitSelect;
app.commit_select.list = vec![
make_test_commit("aaa1111"),
make_test_commit("bbb2222"),
make_test_commit("ccc3333"),
];
app.commit_select.visible_count = 3;
app.commit_select.cursor = 2;
handle_commit_select_action(&mut app, Action::CommitSelectUp);
assert_eq!(app.commit_select.cursor, 1);
}
#[test]
fn commit_select_action_toggle_marks_selection() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::CommitSelect;
app.commit_select.list = vec![make_test_commit("aaa1111"), make_test_commit("bbb2222")];
app.commit_select.visible_count = 2;
app.commit_select.cursor = 1;
app.commit_select.has_more = false;
assert!(app.commit_select.selection_range.is_none());
handle_commit_select_action(&mut app, Action::ToggleCommitSelect);
assert_eq!(app.commit_select.selection_range, Some((1, 1)));
}
#[test]
fn review_submit_action_cursor_down_advances_verdict() {
let (mut app, _dir) = make_remote_app();
app.nav.input_mode = InputMode::ReviewSubmit;
{
let r = app.remote_mut().unwrap();
r.review_verdict_cursor = 0;
r.review_body_editing = false;
}
handle_review_submit_action(&mut app, Action::CursorDown(1));
assert_eq!(app.remote().unwrap().review_verdict_cursor, 1);
}
#[test]
fn review_submit_action_select_file_enters_body_editing() {
let (mut app, _dir) = make_remote_app();
app.nav.input_mode = InputMode::ReviewSubmit;
{
let r = app.remote_mut().unwrap();
r.review_verdict_cursor = 0;
r.review_body_editing = false;
r.review_body.clear();
}
handle_review_submit_action(&mut app, Action::SelectFile);
assert!(app.remote().unwrap().review_body_editing);
handle_review_submit_action(&mut app, Action::InsertChar('h'));
handle_review_submit_action(&mut app, Action::InsertChar('i'));
assert_eq!(app.remote().unwrap().review_body, "hi");
}
#[test]
fn review_submit_action_exit_mode_steps_back_then_closes() {
let (mut app, _dir) = make_remote_app();
app.nav.input_mode = InputMode::ReviewSubmit;
{
let r = app.remote_mut().unwrap();
r.review_body_editing = true;
}
handle_review_submit_action(&mut app, Action::ExitMode);
assert_eq!(app.nav.input_mode, InputMode::ReviewSubmit);
assert!(!app.remote().unwrap().review_body_editing);
handle_review_submit_action(&mut app, Action::ExitMode);
assert_eq!(app.nav.input_mode, InputMode::Normal);
}
#[test]
fn palette_submit_invokes_keybinding_entry_for_help() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::CommandPalette;
app.palette.set_buffer("Help".to_string());
handle_command_palette_action(&mut app, Action::SubmitInput);
assert_eq!(app.nav.input_mode, InputMode::Help);
}
#[test]
fn palette_submit_falls_back_to_command_handler_for_no_match() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::CommandPalette;
app.palette
.set_buffer("zzz_not_a_palette_match".to_string());
use crate::ui::command_palette::filter_entries;
assert!(
filter_entries("zzz_not_a_palette_match").is_empty(),
"test setup precondition: filter must be empty so we hit the fallback branch"
);
handle_command_palette_action(&mut app, Action::SubmitInput);
let msg = app.message.as_ref().expect("status set after fallback");
assert!(
msg.content.contains("Unknown command"),
"expected unknown-command warning from command handler, got: {}",
msg.content
);
}
#[test]
fn try_submit_reply_empty_buffer_sets_message_and_keeps_thread() {
let (mut app, _dir) = make_remote_app();
app.remote_mut().unwrap().replying_to_thread = Some("thread-empty".to_string());
app.nav.input_mode = InputMode::Comment;
app.comment.buffer = " \t ".to_string(); app.comment.cursor = app.comment.buffer.len();
handle_comment_action(&mut app, Action::SubmitInput);
assert_eq!(app.comment.buffer, " \t ");
let msg = app.message.as_ref().expect("status set");
assert!(
msg.content.contains("Reply cannot be empty"),
"expected reply-empty message, got: {}",
msg.content
);
assert_eq!(app.engine.session().review_comments.len(), 0);
}
#[test]
fn try_submit_reply_without_forge_warns_and_clears_thread() {
let (mut app, _dir) = make_remote_app();
assert!(!app.has_forge());
app.remote_mut().unwrap().replying_to_thread = Some("thread-noforge".to_string());
app.nav.input_mode = InputMode::Comment;
app.comment.buffer = "non-empty body".to_string();
app.comment.cursor = app.comment.buffer.len();
handle_comment_action(&mut app, Action::SubmitInput);
assert_eq!(app.nav.input_mode, InputMode::Normal);
assert!(app.remote().unwrap().replying_to_thread.is_none());
let msg = app.message.as_ref().expect("warning set");
assert_eq!(msg.message_type, MessageType::Warning);
assert!(
msg.content.to_lowercase().contains("demo mode"),
"expected demo-mode warning, got: {}",
msg.content
);
}
#[test]
fn palette_submit_select_file_triggers_review_keybinding() {
let (mut app, _dir) = build_test_app();
app.nav.input_mode = InputMode::CommandPalette;
app.palette.set_buffer("Review Com".to_string());
handle_command_palette_action(&mut app, Action::SubmitInput);
assert_eq!(app.nav.input_mode, InputMode::Comment);
assert!(app.comment.is_review_level);
}
fn submit_tour(app: &mut App, origin: InputMode, peers: usize) {
app.mcp_peer_count
.store(peers, std::sync::atomic::Ordering::Relaxed);
app.nav.command_origin = origin;
app.nav.input_mode = InputMode::Command;
app.palette.set_buffer("tour".to_string());
handle_command_action(app, Action::SubmitInput);
}
#[test]
fn tour_from_normal_mode_succeeds_when_agent_attached() {
let (mut app, _dir) = build_test_app();
submit_tour(&mut app, InputMode::Normal, 1);
assert!(
app.pending_tour_request.is_some(),
"tour should be queued when invoked from Normal with a peer connected"
);
let msg = app.message.as_ref().expect("status set");
assert_eq!(msg.message_type, MessageType::Info);
assert!(
msg.content.contains("Tour requested"),
"got: {}",
msg.content
);
}
#[test]
fn tour_rejected_when_no_peer_connected() {
let (mut app, _dir) = build_test_app();
submit_tour(&mut app, InputMode::CommitSelect, 0);
assert!(app.pending_tour_request.is_none());
let msg = app.message.as_ref().expect("error set");
assert_eq!(msg.message_type, MessageType::Error);
assert!(msg.content.contains("agent"), "got: {}", msg.content);
}
#[test]
fn tour_uses_selection_when_commits_are_toggled() {
let (mut app, _dir) = build_test_app();
app.commit_select.list = vec![
make_test_commit("aaa1111"),
make_test_commit("bbb2222"),
make_test_commit("ccc3333"),
];
app.commit_select.selection_range = Some((0, 1));
submit_tour(&mut app, InputMode::CommitSelect, 2);
let ids = app
.pending_tour_request
.as_ref()
.expect("tour request queued");
assert_eq!(ids, &vec!["aaa1111".to_string(), "bbb2222".to_string()]);
assert_eq!(app.nav.input_mode, InputMode::CommitSelect);
let msg = app.message.as_ref().expect("info message");
assert_eq!(msg.message_type, MessageType::Info);
assert!(msg.content.contains("2 commits"), "got: {}", msg.content);
}
#[test]
fn tour_falls_back_to_full_revset_when_no_selection() {
let (mut app, _dir) = build_test_app();
app.commit_select.list = vec![
make_test_commit("aaa1111"),
make_test_commit("bbb2222"),
make_test_commit("ccc3333"),
];
app.commit_select.selection_range = None;
submit_tour(&mut app, InputMode::CommitSelect, 1);
let ids = app
.pending_tour_request
.as_ref()
.expect("tour request queued");
assert_eq!(
ids,
&vec![
"aaa1111".to_string(),
"bbb2222".to_string(),
"ccc3333".to_string()
]
);
let msg = app.message.as_ref().expect("info message");
assert!(msg.content.contains("3 commits"), "got: {}", msg.content);
}
#[test]
fn return_to_commits_focuses_commit_selector_when_commits_available() {
let (mut app, _dir) = build_test_app();
app.diff_source =
DiffSource::CommitRange(vec!["aaa1111".to_string(), "bbb2222".to_string()]);
app.inline_selector.commits =
vec![make_test_commit("aaa1111"), make_test_commit("bbb2222")];
app.inline_selector.visible = true;
app.nav.focused_panel = FocusedPanel::Diff;
assert!(app.has_inline_commit_selector());
handle_shared_normal_action(&mut app, Action::ReturnToCommits);
assert_eq!(app.nav.focused_panel, FocusedPanel::CommitSelector);
assert_eq!(app.nav.input_mode, InputMode::Normal);
}
#[test]
fn return_to_commits_enters_picker_when_no_commit_list() {
let (mut app, _dir) = build_test_app();
app.diff_source = DiffSource::WorkingTree;
app.nav.focused_panel = FocusedPanel::Diff;
assert!(!app.has_inline_commit_selector());
handle_shared_normal_action(&mut app, Action::ReturnToCommits);
assert_eq!(app.nav.input_mode, InputMode::CommitSelect);
}
#[test]
fn return_to_commits_is_noop_off_diff_panel() {
let (mut app, _dir) = build_test_app();
app.nav.focused_panel = FocusedPanel::FileList;
let mode_before = app.nav.input_mode;
handle_shared_normal_action(&mut app, Action::ReturnToCommits);
assert_eq!(app.nav.focused_panel, FocusedPanel::FileList);
assert_eq!(app.nav.input_mode, mode_before);
}
#[test]
fn return_to_commits_switches_remote_panel_to_commits() {
let (mut app, _dir) = make_remote_app();
app.nav.focused_panel = FocusedPanel::Diff;
app.remote_mut().unwrap().remote_panel = RemotePanel::Files;
handle_shared_normal_action(&mut app, Action::ReturnToCommits);
assert_eq!(
app.remote().unwrap().remote_panel,
RemotePanel::Commits,
"Esc from the Diff panel in remote mode surfaces the commit list"
);
}
}