use crate::ai_command_runner::AiCommandRunner;
use crate::config::OrchestratorConfig;
use crate::error::Result;
use crate::tui::events::{LogEntry, OrchestratorEvent, TuiCommand};
use crate::tui::state::AppState;
use crate::tui::types::{AppMode, StopMode};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::DefaultTerminal;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use tracing::debug;
use super::terminal::{execute_worktree_command, suspend_terminal_and_execute_sync};
use super::worktrees::load_worktrees_with_conflict_check;
pub struct KeyEventContext<'a> {
pub app: &'a mut AppState,
pub terminal: &'a mut DefaultTerminal,
pub repo_root: &'a Path,
pub config: &'a OrchestratorConfig,
pub worktree_base_dir: &'a Path,
pub tx: &'a mpsc::Sender<OrchestratorEvent>,
pub cmd_tx: &'a mpsc::Sender<TuiCommand>,
pub ai_runner: &'a AiCommandRunner,
pub graceful_stop_flag: &'a Arc<AtomicBool>,
pub orchestrator_cancel: &'a Option<CancellationToken>,
pub orchestrator_handle: &'a Option<tokio::task::JoinHandle<Result<()>>>,
}
fn request_local_tui_quit(app: &mut AppState, orchestrator_cancel: &Option<CancellationToken>) {
app.should_quit = true;
if let Some(cancel) = orchestrator_cancel {
cancel.cancel();
app.add_log(LogEntry::warn(
"Quit requested: cancelling local orchestration before TUI shutdown",
));
}
}
pub async fn handle_tab_key(ctx: &mut KeyEventContext<'_>) -> Result<()> {
use crate::tui::types::ViewMode;
let new_view = match ctx.app.view_mode {
ViewMode::Changes => ViewMode::Worktrees,
ViewMode::Worktrees => ViewMode::Changes,
};
if new_view == ViewMode::Worktrees {
let load_tx = ctx.tx.clone();
let load_repo_root = ctx.repo_root.to_path_buf();
tokio::spawn(async move {
match load_worktrees_with_conflict_check(&load_repo_root).await {
Ok(worktrees) => {
let _ = load_tx
.send(OrchestratorEvent::WorktreesRefreshed { worktrees })
.await;
}
Err(e) => {
let _ = load_tx
.send(OrchestratorEvent::Log(LogEntry::error(format!(
"Failed to load worktrees: {}",
e
))))
.await;
}
}
});
}
ctx.app.view_mode = new_view;
Ok(())
}
pub fn handle_cursor_movement(app: &mut AppState, is_up: bool) {
use crate::tui::types::ViewMode;
match app.view_mode {
ViewMode::Changes => {
if is_up {
app.cursor_up()
} else {
app.cursor_down()
}
}
ViewMode::Worktrees => {
if is_up {
app.worktree_cursor_up()
} else {
app.worktree_cursor_down()
}
}
}
}
pub async fn handle_editor_launch(ctx: &mut KeyEventContext<'_>) -> Result<()> {
use crate::tui::types::ViewMode;
let view_mode = ctx.app.view_mode;
let change_id = if !ctx.app.changes.is_empty() && ctx.app.cursor_index < ctx.app.changes.len() {
Some(ctx.app.changes[ctx.app.cursor_index].id.clone())
} else {
None
};
let worktree_path = ctx.app.get_selected_worktree_path();
if view_mode == ViewMode::Worktrees && ctx.app.suppress_if_selected_worktree_deleting() {
ctx.app.add_log(LogEntry::warn(
"Editor ignored: worktree is already being deleted",
));
return Ok(());
}
suspend_terminal_and_execute_sync(ctx.terminal, || {
match view_mode {
ViewMode::Changes => {
if let Some(id) = change_id {
if let Err(e) = crate::tui::utils::launch_editor_for_change(&id) {
eprintln!("Failed to launch editor: {}", e);
}
}
}
ViewMode::Worktrees => {
if let Some(path) = worktree_path {
if let Err(e) = crate::tui::utils::launch_editor_in_dir(&path) {
eprintln!("Failed to launch editor: {}", e);
}
}
}
}
Ok(())
})
}
pub async fn handle_merge_key(ctx: &mut KeyEventContext<'_>) -> Result<()> {
use crate::tui::types::ViewMode;
debug!("M key pressed: view_mode={:?}", ctx.app.view_mode);
match ctx.app.view_mode {
ViewMode::Changes => {
debug!("M key (Changes view): attempting resolve_merge");
if let Some(cmd) = ctx.app.resolve_merge() {
debug!("M key (Changes view): sending command {:?}", cmd);
let _ = ctx.cmd_tx.send(cmd).await;
} else {
debug!("M key (Changes view): resolve_merge returned None");
}
}
ViewMode::Worktrees => {
debug!("M key (Worktrees view): attempting request_merge_worktree_branch");
if let Some(cmd) = ctx.app.request_merge_worktree_branch() {
debug!("M key (Worktrees view): sending command {:?}", cmd);
let _ = ctx.cmd_tx.send(cmd).await;
} else {
debug!("M key (Worktrees view): request_merge_worktree_branch returned None");
}
}
}
Ok(())
}
pub fn handle_esc_key(ctx: &mut KeyEventContext<'_>) {
match ctx.app.mode {
AppMode::Running => {
ctx.app.stop_mode = StopMode::GracefulPending;
ctx.graceful_stop_flag.store(true, Ordering::SeqCst);
ctx.app.mode = AppMode::Stopping;
ctx.app
.add_log(LogEntry::warn("Stopping after current change completes..."));
}
AppMode::Stopping => {
ctx.app.stop_mode = StopMode::ForceStopped;
if let Some(cancel) = ctx.orchestrator_cancel {
cancel.cancel();
}
ctx.app
.handle_orchestrator_event(OrchestratorEvent::Stopped);
ctx.app.current_change = None;
ctx.app.add_log(LogEntry::warn("Force stopped"));
}
_ => {}
}
}
fn handle_start_key_inner(
app: &mut AppState,
graceful_stop_flag: &AtomicBool,
orchestrator_handle: &Option<tokio::task::JoinHandle<Result<()>>>,
) -> Option<TuiCommand> {
if app.mode == AppMode::Stopping {
if orchestrator_handle
.as_ref()
.is_some_and(|h| !h.is_finished())
{
graceful_stop_flag.store(false, Ordering::SeqCst);
app.stop_mode = StopMode::None;
app.mode = AppMode::Running;
app.add_log(LogEntry::info("Stop canceled, continuing..."));
} else {
app.add_log(LogEntry::warn(
"Cannot cancel stop: processing already completed",
));
}
return None;
}
if app.mode == AppMode::Error {
app.retry_error_changes()
} else if app.mode == AppMode::Stopped {
app.resume_processing()
} else {
app.start_processing()
}
}
pub fn handle_start_key(ctx: &mut KeyEventContext<'_>) -> Option<TuiCommand> {
handle_start_key_inner(ctx.app, ctx.graceful_stop_flag, ctx.orchestrator_handle)
}
pub async fn handle_enter_key(ctx: &mut KeyEventContext<'_>) -> Result<()> {
use crate::tui::types::ViewMode;
if ctx.app.view_mode != ViewMode::Worktrees {
ctx.app
.add_log(LogEntry::warn("Enter ignored: not in Worktrees view"));
return Ok(());
}
let Some(worktree_path_str) = ctx.app.get_selected_worktree_path() else {
ctx.app
.add_log(LogEntry::warn("Enter ignored: no worktree selected"));
return Ok(());
};
if ctx.app.suppress_if_selected_worktree_deleting() {
ctx.app.add_log(LogEntry::warn(
"Enter ignored: worktree is already being deleted",
));
return Ok(());
}
let Some(template) = ctx.config.get_worktree_command().map(str::to_string) else {
ctx.app.add_log(LogEntry::warn(
"Enter ignored: worktree_command not configured",
));
return Ok(());
};
let Some(repo_root_str) = ctx.repo_root.to_str() else {
ctx.app.add_log(LogEntry::error(
"Failed to resolve repo root path".to_string(),
));
return Ok(());
};
let command =
OrchestratorConfig::expand_worktree_command(&template, &worktree_path_str, repo_root_str);
ctx.app.add_log(LogEntry::info(format!(
"Running worktree command in {}",
worktree_path_str
)));
let worktree_path = Path::new(&worktree_path_str);
execute_worktree_command(
ctx.terminal,
&command,
worktree_path,
ctx.ai_runner,
ctx.app,
)
.await
}
pub async fn handle_plus_key(ctx: &mut KeyEventContext<'_>) -> Result<()> {
use crate::tui::types::ViewMode;
if ctx.app.view_mode != ViewMode::Worktrees {
return Ok(());
}
let Some(template) = ctx.config.get_worktree_command().map(str::to_string) else {
return Ok(());
};
let is_git_repo = match crate::vcs::git::commands::check_git_repo(ctx.repo_root).await {
Ok(is_repo) => is_repo,
Err(err) => {
ctx.app.add_log(LogEntry::error(format!(
"Failed to check git repo: {}",
err
)));
return Ok(());
}
};
if !super::worktrees::should_trigger_worktree_command(ctx.config, is_git_repo) {
return Ok(());
}
if let Err(err) = std::fs::create_dir_all(ctx.worktree_base_dir) {
ctx.app.add_log(LogEntry::error(format!(
"Failed to prepare worktree base dir: {}",
err
)));
return Ok(());
}
let worktree_path = super::worktrees::build_worktree_path(ctx.worktree_base_dir);
let Some(worktree_path_str) = worktree_path.to_str() else {
ctx.app.add_log(LogEntry::error(
"Failed to resolve worktree path".to_string(),
));
return Ok(());
};
let Some(repo_root_str) = ctx.repo_root.to_str() else {
ctx.app.add_log(LogEntry::error(
"Failed to resolve repo root path".to_string(),
));
return Ok(());
};
let branch_name = match crate::vcs::git::commands::generate_unique_branch_name(
ctx.repo_root,
"ws-session",
10,
)
.await
{
Ok(name) => name,
Err(err) => {
ctx.app.add_log(LogEntry::error(format!(
"Failed to generate unique branch name: {}",
err
)));
return Ok(());
}
};
if let Err(err) = crate::vcs::git::commands::worktree_add(
ctx.repo_root,
worktree_path_str,
&branch_name,
"HEAD",
)
.await
{
ctx.app.add_log(LogEntry::error(format!(
"Failed to create worktree: {}",
err
)));
return Ok(());
}
ctx.app.add_log(LogEntry::info(format!(
"Created worktree with branch '{}'",
branch_name
)));
if let Err(err) =
crate::vcs::git::commands::run_worktree_setup(ctx.repo_root, &worktree_path).await
{
ctx.app.add_log(LogEntry::error(format!(
"Failed to run worktree setup: {}",
err
)));
if let Err(cleanup_err) = crate::vcs::git::commands::worktree_remove_with_options(
ctx.repo_root,
worktree_path_str,
crate::vcs::git::commands::WorktreeRemoveOptions {
skip_teardown: true,
},
)
.await
{
ctx.app.add_log(LogEntry::error(format!(
"Failed to cleanup worktree after setup failure: {}",
cleanup_err
)));
}
return Ok(());
}
let command =
OrchestratorConfig::expand_worktree_command(&template, worktree_path_str, repo_root_str);
ctx.app.add_log(LogEntry::info(format!(
"Running worktree command in {}",
worktree_path_str
)));
execute_worktree_command(
ctx.terminal,
&command,
&worktree_path,
ctx.ai_runner,
ctx.app,
)
.await
}
pub(crate) fn handle_warning_popup_key(app: &mut AppState, key: KeyEvent) -> bool {
if app.warning_popup.is_none() {
return false;
}
match key.code {
KeyCode::Esc => {
app.clear_warning_popup();
}
KeyCode::Up | KeyCode::Char('k') => {
app.scroll_warning_popup(-1);
}
KeyCode::Down | KeyCode::Char('j') => {
app.scroll_warning_popup(1);
}
KeyCode::PageUp => {
app.scroll_warning_popup(-5);
}
KeyCode::PageDown => {
app.scroll_warning_popup(5);
}
_ => {}
}
true
}
pub async fn handle_key_event(
key: KeyEvent,
ctx: &mut KeyEventContext<'_>,
) -> Result<Option<TuiCommand>> {
let had_warning_message = ctx.app.warning_message.is_some();
if handle_warning_popup_key(ctx.app, key) {
ctx.app.warning_message = None;
return Ok(None);
}
if ctx.app.mode == AppMode::QrPopup {
ctx.app.hide_qr_popup();
return Ok(None);
}
if let AppMode::ConfirmForceKill { ref change_id } = ctx.app.mode {
let cid = change_id.clone();
match (key.code, key.modifiers) {
(KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => {
ctx.app.mode = AppMode::Running;
ctx.app
.add_log(LogEntry::info(format!("Force-kill confirmed: {}", cid)));
let _ = ctx.cmd_tx.send(TuiCommand::DequeueChange(cid)).await;
}
(KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) | (KeyCode::Esc, _) => {
ctx.app.mode = AppMode::Running;
ctx.app
.add_log(LogEntry::info("Force-kill canceled".to_string()));
}
_ => {}
}
return Ok(None);
}
if ctx.app.mode == AppMode::ConfirmWorktreeDelete {
match (key.code, key.modifiers) {
(KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => {
if let Some(cmd) = ctx.app.confirm_worktree_action_delete() {
let _ = ctx.cmd_tx.send(cmd).await;
}
}
(KeyCode::Char('n'), _) | (KeyCode::Char('N'), _) | (KeyCode::Esc, _) => {
ctx.app.cancel_worktree_action();
}
(KeyCode::Char('s'), _) | (KeyCode::Char('S'), _) => {
if let Some(cmd) = ctx.app.confirm_worktree_action_delete_with_options(true) {
let _ = ctx.cmd_tx.send(cmd).await;
}
}
_ => {}
}
return Ok(None);
}
let mut cmd_to_start: Option<TuiCommand> = None;
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
request_local_tui_quit(ctx.app, ctx.orchestrator_cancel);
}
(KeyCode::Tab, _) => {
handle_tab_key(ctx).await?;
}
(KeyCode::Up, _) | (KeyCode::Char('k'), _) => {
handle_cursor_movement(ctx.app, true);
}
(KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
handle_cursor_movement(ctx.app, false);
}
(KeyCode::Char(' '), _) => {
if let Some(cmd) = ctx.app.toggle_selection() {
let _ = ctx.cmd_tx.send(cmd).await;
}
}
(KeyCode::Char('x'), _) => {
use crate::tui::types::ViewMode;
if ctx.app.view_mode == ViewMode::Changes {
let commands = ctx.app.toggle_all_marks();
for cmd in commands {
let _ = ctx.cmd_tx.send(cmd).await;
}
}
}
(KeyCode::Char('e'), _) => {
handle_editor_launch(ctx).await?;
}
(KeyCode::Char('m'), _) | (KeyCode::Char('M'), _) => {
handle_merge_key(ctx).await?;
}
(KeyCode::Char('d'), _) | (KeyCode::Char('D'), _) => {
use crate::tui::types::ViewMode;
if ctx.app.view_mode == ViewMode::Worktrees {
ctx.app.request_worktree_delete_from_list();
}
}
(KeyCode::Esc, _) => {
handle_esc_key(ctx);
}
_ if ctx.app.tui_config.matches_start_key(&key) => {
cmd_to_start = handle_start_key(ctx);
}
(KeyCode::PageUp, _) => {
ctx.app.scroll_logs_up(5);
}
(KeyCode::PageDown, _) => {
ctx.app.scroll_logs_down(5);
}
(KeyCode::Home, _) => {
ctx.app.scroll_logs_to_top();
}
(KeyCode::End, _) => {
ctx.app.scroll_logs_to_bottom();
}
(KeyCode::Char('='), _) => {
ctx.app.toggle_parallel_mode();
}
(KeyCode::Enter, _) => {
handle_enter_key(ctx).await?;
}
(KeyCode::Char('+'), _) => {
handle_plus_key(ctx).await?;
}
(KeyCode::Char('w'), _) if ctx.app.web_url.is_some() => {
ctx.app.show_qr_popup();
}
(KeyCode::Char('l'), _) => {
use crate::tui::types::ViewMode;
if ctx.app.view_mode == ViewMode::Changes {
ctx.app.toggle_logs_panel();
}
}
(KeyCode::Char('K'), _) => {
use crate::tui::types::ViewMode;
if ctx.app.view_mode == ViewMode::Changes
&& ctx.app.mode == AppMode::Running
&& !ctx.app.changes.is_empty()
&& ctx.app.cursor_index < ctx.app.changes.len()
{
let change = &ctx.app.changes[ctx.app.cursor_index];
if matches!(
change.display_status_cache.as_str(),
"applying" | "accepting" | "archiving" | "resolving"
) {
let cid = change.id.clone();
ctx.app.mode = AppMode::ConfirmForceKill {
change_id: cid.clone(),
};
ctx.app.add_log(LogEntry::warn(format!(
"Confirm force-kill for '{}': press Y to confirm, N/Esc to cancel",
cid
)));
}
}
}
_ => {}
}
if had_warning_message {
ctx.app.warning_message = None;
}
Ok(cmd_to_start)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openspec::{Change, ProposalMetadata};
use crate::tui::config::TuiConfig;
use crossterm::event::KeyCode;
fn create_test_change(id: &str) -> Change {
Change {
id: id.to_string(),
completed_tasks: 0,
total_tasks: 1,
last_modified: "now".to_string(),
dependencies: Vec::new(),
metadata: ProposalMetadata::default(),
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn inert_stop_flag() -> Arc<AtomicBool> {
Arc::new(AtomicBool::new(false))
}
#[test]
fn log_navigation_state_methods_preserve_existing_key_semantics() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
for index in 0..12 {
app.add_log(LogEntry::info(format!("log {index}")));
}
assert_eq!(app.log_scroll_offset, 0);
assert!(app.log_auto_scroll);
app.scroll_logs_up(5);
assert_eq!(app.log_scroll_offset, 5);
assert!(!app.log_auto_scroll);
app.scroll_logs_down(5);
assert_eq!(app.log_scroll_offset, 0);
assert!(app.log_auto_scroll);
app.scroll_logs_to_top();
assert_eq!(app.log_scroll_offset, 11);
assert!(!app.log_auto_scroll);
app.scroll_logs_to_bottom();
assert_eq!(app.log_scroll_offset, 0);
assert!(app.log_auto_scroll);
assert!(app.logs_panel_enabled);
app.toggle_logs_panel();
assert!(!app.logs_panel_enabled);
app.toggle_logs_panel();
assert!(app.logs_panel_enabled);
}
#[test]
fn configured_start_key_matches_default_and_custom_bindings() {
let mut app = AppState::new(vec![create_test_change("run-me")]);
app.changes[0].selected = true;
assert!(app.tui_config.matches_start_key(&key(KeyCode::F(5))));
assert!(app.tui_config.matches_start_key(&key(KeyCode::Char('!'))));
let custom = TuiConfig::parse_jsonc(
r#"{"keybindings":{"start":["F5","!"]}}"#,
std::path::Path::new("/tmp/tui.jsonc"),
)
.unwrap();
app.set_tui_config(custom);
assert!(app.tui_config.matches_start_key(&key(KeyCode::F(5))));
assert!(app.tui_config.matches_start_key(&key(KeyCode::Char('!'))));
assert!(!app.tui_config.matches_start_key(&key(KeyCode::Char('x'))));
}
#[test]
fn configured_start_key_triggers_same_command_as_f5() {
let graceful_stop = inert_stop_flag();
let handle = None;
let custom = TuiConfig::parse_jsonc(
r#"{"keybindings":{"start":["F5","!"]}}"#,
std::path::Path::new("/tmp/tui.jsonc"),
)
.unwrap();
let mut f5_app = AppState::new(vec![create_test_change("run-me")]);
f5_app.set_tui_config(custom.clone());
f5_app.changes[0].selected = true;
let f5_command = if f5_app.tui_config.matches_start_key(&key(KeyCode::F(5))) {
handle_start_key_inner(&mut f5_app, &graceful_stop, &handle)
} else {
None
};
let mut bang_app = AppState::new(vec![create_test_change("run-me")]);
bang_app.set_tui_config(custom);
bang_app.changes[0].selected = true;
let bang_command = if bang_app
.tui_config
.matches_start_key(&key(KeyCode::Char('!')))
{
handle_start_key_inner(&mut bang_app, &graceful_stop, &handle)
} else {
None
};
assert_eq!(format!("{:?}", f5_command), format!("{:?}", bang_command));
assert!(matches!(
bang_command,
Some(TuiCommand::StartProcessing(ids)) if ids == vec!["run-me".to_string()]
));
}
#[test]
fn f5_on_merge_wait_row_does_not_emit_resolve_merge() {
let mut app = AppState::new(vec![
create_test_change("merge-wait"),
create_test_change("run-me"),
]);
app.mode = AppMode::Select;
app.cursor_index = 0;
app.changes[0].display_status_cache = "merge wait".to_string();
app.changes[1].selected = true;
let graceful_stop = inert_stop_flag();
let handle = None;
let command = handle_start_key_inner(&mut app, &graceful_stop, &handle);
assert!(
!matches!(command, Some(TuiCommand::ResolveMerge(_))),
"F5 must not dispatch cursor-local ResolveMerge for MergeWait rows"
);
assert!(matches!(
command,
Some(TuiCommand::StartProcessing(ids)) if ids == vec!["run-me".to_string()]
));
assert_eq!(app.changes[0].display_status_cache, "merge wait");
assert_eq!(app.changes[1].display_status_cache, "queued");
}
#[test]
fn f5_delegates_start_resume_and_retry_while_resolving() {
let graceful_stop = inert_stop_flag();
let handle = None;
let mut select_app = AppState::new(vec![create_test_change("select-a")]);
select_app.mode = AppMode::Select;
select_app.is_resolving = true;
select_app.changes[0].selected = true;
let command = handle_start_key_inner(&mut select_app, &graceful_stop, &handle);
assert!(matches!(
command,
Some(TuiCommand::StartProcessing(ids)) if ids == vec!["select-a".to_string()]
));
assert!(select_app.warning_message.is_none());
let mut stopped_app = AppState::new(vec![create_test_change("stopped-a")]);
stopped_app.mode = AppMode::Stopped;
stopped_app.is_resolving = true;
stopped_app.changes[0].selected = true;
let command = handle_start_key_inner(&mut stopped_app, &graceful_stop, &handle);
assert!(matches!(
command,
Some(TuiCommand::StartProcessing(ids)) if ids == vec!["stopped-a".to_string()]
));
assert!(stopped_app.warning_message.is_none());
let mut errobang_app = AppState::new(vec![create_test_change("error-a")]);
errobang_app.mode = AppMode::Error;
errobang_app.is_resolving = true;
errobang_app.changes[0].set_error_message_cache("boom".to_string());
errobang_app.changes[0].selected = true;
let shared = std::sync::Arc::new(tokio::sync::RwLock::new(
crate::orchestration::state::OrchestratorState::new(vec!["error-a".to_string()], 0),
));
shared.blocking_write().apply_execution_event(
&crate::events::ExecutionEvent::ProcessingError {
id: "error-a".to_string(),
error: "boom".to_string(),
},
);
errobang_app.set_shared_state(shared);
let command = handle_start_key_inner(&mut errobang_app, &graceful_stop, &handle);
assert!(matches!(
command,
Some(TuiCommand::StartProcessing(ids)) if ids == vec!["error-a".to_string()]
));
assert!(errobang_app.warning_message.is_none());
}
#[test]
fn f5_on_merge_wait_with_no_runnable_work_is_noop_not_resolve() {
let mut app = AppState::new(vec![create_test_change("merge-wait")]);
app.mode = AppMode::Select;
app.cursor_index = 0;
app.changes[0].display_status_cache = "merge wait".to_string();
let graceful_stop = inert_stop_flag();
let handle = None;
let command = handle_start_key_inner(&mut app, &graceful_stop, &handle);
assert!(command.is_none());
assert_eq!(app.changes[0].display_status_cache, "merge wait");
assert_eq!(app.warning_message.as_deref(), Some("No changes selected"));
}
#[test]
fn warning_popup_scroll_keys_do_not_move_underlying_cursor_or_close_popup() {
let mut app = AppState::new(vec![create_test_change("a"), create_test_change("b")]);
app.show_warning_popup("warning", "line 1\nline 2\nline 3");
let cursor_before = app.cursor_index;
assert!(handle_warning_popup_key(&mut app, key(KeyCode::Down)));
assert_eq!(app.cursor_index, cursor_before);
assert_eq!(app.warning_popup_scroll, 1);
assert!(app.warning_popup.is_some());
}
#[test]
fn warning_popup_close_key_clears_popup_and_resets_scroll() {
let mut app = AppState::new(vec![create_test_change("a")]);
app.show_warning_popup("warning", "diagnostic");
app.warning_popup_scroll = 7;
assert!(handle_warning_popup_key(&mut app, key(KeyCode::Esc)));
assert!(app.warning_popup.is_none());
assert_eq!(app.warning_popup_scroll, 0);
}
#[test]
fn warning_popup_ignores_non_popup_keys_without_underlying_action() {
let mut app = AppState::new(vec![create_test_change("a"), create_test_change("b")]);
app.show_warning_popup("warning", "diagnostic");
let cursor_before = app.cursor_index;
assert!(handle_warning_popup_key(&mut app, key(KeyCode::Char('x'))));
assert_eq!(app.cursor_index, cursor_before);
assert_eq!(app.warning_popup_scroll, 0);
assert!(app.warning_popup.is_some());
}
#[test]
fn ctrl_c_quit_cancels_local_orchestrator_token() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
let token = CancellationToken::new();
request_local_tui_quit(&mut app, &Some(token.clone()));
assert!(app.should_quit);
assert!(token.is_cancelled());
assert!(app
.logs
.iter()
.any(|entry| entry.message.contains("cancelling local orchestration")));
}
#[test]
fn ctrl_c_quit_without_local_orchestrator_only_sets_quit() {
let mut app = AppState::new(vec![create_test_change("change-a")]);
request_local_tui_quit(&mut app, &None);
assert!(app.should_quit);
}
}