1use std::cmp::Ordering;
2use std::collections::{HashMap, HashSet, VecDeque};
3use std::io::{self};
4use std::path::{Path, PathBuf};
5use std::process::Command as StdCommand;
6use std::sync::{
7 atomic::{AtomicBool, Ordering as AtomicOrdering},
8 Arc,
9};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use anyhow::Result;
14use crossterm::event::{
15 self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
16 Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
17 KeyboardEnhancementFlags, MouseButton, MouseEvent, MouseEventKind, PopKeyboardEnhancementFlags,
18 PushKeyboardEnhancementFlags,
19};
20use crossterm::terminal::{
21 disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, EnterAlternateScreen,
22 LeaveAlternateScreen,
23};
24use ratatui::backend::CrosstermBackend;
25use ratatui::layout::{Constraint, Direction, Layout, Rect};
26use ratatui::style::{Color, Modifier, Style};
27use ratatui::text::{Line, Span, Text};
28use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
29use ratatui::Terminal;
30use ratatui_textarea::TextArea;
31use tokio::sync::{mpsc, Mutex};
32use tokio::time::{self, MissedTickBehavior};
33
34use crate::agent::Agent;
35use crate::events::{AgentEvent, EventSink};
36use crate::life::LifeField;
37use crate::sessions::{self, SessionSnapshot};
38use crate::store;
39use crate::types::Message;
40
41mod app;
42mod commands;
43mod markdown;
44mod render;
45mod selection;
46mod slash_popup;
47mod state;
48mod style;
49mod terminal;
50mod time_utils;
51mod util;
52mod workspace;
53mod wrap;
54
55use app::*;
56use commands::*;
57use markdown::*;
58use render::*;
59use selection::*;
60use slash_popup::*;
61use state::*;
62use style::*;
63use terminal::*;
64use time_utils::*;
65use util::*;
66use workspace::*;
67use wrap::*;
68
69const COMPOSER_HEIGHT: u16 = 6;
70const MIN_TERMINAL_WIDTH: u16 = 72;
71const MIN_TERMINAL_HEIGHT: u16 = 22;
72const COMPACT_MIN_TERMINAL_WIDTH: u16 = 40;
73const COMPACT_MIN_TERMINAL_HEIGHT: u16 = 8;
74const TIMELINE_LIMIT: usize = 220;
75const TOOL_HISTORY_LIMIT: usize = 20;
76const FILE_CHANGE_LIMIT: usize = 36;
77const COMPACT_TIMELINE_LIMIT: usize = 24;
78const WORKSPACE_REFRESH_INTERVAL: Duration = Duration::from_millis(400);
79const VIEW_CHANGE_SCROLL_SUPPRESS: Duration = Duration::from_millis(750);
80const PROMPT_SEPARATOR: &str = " › ";
81const COMMAND_SEPARATOR: &str = " / ";
82const CONTINUATION_PREFIX: &str = " ";
83const COMPACT_LABEL_WIDTH: usize = 10;
84
85type UiTerminal = Terminal<CrosstermBackend<io::Stdout>>;
86
87#[derive(Clone)]
88pub struct TuiMetadata {
89 pub cwd: String,
90 pub workspace_host_path: Option<PathBuf>,
91 pub store_path: PathBuf,
92 pub model: String,
93 pub base_url: String,
94 pub backend: String,
95 pub reasoning_effort: Option<String>,
96 pub session_id: Option<String>,
97 pub sandbox_status: String,
98 pub agents_md_status: String,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum UiMode {
103 Full,
104 Compact,
105}
106
107#[derive(Debug)]
108enum AppAction {
109 None,
110 Quit,
111 Submit(String),
112 ResumeSession(String),
113}
114
115fn next_queued_input_event(
116 input_rx: &mut mpsc::UnboundedReceiver<CrosstermEvent>,
117 drop_scroll_events: bool,
118) -> Option<CrosstermEvent> {
119 while let Ok(event) = input_rx.try_recv() {
120 if drop_scroll_events
121 && matches!(
122 event,
123 CrosstermEvent::Mouse(MouseEvent {
124 kind: MouseEventKind::ScrollUp
125 | MouseEventKind::ScrollDown
126 | MouseEventKind::ScrollLeft
127 | MouseEventKind::ScrollRight,
128 ..
129 })
130 )
131 {
132 continue;
133 }
134 return Some(event);
135 }
136 None
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140enum SlashCommand {
141 Exit,
142 Sessions,
143 Copy,
144 Plan { instruction: String },
145 Run { workset_id: String },
146 Goal { subcommand: GoalSubcommand },
147 Custom { name: String, args: String },
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151enum GoalSubcommand {
152 Show,
153 Set { objective: String },
154 Clear,
155 Pause,
156 Resume,
157 Edit { objective: String },
158}
159
160#[derive(Debug)]
161pub enum TuiOutcome {
162 Exit,
163 ResumeSession(String),
164}
165
166pub async fn run(
167 mut agent: Agent,
168 metadata: TuiMetadata,
169 restored_messages: Vec<Message>,
170 mut session_snapshot: Option<SessionSnapshot>,
171 start_in_session_picker: bool,
172 ui_mode: UiMode,
173) -> Result<TuiOutcome> {
174 tracing::info!(
175 ui_mode = ?ui_mode,
176 cwd = %metadata.cwd,
177 workspace_host_path = ?metadata.workspace_host_path,
178 store_path = %metadata.store_path.display(),
179 model = %metadata.model,
180 base_url = %metadata.base_url,
181 backend = %metadata.backend,
182 reasoning_effort = ?metadata.reasoning_effort,
183 session_id = ?metadata.session_id,
184 sandbox_status = %metadata.sandbox_status,
185 agents_md_status = %metadata.agents_md_status,
186 restored_message_count = restored_messages.len(),
187 start_in_session_picker,
188 "starting tui runtime"
189 );
190 let (input_tx, mut input_rx) = mpsc::unbounded_channel::<CrosstermEvent>();
191 let (event_tx, mut event_rx) = mpsc::unbounded_channel::<AgentEvent>();
192
193 agent.set_event_sink(EventSink::channel(event_tx));
194 let agent = Arc::new(Mutex::new(agent));
195
196 tracing::debug!("enabling raw mode for tui");
197 enable_raw_mode()?;
198 let mut stdout = io::stdout();
199 tracing::debug!("entering alternate screen for tui");
200 crossterm::execute!(stdout, EnterAlternateScreen)?;
201 let backend = CrosstermBackend::new(stdout);
202 let mut terminal = Terminal::new(backend)?;
203 terminal.hide_cursor()?;
204
205 let keyboard_enhancements_enabled = enable_keyboard_enhancements(&mut terminal);
206 let bracketed_paste_enabled = enable_bracketed_paste(&mut terminal);
207 let mouse_capture_enabled = if std::env::var_os("SAC_DISABLE_MOUSE").is_some() {
208 false
209 } else {
210 enable_mouse_capture(&mut terminal)
211 };
212 tracing::debug!(
213 keyboard_enhancements_enabled,
214 bracketed_paste_enabled,
215 mouse_capture_enabled,
216 "tui input capabilities configured"
217 );
218
219 let running = Arc::new(AtomicBool::new(true));
220 let input_thread = spawn_input_thread(running.clone(), input_tx);
221
222 let response_duration_history = session_snapshot.as_ref().and_then(|snapshot| {
223 snapshot.response_durations_ms.as_ref().map(|durations| {
224 durations
225 .iter()
226 .map(|duration| duration.map(Duration::from_millis))
227 .collect::<Vec<_>>()
228 })
229 });
230 let (last_response_duration, previous_response_duration) = session_snapshot
231 .as_ref()
232 .map(|snapshot| {
233 (
234 snapshot
235 .last_response_duration_ms
236 .map(Duration::from_millis),
237 snapshot
238 .previous_response_duration_ms
239 .map(Duration::from_millis),
240 )
241 })
242 .unwrap_or_default();
243 let timeline_json_for_restore = session_snapshot
244 .as_ref()
245 .and_then(|snapshot| snapshot.timeline_json.as_deref())
246 .map(|s| s.to_string());
247 let mut app = App::new_with_mode(
248 metadata,
249 &restored_messages,
250 start_in_session_picker,
251 ui_mode,
252 );
253 app.hydrate_timeline(timeline_json_for_restore.as_deref());
254 app.restore_response_duration_history(
255 response_duration_history.as_deref(),
256 last_response_duration,
257 previous_response_duration,
258 );
259 let (ws_tx, ws_rx) = mpsc::channel::<WorkspaceSnapshot>(1);
260 app.workspace_tx = Some(ws_tx);
261 app.workspace_rx = Some(ws_rx);
262 let mut animation_tick = time::interval(Duration::from_millis(75));
263 animation_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
264 let mut terminal_refresh_tick = time::interval(Duration::from_millis(500));
265 terminal_refresh_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
266 terminal.draw(|frame| app.render(frame))?;
267
268 let mut outcome = TuiOutcome::Exit;
269
270 let loop_result = async {
271 while !app.quit {
272 tokio::select! {
273 event = input_rx.recv() => {
274 match event {
275 Some(event) => {
276 let mut terminal_action = false;
277 if let Some(action) = app.handle_crossterm_event(event) {
278 match action {
279 AppAction::Submit(prompt) => {
280 submit_prompt(prompt, agent.clone(), &mut app, &mut terminal)?;
281 terminal_action = true;
282 }
283 AppAction::ResumeSession(session_id) => {
284 outcome = TuiOutcome::ResumeSession(session_id);
285 app.quit = true;
286 terminal_action = true;
287 }
288 AppAction::Quit | AppAction::None => {}
289 }
290 }
291 let mut drop_queued_scroll_events = app.suppressing_mouse_scroll();
292 if !terminal_action {
293 while let Some(next_event) = next_queued_input_event(
294 &mut input_rx,
295 drop_queued_scroll_events,
296 ) {
297 if let Some(action) = app.handle_crossterm_event(next_event) {
298 match action {
299 AppAction::Submit(prompt) => {
300 submit_prompt(prompt, agent.clone(), &mut app, &mut terminal)?;
301 break;
302 }
303 AppAction::ResumeSession(session_id) => {
304 outcome = TuiOutcome::ResumeSession(session_id);
305 app.quit = true;
306 break;
307 }
308 AppAction::Quit => {
309 app.quit = true;
310 break;
311 }
312 AppAction::None => {}
313 }
314 }
315 if app.suppressing_mouse_scroll() {
316 drop_queued_scroll_events = true;
317 }
318 }
319 }
320 }
321 None => {
322 tracing::error!("input thread terminated unexpectedly; shutting down tui");
323 app.quit = true;
324 }
325 }
326 }
327 Some(agent_event) = event_rx.recv() => {
328 app.apply_agent_event(agent_event);
329 }
330 result = async {
331 match app.result_rx.as_mut() {
332 Some(rx) => match rx.await {
333 Ok(val) => Some(val),
334 Err(_) => {
335 tracing::error!("agent task terminated unexpectedly (oneshot sender dropped)");
336 Some(Err("Internal error: agent task terminated unexpectedly".to_string()))
337 }
338 },
339 None => std::future::pending::<Option<Result<String, String>>>().await,
340 }
341 } => {
342 if let Some(result) = result {
343 let completed_duration = app
344 .working_started_at
345 .map(|started| started.elapsed())
346 .unwrap_or_default();
347 app.result_rx = None;
348 app.steering_tx = None;
349 app.goal_pause_tx = None;
350 app.budget_limit_steering_sent = false;
351 app.working_frame = 0;
352 app.working_started_at = None;
353 app.reset_life();
354 match result {
355 Ok(response) => {
356 app.complete_top_level_response(response, completed_duration);
357 }
358 Err(error) => {
359 app.note_send_error(error);
364 }
365 }
366 app.reload_goal_from_store();
372
373 if let Some(snapshot) = session_snapshot.as_mut() {
374 let agent = agent.lock().await;
375 let (last_response_duration_ms, previous_response_duration_ms) =
376 app.response_duration_snapshot_ms();
377 let response_durations_ms = app.response_duration_history_snapshot_ms();
378 let timeline_json = serde_json::to_string(
379 &app.timeline.iter().collect::<Vec<_>>()
380 ).ok();
381 persist_session_snapshot(
382 snapshot,
383 &agent,
384 last_response_duration_ms,
385 previous_response_duration_ms,
386 response_durations_ms,
387 timeline_json,
388 )
389 .await?;
390 }
391
392 app.goal_clear_requested = false;
398 app.goal_pause_requested = false;
399 }
400 }
401 _ = animation_tick.tick() => {
402 if app.result_rx.is_some() {
403 app.working_frame = app.working_frame.wrapping_add(1);
404 app.advance_life();
405 }
406 app.maybe_refresh_workspace();
407 }
408 _ = terminal_refresh_tick.tick() => {
409 if app.result_rx.is_none() {
410 let agent_guard = agent.lock().await;
411 let terminal_manager = agent_guard.terminal_manager();
412 let terminals = terminal_manager.list().await;
413 drop(agent_guard);
414 app.update_terminals(None, terminals);
415 }
416 }
417 }
418
419 app.check_workspace_channel();
420 terminal.draw(|frame| app.render(frame))?;
421 }
422
423 Ok::<(), anyhow::Error>(())
424 }
425 .await;
426
427 tracing::debug!(outcome = ?outcome, "starting tui cleanup");
428 running.store(false, AtomicOrdering::SeqCst);
429 let _ = input_thread.join();
430
431 let cleanup_result = (|| -> io::Result<()> {
432 if keyboard_enhancements_enabled {
433 let _ = crossterm::execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags);
434 }
435 if bracketed_paste_enabled {
436 let _ = crossterm::execute!(terminal.backend_mut(), DisableBracketedPaste);
437 }
438 if mouse_capture_enabled {
439 let _ = crossterm::execute!(terminal.backend_mut(), DisableMouseCapture);
440 }
441 terminal.show_cursor()?;
442 crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
443 disable_raw_mode()
444 })();
445
446 loop_result?;
447 cleanup_result?;
448 tracing::info!(outcome = ?outcome, "tui runtime exited");
449 Ok(outcome)
450}
451
452fn submit_prompt(
453 prompt: String,
454 agent: Arc<Mutex<Agent>>,
455 app: &mut App,
456 terminal: &mut UiTerminal,
457) -> Result<()> {
458 let agent_prompt = expand_user_prompt(
459 &prompt,
460 app.command_registry.as_deref(),
461 std::path::Path::new(&app.metadata.cwd),
462 );
463 tracing::info!(
464 prompt_len = prompt.len(),
465 expanded_prompt_len = agent_prompt.len(),
466 slash_mode = composer_is_slash_mode(&[prompt.clone()]),
467 "submitting prompt from tui"
468 );
469 app.note_prompt_submitted(&prompt);
470 app.current_prompt = prompt;
471 app.clear_composer();
472 app.working_frame = 0;
473 app.working_started_at = Some(Instant::now());
474 app.reset_life();
475 app.budget_limit_steering_sent = false;
476
477 let (steering_tx, steering_rx) = mpsc::unbounded_channel::<String>();
482 app.steering_tx = Some(steering_tx);
483
484 let (goal_pause_tx, goal_pause_rx) = mpsc::unbounded_channel::<String>();
488 app.goal_pause_tx = Some(goal_pause_tx);
489
490 let has_active_goal = app
494 .goal
495 .as_ref()
496 .is_some_and(|g| g.status == crate::goal::GoalStatus::Active);
497
498 let (tx, rx) = tokio::sync::oneshot::channel();
499 app.result_rx = Some(rx);
500
501 tokio::spawn(async move {
502 let result = {
503 let mut agent = agent.lock().await;
504 agent.set_steering_rx(steering_rx);
505 if has_active_goal {
506 agent
507 .send_with_goal(&agent_prompt, Some(&mut { goal_pause_rx }))
508 .await
509 .map_err(|error| error.to_string())
510 } else {
511 agent
512 .send(&agent_prompt)
513 .await
514 .map_err(|error| error.to_string())
515 }
516 };
517 let _ = tx.send(result);
518 });
519
520 terminal.draw(|frame| app.render(frame))?;
521 Ok(())
522}
523
524fn build_composer() -> TextArea<'static> {
525 TextArea::default()
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crossterm::event::KeyEventState;
532 use std::time::{SystemTime, UNIX_EPOCH};
533
534 fn temp_dir(label: &str) -> PathBuf {
535 let unique = SystemTime::now()
536 .duration_since(UNIX_EPOCH)
537 .unwrap()
538 .as_nanos();
539 let dir = std::env::temp_dir().join(format!("sac_tui_{label}_{unique}"));
540 std::fs::create_dir_all(&dir).unwrap();
541 dir
542 }
543
544 fn metadata_for(path: &Path) -> TuiMetadata {
545 TuiMetadata {
546 cwd: path.display().to_string(),
547 workspace_host_path: Some(path.to_path_buf()),
548 store_path: path.join(".sac").join("store.db"),
549 model: "gpt-test".to_string(),
550 base_url: "https://example.com/v1".to_string(),
551 backend: "openai-responses".to_string(),
552 reasoning_effort: Some("medium".to_string()),
553 session_id: None,
554 sandbox_status: "off".to_string(),
555 agents_md_status: "off".to_string(),
556 }
557 }
558
559 fn test_thread_view(name: &str, updated_at_ts: u64) -> ThreadView {
560 ThreadView {
561 name: name.to_string(),
562 action: format!("inspect {name}"),
563 state: ThreadState::Retained,
564 updated_at: format!("00:00:{updated_at_ts:02}"),
565 updated_at_ts,
566 episodes: 1,
567 summary: String::new(),
568 }
569 }
570
571 fn test_scroll_event(kind: MouseEventKind) -> CrosstermEvent {
572 CrosstermEvent::Mouse(MouseEvent {
573 kind,
574 column: 0,
575 row: 0,
576 modifiers: KeyModifiers::NONE,
577 })
578 }
579
580 fn press_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) {
581 assert!(matches!(
582 app.handle_key_event(KeyEvent::new(code, modifiers)),
583 AppAction::None
584 ));
585 }
586
587 #[test]
588 fn shift_enter_inserts_newline() {
589 let dir = temp_dir("newline");
590 let mut app = App::new(metadata_for(&dir), &[], false);
591 app.composer.insert_str("hello");
592
593 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));
594
595 assert!(matches!(action, AppAction::None));
596 assert_eq!(app.prompt(), "hello\n");
597 let _ = std::fs::remove_dir_all(dir);
598 }
599
600 #[test]
601 fn control_j_inserts_newline_without_deleting_text() {
602 let dir = temp_dir("ctrl-j-newline");
603 let mut app = App::new(metadata_for(&dir), &[], false);
604 app.composer.insert_str("hello");
605
606 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
607
608 assert!(matches!(action, AppAction::None));
609 assert_eq!(app.prompt(), "hello\n");
610 let _ = std::fs::remove_dir_all(dir);
611 }
612
613 #[test]
614 fn enter_submits_prompt() {
615 let dir = temp_dir("submit");
616 let mut app = App::new(metadata_for(&dir), &[], false);
617 app.composer.insert_str("hello");
618
619 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
620
621 match action {
622 AppAction::Submit(prompt) => assert_eq!(prompt, "hello"),
623 _ => panic!("expected submit"),
624 }
625 let _ = std::fs::remove_dir_all(dir);
626 }
627
628 #[test]
629 fn plan_command_submits_raw_prompt() {
630 let dir = temp_dir("plan-submit");
631 let mut app = App::new(metadata_for(&dir), &[], false);
632 app.composer.insert_str("/plan refresh auth flow");
633
634 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
635
636 match action {
637 AppAction::Submit(prompt) => assert_eq!(prompt, "/plan refresh auth flow"),
638 _ => panic!("expected submit"),
639 }
640 let _ = std::fs::remove_dir_all(dir);
641 }
642
643 #[test]
644 fn run_command_submits_raw_prompt() {
645 let dir = temp_dir("run-submit");
646 let mut app = App::new(metadata_for(&dir), &[], false);
647 app.composer.insert_str("/run auth-refresh");
648
649 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
650
651 match action {
652 AppAction::Submit(prompt) => assert_eq!(prompt, "/run auth-refresh"),
653 _ => panic!("expected submit"),
654 }
655 let _ = std::fs::remove_dir_all(dir);
656 }
657
658 #[test]
659 fn slash_exit_quits() {
660 let dir = temp_dir("exit");
661 let mut app = App::new(metadata_for(&dir), &[], false);
662 app.composer.insert_str("/exit");
663
664 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
665
666 assert!(matches!(action, AppAction::Quit));
667 assert!(app.quit);
668 let _ = std::fs::remove_dir_all(dir);
669 }
670
671 #[test]
672 fn repeat_backspace_is_processed() {
673 let dir = temp_dir("backspace");
674 let mut app = App::new(metadata_for(&dir), &[], false);
675 app.composer.insert_str("ab");
676
677 let action = app.handle_key_event(KeyEvent {
678 code: KeyCode::Backspace,
679 modifiers: KeyModifiers::NONE,
680 kind: KeyEventKind::Repeat,
681 state: KeyEventState::NONE,
682 });
683
684 assert!(matches!(action, AppAction::None));
685 assert_eq!(app.prompt(), "a");
686 let _ = std::fs::remove_dir_all(dir);
687 }
688
689 #[test]
690 fn multiline_paste_inserts_newlines_without_submit() {
691 let dir = temp_dir("paste");
692 let mut app = App::new(metadata_for(&dir), &[], false);
693
694 let action = app.handle_paste("hello\nworld");
695
696 assert!(matches!(action, AppAction::None));
697 assert_eq!(app.prompt(), "hello\nworld");
698 let _ = std::fs::remove_dir_all(dir);
699 }
700
701 #[test]
702 fn pasted_crlf_is_normalized_to_newlines() {
703 let dir = temp_dir("paste-crlf");
704 let mut app = App::new(metadata_for(&dir), &[], false);
705
706 app.handle_paste("hello\r\nworld\rtest");
707
708 assert_eq!(app.prompt(), "hello\nworld\ntest");
709 let _ = std::fs::remove_dir_all(dir);
710 }
711
712 #[test]
713 fn slash_command_mode_uses_command_prefix() {
714 let view = wrapped_composer_view(&["/sessions".to_string()], (0, 9), 20, 4);
715
716 assert_eq!(line_to_plain_text(&view.lines[0]), " / sessions");
717 assert_eq!(view.lines[0].spans[0].style.fg, Some(Color::Yellow));
718 assert_eq!(view.lines[0].spans[1].style.fg, Some(Color::Yellow));
719 assert_eq!(view.cursor_col, composer_prefix_width() as u16 + 8);
720 }
721
722 #[test]
723 fn normal_prompt_prefix_returns_after_slash_removed() {
724 let slash = wrapped_composer_view(&["/".to_string()], (0, 1), 20, 4);
725 let normal = wrapped_composer_view(&["".to_string()], (0, 0), 20, 4);
726
727 assert_eq!(line_to_plain_text(&slash.lines[0]), " / ");
728 assert_eq!(line_to_plain_text(&normal.lines[0]), " › ");
729 assert_eq!(normal.lines[0].spans[0].style.fg, Some(Color::Cyan));
730 assert_eq!(normal.lines[0].spans[1].style.fg, Some(Color::White));
731 }
732
733 #[test]
734 fn invalid_slash_command_shows_composer_notice() {
735 let dir = temp_dir("invalid-command");
736 let mut app = App::new(metadata_for(&dir), &[], false);
737 app.composer.insert_str("/bogus");
738
739 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
740
741 assert!(matches!(action, AppAction::None));
742 assert_eq!(app.prompt(), "/bogus");
743 let notice = app
744 .composer_notice
745 .as_ref()
746 .expect("expected composer notice");
747 assert_eq!(notice.text, "unknown command: /bogus");
748 assert_eq!(notice.tone, Tone::Warning);
749 let _ = std::fs::remove_dir_all(dir);
750 }
751
752 #[test]
753 fn run_command_requires_workset() {
754 let dir = temp_dir("run-usage");
755 let mut app = App::new(metadata_for(&dir), &[], false);
756 app.composer.insert_str("/run");
757
758 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
759
760 assert!(matches!(action, AppAction::None));
761 let notice = app
762 .composer_notice
763 .as_ref()
764 .expect("expected composer notice");
765 assert_eq!(notice.text, "usage: /run <workset>");
766 let _ = std::fs::remove_dir_all(dir);
767 }
768
769 #[test]
770 fn run_command_rejects_freeform_instruction() {
771 let dir = temp_dir("run-freeform");
772 let mut app = App::new(metadata_for(&dir), &[], false);
773 app.composer.insert_str("/run refresh auth flow");
774
775 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
776
777 assert!(matches!(action, AppAction::None));
778 let notice = app
779 .composer_notice
780 .as_ref()
781 .expect("expected composer notice");
782 assert_eq!(notice.text, "usage: /run <workset>");
783 let _ = std::fs::remove_dir_all(dir);
784 }
785
786 #[test]
787 fn plan_command_expands_to_workset_prompt() {
788 let expanded = expand_user_prompt("/plan refresh auth flow", None, Path::new("/tmp"));
789
790 assert!(expanded.contains("# /plan: Workset Planning"));
791 assert!(expanded.contains("workset_define"));
792 assert!(expanded.contains("goal"));
793 assert!(expanded.contains("role"));
794 assert!(expanded.contains("depends_on"));
795 assert!(expanded.contains("acceptance"));
796 assert!(expanded.contains("refresh auth flow"));
797 assert!(expanded.contains("Do not do mutating implementation work in this step."));
798 assert!(!expanded.contains("thread_name"));
799 }
800
801 #[test]
802 fn run_command_expands_to_existing_workset_prompt() {
803 let expanded = expand_user_prompt("/run auth-refresh", None, Path::new("/tmp"));
804
805 assert!(expanded.contains("# /run: Workset Execution"));
806 assert!(expanded.contains("workset_read"));
807 assert!(expanded.contains("auth-refresh"));
808 assert!(expanded.contains("run `/plan <instruction>` first"));
809 assert!(expanded.contains("Use `thread` for implementation and verification work."));
810 assert!(!expanded.contains("Create exactly one durable"));
811 }
812
813 fn define_test_workset(path: &Path, session_id: &str, id: &str) {
814 store::define_workset(
815 path,
816 session_id,
817 &store::WorksetDefinition {
818 id: id.to_string(),
819 goal: "refresh auth flow".to_string(),
820 status: "planned".to_string(),
821 summary: "Auth work units.".to_string(),
822 verification_recipe: Some("cargo test".to_string()),
823 items: vec![
824 store::WorksetItemDefinition {
825 title: "Inspect auth flow".to_string(),
826 scope: "crates/sac/src".to_string(),
827 description: "Find auth flow entry points.".to_string(),
828 role: "research".to_string(),
829 depends_on: Vec::new(),
830 acceptance: "Auth entry points are identified.".to_string(),
831 notes: None,
832 status: None,
833 },
834 store::WorksetItemDefinition {
835 title: "Apply auth flow update".to_string(),
836 scope: "crates/sac/src/tui.rs".to_string(),
837 description: "Make the scoped auth UI change.".to_string(),
838 role: "implement".to_string(),
839 depends_on: vec!["Inspect auth flow".to_string()],
840 acceptance: "The auth UI change is implemented.".to_string(),
841 notes: None,
842 status: None,
843 },
844 ],
845 },
846 )
847 .unwrap();
848 }
849
850 #[test]
851 fn app_loads_worksets_for_session() {
852 let dir = temp_dir("workset-panel");
853 let store_path = dir.join("store.db");
854 let session_id = "session-worksets";
855 define_test_workset(&store_path, session_id, "plan-auth");
856 let mut metadata = metadata_for(&dir);
857 metadata.store_path = store_path.clone();
858 metadata.session_id = Some(session_id.to_string());
859
860 let app = App::new(metadata, &[], false);
861
862 assert_eq!(app.worksets.items.len(), 1);
863 assert_eq!(app.worksets.items[0].id, "plan-auth");
864 assert_eq!(app.worksets.items[0].items.len(), 2);
865 let _ = std::fs::remove_dir_all(dir);
866 }
867
868 #[test]
869 fn workset_tool_finish_refreshes_worksets() {
870 let dir = temp_dir("workset-refresh");
871 let store_path = dir.join("store.db");
872 let session_id = "session-workset-refresh";
873 let mut metadata = metadata_for(&dir);
874 metadata.store_path = store_path.clone();
875 metadata.session_id = Some(session_id.to_string());
876 let mut app = App::new(metadata, &[], false);
877 assert!(app.worksets.items.is_empty());
878
879 define_test_workset(&store_path, session_id, "plan-ui");
880 app.apply_agent_event(AgentEvent::ToolCallFinished {
881 thread_name: None,
882 call_id: "call-workset".to_string(),
883 name: "workset_define".to_string(),
884 content_preview: "Saved workset 'plan-ui' with 1 item(s).".to_string(),
885 content: None,
886 is_error: false,
887 });
888
889 assert_eq!(app.worksets.items.len(), 1);
890 assert_eq!(app.worksets.items[0].id, "plan-ui");
891 let _ = std::fs::remove_dir_all(dir);
892 }
893
894 #[test]
895 fn workset_item_lines_include_role_scope_title_and_acceptance() {
896 let item = store::WorksetItemRecord {
897 position: 1,
898 title: "Apply auth flow update".to_string(),
899 scope: "crates/sac/src/tui.rs".to_string(),
900 description: "Make the scoped auth UI change.".to_string(),
901 role: "implement".to_string(),
902 status: "planned".to_string(),
903 depends_on: vec!["Inspect auth flow".to_string()],
904 acceptance: "The auth UI change is implemented.".to_string(),
905 notes: None,
906 updated_at: "2026-04-23 00:00:00".to_string(),
907 };
908
909 let rendered = render_workset_item_lines(&item, 80)
910 .iter()
911 .map(line_to_plain_text)
912 .collect::<Vec<_>>()
913 .join("\n");
914
915 assert!(rendered.contains("IMPLEMENT"));
916 assert!(rendered.contains("SCOPE"));
917 assert!(rendered.contains("DEPS"));
918 assert!(rendered.contains("PASS"));
919 assert!(rendered.contains("Inspect auth flow"));
920 assert!(rendered.contains("Apply auth flow update"));
921 assert!(rendered.contains("crates/sac/src/tui.rs"));
922 assert!(rendered.contains("The auth UI change is implemented."));
923 }
924
925 #[test]
926 fn workset_item_lines_wrap_long_fields() {
927 let item = store::WorksetItemRecord {
928 position: 1,
929 title: "Apply auth flow update with long title".to_string(),
930 scope: "crates/sac/src/tui.rs and crates/sac/src/store.rs".to_string(),
931 description: "Make the scoped auth UI change.".to_string(),
932 role: "implement".to_string(),
933 status: "planned".to_string(),
934 depends_on: vec!["Inspect auth flow before implementation starts".to_string()],
935 acceptance: "The auth UI change is implemented and verified with targeted tests."
936 .to_string(),
937 notes: Some("Keep unrelated worktree changes intact while editing.".to_string()),
938 updated_at: "2026-04-23 00:00:00".to_string(),
939 };
940
941 let rendered = render_workset_item_lines(&item, 36)
942 .iter()
943 .map(line_to_plain_text)
944 .collect::<Vec<_>>();
945 let joined = rendered.join("\n");
946
947 assert!(rendered.len() > 6);
948 assert!(joined.contains("update with long"));
949 assert!(joined.contains("crates/sac/src/store.rs"));
950 assert!(joined.contains("targeted"));
951 assert!(joined.contains("tests."));
952 assert!(!joined.contains('…'));
953 }
954
955 #[test]
956 fn wrapped_prefixed_lines_use_continuation_indent() {
957 let mut lines = Vec::new();
958 push_wrapped_prefixed_lines(
959 &mut lines,
960 " verify ",
961 "cargo test -p sac plus a focused manual check",
962 28,
963 Style::default().fg(Color::DarkGray),
964 Style::default().fg(Color::DarkGray),
965 );
966
967 let rendered = lines.iter().map(line_to_plain_text).collect::<Vec<_>>();
968
969 assert!(rendered.len() > 1);
970 assert!(rendered[0].starts_with(" verify "));
971 assert!(rendered[1].starts_with(" "));
972 assert!(!rendered.join("\n").contains('…'));
973 }
974
975 #[test]
976 fn workset_prompt_displays_as_original_slash_command() {
977 let expanded = build_plan_command_prompt("split this into reviewable units");
978 let expanded_run = build_run_command_prompt("auth-refresh");
979
980 assert_eq!(
981 display_prompt_from_message(&expanded),
982 "/plan split this into reviewable units"
983 );
984 assert_eq!(
985 display_prompt_from_message(&expanded_run),
986 "/run auth-refresh"
987 );
988 }
989
990 #[test]
991 fn run_started_does_not_replace_submitted_prompt() {
992 let dir = temp_dir("run-started-prompt");
993 let mut app = App::new(metadata_for(&dir), &[], false);
994 app.note_prompt_submitted("/plan refresh auth flow");
995
996 app.apply_agent_event(AgentEvent::RunStarted {
997 thread_name: None,
998 prompt_preview: "# /plan: Workset Planning".to_string(),
999 });
1000
1001 assert_eq!(app.prompts, vec!["/plan refresh auth flow".to_string()]);
1002 assert_eq!(app.selected_prompt, Some(0));
1003 assert_eq!(app.displayed_prompt_index(), Some(0));
1004
1005 let mut fallback_app = App::new(metadata_for(&dir), &[], false);
1006 fallback_app.apply_agent_event(AgentEvent::RunStarted {
1007 thread_name: None,
1008 prompt_preview: "restored run preview".to_string(),
1009 });
1010 fallback_app.apply_agent_event(AgentEvent::RunStarted {
1011 thread_name: Some("worker".to_string()),
1012 prompt_preview: "worker preview".to_string(),
1013 });
1014 assert_eq!(
1015 fallback_app.prompts,
1016 vec!["restored run preview".to_string()]
1017 );
1018 assert_eq!(fallback_app.selected_prompt, Some(0));
1019 let _ = std::fs::remove_dir_all(dir);
1020 }
1021
1022 #[test]
1023 fn prompt_history_hydrates_user_messages_and_slash_display_mapping() {
1024 let dir = temp_dir("prompt-history-hydrate");
1025 let messages = vec![
1026 Message::User {
1027 content: "first prompt".to_string(),
1028 },
1029 Message::Assistant {
1030 content: Some("reply".to_string()),
1031 reasoning_text: None,
1032 reasoning_details: None,
1033 tool_calls: None,
1034 },
1035 Message::User {
1036 content: build_plan_command_prompt("split this into reviewable units"),
1037 },
1038 Message::User {
1039 content: build_run_command_prompt("auth-refresh"),
1040 },
1041 ];
1042
1043 let app = App::new(metadata_for(&dir), &messages, false);
1044
1045 assert_eq!(
1046 app.prompts,
1047 vec![
1048 "first prompt".to_string(),
1049 "/plan split this into reviewable units".to_string(),
1050 "/run auth-refresh".to_string(),
1051 ]
1052 );
1053 assert_eq!(app.selected_prompt, Some(2));
1054 assert_eq!(app.displayed_prompt_index(), Some(2));
1055 assert!(line_to_plain_text(&app.prompt_panel_title()).contains("PROMPTS 3/3"));
1056 let _ = std::fs::remove_dir_all(dir);
1057 }
1058
1059 #[test]
1060 fn composer_title_shows_timer_only_while_run_is_active() {
1061 let dir = temp_dir("composer-title-timer");
1062 let mut app = App::new(metadata_for(&dir), &[], false);
1063
1064 let idle_text = line_to_plain_text(&app.composer_panel_title());
1065 assert_eq!(idle_text, " [ ASK ] ");
1066 assert!(!idle_text.contains("T+"));
1067
1068 let (_tx, rx) = tokio::sync::oneshot::channel();
1069 app.result_rx = Some(rx);
1070 app.working_started_at = Some(Instant::now() - Duration::from_secs(3));
1071
1072 let running_title = app.composer_panel_title();
1073 let running_text = line_to_plain_text(&running_title);
1074 assert!(running_text.starts_with(" [ ASK T+"));
1075 assert!(running_text.ends_with(" ] "));
1076 assert!(running_title.spans.iter().any(|span| {
1077 span.content.as_ref().contains("ASK")
1078 && span.style.fg == Some(Color::Cyan)
1079 && span.style.add_modifier.contains(Modifier::BOLD)
1080 }));
1081 assert!(running_title.spans.iter().any(|span| {
1082 span.content.as_ref().starts_with("T+") && span.style.fg == Some(Color::Green)
1083 }));
1084
1085 app.result_rx = None;
1086 app.working_started_at = None;
1087 app.complete_top_level_response("done".to_string(), Duration::from_secs(7));
1088 assert_eq!(
1089 app.responses.last().and_then(|response| response.duration),
1090 Some(Duration::from_secs(7))
1091 );
1092
1093 let idle_again_text = line_to_plain_text(&app.composer_panel_title());
1094 assert_eq!(idle_again_text, " [ ASK ] ");
1095 assert!(!idle_again_text.contains("T+"));
1096
1097 let _ = std::fs::remove_dir_all(dir);
1098 }
1099
1100 #[test]
1101 fn prompt_history_focus_navigation_guard_and_reset_to_latest() {
1102 let dir = temp_dir("prompt-history-nav");
1103 let mut app = App::new(metadata_for(&dir), &[], false);
1104 app.note_prompt_submitted("one");
1105 app.note_prompt_submitted("two");
1106 app.note_prompt_submitted("three");
1107
1108 press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
1109 assert_eq!(app.selected_prompt, Some(2));
1110
1111 press_key(&mut app, KeyCode::Char('p'), KeyModifiers::CONTROL);
1112 assert!(matches!(
1113 app.screen,
1114 ScreenMode::Focused(FocusPanel::Prompt)
1115 ));
1116 assert_eq!(app.displayed_prompt_index(), Some(2));
1117
1118 app.panel_scrolls.insert(PanelId::Prompt, 12);
1119 press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
1120 assert_eq!(app.selected_prompt, Some(1));
1121 assert_eq!(app.panel_scrolls.get(&PanelId::Prompt), Some(&0));
1122 assert!(line_to_plain_text(&app.prompt_panel_title()).contains("PROMPTS 2/3"));
1123
1124 press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
1125 press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
1126 assert_eq!(app.selected_prompt, Some(0));
1127
1128 press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
1129 press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
1130 press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
1131 assert_eq!(app.selected_prompt, Some(2));
1132
1133 app.selected_prompt = Some(0);
1134 press_key(&mut app, KeyCode::Char('p'), KeyModifiers::CONTROL);
1135 assert_eq!(app.screen, ScreenMode::Dashboard);
1136 assert_eq!(app.selected_prompt, Some(2));
1137 assert_eq!(app.displayed_prompt_index(), Some(2));
1138
1139 press_key(&mut app, KeyCode::Char('p'), KeyModifiers::CONTROL);
1140 press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
1141 assert_eq!(app.selected_prompt, Some(1));
1142 press_key(&mut app, KeyCode::Esc, KeyModifiers::NONE);
1143 assert_eq!(app.screen, ScreenMode::Dashboard);
1144 assert_eq!(app.selected_prompt, Some(2));
1145
1146 let _ = std::fs::remove_dir_all(dir);
1147 }
1148
1149 #[test]
1150 fn switching_focus_from_prompt_returns_display_to_latest() {
1151 let dir = temp_dir("prompt-focus-switch");
1152 let mut app = App::new(metadata_for(&dir), &[], false);
1153 app.note_prompt_submitted("one");
1154 app.note_prompt_submitted("two");
1155 app.complete_top_level_response("reply".to_string(), Duration::from_secs(1));
1156 app.screen = ScreenMode::Focused(FocusPanel::Prompt);
1157 app.selected_prompt = Some(0);
1158
1159 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
1160 assert!(matches!(action, AppAction::None));
1161 assert!(matches!(
1162 app.screen,
1163 ScreenMode::Focused(FocusPanel::Response)
1164 ));
1165 assert_eq!(app.selected_prompt, Some(1));
1166 assert_eq!(app.displayed_prompt_index(), Some(1));
1167 let _ = std::fs::remove_dir_all(dir);
1168 }
1169
1170 #[test]
1171 fn primary_scroll_panel_uses_prompt_when_prompt_focused() {
1172 let dir = temp_dir("prompt-primary-scroll");
1173 let mut app = App::new(metadata_for(&dir), &[], false);
1174 app.screen = ScreenMode::Focused(FocusPanel::Prompt);
1175
1176 assert_eq!(app.primary_scroll_panel(), PanelId::Prompt);
1177 let _ = std::fs::remove_dir_all(dir);
1178 }
1179
1180 #[test]
1181 fn compact_stream_uses_latest_prompt_from_history() {
1182 let dir = temp_dir("compact-stream-latest-prompt");
1183 let mut app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
1184 app.note_prompt_submitted("first compact prompt");
1185 app.note_prompt_submitted("second compact prompt");
1186
1187 let lines = app.compact_stream_lines(80);
1188
1189 assert!(line_to_plain_text(&lines[0]).contains("second compact prompt"));
1190 let _ = std::fs::remove_dir_all(dir);
1191 }
1192
1193 #[test]
1194 fn compact_stream_uses_event_glyphs_and_latest_response() {
1195 let dir = temp_dir("compact-stream");
1196 let mut app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
1197 app.note_prompt_submitted("implement compact mode");
1198
1199 app.apply_agent_event(AgentEvent::ThreadStarted {
1200 name: "impl".to_string(),
1201 action: "build compact ui".to_string(),
1202 source_threads: Vec::new(),
1203 });
1204 app.complete_top_level_response("Compact mode ready.".to_string(), Duration::from_secs(2));
1205
1206 let rendered = app
1207 .compact_stream_lines(80)
1208 .iter()
1209 .map(line_to_plain_text)
1210 .collect::<Vec<_>>()
1211 .join("\n");
1212
1213 assert!(rendered.contains("implement compact mode"));
1214 assert!(rendered.contains("+ impl"));
1215 assert!(rendered.contains("Compact mode ready."));
1216 assert!(!rendered.contains("assistant Compact mode ready."));
1217 assert!(!rendered.contains("waiting for first reply"));
1218 assert_eq!(app.primary_scroll_panel(), PanelId::CompactStream);
1219 let _ = std::fs::remove_dir_all(dir);
1220 }
1221
1222 #[test]
1223 fn compact_stream_keeps_full_response_scrollback() {
1224 let dir = temp_dir("compact-stream-height");
1225 let mut app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
1226 app.note_prompt_submitted("implement compact mode with no required vertical scroll");
1227 for index in 0..8 {
1228 app.push_timeline(
1229 format!("thread-{index}"),
1230 format!("tool call • detail {index}"),
1231 Tone::Info,
1232 );
1233 }
1234 app.complete_top_level_response(
1235 "Compact mode ready.\n\nIt still keeps the full response available for scrolling.\n\n- one\n- two\n- three".to_string(),
1236 Duration::from_secs(2),
1237 );
1238
1239 let lines = app.compact_stream_lines(48);
1240
1241 assert!(lines.len() > 4);
1242 let rendered = lines
1243 .iter()
1244 .map(line_to_plain_text)
1245 .collect::<Vec<_>>()
1246 .join("\n");
1247 assert!(rendered.contains("you"));
1248 assert!(rendered.contains("full response available"));
1249 assert!(rendered.contains("three"));
1250 let _ = std::fs::remove_dir_all(dir);
1251 }
1252
1253 #[test]
1254 fn compact_mode_allows_phone_height_terminals() {
1255 let dir = temp_dir("compact-min-size");
1256 let app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
1257
1258 assert_eq!(
1259 app.minimum_terminal_size(),
1260 (COMPACT_MIN_TERMINAL_WIDTH, COMPACT_MIN_TERMINAL_HEIGHT)
1261 );
1262 assert!(COMPACT_MIN_TERMINAL_HEIGHT < MIN_TERMINAL_HEIGHT);
1263 let _ = std::fs::remove_dir_all(dir);
1264 }
1265
1266 #[test]
1267 fn compact_dashboard_hint_legend_lists_all_bindings() {
1268 let dir = temp_dir("compact-hint-legend");
1269 let mut app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
1270 app.hint_visible = true;
1271
1272 let rendered = app
1273 .compact_hint_legend_lines(40)
1274 .iter()
1275 .map(line_to_plain_text)
1276 .collect::<Vec<_>>()
1277 .join("\n");
1278
1279 assert!(rendered.contains("C-P"), "got: {}", rendered);
1280 assert!(rendered.contains("Prompts"), "got: {}", rendered);
1281 assert!(rendered.contains("C-W"), "got: {}", rendered);
1282 assert!(rendered.contains("Workspace"), "got: {}", rendered);
1283 assert!(rendered.contains("C-F"), "got: {}", rendered);
1284 assert!(!rendered.contains("/"), "got: {}", rendered);
1285 let _ = std::fs::remove_dir_all(dir);
1286 }
1287
1288 #[test]
1289 fn compact_hint_overlay_gating_matches_mode_and_visibility() {
1290 let dir = temp_dir("compact-hint-gating");
1291 let mut app = App::new_with_mode(metadata_for(&dir), &[], false, UiMode::Compact);
1292 app.hint_visible = true;
1293 assert!(app.should_render_compact_hint_overlay());
1294
1295 app.help_visible = true;
1296 assert!(!app.should_render_compact_hint_overlay());
1297 app.help_visible = false;
1298
1299 app.screen = ScreenMode::Focused(FocusPanel::Prompt);
1300 assert!(!app.should_render_compact_hint_overlay());
1301 app.screen = ScreenMode::Dashboard;
1302
1303 app.screen = ScreenMode::SessionPicker { startup: false };
1304 assert!(!app.should_render_compact_hint_overlay());
1305 let _ = std::fs::remove_dir_all(dir);
1306 }
1307
1308 #[test]
1309 fn help_focus_rows_use_full_binding_text() {
1310 let dir = temp_dir("help-focus-rows");
1311 let app = App::new(metadata_for(&dir), &[], false);
1312
1313 let rendered = app
1314 .pane_focus_help_rows()
1315 .iter()
1316 .map(line_to_plain_text)
1317 .collect::<Vec<_>>()
1318 .join("\n");
1319
1320 assert!(rendered.contains("Ctrl-P"), "got: {}", rendered);
1321 assert!(rendered.contains("focus prompts"), "got: {}", rendered);
1322 assert!(rendered.contains("Ctrl-W"), "got: {}", rendered);
1323 assert!(rendered.contains("focus workspace"), "got: {}", rendered);
1324 assert!(!rendered.contains("Ctrl-1"), "got: {}", rendered);
1325 let _ = std::fs::remove_dir_all(dir);
1326 }
1327
1328 #[test]
1329 fn restored_message_count_ignores_system_and_tool_messages() {
1330 let messages = vec![
1331 Message::System {
1332 content: "system".to_string(),
1333 },
1334 Message::Tool {
1335 tool_call_id: "call-1".to_string(),
1336 content: "tool result".to_string(),
1337 },
1338 Message::Assistant {
1339 content: None,
1340 reasoning_text: Some("thinking".to_string()),
1341 reasoning_details: None,
1342 tool_calls: None,
1343 },
1344 Message::User {
1345 content: "hello".to_string(),
1346 },
1347 ];
1348
1349 assert_eq!(visible_restored_message_count(&messages), 1);
1350 }
1351
1352 #[test]
1353 fn sessions_command_opens_picker() {
1354 let dir = temp_dir("sessions-command");
1355 let mut app = App::new(metadata_for(&dir), &[], false);
1356 app.composer.insert_str("/sessions");
1357
1358 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
1359
1360 assert!(matches!(action, AppAction::None));
1361 assert!(matches!(
1362 app.screen,
1363 ScreenMode::SessionPicker { startup: false }
1364 ));
1365 let _ = std::fs::remove_dir_all(dir);
1366 }
1367
1368 #[test]
1369 fn question_mark_toggles_help_when_composer_is_empty() {
1370 let dir = temp_dir("help-toggle");
1371 let mut app = App::new(metadata_for(&dir), &[], false);
1372
1373 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
1374 assert!(matches!(action, AppAction::None));
1375 assert!(app.help_visible);
1376
1377 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
1378 assert!(matches!(action, AppAction::None));
1379 assert!(!app.help_visible);
1380 let _ = std::fs::remove_dir_all(dir);
1381 }
1382
1383 #[test]
1384 fn question_mark_inserts_into_nonempty_composer() {
1385 let dir = temp_dir("help-literal-question-mark");
1386 let mut app = App::new(metadata_for(&dir), &[], false);
1387 app.composer.insert_str("why");
1388
1389 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
1390 assert!(matches!(action, AppAction::None));
1391 assert_eq!(app.prompt(), "why?");
1392 assert!(!app.help_visible);
1393 let _ = std::fs::remove_dir_all(dir);
1394 }
1395
1396 #[test]
1397 fn ctrl_h_toggles_hint_mode_without_editing_composer() {
1398 let dir = temp_dir("hint-toggle-h");
1399 let mut app = App::new(metadata_for(&dir), &[], false);
1400
1401 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
1402 assert!(matches!(action, AppAction::None));
1403 assert!(app.hint_visible);
1404 assert_eq!(app.prompt(), "");
1405
1406 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
1407 assert!(matches!(action, AppAction::None));
1408 assert!(!app.hint_visible);
1409 assert_eq!(app.prompt(), "");
1410 let _ = std::fs::remove_dir_all(dir);
1411 }
1412
1413 #[test]
1414 fn repeat_ctrl_h_is_ignored() {
1415 let dir = temp_dir("hint-toggle-repeat");
1416 let mut app = App::new(metadata_for(&dir), &[], false);
1417 app.hint_visible = true;
1418
1419 let action = app.handle_key_event(KeyEvent {
1420 code: KeyCode::Char('h'),
1421 modifiers: KeyModifiers::CONTROL,
1422 kind: KeyEventKind::Repeat,
1423 state: KeyEventState::NONE,
1424 });
1425
1426 assert!(matches!(action, AppAction::None));
1427 assert!(app.hint_visible);
1428 let _ = std::fs::remove_dir_all(dir);
1429 }
1430
1431 #[test]
1432 fn hint_toggle_requests_scroll_reset() {
1433 let dir = temp_dir("hint-scroll-reset");
1434 let mut app = App::new(metadata_for(&dir), &[], false);
1435
1436 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
1437 assert!(matches!(action, AppAction::None));
1438 assert!(app.hint_visible);
1439 assert!(app.suppressing_mouse_scroll());
1440 app.suppress_mouse_scroll_until = Some(Instant::now() - Duration::from_millis(1));
1441 assert!(!app.suppressing_mouse_scroll());
1442 let _ = std::fs::remove_dir_all(dir);
1443 }
1444
1445 #[test]
1446 fn ctrl_e_toggles_events_focus() {
1447 let dir = temp_dir("events-focus");
1448 let mut app = App::new(metadata_for(&dir), &[], false);
1449
1450 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
1451 assert!(matches!(action, AppAction::None));
1452 assert!(matches!(
1453 app.screen,
1454 ScreenMode::Focused(FocusPanel::Events)
1455 ));
1456
1457 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
1458 assert!(matches!(action, AppAction::None));
1459 assert_eq!(app.screen, ScreenMode::Dashboard);
1460 let _ = std::fs::remove_dir_all(dir);
1461 }
1462
1463 #[test]
1464 fn response_history_focus_navigation_and_reset_to_latest() {
1465 let dir = temp_dir("response-history-nav");
1466 let mut app = App::new(metadata_for(&dir), &[], false);
1467 app.complete_top_level_response("one".to_string(), Duration::from_secs(1));
1468 app.complete_top_level_response("two".to_string(), Duration::from_secs(2));
1469 app.complete_top_level_response("three".to_string(), Duration::from_secs(3));
1470
1471 press_key(&mut app, KeyCode::Char('r'), KeyModifiers::CONTROL);
1472 assert!(matches!(
1473 app.screen,
1474 ScreenMode::Focused(FocusPanel::Response)
1475 ));
1476 assert_eq!(app.displayed_response_index(), Some(2));
1477
1478 app.panel_scrolls.insert(PanelId::Response, 12);
1479 press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
1480 assert_eq!(app.selected_response, Some(1));
1481 assert_eq!(app.panel_scrolls.get(&PanelId::Response), Some(&0));
1482 assert_eq!(app.displayed_response_index(), Some(1));
1483
1484 press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
1485 press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
1486 assert_eq!(app.selected_response, Some(0));
1487
1488 press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
1489 press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
1490 press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
1491 assert_eq!(app.selected_response, Some(2));
1492
1493 app.selected_response = Some(0);
1494 press_key(&mut app, KeyCode::Char('r'), KeyModifiers::CONTROL);
1495 assert_eq!(app.screen, ScreenMode::Dashboard);
1496 assert_eq!(app.selected_response, Some(2));
1497 assert_eq!(app.displayed_response_index(), Some(2));
1498
1499 let _ = std::fs::remove_dir_all(dir);
1500 }
1501
1502 #[test]
1503 fn ctrl_o_still_focuses_tools() {
1504 let dir = temp_dir("tools-focus");
1505 let mut app = App::new(metadata_for(&dir), &[], false);
1506
1507 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
1508 assert!(matches!(action, AppAction::None));
1509 assert!(matches!(app.screen, ScreenMode::Focused(FocusPanel::Tools)));
1510 let _ = std::fs::remove_dir_all(dir);
1511 }
1512
1513 #[test]
1514 fn title_badges_show_expected_bindings() {
1515 let dir = temp_dir("hint-title-badges");
1516 let mut app = App::new(metadata_for(&dir), &[], false);
1517 app.complete_top_level_response("one".to_string(), Duration::from_secs(1));
1518 app.complete_top_level_response("two".to_string(), Duration::from_secs(2));
1519 app.hint_visible = true;
1520
1521 let prompt_title = line_to_plain_text(&app.prompt_panel_title());
1522 assert!(prompt_title.contains("C-P"), "got: {}", prompt_title);
1523
1524 let workspace_title =
1525 line_to_plain_text(&app.static_focus_title(FocusPanel::Workspace, "WORKSPACE"));
1526 assert!(workspace_title.contains("C-W"), "got: {}", workspace_title);
1527 assert!(!workspace_title.contains('/'), "got: {}", workspace_title);
1528
1529 let previous_title = line_to_plain_text(&app.render_previous_title_for_test());
1530 assert!(previous_title.contains("C-G"), "got: {}", previous_title);
1531 let _ = std::fs::remove_dir_all(dir);
1532 }
1533
1534 #[test]
1535 fn ctrl_g_focuses_previous_response() {
1536 let dir = temp_dir("previous-response-focus");
1537 let mut app = App::new(metadata_for(&dir), &[], false);
1538 app.complete_top_level_response("one".to_string(), Duration::from_secs(1));
1539 app.complete_top_level_response("two".to_string(), Duration::from_secs(2));
1540
1541 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL));
1542 assert!(matches!(action, AppAction::None));
1543 assert!(matches!(
1544 app.screen,
1545 ScreenMode::Focused(FocusPanel::PreviousResponse)
1546 ));
1547 assert_eq!(app.displayed_previous_response_index(), Some(0));
1548 let _ = std::fs::remove_dir_all(dir);
1549 }
1550
1551 #[test]
1552 fn ctrl_l_focuses_terminals_and_ctrl_f_focuses_file_changes() {
1553 let dir = temp_dir("terminals-file-focus");
1554 let mut app = App::new(metadata_for(&dir), &[], false);
1555
1556 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
1557 assert!(matches!(action, AppAction::None));
1558 assert!(matches!(
1559 app.screen,
1560 ScreenMode::Focused(FocusPanel::Terminals)
1561 ));
1562 assert_eq!(app.primary_scroll_panel(), PanelId::Terminals);
1563
1564 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
1565 assert!(matches!(action, AppAction::None));
1566 assert!(matches!(
1567 app.screen,
1568 ScreenMode::Focused(FocusPanel::FileChanges)
1569 ));
1570 assert_eq!(app.primary_scroll_panel(), PanelId::FileChanges);
1571 let _ = std::fs::remove_dir_all(dir);
1572 }
1573
1574 #[test]
1575 fn previous_response_focus_supports_left_right_navigation() {
1576 let dir = temp_dir("previous-response-nav");
1577 let mut app = App::new(metadata_for(&dir), &[], false);
1578 app.complete_top_level_response("one".to_string(), Duration::from_secs(1));
1579 app.complete_top_level_response("two".to_string(), Duration::from_secs(2));
1580 app.complete_top_level_response("three".to_string(), Duration::from_secs(3));
1581
1582 press_key(&mut app, KeyCode::Char('g'), KeyModifiers::CONTROL);
1583 assert_eq!(app.displayed_previous_response_index(), Some(1));
1584
1585 press_key(&mut app, KeyCode::Left, KeyModifiers::NONE);
1586 assert_eq!(app.displayed_previous_response_index(), Some(0));
1587
1588 press_key(&mut app, KeyCode::Right, KeyModifiers::NONE);
1589 assert_eq!(app.displayed_previous_response_index(), Some(1));
1590 let _ = std::fs::remove_dir_all(dir);
1591 }
1592
1593 #[test]
1594 fn ctrl_k_still_focuses_worksets_outside_plain_thread_navigation() {
1595 let dir = temp_dir("ctrl-k-worksets-focus");
1596 let mut app = App::new(metadata_for(&dir), &[], false);
1597 app.screen = ScreenMode::Focused(FocusPanel::Threads);
1598 app.threads
1599 .insert("first".to_string(), test_thread_view("first", 2));
1600 app.selected_thread = Some("first".to_string());
1601
1602 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
1603 assert!(matches!(action, AppAction::None));
1604 assert!(matches!(
1605 app.screen,
1606 ScreenMode::Focused(FocusPanel::Worksets)
1607 ));
1608 let _ = std::fs::remove_dir_all(dir);
1609 }
1610
1611 #[test]
1612 fn thread_lifecycle_switches_active_to_idle() {
1613 let dir = temp_dir("thread");
1614 let mut app = App::new(metadata_for(&dir), &[], false);
1615
1616 app.apply_agent_event(AgentEvent::ThreadStarted {
1617 name: "auth".to_string(),
1618 action: "inspect auth flow".to_string(),
1619 source_threads: vec!["tests".to_string()],
1620 });
1621 let thread = app.threads.get("auth").unwrap();
1622 assert_eq!(thread.state, ThreadState::Active);
1623 assert_eq!(thread.action, "inspect auth flow");
1624
1625 app.apply_agent_event(AgentEvent::ThreadFinished {
1626 name: "auth".to_string(),
1627 exit_code: 0,
1628 timed_out: false,
1629 timeout_reason: None,
1630 });
1631 let thread = app.threads.get("auth").unwrap();
1632 assert_eq!(thread.state, ThreadState::Retained);
1633 assert_eq!(thread.summary, "exit 0");
1634 let _ = std::fs::remove_dir_all(dir);
1635 }
1636
1637 #[test]
1638 fn thread_navigation_requests_scroll_reset() {
1639 let dir = temp_dir("thread-scroll-suppress");
1640 let mut app = App::new(metadata_for(&dir), &[], false);
1641 app.screen = ScreenMode::Focused(FocusPanel::Threads);
1642 app.threads
1643 .insert("first".to_string(), test_thread_view("first", 2));
1644 app.threads
1645 .insert("second".to_string(), test_thread_view("second", 1));
1646 app.selected_thread = Some("first".to_string());
1647 app.panel_scrolls.insert(PanelId::ThreadEpisodes, 20);
1648
1649 let action = app.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
1650 assert!(matches!(action, AppAction::None));
1651 assert_eq!(app.selected_thread.as_deref(), Some("second"));
1652 assert_eq!(app.panel_scrolls.get(&PanelId::ThreadEpisodes), Some(&0));
1653 assert!(app.suppressing_mouse_scroll());
1654 app.suppress_mouse_scroll_until = Some(Instant::now() - Duration::from_millis(1));
1655 assert!(!app.suppressing_mouse_scroll());
1656 let _ = std::fs::remove_dir_all(dir);
1657 }
1658
1659 #[test]
1660 fn queued_scroll_filter_drops_only_pending_scroll_events() {
1661 let (tx, mut rx) = mpsc::unbounded_channel();
1662 for kind in [MouseEventKind::ScrollDown, MouseEventKind::ScrollRight] {
1663 tx.send(test_scroll_event(kind)).unwrap();
1664 }
1665 tx.send(CrosstermEvent::Key(KeyEvent::new(
1666 KeyCode::Char('x'),
1667 KeyModifiers::NONE,
1668 )))
1669 .unwrap();
1670 drop(tx);
1671
1672 assert!(matches!(
1673 next_queued_input_event(&mut rx, true),
1674 Some(CrosstermEvent::Key(KeyEvent {
1675 code: KeyCode::Char('x'),
1676 ..
1677 }))
1678 ));
1679 assert!(next_queued_input_event(&mut rx, true).is_none());
1680 }
1681
1682 #[test]
1683 fn tool_finishes_into_recent_history() {
1684 let dir = temp_dir("tool");
1685 let mut app = App::new(metadata_for(&dir), &[], false);
1686
1687 app.apply_agent_event(AgentEvent::ToolCallStarted {
1688 thread_name: Some("coder-1".to_string()),
1689 call_id: "call-1".to_string(),
1690 name: "edit".to_string(),
1691 args_preview: "crates/sac/src/tui.rs".to_string(),
1692 args_detail: None,
1693 });
1694 app.apply_agent_event(AgentEvent::ToolCallFinished {
1695 thread_name: Some("coder-1".to_string()),
1696 call_id: "call-1".to_string(),
1697 name: "edit".to_string(),
1698 content_preview: "ok".to_string(),
1699 content: None,
1700 is_error: false,
1701 });
1702
1703 assert!(app.active_tools.is_empty());
1704 assert_eq!(app.recent_tools.len(), 1);
1705 assert_eq!(app.recent_tools[0].name, "edit");
1706 assert_eq!(app.recent_tools[0].status, ToolStatus::Ok);
1707 let _ = std::fs::remove_dir_all(dir);
1708 }
1709
1710 #[test]
1711 fn response_history_tracks_runtime_snapshots_and_ignored_event() {
1712 let dir = temp_dir("response-runtime");
1713 let mut app = App::new(metadata_for(&dir), &[], false);
1714
1715 assert_eq!(
1716 format_optional_runtime(app.displayed_run_duration()),
1717 "T+--:--:--"
1718 );
1719 assert_eq!(app.response_duration_snapshot_ms(), (None, None));
1720 assert_eq!(
1721 app.response_duration_history_snapshot_ms(),
1722 Vec::<Option<u64>>::new()
1723 );
1724
1725 app.apply_agent_event(AgentEvent::AssistantMessage {
1726 thread_name: None,
1727 content: "ignored".to_string(),
1728 });
1729 assert!(app.responses.is_empty());
1730
1731 let (_tx, rx) = tokio::sync::oneshot::channel();
1732 app.result_rx = Some(rx);
1733 app.working_started_at = Some(Instant::now() - Duration::from_secs(3));
1734 let (runtime, is_live) = app.response_panel_runtime(None);
1735 assert!(runtime.is_some());
1736 assert!(is_live);
1737 app.result_rx = None;
1738 app.working_started_at = None;
1739
1740 app.complete_top_level_response("first reply".to_string(), Duration::from_secs(1));
1741 assert_eq!(app.responses.len(), 1);
1742 assert_eq!(app.responses[0].content, "first reply");
1743 assert_eq!(app.responses[0].duration, Some(Duration::from_secs(1)));
1744 assert_eq!(app.selected_response, Some(0));
1745 assert_eq!(app.response_duration_snapshot_ms(), (Some(1_000), None));
1746 assert_eq!(
1747 app.response_duration_history_snapshot_ms(),
1748 vec![Some(1_000)]
1749 );
1750
1751 app.note_prompt_submitted("second prompt");
1752 assert_eq!(app.responses.len(), 1);
1753 assert_eq!(app.displayed_response_index(), Some(0));
1754 assert_eq!(app.displayed_previous_response_index(), None);
1755
1756 app.complete_top_level_response("second reply".to_string(), Duration::from_secs(2));
1757 assert_eq!(app.responses.len(), 2);
1758 assert_eq!(app.responses[1].content, "second reply");
1759 assert_eq!(app.responses[1].duration, Some(Duration::from_secs(2)));
1760 assert_eq!(app.selected_response, Some(1));
1761 assert_eq!(
1762 app.response_duration_snapshot_ms(),
1763 (Some(2_000), Some(1_000))
1764 );
1765 assert_eq!(
1766 app.response_duration_history_snapshot_ms(),
1767 vec![Some(1_000), Some(2_000)]
1768 );
1769
1770 let _ = std::fs::remove_dir_all(dir);
1771 }
1772
1773 #[test]
1774 fn response_duration_history_round_trips_through_session_snapshot_and_resume_restore() {
1775 let dir = temp_dir("response-duration-round-trip");
1776 let mut metadata = metadata_for(&dir);
1777 let messages = vec![
1778 Message::Assistant {
1779 content: Some("first reply".to_string()),
1780 reasoning_text: None,
1781 reasoning_details: None,
1782 tool_calls: None,
1783 },
1784 Message::Assistant {
1785 content: Some("second reply".to_string()),
1786 reasoning_text: None,
1787 reasoning_details: None,
1788 tool_calls: None,
1789 },
1790 Message::Assistant {
1791 content: Some("third reply".to_string()),
1792 reasoning_text: None,
1793 reasoning_details: None,
1794 tool_calls: None,
1795 },
1796 ];
1797 let session_id = "session-response-durations".to_string();
1798 metadata.session_id = Some(session_id.clone());
1799 let mut snapshot = sessions::new_snapshot(
1800 session_id.clone(),
1801 dir.clone(),
1802 metadata.store_path.clone(),
1803 metadata.model.clone(),
1804 metadata.base_url.clone(),
1805 crate::model::BackendKind::OpenAiResponses,
1806 None,
1807 None,
1808 messages,
1809 );
1810 snapshot.last_response_duration_ms = Some(3_333);
1811 snapshot.previous_response_duration_ms = Some(2_222);
1812 snapshot.response_durations_ms = Some(vec![Some(1_111), Some(2_222), Some(3_333)]);
1813 sessions::create_session(&snapshot).unwrap();
1814
1815 let loaded = sessions::load_session(&metadata.store_path, &session_id).unwrap();
1816 assert_eq!(
1817 loaded.response_durations_ms,
1818 Some(vec![Some(1_111), Some(2_222), Some(3_333)])
1819 );
1820 let restored_durations = loaded.response_durations_ms.as_ref().map(|durations| {
1821 durations
1822 .iter()
1823 .map(|duration| duration.map(Duration::from_millis))
1824 .collect::<Vec<_>>()
1825 });
1826 let mut app = App::new_with_mode(metadata, &loaded.messages, false, UiMode::Full);
1827 app.restore_response_duration_history(
1828 restored_durations.as_deref(),
1829 loaded.last_response_duration_ms.map(Duration::from_millis),
1830 loaded
1831 .previous_response_duration_ms
1832 .map(Duration::from_millis),
1833 );
1834
1835 let contents = app
1836 .responses
1837 .iter()
1838 .map(|response| response.content.as_str())
1839 .collect::<Vec<_>>();
1840 assert_eq!(contents, vec!["first reply", "second reply", "third reply"]);
1841 assert_eq!(
1842 app.response_duration_history_snapshot_ms(),
1843 vec![Some(1_111), Some(2_222), Some(3_333)]
1844 );
1845 assert_eq!(
1846 app.response_duration_snapshot_ms(),
1847 (Some(3_333), Some(2_222))
1848 );
1849
1850 let _ = std::fs::remove_dir_all(dir);
1851 }
1852
1853 #[test]
1854 fn response_duration_history_restore_prefers_full_vector() {
1855 let dir = temp_dir("response-runtime-full-history");
1856 let metadata = metadata_for(&dir);
1857 let messages = vec![
1858 Message::Assistant {
1859 content: Some("first reply".to_string()),
1860 reasoning_text: None,
1861 reasoning_details: None,
1862 tool_calls: None,
1863 },
1864 Message::Assistant {
1865 content: Some("tool call carrier".to_string()),
1866 reasoning_text: None,
1867 reasoning_details: None,
1868 tool_calls: Some(vec![crate::types::ToolCall {
1869 id: "call-1".to_string(),
1870 call_type: "function".to_string(),
1871 function: crate::types::FunctionCall {
1872 name: "read".to_string(),
1873 arguments: "{}".to_string(),
1874 },
1875 }]),
1876 },
1877 Message::Assistant {
1878 content: Some("second reply".to_string()),
1879 reasoning_text: None,
1880 reasoning_details: None,
1881 tool_calls: None,
1882 },
1883 Message::Assistant {
1884 content: None,
1885 reasoning_text: Some("reasoning-only final".to_string()),
1886 reasoning_details: None,
1887 tool_calls: None,
1888 },
1889 Message::Assistant {
1890 content: Some("third reply".to_string()),
1891 reasoning_text: None,
1892 reasoning_details: None,
1893 tool_calls: Some(Vec::new()),
1894 },
1895 ];
1896
1897 let mut app = App::new_with_mode(metadata, &messages, false, UiMode::Full);
1898 let response_durations = vec![
1899 Some(Duration::from_secs(1)),
1900 None,
1901 Some(Duration::from_secs(3)),
1902 Some(Duration::from_secs(4)),
1903 ];
1904 app.restore_response_duration_history(
1905 Some(response_durations.as_slice()),
1906 Some(Duration::from_secs(9)),
1907 Some(Duration::from_secs(4)),
1908 );
1909
1910 let contents = app
1911 .responses
1912 .iter()
1913 .map(|response| response.content.as_str())
1914 .collect::<Vec<_>>();
1915 assert_eq!(
1916 contents,
1917 vec![
1918 "first reply",
1919 "second reply",
1920 "[No response]",
1921 "third reply"
1922 ]
1923 );
1924 assert_eq!(app.responses[0].duration, Some(Duration::from_secs(1)));
1925 assert_eq!(app.responses[1].duration, None);
1926 assert_eq!(app.responses[2].duration, Some(Duration::from_secs(3)));
1927 assert_eq!(app.responses[3].duration, Some(Duration::from_secs(4)));
1928 assert_eq!(
1929 app.response_duration_history_snapshot_ms(),
1930 vec![Some(1_000), None, Some(3_000), Some(4_000)]
1931 );
1932
1933 let _ = std::fs::remove_dir_all(dir);
1934 }
1935
1936 #[test]
1937 fn response_durations_restore_with_hydrated_response_history() {
1938 let dir = temp_dir("response-runtime-hydrate");
1939 let metadata = metadata_for(&dir);
1940 let messages = vec![
1941 Message::Assistant {
1942 content: Some("first reply".to_string()),
1943 reasoning_text: None,
1944 reasoning_details: None,
1945 tool_calls: None,
1946 },
1947 Message::Assistant {
1948 content: Some("tool call carrier".to_string()),
1949 reasoning_text: None,
1950 reasoning_details: None,
1951 tool_calls: Some(vec![crate::types::ToolCall {
1952 id: "call-1".to_string(),
1953 call_type: "function".to_string(),
1954 function: crate::types::FunctionCall {
1955 name: "read".to_string(),
1956 arguments: "{}".to_string(),
1957 },
1958 }]),
1959 },
1960 Message::Assistant {
1961 content: Some("second reply".to_string()),
1962 reasoning_text: None,
1963 reasoning_details: None,
1964 tool_calls: None,
1965 },
1966 Message::Assistant {
1967 content: Some("third reply".to_string()),
1968 reasoning_text: None,
1969 reasoning_details: None,
1970 tool_calls: Some(Vec::new()),
1971 },
1972 ];
1973
1974 let mut app = App::new_with_mode(metadata, &messages, false, UiMode::Full);
1975 app.restore_response_duration_history(
1976 None,
1977 Some(Duration::from_secs(9)),
1978 Some(Duration::from_secs(4)),
1979 );
1980
1981 let contents = app
1982 .responses
1983 .iter()
1984 .map(|response| response.content.as_str())
1985 .collect::<Vec<_>>();
1986 assert_eq!(contents, vec!["first reply", "second reply", "third reply"]);
1987 assert_eq!(app.responses[0].duration, None);
1988 assert_eq!(app.responses[1].duration, Some(Duration::from_secs(4)));
1989 assert_eq!(app.responses[2].duration, Some(Duration::from_secs(9)));
1990 assert_eq!(app.selected_response, Some(2));
1991
1992 let _ = std::fs::remove_dir_all(dir);
1993 }
1994
1995 #[test]
1996 fn selection_extract_preserves_original_newlines_only() {
1997 let lines = vec![
1998 "alpha beta gamma delta".to_string(),
1999 "second line".to_string(),
2000 ];
2001 let rows = wrap_logical_lines(&lines, 8);
2002 let view = PanelView {
2003 id: PanelId::Response,
2004 inner: Rect::new(0, 0, 20, 10),
2005 logical_lines: lines,
2006 rows,
2007 scroll_offset: 0,
2008 visible_rows: 10,
2009 };
2010 let selection = SelectionState {
2011 anchor: SelectionPoint {
2012 panel: PanelId::Response,
2013 logical_line: 0,
2014 char_index: 6,
2015 },
2016 focus: SelectionPoint {
2017 panel: PanelId::Response,
2018 logical_line: 1,
2019 char_index: 6,
2020 },
2021 dragging: false,
2022 };
2023
2024 let extracted = extract_selection_text(&view, &selection);
2025 assert_eq!(extracted, "beta gamma delta\nsecond");
2026 }
2027
2028 #[test]
2029 fn workspace_without_host_path_is_unavailable() {
2030 let snapshot = WorkspaceSnapshot::load("/workspace/project", None);
2031 assert!(snapshot.error.is_some());
2032 assert_eq!(snapshot.host_root, None);
2033 }
2034
2035 #[test]
2036 fn markdown_renderer_formats_common_blocks() {
2037 let rendered = render_markdown_lines(
2038 "# Heading\n- item\n> quote\nLink to [site](https://example.com)\n| Name | Value |\n| --- | --- |\n| one | 1 |\n```rust\nfn main() {}\n```",
2039 None,
2040 );
2041 let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
2042
2043 assert_eq!(plain[0], "# Heading");
2044 assert_eq!(plain[1], "• item");
2045 assert_eq!(plain[2], "> quote");
2046 assert_eq!(plain[3], "Link to site <https://example.com>");
2047 assert_eq!(plain[4], "┌──────┬───────┐");
2048 assert_eq!(plain[5], "│ Name │ Value │");
2049 assert_eq!(plain[6], "├──────┼───────┤");
2050 assert_eq!(plain[7], "│ one │ 1 │");
2051 assert_eq!(plain[8], "└──────┴───────┘");
2052 assert_eq!(plain[9], "```rust");
2053 assert_eq!(plain[10], "fn main() {}");
2054 assert_eq!(plain[11], "```");
2055 }
2056
2057 #[test]
2058 fn parse_remote_label_handles_ssh() {
2059 assert_eq!(
2060 parse_remote_label("git@github.com:secemp9/sac.git").as_deref(),
2061 Some("secemp9/sac")
2062 );
2063 }
2064
2065 #[test]
2066 fn parse_status_porcelain_tracks_untracked_and_staged() {
2067 let raw = "M crates/sac/src/tui.rs\nA README.md\n?? notes.txt\n";
2068 let (counts, files) = parse_status_porcelain(raw);
2069
2070 assert_eq!(counts.modified, 1);
2071 assert_eq!(counts.added, 1);
2072 assert_eq!(counts.untracked, 1);
2073 assert_eq!(counts.staged, 2);
2074 assert!(files.contains_key("notes.txt"));
2075 }
2076
2077 #[test]
2078 fn markdown_table_without_delimiter_renders_as_table() {
2079 let rendered =
2083 render_markdown_lines("| Name | Value |\n| one | 1 |\n| two | 2 |", None);
2084 let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
2085
2086 assert_eq!(plain[0], "┌──────┬───────┐");
2088 assert_eq!(plain[1], "│ Name │ Value │");
2089 assert_eq!(plain[2], "├──────┼───────┤");
2090 assert_eq!(plain[3], "│ one │ 1 │");
2091 assert_eq!(plain[4], "│ two │ 2 │");
2092 assert_eq!(plain[5], "└──────┴───────┘");
2093 }
2094
2095 #[test]
2096 fn markdown_table_without_delimiter_single_column_skips_fallback() {
2097 let rendered = render_markdown_lines("| single |\n| column |", None);
2100 let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
2101 assert!(
2103 plain.iter().any(|l| l.contains('|')),
2104 "single-column pipe lines should fall through to paragraph rendering"
2105 );
2106 }
2107
2108 #[test]
2109 fn markdown_table_row_respects_escaped_and_code_pipes() {
2110 assert_eq!(
2111 parse_markdown_table_row(r"| a \| b | `x|y` | c |"),
2112 Some(vec![
2113 r"a \| b".to_string(),
2114 "`x|y`".to_string(),
2115 "c".to_string()
2116 ])
2117 );
2118 assert_eq!(
2119 parse_markdown_table_row(r"a | b \|"),
2120 Some(vec!["a".to_string(), r"b \|".to_string()])
2121 );
2122 }
2123
2124 #[test]
2125 fn markdown_table_preserves_inline_styles_in_cells() {
2126 let rendered = render_markdown_lines(
2127 "| Col A |\n| --- |\n| **bold** text |\n| [site](https://example.com) |",
2128 None,
2129 );
2130 let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
2131 assert_eq!(plain[3], "│ bold text │");
2132 assert_eq!(plain[4], "│ site <https://example.com> │");
2133
2134 let has_bold = rendered[3]
2135 .spans
2136 .iter()
2137 .any(|s| s.style.add_modifier.contains(Modifier::BOLD));
2138 assert!(has_bold, "cell with **bold** should contain a BOLD span");
2139
2140 let has_underlined = rendered[4]
2141 .spans
2142 .iter()
2143 .any(|s| s.style.add_modifier.contains(Modifier::UNDERLINED));
2144 assert!(
2145 has_underlined,
2146 "cell with [link]() should contain an UNDERLINED span"
2147 );
2148 }
2149
2150 #[test]
2151 fn markdown_table_keeps_rows_with_escaped_pipes_in_same_table() {
2152 let rendered = render_markdown_lines(
2153 "| Feature | Input syntax | Status |\n\
2154|---|---|---|\n\
2155| Standard table | \\| a \\| b \\| with \\|---\\| row | ✅ Working |\n\
2156| Bold text | **double asterisks** | ✅ Preserved |\n\
2157| Italic text | *single asterisks* | ✅ Preserved |\n\
2158| inline code | `backticks` | ✅ Preserved |\n\
2159| Links <https://example.com> | [text](url) | ✅ Preserved |\n\
2160| Pipes in cells: |---| | Literal \\| in content | ✅ Not split |",
2161 None,
2162 );
2163 let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
2164
2165 assert_eq!(plain.iter().filter(|line| line.starts_with('┌')).count(), 1);
2166 assert!(!plain
2167 .iter()
2168 .any(|line| line.starts_with("| Standard table")));
2169 assert!(plain.iter().any(
2170 |line| line.contains("Standard table") && line.contains("| a | b | with |---| row")
2171 ));
2172 assert!(plain
2173 .iter()
2174 .any(|line| line.contains("Pipes in cells:|---|")
2175 && line.contains("Literal | in content")));
2176 }
2177
2178 #[test]
2179 fn markdown_table_uses_terminal_display_width_for_emoji() {
2180 let rendered = render_markdown_lines(
2181 "| Emoji | Meaning |\n\
2182| 🔴 | High severity |\n\
2183| 🟡 | Medium severity |\n\
2184| ⚪ | Low / cosmetic |\n\
2185| ✅ | Verified fixed |",
2186 None,
2187 );
2188 let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
2189 let widths: Vec<usize> = plain.iter().map(|line| display_width(line)).collect();
2190
2191 assert!(widths.iter().all(|width| *width == widths[0]));
2192 }
2193
2194 #[test]
2197 fn goal_pause_while_agent_running_sets_deferred_flag() {
2198 let dir = temp_dir("goal-pause-running");
2199 let mut app = App::new(metadata_for(&dir), &[], false);
2200 app.set_goal("build the feature".to_string());
2201
2202 let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
2204 app.result_rx = Some(rx);
2205 app.composer.insert_str("/goal pause");
2206
2207 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2208
2209 assert!(matches!(action, AppAction::None));
2210 assert!(app.goal_pause_requested);
2211 assert_eq!(
2213 app.goal.as_ref().unwrap().status,
2214 crate::goal::GoalStatus::Active,
2215 );
2216 assert_eq!(app.prompt(), "");
2217 let notice = app.composer_notice.as_ref().expect("expected notice");
2218 assert!(notice.text.contains("pause after current turn"));
2219 assert_eq!(notice.tone, Tone::Warning);
2220 let _ = std::fs::remove_dir_all(dir);
2221 }
2222
2223 #[test]
2224 fn goal_clear_while_agent_running_sets_deferred_flag() {
2225 let dir = temp_dir("goal-clear-running");
2226 let mut app = App::new(metadata_for(&dir), &[], false);
2227 app.set_goal("build the feature".to_string());
2228
2229 let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
2230 app.result_rx = Some(rx);
2231 app.composer.insert_str("/goal clear");
2232
2233 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2234
2235 assert!(matches!(action, AppAction::None));
2236 assert!(app.goal_clear_requested);
2237 assert!(app.goal.is_some()); let notice = app.composer_notice.as_ref().expect("expected notice");
2239 assert!(notice.text.contains("clear after current turn"));
2240 let _ = std::fs::remove_dir_all(dir);
2241 }
2242
2243 #[test]
2244 fn goal_show_while_agent_running_displays_status() {
2245 let dir = temp_dir("goal-show-running");
2246 let mut app = App::new(metadata_for(&dir), &[], false);
2247 app.set_goal("build the feature".to_string());
2248
2249 let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
2250 app.result_rx = Some(rx);
2251 app.composer.insert_str("/goal");
2252
2253 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2254
2255 assert!(matches!(action, AppAction::None));
2256 assert!(!app.goal_pause_requested);
2257 assert!(!app.goal_clear_requested);
2258 let notice = app.composer_notice.as_ref().expect("expected notice");
2259 assert!(notice.text.contains("build the feature"));
2260 let _ = std::fs::remove_dir_all(dir);
2261 }
2262
2263 #[test]
2264 fn non_goal_command_blocked_while_agent_running() {
2265 let dir = temp_dir("non-goal-blocked");
2266 let mut app = App::new(metadata_for(&dir), &[], false);
2267
2268 let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
2269 app.result_rx = Some(rx);
2270 app.composer.insert_str("some prompt text");
2271
2272 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2273
2274 assert!(matches!(action, AppAction::None));
2275 assert_eq!(app.prompt(), "some prompt text");
2277 let _ = std::fs::remove_dir_all(dir);
2278 }
2279
2280 #[test]
2281 fn goal_set_blocked_while_agent_running() {
2282 let dir = temp_dir("goal-set-blocked");
2283 let mut app = App::new(metadata_for(&dir), &[], false);
2284
2285 let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
2286 app.result_rx = Some(rx);
2287 app.composer.insert_str("/goal set new objective");
2288
2289 let action = app.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
2290
2291 assert!(matches!(action, AppAction::None));
2293 assert!(!app.goal_pause_requested);
2294 assert!(!app.goal_clear_requested);
2295 let _ = std::fs::remove_dir_all(dir);
2296 }
2297
2298 #[test]
2299 fn escape_requests_goal_pause_while_agent_running() {
2300 let dir = temp_dir("goal-esc-pause");
2301 let mut app = App::new(metadata_for(&dir), &[], false);
2302 app.set_goal("build the feature".to_string());
2303
2304 let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
2305 app.result_rx = Some(rx);
2306
2307 let action = app.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
2308
2309 assert!(matches!(action, AppAction::None));
2310 assert!(app.goal_pause_requested);
2311 let notice = app.composer_notice.as_ref().expect("expected notice");
2312 assert!(notice.text.contains("pause after current turn"));
2313 let _ = std::fs::remove_dir_all(dir);
2314 }
2315
2316 #[test]
2317 fn escape_does_not_pause_when_no_goal_active() {
2318 let dir = temp_dir("goal-esc-no-goal");
2319 let mut app = App::new(metadata_for(&dir), &[], false);
2320 let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
2322 app.result_rx = Some(rx);
2323
2324 let action = app.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
2325
2326 assert!(matches!(action, AppAction::None));
2327 assert!(!app.goal_pause_requested);
2328 let _ = std::fs::remove_dir_all(dir);
2329 }
2330
2331 #[test]
2332 fn escape_dismisses_focus_even_with_goal_active() {
2333 let dir = temp_dir("goal-esc-focus");
2334 let mut app = App::new(metadata_for(&dir), &[], false);
2335 app.set_goal("build the feature".to_string());
2336 app.screen = ScreenMode::Focused(FocusPanel::Response);
2337
2338 let (_tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
2339 app.result_rx = Some(rx);
2340
2341 let action = app.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
2342
2343 assert!(matches!(action, AppAction::None));
2345 assert!(!app.goal_pause_requested);
2346 assert_eq!(app.screen, ScreenMode::Dashboard);
2347 let _ = std::fs::remove_dir_all(dir);
2348 }
2349
2350 #[test]
2351 fn deferred_pause_flag_resets_after_check() {
2352 let dir = temp_dir("goal-deferred-reset");
2353 let mut app = App::new(metadata_for(&dir), &[], false);
2354 app.set_goal("build the feature".to_string());
2355 app.goal_pause_requested = true;
2356
2357 if app.goal_clear_requested {
2360 app.goal_clear_requested = false;
2361 app.goal_pause_requested = false;
2362 app.clear_goal();
2363 } else if app.goal_pause_requested {
2364 app.goal_pause_requested = false;
2365 app.pause_goal();
2366 }
2367
2368 assert!(!app.goal_pause_requested);
2369 assert_eq!(
2370 app.goal.as_ref().unwrap().status,
2371 crate::goal::GoalStatus::Paused
2372 );
2373 assert!(!app.goal_should_continue());
2374 let _ = std::fs::remove_dir_all(dir);
2375 }
2376
2377 #[test]
2378 fn deferred_clear_takes_precedence_over_pause() {
2379 let dir = temp_dir("goal-deferred-clear-precedence");
2380 let mut app = App::new(metadata_for(&dir), &[], false);
2381 app.set_goal("build the feature".to_string());
2382 app.goal_pause_requested = true;
2383 app.goal_clear_requested = true;
2384
2385 if app.goal_clear_requested {
2387 app.goal_clear_requested = false;
2388 app.goal_pause_requested = false;
2389 app.clear_goal();
2390 } else if app.goal_pause_requested {
2391 app.goal_pause_requested = false;
2392 app.pause_goal();
2393 }
2394
2395 assert!(!app.goal_pause_requested);
2396 assert!(!app.goal_clear_requested);
2397 assert!(app.goal.is_none());
2398 assert!(!app.goal_should_continue());
2399 let _ = std::fs::remove_dir_all(dir);
2400 }
2401
2402 #[test]
2403 fn goal_should_not_continue_after_deferred_pause() {
2404 let dir = temp_dir("goal-no-continue-after-pause");
2405 let mut app = App::new(metadata_for(&dir), &[], false);
2406 app.set_goal("build the feature".to_string());
2407 assert!(app.goal_should_continue());
2408
2409 app.pause_goal();
2410 assert!(!app.goal_should_continue());
2411 let _ = std::fs::remove_dir_all(dir);
2412 }
2413}