Skip to main content

sac/tui/
mod.rs

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                                // Goal error transitions are now handled
360                                // inside the agent's send_with_goal() loop,
361                                // which emits GoalErrorTransition events.
362                                // The TUI just records the send error.
363                                app.note_send_error(error);
364                            }
365                        }
366                        // Goal accounting is now done per-turn inside
367                        // the agent's send_with_goal() loop, which emits
368                        // GoalTurnAccounted events.  Reload the goal from
369                        // the store to get the final state after all
370                        // continuation turns.
371                        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                        // Deferred goal pause/clear is now handled by the
393                        // agent's send_with_goal() via goal_pause_tx, but
394                        // clear the TUI-side flags for cases where
395                        // send_with_goal was not used (no goal was active
396                        // at prompt submission time).
397                        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    // Create a fresh steering channel for this turn.  The sender stays
478    // with the TUI (stored in app.steering_tx) so it can push mid-turn
479    // steering messages.  The receiver is attached to the agent before
480    // the send() call.
481    let (steering_tx, steering_rx) = mpsc::unbounded_channel::<String>();
482    app.steering_tx = Some(steering_tx);
483
484    // Create a goal pause/clear signaling channel.  The TUI sends
485    // "pause" or "clear" strings; the agent's send_with_goal() drains
486    // this between continuation turns.
487    let (goal_pause_tx, goal_pause_rx) = mpsc::unbounded_channel::<String>();
488    app.goal_pause_tx = Some(goal_pause_tx);
489
490    // Determine whether the agent should use goal-aware continuation.
491    // This is true when a goal is active at the time the prompt is
492    // submitted.  The agent will auto-continue internally.
493    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        // LLMs frequently omit the `|---|---|` delimiter row.
2080        // The renderer should fall back to treating two consecutive
2081        // pipe-delimited rows with matching column counts as a table.
2082        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        // Should produce box-drawing table borders, not raw pipe text
2087        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        // Single-column pipe-delimited text is ambiguous (could be inline
2098        // pipe emphasis). Require at least two columns for the fallback.
2099        let rendered = render_markdown_lines("| single |\n| column |", None);
2100        let plain: Vec<String> = rendered.iter().map(line_to_plain_text).collect();
2101        // Should render as plain paragraphs (raw pipes), not a table
2102        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    // ── Deferred goal pause/clear tests ───────────────────────────────
2195
2196    #[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        // Simulate agent running
2203        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        // Goal should still be active (not yet paused)
2212        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()); // not yet cleared
2238        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        // Prompt should NOT have been cleared (still in composer)
2276        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        // /goal set should be blocked while running (not in the allow-list)
2292        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        // No goal set, agent running
2321        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        // Should dismiss focus, not request pause
2344        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        // Simulate what the result_rx handler does:
2358        // check and clear the flags, then call pause_goal()
2359        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        // Simulate what the result_rx handler does
2386        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}