use std::cmp::Ordering;
use std::collections::{HashMap, HashSet, VecDeque};
use std::io::{self};
use std::path::{Path, PathBuf};
use std::process::Command as StdCommand;
use std::sync::{
atomic::{AtomicBool, Ordering as AtomicOrdering},
Arc,
};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
KeyboardEnhancementFlags, MouseButton, MouseEvent, MouseEventKind, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, EnterAlternateScreen,
LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use ratatui::Terminal;
use ratatui_textarea::TextArea;
use tokio::sync::{mpsc, Mutex};
use tokio::time::{self, MissedTickBehavior};
use crate::agent::Agent;
use crate::events::{AgentEvent, EventSink};
use crate::life::LifeField;
use crate::sessions::{self, SessionSnapshot};
use crate::store;
use crate::types::Message;
mod app;
mod commands;
mod markdown;
mod render;
mod selection;
mod slash_popup;
mod state;
mod style;
mod terminal;
mod time_utils;
mod util;
mod workspace;
mod wrap;
use app::*;
use commands::*;
use markdown::*;
use render::*;
use selection::*;
use slash_popup::*;
use state::*;
use style::*;
use terminal::*;
use time_utils::*;
use util::*;
use workspace::*;
use wrap::*;
const COMPOSER_HEIGHT: u16 = 6;
const MIN_TERMINAL_WIDTH: u16 = 72;
const MIN_TERMINAL_HEIGHT: u16 = 22;
const COMPACT_MIN_TERMINAL_WIDTH: u16 = 40;
const COMPACT_MIN_TERMINAL_HEIGHT: u16 = 8;
const TIMELINE_LIMIT: usize = 220;
const TOOL_HISTORY_LIMIT: usize = 20;
const FILE_CHANGE_LIMIT: usize = 36;
const COMPACT_TIMELINE_LIMIT: usize = 24;
const WORKSPACE_REFRESH_INTERVAL: Duration = Duration::from_millis(400);
const VIEW_CHANGE_SCROLL_SUPPRESS: Duration = Duration::from_millis(750);
const PROMPT_SEPARATOR: &str = " › ";
const COMMAND_SEPARATOR: &str = " / ";
const CONTINUATION_PREFIX: &str = " ";
const COMPACT_LABEL_WIDTH: usize = 10;
type UiTerminal = Terminal<CrosstermBackend<io::Stdout>>;
#[derive(Clone)]
pub struct TuiMetadata {
pub cwd: String,
pub workspace_host_path: Option<PathBuf>,
pub store_path: PathBuf,
pub model: String,
pub base_url: String,
pub backend: String,
pub reasoning_effort: Option<String>,
pub session_id: Option<String>,
pub sandbox_status: String,
pub agents_md_status: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UiMode {
Full,
Compact,
}
#[derive(Debug)]
enum AppAction {
None,
Quit,
Submit(String),
ResumeSession(String),
}
fn next_queued_input_event(
input_rx: &mut mpsc::UnboundedReceiver<CrosstermEvent>,
drop_scroll_events: bool,
) -> Option<CrosstermEvent> {
while let Ok(event) = input_rx.try_recv() {
if drop_scroll_events
&& matches!(
event,
CrosstermEvent::Mouse(MouseEvent {
kind: MouseEventKind::ScrollUp
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight,
..
})
)
{
continue;
}
return Some(event);
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SlashCommand {
Exit,
Sessions,
Copy,
Plan { instruction: String },
Run { workset_id: String },
Goal { subcommand: GoalSubcommand },
Custom { name: String, args: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum GoalSubcommand {
Show,
Set { objective: String },
Clear,
Pause,
Resume,
Edit { objective: String },
}
#[derive(Debug)]
pub enum TuiOutcome {
Exit,
ResumeSession(String),
}
pub async fn run(
mut agent: Agent,
metadata: TuiMetadata,
restored_messages: Vec<Message>,
mut session_snapshot: Option<SessionSnapshot>,
start_in_session_picker: bool,
ui_mode: UiMode,
) -> Result<TuiOutcome> {
tracing::info!(
ui_mode = ?ui_mode,
cwd = %metadata.cwd,
workspace_host_path = ?metadata.workspace_host_path,
store_path = %metadata.store_path.display(),
model = %metadata.model,
base_url = %metadata.base_url,
backend = %metadata.backend,
reasoning_effort = ?metadata.reasoning_effort,
session_id = ?metadata.session_id,
sandbox_status = %metadata.sandbox_status,
agents_md_status = %metadata.agents_md_status,
restored_message_count = restored_messages.len(),
start_in_session_picker,
"starting tui runtime"
);
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<CrosstermEvent>();
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<AgentEvent>();
agent.set_event_sink(EventSink::channel(event_tx));
let agent = Arc::new(Mutex::new(agent));
tracing::debug!("enabling raw mode for tui");
enable_raw_mode()?;
let mut stdout = io::stdout();
tracing::debug!("entering alternate screen for tui");
crossterm::execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.hide_cursor()?;
let keyboard_enhancements_enabled = enable_keyboard_enhancements(&mut terminal);
let bracketed_paste_enabled = enable_bracketed_paste(&mut terminal);
let mouse_capture_enabled = if std::env::var_os("SAC_DISABLE_MOUSE").is_some() {
false
} else {
enable_mouse_capture(&mut terminal)
};
tracing::debug!(
keyboard_enhancements_enabled,
bracketed_paste_enabled,
mouse_capture_enabled,
"tui input capabilities configured"
);
let running = Arc::new(AtomicBool::new(true));
let input_thread = spawn_input_thread(running.clone(), input_tx);
let response_duration_history = session_snapshot.as_ref().and_then(|snapshot| {
snapshot.response_durations_ms.as_ref().map(|durations| {
durations
.iter()
.map(|duration| duration.map(Duration::from_millis))
.collect::<Vec<_>>()
})
});
let (last_response_duration, previous_response_duration) = session_snapshot
.as_ref()
.map(|snapshot| {
(
snapshot
.last_response_duration_ms
.map(Duration::from_millis),
snapshot
.previous_response_duration_ms
.map(Duration::from_millis),
)
})
.unwrap_or_default();
let timeline_json_for_restore = session_snapshot
.as_ref()
.and_then(|snapshot| snapshot.timeline_json.as_deref())
.map(|s| s.to_string());
let mut app = App::new_with_mode(
metadata,
&restored_messages,
start_in_session_picker,
ui_mode,
);
app.hydrate_timeline(timeline_json_for_restore.as_deref());
app.restore_response_duration_history(
response_duration_history.as_deref(),
last_response_duration,
previous_response_duration,
);
let (ws_tx, ws_rx) = mpsc::channel::<WorkspaceSnapshot>(1);
app.workspace_tx = Some(ws_tx);
app.workspace_rx = Some(ws_rx);
let mut animation_tick = time::interval(Duration::from_millis(75));
animation_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut terminal_refresh_tick = time::interval(Duration::from_millis(500));
terminal_refresh_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
terminal.draw(|frame| app.render(frame))?;
let mut outcome = TuiOutcome::Exit;
let loop_result = async {
while !app.quit {
tokio::select! {
event = input_rx.recv() => {
match event {
Some(event) => {
let mut terminal_action = false;
if let Some(action) = app.handle_crossterm_event(event) {
match action {
AppAction::Submit(prompt) => {
submit_prompt(prompt, agent.clone(), &mut app, &mut terminal)?;
terminal_action = true;
}
AppAction::ResumeSession(session_id) => {
outcome = TuiOutcome::ResumeSession(session_id);
app.quit = true;
terminal_action = true;
}
AppAction::Quit | AppAction::None => {}
}
}
let mut drop_queued_scroll_events = app.suppressing_mouse_scroll();
if !terminal_action {
while let Some(next_event) = next_queued_input_event(
&mut input_rx,
drop_queued_scroll_events,
) {
if let Some(action) = app.handle_crossterm_event(next_event) {
match action {
AppAction::Submit(prompt) => {
submit_prompt(prompt, agent.clone(), &mut app, &mut terminal)?;
break;
}
AppAction::ResumeSession(session_id) => {
outcome = TuiOutcome::ResumeSession(session_id);
app.quit = true;
break;
}
AppAction::Quit => {
app.quit = true;
break;
}
AppAction::None => {}
}
}
if app.suppressing_mouse_scroll() {
drop_queued_scroll_events = true;
}
}
}
}
None => {
tracing::error!("input thread terminated unexpectedly; shutting down tui");
app.quit = true;
}
}
}
Some(agent_event) = event_rx.recv() => {
app.apply_agent_event(agent_event);
}
result = async {
match app.result_rx.as_mut() {
Some(rx) => match rx.await {
Ok(val) => Some(val),
Err(_) => {
tracing::error!("agent task terminated unexpectedly (oneshot sender dropped)");
Some(Err("Internal error: agent task terminated unexpectedly".to_string()))
}
},
None => std::future::pending::<Option<Result<String, String>>>().await,
}
} => {
if let Some(result) = result {
let completed_duration = app
.working_started_at
.map(|started| started.elapsed())
.unwrap_or_default();
app.result_rx = None;
app.steering_tx = None;
app.goal_pause_tx = None;
app.budget_limit_steering_sent = false;
app.working_frame = 0;
app.working_started_at = None;
app.reset_life();
match result {
Ok(response) => {
app.complete_top_level_response(response, completed_duration);
}
Err(error) => {
app.note_send_error(error);
}
}
app.reload_goal_from_store();
if let Some(snapshot) = session_snapshot.as_mut() {
let agent = agent.lock().await;
let (last_response_duration_ms, previous_response_duration_ms) =
app.response_duration_snapshot_ms();
let response_durations_ms = app.response_duration_history_snapshot_ms();
let timeline_json = serde_json::to_string(
&app.timeline.iter().collect::<Vec<_>>()
).ok();
persist_session_snapshot(
snapshot,
&agent,
last_response_duration_ms,
previous_response_duration_ms,
response_durations_ms,
timeline_json,
)
.await?;
}
app.goal_clear_requested = false;
app.goal_pause_requested = false;
}
}
_ = animation_tick.tick() => {
if app.result_rx.is_some() {
app.working_frame = app.working_frame.wrapping_add(1);
app.advance_life();
}
app.maybe_refresh_workspace();
}
_ = terminal_refresh_tick.tick() => {
if app.result_rx.is_none() {
let agent_guard = agent.lock().await;
let terminal_manager = agent_guard.terminal_manager();
let terminals = terminal_manager.list().await;
drop(agent_guard);
app.update_terminals(None, terminals);
}
}
}
app.check_workspace_channel();
terminal.draw(|frame| app.render(frame))?;
}
Ok::<(), anyhow::Error>(())
}
.await;
tracing::debug!(outcome = ?outcome, "starting tui cleanup");
running.store(false, AtomicOrdering::SeqCst);
let _ = input_thread.join();
let cleanup_result = (|| -> io::Result<()> {
if keyboard_enhancements_enabled {
let _ = crossterm::execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
}
if bracketed_paste_enabled {
let _ = crossterm::execute!(terminal.backend_mut(), DisableBracketedPaste);
}
if mouse_capture_enabled {
let _ = crossterm::execute!(terminal.backend_mut(), DisableMouseCapture);
}
terminal.show_cursor()?;
crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
disable_raw_mode()
})();
loop_result?;
cleanup_result?;
tracing::info!(outcome = ?outcome, "tui runtime exited");
Ok(outcome)
}
fn submit_prompt(
prompt: String,
agent: Arc<Mutex<Agent>>,
app: &mut App,
terminal: &mut UiTerminal,
) -> Result<()> {
let agent_prompt = expand_user_prompt(
&prompt,
app.command_registry.as_deref(),
std::path::Path::new(&app.metadata.cwd),
);
tracing::info!(
prompt_len = prompt.len(),
expanded_prompt_len = agent_prompt.len(),
slash_mode = composer_is_slash_mode(&[prompt.clone()]),
"submitting prompt from tui"
);
app.note_prompt_submitted(&prompt);
app.current_prompt = prompt;
app.clear_composer();
app.working_frame = 0;
app.working_started_at = Some(Instant::now());
app.reset_life();
app.budget_limit_steering_sent = false;
let (steering_tx, steering_rx) = mpsc::unbounded_channel::<String>();
app.steering_tx = Some(steering_tx);
let (goal_pause_tx, goal_pause_rx) = mpsc::unbounded_channel::<String>();
app.goal_pause_tx = Some(goal_pause_tx);
let has_active_goal = app
.goal
.as_ref()
.is_some_and(|g| g.status == crate::goal::GoalStatus::Active);
let (tx, rx) = tokio::sync::oneshot::channel();
app.result_rx = Some(rx);
tokio::spawn(async move {
let result = {
let mut agent = agent.lock().await;
agent.set_steering_rx(steering_rx);
if has_active_goal {
agent
.send_with_goal(&agent_prompt, Some(&mut { goal_pause_rx }))
.await
.map_err(|error| error.to_string())
} else {
agent
.send(&agent_prompt)
.await
.map_err(|error| error.to_string())
}
};
let _ = tx.send(result);
});
terminal.draw(|frame| app.render(frame))?;
Ok(())
}
fn build_composer() -> TextArea<'static> {
TextArea::default()
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEventState;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("sac_tui_{label}_{unique}"));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn metadata_for(path: &Path) -> TuiMetadata {
TuiMetadata {
cwd: path.display().to_string(),
workspace_host_path: Some(path.to_path_buf()),
store_path: path.join(".sac").join("store.db"),
model: "gpt-test".to_string(),
base_url: "https://example.com/v1".to_string(),
backend: "openai-responses".to_string(),
reasoning_effort: Some("medium".to_string()),
session_id: None,
sandbox_status: "off".to_string(),
agents_md_status: "off".to_string(),
}
}
fn test_thread_view(name: &str, updated_at_ts: u64) -> ThreadView {
ThreadView {
name: name.to_string(),
action: format!("inspect {name}"),
state: ThreadState::Retained,
updated_at: format!("00:00:{updated_at_ts:02}"),
updated_at_ts,
episodes: 1,
summary: String::new(),
}
}
fn test_scroll_event(kind: MouseEventKind) -> CrosstermEvent {
CrosstermEvent::Mouse(MouseEvent {
kind,
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
})
}
fn press_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
assert!(matches!(
app.handle_key_event(KeyEvent::new(code, modifiers)),
AppAction::None
));
}
#[test]
fn shift_enter_inserts_newline() {
let dir = temp_dir("newline");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("hello");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
assert!(matches!(action, AppAction::None));
assert_eq!(app.prompt(), "hello\n");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn control_j_inserts_newline_without_deleting_text() {
let dir = temp_dir("ctrl-j-newline");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("hello");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert_eq!(app.prompt(), "hello\n");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn enter_submits_prompt() {
let dir = temp_dir("submit");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("hello");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match action {
AppAction::Submit(prompt) => assert_eq!(prompt, "hello"),
_ => panic!("expected submit"),
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn plan_command_submits_raw_prompt() {
let dir = temp_dir("plan-submit");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("/plan refresh auth flow");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match action {
AppAction::Submit(prompt) => assert_eq!(prompt, "/plan refresh auth flow"),
_ => panic!("expected submit"),
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn run_command_submits_raw_prompt() {
let dir = temp_dir("run-submit");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("/run auth-refresh");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match action {
AppAction::Submit(prompt) => assert_eq!(prompt, "/run auth-refresh"),
_ => panic!("expected submit"),
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn slash_exit_quits() {
let dir = temp_dir("exit");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("/exit");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(action, AppAction::Quit));
assert!(app.quit);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn repeat_backspace_is_processed() {
let dir = temp_dir("backspace");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("ab");
let action = app.handle_key_event(KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Repeat,
state: KeyEventState::NONE,
});
assert!(matches!(action, AppAction::None));
assert_eq!(app.prompt(), "a");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn multiline_paste_inserts_newlines_without_submit() {
let dir = temp_dir("paste");
let mut app = App::new(metadata_for(&dir), &[], false);
let action = app.handle_paste("hello\nworld");
assert!(matches!(action, AppAction::None));
assert_eq!(app.prompt(), "hello\nworld");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn pasted_crlf_is_normalized_to_newlines() {
let dir = temp_dir("paste-crlf");
let mut app = App::new(metadata_for(&dir), &[], false);
app.handle_paste("hello\r\nworld\rtest");
assert_eq!(app.prompt(), "hello\nworld\ntest");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn slash_command_mode_uses_command_prefix() {
let view = wrapped_composer_view(&["/sessions".to_string()], (0, 9), 20, 4);
assert_eq!(line_to_plain_text(&view.lines[0]), " / sessions");
assert_eq!(view.lines[0].spans[0].style.fg, Some(Color::Yellow));
assert_eq!(view.lines[0].spans[1].style.fg, Some(Color::Yellow));
assert_eq!(view.cursor_col, composer_prefix_width() as u16 + 8);
}
#[test]
fn normal_prompt_prefix_returns_after_slash_removed() {
let slash = wrapped_composer_view(&["/".to_string()], (0, 1), 20, 4);
let normal = wrapped_composer_view(&["".to_string()], (0, 0), 20, 4);
assert_eq!(line_to_plain_text(&slash.lines[0]), " / ");
assert_eq!(line_to_plain_text(&normal.lines[0]), " › ");
assert_eq!(normal.lines[0].spans[0].style.fg, Some(Color::Cyan));
assert_eq!(normal.lines[0].spans[1].style.fg, Some(Color::White));
}
#[test]
fn invalid_slash_command_shows_composer_notice() {
let dir = temp_dir("invalid-command");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("/bogus");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert_eq!(app.prompt(), "/bogus");
let notice = app
.composer_notice
.as_ref()
.expect("expected composer notice");
assert_eq!(notice.text, "unknown command: /bogus");
assert_eq!(notice.tone, Tone::Warning);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn run_command_requires_workset() {
let dir = temp_dir("run-usage");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("/run");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
let notice = app
.composer_notice
.as_ref()
.expect("expected composer notice");
assert_eq!(notice.text, "usage: /run <workset>");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn run_command_rejects_freeform_instruction() {
let dir = temp_dir("run-freeform");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("/run refresh auth flow");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
let notice = app
.composer_notice
.as_ref()
.expect("expected composer notice");
assert_eq!(notice.text, "usage: /run <workset>");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn plan_command_expands_to_workset_prompt() {
let expanded = expand_user_prompt("/plan refresh auth flow", None, Path::new("/tmp"));
assert!(expanded.contains("# /plan: Workset Planning"));
assert!(expanded.contains("workset_define"));
assert!(expanded.contains("goal"));
assert!(expanded.contains("role"));
assert!(expanded.contains("depends_on"));
assert!(expanded.contains("acceptance"));
assert!(expanded.contains("refresh auth flow"));
assert!(expanded.contains("Do not do mutating implementation work in this step."));
assert!(!expanded.contains("thread_name"));
}
#[test]
fn run_command_expands_to_existing_workset_prompt() {
let expanded = expand_user_prompt("/run auth-refresh", None, Path::new("/tmp"));
assert!(expanded.contains("# /run: Workset Execution"));
assert!(expanded.contains("workset_read"));
assert!(expanded.contains("auth-refresh"));
assert!(expanded.contains("run `/plan <instruction>` first"));
assert!(expanded.contains("Use `thread` for implementation and verification work."));
assert!(!expanded.contains("Create exactly one durable"));
}
fn define_test_workset(path: &Path, session_id: &str, id: &str) {
store::define_workset(
path,
session_id,
&store::WorksetDefinition {
id: id.to_string(),
goal: "refresh auth flow".to_string(),
status: "planned".to_string(),
summary: "Auth work units.".to_string(),
verification_recipe: Some("cargo test".to_string()),
items: vec![
store::WorksetItemDefinition {
title: "Inspect auth flow".to_string(),
scope: "crates/sac/src".to_string(),
description: "Find auth flow entry points.".to_string(),
role: "research".to_string(),
depends_on: Vec::new(),
acceptance: "Auth entry points are identified.".to_string(),
notes: None,
status: None,
},
store::WorksetItemDefinition {
title: "Apply auth flow update".to_string(),
scope: "crates/sac/src/tui.rs".to_string(),
description: "Make the scoped auth UI change.".to_string(),
role: "implement".to_string(),
depends_on: vec!["Inspect auth flow".to_string()],
acceptance: "The auth UI change is implemented.".to_string(),
notes: None,
status: None,
},
],
},
)
.unwrap();
}
#[test]
fn app_loads_worksets_for_session() {
let dir = temp_dir("workset-panel");
let store_path = dir.join("store.db");
let session_id = "session-worksets";
define_test_workset(&store_path, session_id, "plan-auth");
let mut metadata = metadata_for(&dir);
metadata.store_path = store_path.clone();
metadata.session_id = Some(session_id.to_string());
let app = App::new(metadata, &[], false);
assert_eq!(app.worksets.items.len(), 1);
assert_eq!(app.worksets.items[0].id, "plan-auth");
assert_eq!(app.worksets.items[0].items.len(), 2);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn workset_tool_finish_refreshes_worksets() {
let dir = temp_dir("workset-refresh");
let store_path = dir.join("store.db");
let session_id = "session-workset-refresh";
let mut metadata = metadata_for(&dir);
metadata.store_path = store_path.clone();
metadata.session_id = Some(session_id.to_string());
let mut app = App::new(metadata, &[], false);
assert!(app.worksets.items.is_empty());
define_test_workset(&store_path, session_id, "plan-ui");
app.apply_agent_event(AgentEvent::ToolCallFinished {
thread_name: None,
call_id: "call-workset".to_string(),
name: "workset_define".to_string(),
content_preview: "Saved workset 'plan-ui' with 1 item(s).".to_string(),
content: None,
is_error: false,
});
assert_eq!(app.worksets.items.len(), 1);
assert_eq!(app.worksets.items[0].id, "plan-ui");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn workset_item_lines_include_role_scope_title_and_acceptance() {
let item = store::WorksetItemRecord {
position: 1,
title: "Apply auth flow update".to_string(),
scope: "crates/sac/src/tui.rs".to_string(),
description: "Make the scoped auth UI change.".to_string(),
role: "implement".to_string(),
status: "planned".to_string(),
depends_on: vec!["Inspect auth flow".to_string()],
acceptance: "The auth UI change is implemented.".to_string(),
notes: None,
updated_at: "2026-04-23 00:00:00".to_string(),
};
let rendered = render_workset_item_lines(&item, 80)
.iter()
.map(line_to_plain_text)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("IMPLEMENT"));
assert!(rendered.contains("SCOPE"));
assert!(rendered.contains("DEPS"));
assert!(rendered.contains("PASS"));
assert!(rendered.contains("Inspect auth flow"));
assert!(rendered.contains("Apply auth flow update"));
assert!(rendered.contains("crates/sac/src/tui.rs"));
assert!(rendered.contains("The auth UI change is implemented."));
}
#[test]
fn workset_item_lines_wrap_long_fields() {
let item = store::WorksetItemRecord {
position: 1,
title: "Apply auth flow update with long title".to_string(),
scope: "crates/sac/src/tui.rs and crates/sac/src/store.rs".to_string(),
description: "Make the scoped auth UI change.".to_string(),
role: "implement".to_string(),
status: "planned".to_string(),
depends_on: vec!["Inspect auth flow before implementation starts".to_string()],
acceptance: "The auth UI change is implemented and verified with targeted tests."
.to_string(),
notes: Some("Keep unrelated worktree changes intact while editing.".to_string()),
updated_at: "2026-04-23 00:00:00".to_string(),
};
let rendered = render_workset_item_lines(&item, 36)
.iter()
.map(line_to_plain_text)
.collect::<Vec<_>>();
let joined = rendered.join("\n");
assert!(rendered.len() > 6);
assert!(joined.contains("update with long"));
assert!(joined.contains("crates/sac/src/store.rs"));
assert!(joined.contains("targeted"));
assert!(joined.contains("tests."));
assert!(!joined.contains('…'));
}
#[test]
fn wrapped_prefixed_lines_use_continuation_indent() {
let mut lines = Vec::new();
push_wrapped_prefixed_lines(
&mut lines,
" verify ",
"cargo test -p sac plus a focused manual check",
28,
Style::default().fg(Color::DarkGray),
Style::default().fg(Color::DarkGray),
);
let rendered = lines.iter().map(line_to_plain_text).collect::<Vec<_>>();
assert!(rendered.len() > 1);
assert!(rendered[0].starts_with(" verify "));
assert!(rendered[1].starts_with(" "));
assert!(!rendered.join("\n").contains('…'));
}
#[test]
fn workset_prompt_displays_as_original_slash_command() {
let expanded = build_plan_command_prompt("split this into reviewable units");
let expanded_run = build_run_command_prompt("auth-refresh");
assert_eq!(
display_prompt_from_message(&expanded),
"/plan split this into reviewable units"
);
assert_eq!(
display_prompt_from_message(&expanded_run),
"/run auth-refresh"
);
}
#[test]
fn run_started_does_not_replace_submitted_prompt() {
let dir = temp_dir("run-started-prompt");
let mut app = App::new(metadata_for(&dir), &[], false);
app.note_prompt_submitted("/plan refresh auth flow");
app.apply_agent_event(AgentEvent::RunStarted {
thread_name: None,
prompt_preview: "# /plan: Workset Planning".to_string(),
});
assert_eq!(app.prompts, vec!["/plan refresh auth flow".to_string()]);
assert_eq!(app.selected_prompt, Some(0));
assert_eq!(app.displayed_prompt_index(), Some(0));
let mut fallback_app = App::new(metadata_for(&dir), &[], false);
fallback_app.apply_agent_event(AgentEvent::RunStarted {
thread_name: None,
prompt_preview: "restored run preview".to_string(),
});
fallback_app.apply_agent_event(AgentEvent::RunStarted {
thread_name: Some("worker".to_string()),
prompt_preview: "worker preview".to_string(),
});
assert_eq!(
fallback_app.prompts,
vec!["restored run preview".to_string()]
);
assert_eq!(fallback_app.selected_prompt, Some(0));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn prompt_history_hydrates_user_messages_and_slash_display_mapping() {
let dir = temp_dir("prompt-history-hydrate");
let messages = vec![
Message::User {
content: "first prompt".to_string(),
},
Message::Assistant {
content: Some("reply".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: None,
},
Message::User {
content: build_plan_command_prompt("split this into reviewable units"),
},
Message::User {
content: build_run_command_prompt("auth-refresh"),
},
];
let app = App::new(metadata_for(&dir), &messages, false);
assert_eq!(
app.prompts,
vec![
"first prompt".to_string(),
"/plan split this into reviewable units".to_string(),
"/run auth-refresh".to_string(),
]
);
assert_eq!(app.selected_prompt, Some(2));
assert_eq!(app.displayed_prompt_index(), Some(2));
assert!(line_to_plain_text(&app.prompt_panel_title()).contains("PROMPTS 3/3"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn composer_title_shows_timer_only_while_run_is_active() {
let dir = temp_dir("composer-title-timer");
let mut app = App::new(metadata_for(&dir), &[], false);
let idle_text = line_to_plain_text(&app.composer_panel_title());
assert_eq!(idle_text, " [ ASK ] ");
assert!(!idle_text.contains("T+"));
let (_tx, rx) = tokio::sync::oneshot::channel();
app.result_rx = Some(rx);
app.working_started_at = Some(Instant::now() - Duration::from_secs(3));
let running_title = app.composer_panel_title();
let running_text = line_to_plain_text(&running_title);
assert!(running_text.starts_with(" [ ASK T+"));
assert!(running_text.ends_with(" ] "));
assert!(running_title.spans.iter().any(|span| {
span.content.as_ref().contains("ASK")
&& span.style.fg == Some(Color::Cyan)
&& span.style.add_modifier.contains(Modifier::BOLD)
}));
assert!(running_title.spans.iter().any(|span| {
span.content.as_ref().starts_with("T+") && span.style.fg == Some(Color::Green)
}));
app.result_rx = None;
app.working_started_at = None;
app.complete_top_level_response("done".to_string(), Duration::from_secs(7));
assert_eq!(
app.responses.last().and_then(|response| response.duration),
Some(Duration::from_secs(7))
);
let idle_again_text = line_to_plain_text(&app.composer_panel_title());
assert_eq!(idle_again_text, " [ ASK ] ");
assert!(!idle_again_text.contains("T+"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn prompt_history_focus_navigation_guard_and_reset_to_latest() {
let dir = temp_dir("prompt-history-nav");
let mut app = App::new(metadata_for(&dir), &[], false);
app.note_prompt_submitted("one");
app.note_prompt_submitted("two");
app.note_prompt_submitted("three");
press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
assert_eq!(app.selected_prompt, Some(2));
press_key(&mut app, KeyCode::Char('p'), KeyModifiers::CONTROL);
assert!(matches!(
app.screen,
ScreenMode::Focused(FocusPanel::Prompt)
));
assert_eq!(app.displayed_prompt_index(), Some(2));
app.panel_scrolls.insert(PanelId::Prompt, 12);
press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
assert_eq!(app.selected_prompt, Some(1));
assert_eq!(app.panel_scrolls.get(&PanelId::Prompt), Some(&0));
assert!(line_to_plain_text(&app.prompt_panel_title()).contains("PROMPTS 2/3"));
press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
assert_eq!(app.selected_prompt, Some(0));
press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
assert_eq!(app.selected_prompt, Some(2));
app.selected_prompt = Some(0);
press_key(&mut app, KeyCode::Char('p'), KeyModifiers::CONTROL);
assert_eq!(app.screen, ScreenMode::Dashboard);
assert_eq!(app.selected_prompt, Some(2));
assert_eq!(app.displayed_prompt_index(), Some(2));
press_key(&mut app, KeyCode::Char('p'), KeyModifiers::CONTROL);
press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
assert_eq!(app.selected_prompt, Some(1));
press_key(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.screen, ScreenMode::Dashboard);
assert_eq!(app.selected_prompt, Some(2));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn switching_focus_from_prompt_returns_display_to_latest() {
let dir = temp_dir("prompt-focus-switch");
let mut app = App::new(metadata_for(&dir), &[], false);
app.note_prompt_submitted("one");
app.note_prompt_submitted("two");
app.complete_top_level_response("reply".to_string(), Duration::from_secs(1));
app.screen = ScreenMode::Focused(FocusPanel::Prompt);
app.selected_prompt = Some(0);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert!(matches!(
app.screen,
ScreenMode::Focused(FocusPanel::Response)
));
assert_eq!(app.selected_prompt, Some(1));
assert_eq!(app.displayed_prompt_index(), Some(1));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn primary_scroll_panel_uses_prompt_when_prompt_focused() {
let dir = temp_dir("prompt-primary-scroll");
let mut app = App::new(metadata_for(&dir), &[], false);
app.screen = ScreenMode::Focused(FocusPanel::Prompt);
assert_eq!(app.primary_scroll_panel(), PanelId::Prompt);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn compact_stream_uses_latest_prompt_from_history() {
let dir = temp_dir("compact-stream-latest-prompt");
let mut app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
app.note_prompt_submitted("first compact prompt");
app.note_prompt_submitted("second compact prompt");
let lines = app.compact_stream_lines(80);
assert!(line_to_plain_text(&lines[0]).contains("second compact prompt"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn compact_stream_uses_event_glyphs_and_latest_response() {
let dir = temp_dir("compact-stream");
let mut app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
app.note_prompt_submitted("implement compact mode");
app.apply_agent_event(AgentEvent::ThreadStarted {
name: "impl".to_string(),
action: "build compact ui".to_string(),
source_threads: Vec::new(),
});
app.complete_top_level_response("Compact mode ready.".to_string(), Duration::from_secs(2));
let rendered = app
.compact_stream_lines(80)
.iter()
.map(line_to_plain_text)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("implement compact mode"));
assert!(rendered.contains("+ impl"));
assert!(rendered.contains("Compact mode ready."));
assert!(!rendered.contains("assistant Compact mode ready."));
assert!(!rendered.contains("waiting for first reply"));
assert_eq!(app.primary_scroll_panel(), PanelId::CompactStream);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn compact_stream_keeps_full_response_scrollback() {
let dir = temp_dir("compact-stream-height");
let mut app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
app.note_prompt_submitted("implement compact mode with no required vertical scroll");
for index in 0..8 {
app.push_timeline(
format!("thread-{index}"),
format!("tool call • detail {index}"),
Tone::Info,
);
}
app.complete_top_level_response(
"Compact mode ready.\n\nIt still keeps the full response available for scrolling.\n\n- one\n- two\n- three".to_string(),
Duration::from_secs(2),
);
let lines = app.compact_stream_lines(48);
assert!(lines.len() > 4);
let rendered = lines
.iter()
.map(line_to_plain_text)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("you"));
assert!(rendered.contains("full response available"));
assert!(rendered.contains("three"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn compact_mode_allows_phone_height_terminals() {
let dir = temp_dir("compact-min-size");
let app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
assert_eq!(
app.minimum_terminal_size(),
(COMPACT_MIN_TERMINAL_WIDTH, COMPACT_MIN_TERMINAL_HEIGHT)
);
assert!(COMPACT_MIN_TERMINAL_HEIGHT < MIN_TERMINAL_HEIGHT);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn compact_dashboard_hint_legend_lists_all_bindings() {
let dir = temp_dir("compact-hint-legend");
let mut app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
app.hint_visible = true;
let rendered = app
.compact_hint_legend_lines(40)
.iter()
.map(line_to_plain_text)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("C-P"), "got: {}", rendered);
assert!(rendered.contains("Prompts"), "got: {}", rendered);
assert!(rendered.contains("C-W"), "got: {}", rendered);
assert!(rendered.contains("Workspace"), "got: {}", rendered);
assert!(rendered.contains("C-F"), "got: {}", rendered);
assert!(!rendered.contains("/"), "got: {}", rendered);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn compact_hint_overlay_gating_matches_mode_and_visibility() {
let dir = temp_dir("compact-hint-gating");
let mut app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
app.hint_visible = true;
assert!(app.should_render_compact_hint_overlay());
app.help_visible = true;
assert!(!app.should_render_compact_hint_overlay());
app.help_visible = false;
app.screen = ScreenMode::Focused(FocusPanel::Prompt);
assert!(!app.should_render_compact_hint_overlay());
app.screen = ScreenMode::Dashboard;
app.screen = ScreenMode::SessionPicker { startup: false };
assert!(!app.should_render_compact_hint_overlay());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn help_focus_rows_use_full_binding_text() {
let dir = temp_dir("help-focus-rows");
let app = App::new(metadata_for(&dir), &[], false);
let rendered = app
.pane_focus_help_rows()
.iter()
.map(line_to_plain_text)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Ctrl-P"), "got: {}", rendered);
assert!(rendered.contains("focus prompts"), "got: {}", rendered);
assert!(rendered.contains("Ctrl-W"), "got: {}", rendered);
assert!(rendered.contains("focus workspace"), "got: {}", rendered);
assert!(!rendered.contains("Ctrl-1"), "got: {}", rendered);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn restored_message_count_ignores_system_and_tool_messages() {
let messages = vec![
Message::System {
content: "system".to_string(),
},
Message::Tool {
tool_call_id: "call-1".to_string(),
content: "tool result".to_string(),
},
Message::Assistant {
content: None,
reasoning_text: Some("thinking".to_string()),
reasoning_details: None,
tool_calls: None,
},
Message::User {
content: "hello".to_string(),
},
];
assert_eq!(visible_restored_message_count(&messages), 1);
}
#[test]
fn sessions_command_opens_picker() {
let dir = temp_dir("sessions-command");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("/sessions");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert!(matches!(
app.screen,
ScreenMode::SessionPicker { startup: false }
));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn question_mark_toggles_help_when_composer_is_empty() {
let dir = temp_dir("help-toggle");
let mut app = App::new(metadata_for(&dir), &[], false);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert!(app.help_visible);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert!(!app.help_visible);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn question_mark_inserts_into_nonempty_composer() {
let dir = temp_dir("help-literal-question-mark");
let mut app = App::new(metadata_for(&dir), &[], false);
app.composer.insert_str("why");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert_eq!(app.prompt(), "why?");
assert!(!app.help_visible);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn ctrl_h_toggles_hint_mode_without_editing_composer() {
let dir = temp_dir("hint-toggle-h");
let mut app = App::new(metadata_for(&dir), &[], false);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert!(app.hint_visible);
assert_eq!(app.prompt(), "");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert!(!app.hint_visible);
assert_eq!(app.prompt(), "");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn repeat_ctrl_h_is_ignored() {
let dir = temp_dir("hint-toggle-repeat");
let mut app = App::new(metadata_for(&dir), &[], false);
app.hint_visible = true;
let action = app.handle_key_event(KeyEvent {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Repeat,
state: KeyEventState::NONE,
});
assert!(matches!(action, AppAction::None));
assert!(app.hint_visible);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn hint_toggle_requests_scroll_reset() {
let dir = temp_dir("hint-scroll-reset");
let mut app = App::new(metadata_for(&dir), &[], false);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert!(app.hint_visible);
assert!(app.suppressing_mouse_scroll());
app.suppress_mouse_scroll_until = Some(Instant::now() - Duration::from_millis(1));
assert!(!app.suppressing_mouse_scroll());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn ctrl_e_toggles_events_focus() {
let dir = temp_dir("events-focus");
let mut app = App::new(metadata_for(&dir), &[], false);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert!(matches!(
app.screen,
ScreenMode::Focused(FocusPanel::Events)
));
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert_eq!(app.screen, ScreenMode::Dashboard);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn response_history_focus_navigation_and_reset_to_latest() {
let dir = temp_dir("response-history-nav");
let mut app = App::new(metadata_for(&dir), &[], false);
app.complete_top_level_response("one".to_string(), Duration::from_secs(1));
app.complete_top_level_response("two".to_string(), Duration::from_secs(2));
app.complete_top_level_response("three".to_string(), Duration::from_secs(3));
press_key(&mut app, KeyCode::Char('r'), KeyModifiers::CONTROL);
assert!(matches!(
app.screen,
ScreenMode::Focused(FocusPanel::Response)
));
assert_eq!(app.displayed_response_index(), Some(2));
app.panel_scrolls.insert(PanelId::Response, 12);
press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
assert_eq!(app.selected_response, Some(1));
assert_eq!(app.panel_scrolls.get(&PanelId::Response), Some(&0));
assert_eq!(app.displayed_response_index(), Some(1));
press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
assert_eq!(app.selected_response, Some(0));
press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
assert_eq!(app.selected_response, Some(2));
app.selected_response = Some(0);
press_key(&mut app, KeyCode::Char('r'), KeyModifiers::CONTROL);
assert_eq!(app.screen, ScreenMode::Dashboard);
assert_eq!(app.selected_response, Some(2));
assert_eq!(app.displayed_response_index(), Some(2));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn ctrl_o_still_focuses_tools() {
let dir = temp_dir("tools-focus");
let mut app = App::new(metadata_for(&dir), &[], false);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert!(matches!(app.screen, ScreenMode::Focused(FocusPanel::Tools)));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn title_badges_show_expected_bindings() {
let dir = temp_dir("hint-title-badges");
let mut app = App::new(metadata_for(&dir), &[], false);
app.complete_top_level_response("one".to_string(), Duration::from_secs(1));
app.complete_top_level_response("two".to_string(), Duration::from_secs(2));
app.hint_visible = true;
let prompt_title = line_to_plain_text(&app.prompt_panel_title());
assert!(prompt_title.contains("C-P"), "got: {}", prompt_title);
let workspace_title =
line_to_plain_text(&app.static_focus_title(FocusPanel::Workspace, "WORKSPACE"));
assert!(workspace_title.contains("C-W"), "got: {}", workspace_title);
assert!(!workspace_title.contains('/'), "got: {}", workspace_title);
let previous_title = line_to_plain_text(&app.render_previous_title_for_test());
assert!(previous_title.contains("C-G"), "got: {}", previous_title);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn ctrl_g_focuses_previous_response() {
let dir = temp_dir("previous-response-focus");
let mut app = App::new(metadata_for(&dir), &[], false);
app.complete_top_level_response("one".to_string(), Duration::from_secs(1));
app.complete_top_level_response("two".to_string(), Duration::from_secs(2));
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert!(matches!(
app.screen,
ScreenMode::Focused(FocusPanel::PreviousResponse)
));
assert_eq!(app.displayed_previous_response_index(), Some(0));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn ctrl_l_focuses_terminals_and_ctrl_f_focuses_file_changes() {
let dir = temp_dir("terminals-file-focus");
let mut app = App::new(metadata_for(&dir), &[], false);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert!(matches!(
app.screen,
ScreenMode::Focused(FocusPanel::Terminals)
));
assert_eq!(app.primary_scroll_panel(), PanelId::Terminals);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert!(matches!(
app.screen,
ScreenMode::Focused(FocusPanel::FileChanges)
));
assert_eq!(app.primary_scroll_panel(), PanelId::FileChanges);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn previous_response_focus_supports_left_right_navigation() {
let dir = temp_dir("previous-response-nav");
let mut app = App::new(metadata_for(&dir), &[], false);
app.complete_top_level_response("one".to_string(), Duration::from_secs(1));
app.complete_top_level_response("two".to_string(), Duration::from_secs(2));
app.complete_top_level_response("three".to_string(), Duration::from_secs(3));
press_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL);
assert_eq!(app.displayed_previous_response_index(), Some(1));
press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
assert_eq!(app.displayed_previous_response_index(), Some(0));
press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
assert_eq!(app.displayed_previous_response_index(), Some(1));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn ctrl_k_still_focuses_worksets_outside_plain_thread_navigation() {
let dir = temp_dir("ctrl-k-worksets-focus");
let mut app = App::new(metadata_for(&dir), &[], false);
app.screen = ScreenMode::Focused(FocusPanel::Threads);
app.threads
.insert("first".to_string(), test_thread_view("first", 2));
app.selected_thread = Some("first".to_string());
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
assert!(matches!(action, AppAction::None));
assert!(matches!(
app.screen,
ScreenMode::Focused(FocusPanel::Worksets)
));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn thread_lifecycle_switches_active_to_idle() {
let dir = temp_dir("thread");
let mut app = App::new(metadata_for(&dir), &[], false);
app.apply_agent_event(AgentEvent::ThreadStarted {
name: "auth".to_string(),
action: "inspect auth flow".to_string(),
source_threads: vec!["tests".to_string()],
});
let thread = app.threads.get("auth").unwrap();
assert_eq!(thread.state, ThreadState::Active);
assert_eq!(thread.action, "inspect auth flow");
app.apply_agent_event(AgentEvent::ThreadFinished {
name: "auth".to_string(),
exit_code: 0,
timed_out: false,
timeout_reason: None,
});
let thread = app.threads.get("auth").unwrap();
assert_eq!(thread.state, ThreadState::Retained);
assert_eq!(thread.summary, "exit 0");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn thread_navigation_requests_scroll_reset() {
let dir = temp_dir("thread-scroll-suppress");
let mut app = App::new(metadata_for(&dir), &[], false);
app.screen = ScreenMode::Focused(FocusPanel::Threads);
app.threads
.insert("first".to_string(), test_thread_view("first", 2));
app.threads
.insert("second".to_string(), test_thread_view("second", 1));
app.selected_thread = Some("first".to_string());
app.panel_scrolls.insert(PanelId::ThreadEpisodes, 20);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert_eq!(app.selected_thread.as_deref(), Some("second"));
assert_eq!(app.panel_scrolls.get(&PanelId::ThreadEpisodes), Some(&0));
assert!(app.suppressing_mouse_scroll());
app.suppress_mouse_scroll_until = Some(Instant::now() - Duration::from_millis(1));
assert!(!app.suppressing_mouse_scroll());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn queued_scroll_filter_drops_only_pending_scroll_events() {
let (tx, mut rx) = mpsc::unbounded_channel();
for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] {
tx.send(test_scroll_event(kind)).unwrap();
}
tx.send(CrosstermEvent::Key(KeyEvent::new(
KeyCode::Char('x'),
KeyModifiers::NONE,
)))
.unwrap();
drop(tx);
assert!(matches!(
next_queued_input_event(&mut rx, true),
Some(CrosstermEvent::Key(KeyEvent {
code: KeyCode::Char('x'),
..
}))
));
assert!(next_queued_input_event(&mut rx, true).is_none());
}
#[test]
fn tool_finishes_into_recent_history() {
let dir = temp_dir("tool");
let mut app = App::new(metadata_for(&dir), &[], false);
app.apply_agent_event(AgentEvent::ToolCallStarted {
thread_name: Some("coder-1".to_string()),
call_id: "call-1".to_string(),
name: "edit".to_string(),
args_preview: "crates/sac/src/tui.rs".to_string(),
args_detail: None,
});
app.apply_agent_event(AgentEvent::ToolCallFinished {
thread_name: Some("coder-1".to_string()),
call_id: "call-1".to_string(),
name: "edit".to_string(),
content_preview: "ok".to_string(),
content: None,
is_error: false,
});
assert!(app.active_tools.is_empty());
assert_eq!(app.recent_tools.len(), 1);
assert_eq!(app.recent_tools[0].name, "edit");
assert_eq!(app.recent_tools[0].status, ToolStatus::Ok);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn response_history_tracks_runtime_snapshots_and_ignored_event() {
let dir = temp_dir("response-runtime");
let mut app = App::new(metadata_for(&dir), &[], false);
assert_eq!(
format_optional_runtime(app.displayed_run_duration()),
"T+--:--:--"
);
assert_eq!(app.response_duration_snapshot_ms(), (None, None));
assert_eq!(
app.response_duration_history_snapshot_ms(),
Vec::<Option<u64>>::new()
);
app.apply_agent_event(AgentEvent::AssistantMessage {
thread_name: None,
content: "ignored".to_string(),
});
assert!(app.responses.is_empty());
let (_tx, rx) = tokio::sync::oneshot::channel();
app.result_rx = Some(rx);
app.working_started_at = Some(Instant::now() - Duration::from_secs(3));
let (runtime, is_live) = app.response_panel_runtime(None);
assert!(runtime.is_some());
assert!(is_live);
app.result_rx = None;
app.working_started_at = None;
app.complete_top_level_response("first reply".to_string(), Duration::from_secs(1));
assert_eq!(app.responses.len(), 1);
assert_eq!(app.responses[0].content, "first reply");
assert_eq!(app.responses[0].duration, Some(Duration::from_secs(1)));
assert_eq!(app.selected_response, Some(0));
assert_eq!(app.response_duration_snapshot_ms(), (Some(1_000), None));
assert_eq!(
app.response_duration_history_snapshot_ms(),
vec![Some(1_000)]
);
app.note_prompt_submitted("second prompt");
assert_eq!(app.responses.len(), 1);
assert_eq!(app.displayed_response_index(), Some(0));
assert_eq!(app.displayed_previous_response_index(), None);
app.complete_top_level_response("second reply".to_string(), Duration::from_secs(2));
assert_eq!(app.responses.len(), 2);
assert_eq!(app.responses[1].content, "second reply");
assert_eq!(app.responses[1].duration, Some(Duration::from_secs(2)));
assert_eq!(app.selected_response, Some(1));
assert_eq!(
app.response_duration_snapshot_ms(),
(Some(2_000), Some(1_000))
);
assert_eq!(
app.response_duration_history_snapshot_ms(),
vec![Some(1_000), Some(2_000)]
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn response_duration_history_round_trips_through_session_snapshot_and_resume_restore() {
let dir = temp_dir("response-duration-round-trip");
let mut metadata = metadata_for(&dir);
let messages = vec![
Message::Assistant {
content: Some("first reply".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: None,
},
Message::Assistant {
content: Some("second reply".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: None,
},
Message::Assistant {
content: Some("third reply".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: None,
},
];
let session_id = "session-response-durations".to_string();
metadata.session_id = Some(session_id.clone());
let mut snapshot = sessions::new_snapshot(
session_id.clone(),
dir.clone(),
metadata.store_path.clone(),
metadata.model.clone(),
metadata.base_url.clone(),
crate::model::BackendKind::OpenAiResponses,
None,
None,
messages,
);
snapshot.last_response_duration_ms = Some(3_333);
snapshot.previous_response_duration_ms = Some(2_222);
snapshot.response_durations_ms = Some(vec![Some(1_111), Some(2_222), Some(3_333)]);
sessions::create_session(&snapshot).unwrap();
let loaded = sessions::load_session(&metadata.store_path, &session_id).unwrap();
assert_eq!(
loaded.response_durations_ms,
Some(vec![Some(1_111), Some(2_222), Some(3_333)])
);
let restored_durations = loaded.response_durations_ms.as_ref().map(|durations| {
durations
.iter()
.map(|duration| duration.map(Duration::from_millis))
.collect::<Vec<_>>()
});
let mut app = App::new_with_mode(metadata, &loaded.messages, false, UiMode::Full);
app.restore_response_duration_history(
restored_durations.as_deref(),
loaded.last_response_duration_ms.map(Duration::from_millis),
loaded
.previous_response_duration_ms
.map(Duration::from_millis),
);
let contents = app
.responses
.iter()
.map(|response| response.content.as_str())
.collect::<Vec<_>>();
assert_eq!(contents, vec!["first reply", "second reply", "third reply"]);
assert_eq!(
app.response_duration_history_snapshot_ms(),
vec![Some(1_111), Some(2_222), Some(3_333)]
);
assert_eq!(
app.response_duration_snapshot_ms(),
(Some(3_333), Some(2_222))
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn response_duration_history_restore_prefers_full_vector() {
let dir = temp_dir("response-runtime-full-history");
let metadata = metadata_for(&dir);
let messages = vec![
Message::Assistant {
content: Some("first reply".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: None,
},
Message::Assistant {
content: Some("tool call carrier".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: Some(vec![crate::types::ToolCall {
id: "call-1".to_string(),
call_type: "function".to_string(),
function: crate::types::FunctionCall {
name: "read".to_string(),
arguments: "{}".to_string(),
},
}]),
},
Message::Assistant {
content: Some("second reply".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: None,
},
Message::Assistant {
content: None,
reasoning_text: Some("reasoning-only final".to_string()),
reasoning_details: None,
tool_calls: None,
},
Message::Assistant {
content: Some("third reply".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: Some(Vec::new()),
},
];
let mut app = App::new_with_mode(metadata, &messages, false, UiMode::Full);
let response_durations = vec![
Some(Duration::from_secs(1)),
None,
Some(Duration::from_secs(3)),
Some(Duration::from_secs(4)),
];
app.restore_response_duration_history(
Some(response_durations.as_slice()),
Some(Duration::from_secs(9)),
Some(Duration::from_secs(4)),
);
let contents = app
.responses
.iter()
.map(|response| response.content.as_str())
.collect::<Vec<_>>();
assert_eq!(
contents,
vec![
"first reply",
"second reply",
"[No response]",
"third reply"
]
);
assert_eq!(app.responses[0].duration, Some(Duration::from_secs(1)));
assert_eq!(app.responses[1].duration, None);
assert_eq!(app.responses[2].duration, Some(Duration::from_secs(3)));
assert_eq!(app.responses[3].duration, Some(Duration::from_secs(4)));
assert_eq!(
app.response_duration_history_snapshot_ms(),
vec![Some(1_000), None, Some(3_000), Some(4_000)]
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn response_durations_restore_with_hydrated_response_history() {
let dir = temp_dir("response-runtime-hydrate");
let metadata = metadata_for(&dir);
let messages = vec![
Message::Assistant {
content: Some("first reply".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: None,
},
Message::Assistant {
content: Some("tool call carrier".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: Some(vec![crate::types::ToolCall {
id: "call-1".to_string(),
call_type: "function".to_string(),
function: crate::types::FunctionCall {
name: "read".to_string(),
arguments: "{}".to_string(),
},
}]),
},
Message::Assistant {
content: Some("second reply".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: None,
},
Message::Assistant {
content: Some("third reply".to_string()),
reasoning_text: None,
reasoning_details: None,
tool_calls: Some(Vec::new()),
},
];
let mut app = App::new_with_mode(metadata, &messages, false, UiMode::Full);
app.restore_response_duration_history(
None,
Some(Duration::from_secs(9)),
Some(Duration::from_secs(4)),
);
let contents = app
.responses
.iter()
.map(|response| response.content.as_str())
.collect::<Vec<_>>();
assert_eq!(contents, vec!["first reply", "second reply", "third reply"]);
assert_eq!(app.responses[0].duration, None);
assert_eq!(app.responses[1].duration, Some(Duration::from_secs(4)));
assert_eq!(app.responses[2].duration, Some(Duration::from_secs(9)));
assert_eq!(app.selected_response, Some(2));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn selection_extract_preserves_original_newlines_only() {
let lines = vec![
"alpha beta gamma delta".to_string(),
"second line".to_string(),
];
let rows = wrap_logical_lines(&lines, 8);
let view = PanelView {
id: PanelId::Response,
inner: Rect::new(0, 0, 20, 10),
logical_lines: lines,
rows,
scroll_offset: 0,
visible_rows: 10,
};
let selection = SelectionState {
anchor: SelectionPoint {
panel: PanelId::Response,
logical_line: 0,
char_index: 6,
},
focus: SelectionPoint {
panel: PanelId::Response,
logical_line: 1,
char_index: 6,
},
dragging: false,
};
let extracted = extract_selection_text(&view, &selection);
assert_eq!(extracted, "beta gamma delta\nsecond");
}
#[test]
fn workspace_without_host_path_is_unavailable() {
let snapshot = WorkspaceSnapshot::load("/workspace/project", None);
assert!(snapshot.error.is_some());
assert_eq!(snapshot.host_root, None);
}
#[test]
fn markdown_renderer_formats_common_blocks() {
let rendered = render_markdown_lines(
"# Heading\n- item\n> quote\nLink to [site](https://example.com)\n| Name | Value |\n| --- | --- |\n| one | 1 |\n```rust\nfn main() {}\n```",
None,
);
let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
assert_eq!(plain[0], "# Heading");
assert_eq!(plain[1], "• item");
assert_eq!(plain[2], "> quote");
assert_eq!(plain[3], "Link to site <https://example.com>");
assert_eq!(plain[4], "┌──────┬───────┐");
assert_eq!(plain[5], "│ Name │ Value │");
assert_eq!(plain[6], "├──────┼───────┤");
assert_eq!(plain[7], "│ one │ 1 │");
assert_eq!(plain[8], "└──────┴───────┘");
assert_eq!(plain[9], "```rust");
assert_eq!(plain[10], "fn main() {}");
assert_eq!(plain[11], "```");
}
#[test]
fn parse_remote_label_handles_ssh() {
assert_eq!(
parse_remote_label("git@github.com:secemp9/sac.git").as_deref(),
Some("secemp9/sac")
);
}
#[test]
fn parse_status_porcelain_tracks_untracked_and_staged() {
let raw = "M crates/sac/src/tui.rs\nA README.md\n?? notes.txt\n";
let (counts, files) = parse_status_porcelain(raw);
assert_eq!(counts.modified, 1);
assert_eq!(counts.added, 1);
assert_eq!(counts.untracked, 1);
assert_eq!(counts.staged, 2);
assert!(files.contains_key("notes.txt"));
}
#[test]
fn markdown_table_without_delimiter_renders_as_table() {
let rendered =
render_markdown_lines("| Name | Value |\n| one | 1 |\n| two | 2 |", None);
let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
assert_eq!(plain[0], "┌──────┬───────┐");
assert_eq!(plain[1], "│ Name │ Value │");
assert_eq!(plain[2], "├──────┼───────┤");
assert_eq!(plain[3], "│ one │ 1 │");
assert_eq!(plain[4], "│ two │ 2 │");
assert_eq!(plain[5], "└──────┴───────┘");
}
#[test]
fn markdown_table_without_delimiter_single_column_skips_fallback() {
let rendered = render_markdown_lines("| single |\n| column |", None);
let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
assert!(
plain.iter().any(|l| l.contains('|')),
"single-column pipe lines should fall through to paragraph rendering"
);
}
#[test]
fn markdown_table_row_respects_escaped_and_code_pipes() {
assert_eq!(
parse_markdown_table_row(r"| a \| b | `x|y` | c |"),
Some(vec![
r"a \| b".to_string(),
"`x|y`".to_string(),
"c".to_string()
])
);
assert_eq!(
parse_markdown_table_row(r"a | b \|"),
Some(vec!["a".to_string(), r"b \|".to_string()])
);
}
#[test]
fn markdown_table_preserves_inline_styles_in_cells() {
let rendered = render_markdown_lines(
"| Col A |\n| --- |\n| **bold** text |\n| [site](https://example.com) |",
None,
);
let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
assert_eq!(plain[3], "│ bold text │");
assert_eq!(plain[4], "│ site <https://example.com> │");
let has_bold = rendered[3]
.spans
.iter()
.any(|s| s.style.add_modifier.contains(Modifier::BOLD));
assert!(has_bold, "cell with **bold** should contain a BOLD span");
let has_underlined = rendered[4]
.spans
.iter()
.any(|s| s.style.add_modifier.contains(Modifier::UNDERLINED));
assert!(
has_underlined,
"cell with [link]() should contain an UNDERLINED span"
);
}
#[test]
fn markdown_table_keeps_rows_with_escaped_pipes_in_same_table() {
let rendered = render_markdown_lines(
"| Feature | Input syntax | Status |\n\
|---|---|---|\n\
| Standard table | \\| a \\| b \\| with \\|---\\| row | ✅ Working |\n\
| Bold text | **double asterisks** | ✅ Preserved |\n\
| Italic text | *single asterisks* | ✅ Preserved |\n\
| inline code | `backticks` | ✅ Preserved |\n\
| Links <https://example.com> | [text](url) | ✅ Preserved |\n\
| Pipes in cells: |---| | Literal \\| in content | ✅ Not split |",
None,
);
let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
assert_eq!(plain.iter().filter(|line| line.starts_with('┌')).count(), 1);
assert!(!plain
.iter()
.any(|line| line.starts_with("| Standard table")));
assert!(plain.iter().any(
|line| line.contains("Standard table") && line.contains("| a | b | with |---| row")
));
assert!(plain
.iter()
.any(|line| line.contains("Pipes in cells:|---|")
&& line.contains("Literal | in content")));
}
#[test]
fn markdown_table_uses_terminal_display_width_for_emoji() {
let rendered = render_markdown_lines(
"| Emoji | Meaning |\n\
| 🔴 | High severity |\n\
| 🟡 | Medium severity |\n\
| ⚪ | Low / cosmetic |\n\
| ✅ | Verified fixed |",
None,
);
let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
let widths: Vec<usize> = plain.iter().map(|line| display_width(line)).collect();
assert!(widths.iter().all(|width| *width == widths[0]));
}
#[test]
fn goal_pause_while_agent_running_sets_deferred_flag() {
let dir = temp_dir("goal-pause-running");
let mut app = App::new(metadata_for(&dir), &[], false);
app.set_goal("build the feature".to_string());
let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
app.result_rx = Some(rx);
app.composer.insert_str("/goal pause");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert!(app.goal_pause_requested);
assert_eq!(
app.goal.as_ref().unwrap().status,
crate::goal::GoalStatus::Active,
);
assert_eq!(app.prompt(), "");
let notice = app.composer_notice.as_ref().expect("expected notice");
assert!(notice.text.contains("pause after current turn"));
assert_eq!(notice.tone, Tone::Warning);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn goal_clear_while_agent_running_sets_deferred_flag() {
let dir = temp_dir("goal-clear-running");
let mut app = App::new(metadata_for(&dir), &[], false);
app.set_goal("build the feature".to_string());
let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
app.result_rx = Some(rx);
app.composer.insert_str("/goal clear");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert!(app.goal_clear_requested);
assert!(app.goal.is_some()); let notice = app.composer_notice.as_ref().expect("expected notice");
assert!(notice.text.contains("clear after current turn"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn goal_show_while_agent_running_displays_status() {
let dir = temp_dir("goal-show-running");
let mut app = App::new(metadata_for(&dir), &[], false);
app.set_goal("build the feature".to_string());
let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
app.result_rx = Some(rx);
app.composer.insert_str("/goal");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert!(!app.goal_pause_requested);
assert!(!app.goal_clear_requested);
let notice = app.composer_notice.as_ref().expect("expected notice");
assert!(notice.text.contains("build the feature"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn non_goal_command_blocked_while_agent_running() {
let dir = temp_dir("non-goal-blocked");
let mut app = App::new(metadata_for(&dir), &[], false);
let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
app.result_rx = Some(rx);
app.composer.insert_str("some prompt text");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert_eq!(app.prompt(), "some prompt text");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn goal_set_blocked_while_agent_running() {
let dir = temp_dir("goal-set-blocked");
let mut app = App::new(metadata_for(&dir), &[], false);
let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
app.result_rx = Some(rx);
app.composer.insert_str("/goal set new objective");
let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert!(!app.goal_pause_requested);
assert!(!app.goal_clear_requested);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn escape_requests_goal_pause_while_agent_running() {
let dir = temp_dir("goal-esc-pause");
let mut app = App::new(metadata_for(&dir), &[], false);
app.set_goal("build the feature".to_string());
let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
app.result_rx = Some(rx);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert!(app.goal_pause_requested);
let notice = app.composer_notice.as_ref().expect("expected notice");
assert!(notice.text.contains("pause after current turn"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn escape_does_not_pause_when_no_goal_active() {
let dir = temp_dir("goal-esc-no-goal");
let mut app = App::new(metadata_for(&dir), &[], false);
let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
app.result_rx = Some(rx);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert!(!app.goal_pause_requested);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn escape_dismisses_focus_even_with_goal_active() {
let dir = temp_dir("goal-esc-focus");
let mut app = App::new(metadata_for(&dir), &[], false);
app.set_goal("build the feature".to_string());
app.screen = ScreenMode::Focused(FocusPanel::Response);
let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
app.result_rx = Some(rx);
let action = app.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(action, AppAction::None));
assert!(!app.goal_pause_requested);
assert_eq!(app.screen, ScreenMode::Dashboard);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn deferred_pause_flag_resets_after_check() {
let dir = temp_dir("goal-deferred-reset");
let mut app = App::new(metadata_for(&dir), &[], false);
app.set_goal("build the feature".to_string());
app.goal_pause_requested = true;
if app.goal_clear_requested {
app.goal_clear_requested = false;
app.goal_pause_requested = false;
app.clear_goal();
} else if app.goal_pause_requested {
app.goal_pause_requested = false;
app.pause_goal();
}
assert!(!app.goal_pause_requested);
assert_eq!(
app.goal.as_ref().unwrap().status,
crate::goal::GoalStatus::Paused
);
assert!(!app.goal_should_continue());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn deferred_clear_takes_precedence_over_pause() {
let dir = temp_dir("goal-deferred-clear-precedence");
let mut app = App::new(metadata_for(&dir), &[], false);
app.set_goal("build the feature".to_string());
app.goal_pause_requested = true;
app.goal_clear_requested = true;
if app.goal_clear_requested {
app.goal_clear_requested = false;
app.goal_pause_requested = false;
app.clear_goal();
} else if app.goal_pause_requested {
app.goal_pause_requested = false;
app.pause_goal();
}
assert!(!app.goal_pause_requested);
assert!(!app.goal_clear_requested);
assert!(app.goal.is_none());
assert!(!app.goal_should_continue());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn goal_should_not_continue_after_deferred_pause() {
let dir = temp_dir("goal-no-continue-after-pause");
let mut app = App::new(metadata_for(&dir), &[], false);
app.set_goal("build the feature".to_string());
assert!(app.goal_should_continue());
app.pause_goal();
assert!(!app.goal_should_continue());
let _ = std::fs::remove_dir_all(dir);
}
}