Skip to main content

sparrow/tui/
mod.rs

1use std::io;
2use std::time::Instant;
3
4use crate::event::Event;
5use crate::tui::theme::Theme;
6use crossterm::{
7    event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers},
8    execute,
9    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
10};
11use ratatui::{
12    Frame,
13    layout::{Constraint, Direction, Layout, Rect},
14    style::{Color, Modifier, Style},
15    text::{Line, Span, Text},
16    widgets::{Block, Borders, Paragraph},
17};
18use tokio::sync::mpsc;
19
20pub mod formatters;
21pub mod renderer;
22pub mod theme;
23
24/// Make the host console accept the UTF-8 output the TUI emits.
25///
26/// On Windows the default console code page (CP1252 / OEM-850) silently
27/// corrupts every multi-byte character we draw — `•` becomes `â¢`, `·`
28/// becomes `·`, box-drawing chars become noise, and a few bytes get
29/// dropped along the way ("binary" → "binana"). The fix is a single
30/// `SetConsoleOutputCP(65001)` call on stdout's code page before we
31/// enter the alternate screen.
32///
33/// On Unix the terminal already speaks UTF-8 — no-op.
34fn ensure_utf8_console() {
35    #[cfg(windows)]
36    {
37        // Minimal FFI shim — equivalent to `chcp 65001` but applied to
38        // the process's *output* code page only, so it does not leak into
39        // child processes or the shell prompt after we exit.
40        unsafe extern "system" {
41            fn SetConsoleOutputCP(wCodePageID: u32) -> i32;
42            fn SetConsoleCP(wCodePageID: u32) -> i32;
43        }
44        const CP_UTF8: u32 = 65001;
45        unsafe {
46            let _ = SetConsoleOutputCP(CP_UTF8);
47            let _ = SetConsoleCP(CP_UTF8);
48        }
49    }
50}
51pub mod ansi_bridge;
52
53type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>;
54
55#[derive(Debug, Clone)]
56struct LogLine {
57    text: String,
58    style: LogStyle,
59    indent: u16,
60    /// If set, this line is a child of collapsible group N (hidden when collapsed).
61    group: Option<usize>,
62    /// If set, this line IS the collapsible header for group N.
63    header_for: Option<usize>,
64}
65
66/// A collapsible task group in the scroll log (a run, an agent phase, a tool call).
67#[derive(Debug, Clone)]
68struct TaskGroup {
69    title: String,
70    collapsed: bool,
71    style: LogStyle,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq)]
75enum LogStyle {
76    Normal,
77    Dim,
78    Brand,
79    Agent,
80    Planner,
81    Verifier,
82    Rem,
83    Steel,
84    Gold,
85    Prompt,
86    Cmd,
87    Ok,
88    Warn,
89    Err,
90    Accent,
91}
92
93impl LogStyle {
94    fn color(&self, theme: &Theme) -> Color {
95        match self {
96            LogStyle::Normal => theme.fg,
97            LogStyle::Dim => theme.dim,
98            LogStyle::Brand => theme.brand,
99            LogStyle::Agent => theme.agent,
100            LogStyle::Planner => theme.planner,
101            LogStyle::Verifier => theme.verifier,
102            LogStyle::Rem => theme.rem,
103            LogStyle::Steel => theme.steel,
104            LogStyle::Gold => theme.gold,
105            LogStyle::Prompt => theme.brand,
106            LogStyle::Cmd => theme.fg,
107            LogStyle::Ok => theme.add,
108            LogStyle::Warn => theme.verifier,
109            LogStyle::Err => theme.rem,
110            LogStyle::Accent => theme.brand,
111        }
112    }
113}
114
115const SLASH_COMMANDS: &[&str] = &[
116    "/help",
117    "/plan",
118    "/permissions",
119    "/memory",
120    "/compact",
121    "/model",
122    "/agents",
123    "/sessions",
124    "/export",
125    "/run",
126    "/chat",
127    "/swarm",
128    "/agent",
129    "/skills",
130    "/checkpoint",
131    "/rewind",
132    "/replay",
133    "/auth",
134    "/clear",
135    "/collapse",
136    "/expand",
137    "/exit",
138];
139
140const HISTORY_MAX: usize = 100;
141
142// ─── Swarm lanes ─────────────────────────────────────────────────────────────
143
144#[derive(Debug, Clone)]
145struct LaneState {
146    /// AgentStatus name (Idle/Thinking/Working/Done/Error/WaitingForApproval)
147    status: String,
148    /// last note text
149    note: String,
150    /// Brain id
151    model: String,
152}
153
154impl Default for LaneState {
155    fn default() -> Self {
156        Self {
157            status: "Idle".into(),
158            note: "".into(),
159            model: "".into(),
160        }
161    }
162}
163
164#[derive(Debug, Clone, Default)]
165struct SwarmLanesState {
166    planner: LaneState,
167    coder: LaneState,
168    verifier: LaneState,
169    /// Frame at which the swarm started; used to fade-in lanes.
170    started_at_frame: u64,
171}
172
173// ─── Diff panel ──────────────────────────────────────────────────────────────
174
175#[derive(Debug, Clone)]
176enum DiffLineKind {
177    Context,
178    Plus,
179    Minus,
180    Hunk,
181}
182
183#[derive(Debug, Clone)]
184struct DiffLineEntry {
185    kind: DiffLineKind,
186    text: String,
187}
188
189#[derive(Debug, Clone)]
190struct DiffEntry {
191    file: String,
192    plus: u32,
193    minus: u32,
194    lines: Vec<DiffLineEntry>,
195    applied: bool,
196}
197
198fn parse_diff_patch(patch: &str) -> Vec<DiffLineEntry> {
199    let mut out = Vec::new();
200    for line in patch.lines().take(40) {
201        let kind = if line.starts_with("+++") || line.starts_with("---") {
202            DiffLineKind::Context
203        } else if line.starts_with("@@") {
204            DiffLineKind::Hunk
205        } else if line.starts_with('+') {
206            DiffLineKind::Plus
207        } else if line.starts_with('-') {
208            DiffLineKind::Minus
209        } else {
210            DiffLineKind::Context
211        };
212        out.push(DiffLineEntry {
213            kind,
214            text: line.to_string(),
215        });
216    }
217    out
218}
219
220fn truncate_for_width(text: &str, width: usize) -> String {
221    if width == 0 {
222        return String::new();
223    }
224    let mut out = String::new();
225    for ch in text.chars().take(width) {
226        out.push(ch);
227    }
228    if text.chars().count() > width && width > 1 {
229        out.pop();
230        out.push('…');
231    }
232    out
233}
234
235fn syntax_spans(text: &str, theme: &Theme, base: Color) -> Vec<Span<'static>> {
236    const KEYWORDS: &[&str] = &[
237        "fn", "pub", "if", "else", "return", "let", "mut", "const", "struct", "impl", "trait",
238        "use", "as", "match",
239    ];
240    let violet = Color::Rgb(0xb4, 0x8e, 0xff);
241    let mut spans = Vec::new();
242    let mut buf = String::new();
243    let chars = text.chars();
244    let mut in_string = false;
245
246    let flush_word = |word: &mut String, spans: &mut Vec<Span<'static>>, next_is_call: bool| {
247        if word.is_empty() {
248            return;
249        }
250        let style = if KEYWORDS.contains(&word.as_str()) {
251            Style::default().fg(violet).add_modifier(Modifier::BOLD)
252        } else if next_is_call {
253            Style::default().fg(theme.gold)
254        } else {
255            Style::default().fg(base)
256        };
257        spans.push(Span::styled(std::mem::take(word), style));
258    };
259
260    for ch in chars {
261        if ch == '"' {
262            if in_string {
263                buf.push(ch);
264                spans.push(Span::styled(
265                    std::mem::take(&mut buf),
266                    Style::default().fg(theme.add),
267                ));
268                in_string = false;
269            } else {
270                flush_word(&mut buf, &mut spans, false);
271                buf.push(ch);
272                in_string = true;
273            }
274            continue;
275        }
276        if in_string {
277            buf.push(ch);
278            continue;
279        }
280        if ch.is_alphanumeric() || ch == '_' {
281            buf.push(ch);
282            continue;
283        }
284        let next_is_call = ch == '(';
285        flush_word(&mut buf, &mut spans, next_is_call);
286        spans.push(Span::styled(ch.to_string(), Style::default().fg(base)));
287    }
288    if in_string {
289        spans.push(Span::styled(buf, Style::default().fg(theme.add)));
290    } else {
291        flush_word(&mut buf, &mut spans, false);
292    }
293    spans
294}
295
296// ─── Checkpoint timeline ─────────────────────────────────────────────────────
297
298#[derive(Debug, Clone)]
299struct CheckpointNode {
300    id: String,
301    label: String,
302    current: bool,
303}
304
305// ─── Toast (skill learned, etc.) ─────────────────────────────────────────────
306
307#[derive(Debug, Clone)]
308struct Toast {
309    text: String,
310    /// frames since spawn
311    age: u32,
312    /// total lifetime in frames
313    max_age: u32,
314}
315
316pub struct Tui {
317    theme: Theme,
318    lines: Vec<LogLine>,
319    route: String,
320    cost_usd: f64,
321    total_tokens: u64,
322    autonomy: String,
323    /// Multi-line input. input_lines[0] = first row of the prompt.
324    input_lines: Vec<String>,
325    /// Cursor row within input_lines.
326    cursor_row: usize,
327    /// Cursor col (byte index) within input_lines[cursor_row].
328    cursor_col: usize,
329    /// Command history, oldest first.
330    history: Vec<String>,
331    /// When navigating history, index into history; None = fresh editing.
332    history_idx: Option<usize>,
333    /// Pending injection mode: next Enter sends as injection, not new task.
334    inject_pending: bool,
335    scroll: u16,
336    frame: u64,
337    spinner_idx: usize,
338    booted: bool,
339    boot_progress: u32,
340    event_rx: Option<mpsc::UnboundedReceiver<Event>>,
341    task_tx: Option<mpsc::UnboundedSender<String>>,
342    history_path: Option<std::path::PathBuf>,
343
344    // ── Batch 3 additions ─────────────────────────────────────────────────
345    /// Active swarm lanes (None when not in swarm mode).
346    swarm_lanes: Option<SwarmLanesState>,
347    /// Pending diffs (cap = 3, FIFO).
348    pending_diffs: std::collections::VecDeque<DiffEntry>,
349    /// Checkpoint timeline nodes.
350    checkpoints: Vec<CheckpointNode>,
351    /// Centered overlay toast (skill learned, etc.).
352    toast: Option<Toast>,
353    /// Cost flash counter (frames remaining of bold cost).
354    cost_flash_frames: u32,
355    last_cost: f64,
356    /// Token flash counter.
357    tok_flash_frames: u32,
358    last_tokens: u64,
359
360    // ── Collapsible task groups ───────────────────────────────────────────
361    /// Collapsible task groups; child lines reference these by index.
362    groups: Vec<TaskGroup>,
363    /// Group that new lines are attached to (None = top level).
364    current_group: Option<usize>,
365    /// Group header currently focused for collapse/expand (Ctrl+↑/↓, Ctrl+O).
366    focus_group: Option<usize>,
367
368    // ── Replay scrubber ───────────────────────────────────────────────────
369    /// When set, the TUI is in replay mode: scrub events with ←/→.
370    replay_events: Option<Vec<Event>>,
371    replay_idx: usize,
372    /// Strips <think> reasoning blocks from streamed deltas.
373    think: crate::event::ThinkStripper,
374    /// Known agent names for `@<name>` autocomplete; populated by the host.
375    agent_names: Vec<String>,
376    /// Currently active agent (toggled via @picker). None = default pipeline.
377    active_agent: Option<String>,
378    /// Cached agent souls: name → (role, personality_b64).
379    agent_souls: std::collections::HashMap<String, (String, String)>,
380    /// Rich terminal renderer (syntax highlighting, markdown, diffs).
381    term_renderer: crate::tui::renderer::TermRenderer,
382    /// v0.9 Pilier 2: when true, status events render as plain-language lines
383    /// via the humanize table instead of technical labels.
384    simple: bool,
385    experience_mode: String,
386    lang: crate::humanize::Lang,
387}
388
389impl Tui {
390    pub fn new() -> Self {
391        // Resolve history path: ~/.local/state/sparrow/tui_history.txt
392        let history_path = dirs::state_dir()
393            .or_else(dirs::data_local_dir)
394            .or_else(dirs::data_dir)
395            .map(|d| d.join("sparrow").join("tui_history.txt"));
396        let history = history_path
397            .as_ref()
398            .and_then(|p| std::fs::read_to_string(p).ok())
399            .map(|s| s.lines().map(String::from).collect())
400            .unwrap_or_default();
401
402        // Pick theme from $SPARROW_THEME or default to `captain`.
403        let theme = std::env::var("SPARROW_THEME")
404            .ok()
405            .map(|n| crate::tui::theme::by_name(&n))
406            .unwrap_or_default();
407        let mut tui = Self {
408            theme,
409            lines: Vec::new(),
410            route: "idle".into(),
411            cost_usd: 0.0,
412            total_tokens: 0,
413            autonomy: "supervised".into(),
414            input_lines: vec![String::new()],
415            cursor_row: 0,
416            cursor_col: 0,
417            history,
418            history_idx: None,
419            inject_pending: false,
420            scroll: 0,
421            frame: 0,
422            spinner_idx: 0,
423            booted: false,
424            boot_progress: 0,
425            event_rx: None,
426            task_tx: None,
427            history_path,
428            swarm_lanes: None,
429            pending_diffs: std::collections::VecDeque::new(),
430            checkpoints: Vec::new(),
431            toast: None,
432            cost_flash_frames: 0,
433            last_cost: 0.0,
434            tok_flash_frames: 0,
435            last_tokens: 0,
436            groups: Vec::new(),
437            current_group: None,
438            focus_group: None,
439            replay_events: None,
440            replay_idx: 0,
441            think: crate::event::ThinkStripper::new(),
442            agent_names: Vec::new(),
443            active_agent: None,
444            agent_souls: std::collections::HashMap::new(),
445            term_renderer: crate::tui::renderer::TermRenderer::new(
446                crate::tui::renderer::RenderConfig::default(),
447            ),
448            // Default to the human-first experience; run_tui overrides from config.
449            simple: true,
450            experience_mode: "simple".into(),
451            lang: crate::humanize::Lang::Fr,
452        };
453        tui.show_splash();
454        tui
455    }
456
457    /// Apply the resolved experience (simple/pro + language) from config.
458    pub fn with_experience(mut self, simple: bool, lang: crate::humanize::Lang) -> Self {
459        self.simple = simple;
460        self.lang = lang;
461        self
462    }
463
464    pub fn with_experience_mode(mut self, mode: &str) -> Self {
465        self.experience_mode = mode.trim().to_lowercase();
466        self.lines.clear();
467        self.show_splash();
468        self
469    }
470
471    /// Show a rich-formatted splash screen demonstrating TUI capabilities.
472    fn show_splash(&mut self) {
473        self.add_line("══════════════════════════════════════", LogStyle::Brand, 0);
474        self.add_line(
475            "  🐦 SPARROW — one cli · grows with you",
476            LogStyle::Brand,
477            0,
478        );
479        self.add_line("══════════════════════════════════════", LogStyle::Brand, 0);
480        self.add_line("", LogStyle::Cmd, 0);
481        match self.experience_mode.as_str() {
482            "builder" => {
483                self.add_line("Builder menu:", LogStyle::Cmd, 0);
484                self.add_line("  Run       → sparrow run \"task\"", LogStyle::Dim, 0);
485                self.add_line("  Test      → sparrow test --fix", LogStyle::Dim, 0);
486                self.add_line(
487                    "  Refactor  → /refactor or refactor-safely",
488                    LogStyle::Dim,
489                    0,
490                );
491                self.add_line("  Git       → sparrow commit --dry-run", LogStyle::Dim, 0);
492                self.add_line("  Debug     → /tools + terminal output", LogStyle::Dim, 0);
493                self.add_line("  Replay    → sparrow replay <run>", LogStyle::Dim, 0);
494            }
495            "pro" => {
496                self.add_line("Expert palette:", LogStyle::Cmd, 0);
497                self.add_line(
498                    "  /help     → all commands and skill entries",
499                    LogStyle::Dim,
500                    0,
501                );
502                self.add_line("  --json    → CI/headless event stream", LogStyle::Dim, 0);
503                self.add_line("  Ctrl+R    → rewind to last checkpoint", LogStyle::Dim, 0);
504                self.add_line("  Ctrl+I    → inject into the active run", LogStyle::Dim, 0);
505            }
506            _ => {
507                self.add_line("Try these (type in the input below):", LogStyle::Cmd, 0);
508                self.add_line("  Répare mon problème  → sparrow fix", LogStyle::Dim, 0);
509                self.add_line(
510                    "  Explique             → sparrow explique",
511                    LogStyle::Dim,
512                    0,
513                );
514                self.add_line("  Mes fichiers         → /files", LogStyle::Dim, 0);
515                self.add_line("  Réglages             → /permissions", LogStyle::Dim, 0);
516            }
517        }
518        self.add_line("", LogStyle::Cmd, 0);
519        // Demo: formatted code
520        self.add_line("# RICH RENDERING DEMO", LogStyle::Gold, 0);
521        self.add_line("", LogStyle::Cmd, 0);
522        self.add_line("Code blocks get syntax highlighting:", LogStyle::Cmd, 0);
523        self.add_line("```rust", LogStyle::Dim, 0);
524        self.add_line("fn main() {", LogStyle::Cmd, 0);
525        self.add_line("    println!(\"Hello, Sparrow!\");", LogStyle::Cmd, 0);
526        self.add_line("}", LogStyle::Cmd, 0);
527        self.add_line("```", LogStyle::Dim, 0);
528        self.add_line("", LogStyle::Cmd, 0);
529        self.add_line(
530            "Diffs are colored (additions in green, deletions in red):",
531            LogStyle::Cmd,
532            0,
533        );
534        self.add_line("--- a/src/main.rs", LogStyle::Dim, 0);
535        self.add_line("+++ b/src/main.rs", LogStyle::Dim, 0);
536        self.add_line("@@ -10,6 +10,8 @@ fn main() {", LogStyle::Dim, 0);
537        self.add_line("+    let config = load_config()?;", LogStyle::Ok, 0);
538        self.add_line("     let engine = Engine::new();", LogStyle::Cmd, 0);
539        self.add_line("-    engine.run_old();", LogStyle::Err, 0);
540        self.add_line("+    engine.run_with_config(&config);", LogStyle::Ok, 0);
541        self.add_line("", LogStyle::Cmd, 0);
542        self.add_line("JSON is pretty-printed:", LogStyle::Cmd, 0);
543        self.add_line("{", LogStyle::Dim, 0);
544        self.add_line("  \"status\": \"ready\",", LogStyle::Ok, 0);
545        self.add_line("  \"version\": \"0.5.9\",", LogStyle::Gold, 0);
546        self.add_line(
547            "  \"agents\": [\"nova\", \"planner\", \"coder\"]",
548            LogStyle::Cmd,
549            0,
550        );
551        self.add_line("}", LogStyle::Dim, 0);
552        self.add_line("", LogStyle::Cmd, 0);
553        self.add_line("→ Type a task or /command to begin.", LogStyle::Brand, 0);
554    }
555
556    /// Launch the TUI as a replay scrubber over a recorded transcript.
557    /// ←/→ step through events; Home/End jump to start/end.
558    pub fn with_replay(mut self, events: Vec<Event>) -> Self {
559        self.replay_events = Some(events);
560        self.replay_idx = 0;
561        self.booted = true; // skip boot animation in replay mode
562        self
563    }
564
565    /// Test-only: force the cockpit past the boot animation so a render
566    /// snapshot exercises the live layout rather than the splash screen.
567    #[doc(hidden)]
568    pub fn force_booted(&mut self) {
569        self.booted = true;
570    }
571
572    /// Test-only: drive the boot animation to a given progress so the splash
573    /// renders its mid/late state (wordmark, boot log, ready) instead of the
574    /// intentionally-blank first frame.
575    #[doc(hidden)]
576    pub fn debug_set_boot_progress(&mut self, progress: u32) {
577        self.boot_progress = progress;
578    }
579
580    /// Test-only: render one frame to an in-memory [`TestBackend`] and return
581    /// the buffer as plain text lines (one `String` per terminal row). This is
582    /// the only way to exercise the real `render` path headlessly — the
583    /// interactive `run`/`main_loop` require a live terminal.
584    ///
585    /// [`TestBackend`]: ratatui::backend::TestBackend
586    #[doc(hidden)]
587    pub fn render_to_lines(&mut self, width: u16, height: u16) -> Vec<String> {
588        let backend = ratatui::backend::TestBackend::new(width, height);
589        let mut terminal = ratatui::Terminal::new(backend).expect("test terminal");
590        terminal
591            .draw(|f| self.render(f, 0.0))
592            .expect("render must not fail");
593        let buf = terminal.backend().buffer().clone();
594        (0..height)
595            .map(|y| {
596                (0..width)
597                    .map(|x| buf[(x, y)].symbol())
598                    .collect::<String>()
599                    .trim_end()
600                    .to_string()
601            })
602            .collect()
603    }
604
605    /// Rebuild the log from replay events up to `replay_idx`.
606    fn rebuild_replay(&mut self) {
607        let Some(events) = self.replay_events.clone() else {
608            return;
609        };
610        self.lines.clear();
611        self.groups.clear();
612        self.current_group = None;
613        self.focus_group = None;
614        self.cost_usd = 0.0;
615        self.total_tokens = 0;
616        let upto = self.replay_idx.min(events.len());
617        for ev in events.iter().take(upto) {
618            self.push_event(ev.clone());
619        }
620        let total = events.len();
621        self.add_line(
622            &format!(
623                "── replay {}/{}  (←/→ step · Home/End jump · q quit) ──",
624                upto, total
625            ),
626            LogStyle::Accent,
627            0,
628        );
629    }
630
631    /// Snapshot current input as a single joined string.
632    fn current_input(&self) -> String {
633        self.input_lines.join("\n")
634    }
635
636    /// Replace current input with a single-line snapshot (used by history nav).
637    fn set_input(&mut self, s: &str) {
638        self.input_lines = s.split('\n').map(String::from).collect();
639        if self.input_lines.is_empty() {
640            self.input_lines.push(String::new());
641        }
642        self.cursor_row = self.input_lines.len() - 1;
643        self.cursor_col = self.input_lines[self.cursor_row].len();
644    }
645
646    /// Append current input to history (de-dup against last entry) and persist.
647    fn push_history(&mut self, entry: &str) {
648        if entry.trim().is_empty() {
649            return;
650        }
651        if self.history.last().map(|s| s.as_str()) == Some(entry) {
652            return;
653        }
654        self.history.push(entry.to_string());
655        if self.history.len() > HISTORY_MAX {
656            let excess = self.history.len() - HISTORY_MAX;
657            self.history.drain(..excess);
658        }
659        if let Some(path) = &self.history_path {
660            if let Some(parent) = path.parent() {
661                let _ = std::fs::create_dir_all(parent);
662            }
663            let _ = std::fs::write(path, self.history.join("\n"));
664        }
665    }
666
667    fn clear_log_state(&mut self) {
668        self.lines.clear();
669        self.groups.clear();
670        self.current_group = None;
671        self.focus_group = None;
672    }
673
674    /// Match autocomplete candidates for the current input.
675    fn autocomplete_matches(&self) -> Vec<&'static str> {
676        let line = self
677            .input_lines
678            .get(self.cursor_row)
679            .map(String::as_str)
680            .unwrap_or("");
681        if line.starts_with('/') {
682            return SLASH_COMMANDS
683                .iter()
684                .filter(|c| c.starts_with(line) && **c != line)
685                .copied()
686                .take(5)
687                .collect();
688        }
689        vec![]
690    }
691
692    /// Test hook: mutable access to the first input line.
693    #[doc(hidden)]
694    pub fn debug_first_line_mut(&mut self) -> &mut String {
695        if self.input_lines.is_empty() {
696            self.input_lines.push(String::new());
697        }
698        &mut self.input_lines[0]
699    }
700
701    /// Test hook: set the cursor column.
702    #[doc(hidden)]
703    pub fn debug_set_cursor_col(&mut self, col: usize) {
704        self.cursor_row = 0;
705        self.cursor_col = col;
706    }
707
708    /// `@<name>` agent picker: returns owned strings prefixed with `@`. Separate
709    /// from the slash autocomplete because the candidate list is dynamic.
710    pub fn agent_matches(&self) -> Vec<String> {
711        // Find the last `@` token on the current line.
712        let line = &self.input_lines[self.cursor_row];
713        let upto = line.get(..self.cursor_col).unwrap_or(line);
714        let Some(at_pos) = upto.rfind('@') else {
715            return vec![];
716        };
717        // Don't trigger when `@` is preceded by a non-whitespace char (so e-mails
718        // like foo@example don't fire the picker).
719        if at_pos > 0
720            && !upto[..at_pos]
721                .chars()
722                .last()
723                .map(|c| c.is_whitespace())
724                .unwrap_or(true)
725        {
726            return vec![];
727        }
728        let prefix = &upto[at_pos + 1..];
729        // Bail if the fragment already contains whitespace — picker is over.
730        if prefix.contains(char::is_whitespace) {
731            return vec![];
732        }
733        self.agent_names
734            .iter()
735            .filter(|n| n.starts_with(prefix))
736            .take(5)
737            .map(|n| format!("@{}", n))
738            .collect()
739    }
740
741    /// Populate the `@<name>` agent picker with the agents the host knows about.
742    pub fn with_agents(mut self, names: Vec<String>) -> Self {
743        self.agent_names = names;
744        self
745    }
746
747    /// Toggle an agent on/off. When toggled on, all subsequent tasks run with
748    /// that agent's identity. Toggle again (or toggle another agent) to switch.
749    pub fn toggle_agent(&mut self, name: &str) {
750        if self.active_agent.as_deref() == Some(name) {
751            // Deselect
752            self.active_agent = None;
753        } else {
754            // Select — cache the agent soul
755            self.active_agent = Some(name.to_string());
756            if !self.agent_souls.contains_key(name) {
757                self.cache_agent_soul(name);
758            }
759        }
760    }
761
762    /// Load and cache an agent's soul (role + base64 personality).
763    fn cache_agent_soul(&mut self, name: &str) {
764        let path = dirs::config_dir()
765            .unwrap_or_default()
766            .join("sparrow")
767            .join("agents")
768            .join(format!("{}.soul.toml", name));
769        if let Ok(content) = std::fs::read_to_string(&path) {
770            let role = content
771                .lines()
772                .find(|l| l.starts_with("role"))
773                .and_then(|l| l.split('=').nth(1))
774                .map(|s| s.trim().trim_matches('"').to_string())
775                .unwrap_or_default();
776            let personality = content
777                .lines()
778                .find(|l| l.starts_with("personality"))
779                .and_then(|l| l.split('=').nth(1))
780                .map(|s| s.trim().trim_matches('"').to_string())
781                .unwrap_or_default();
782            use base64::{Engine as _, engine::general_purpose::STANDARD};
783            let b64 = STANDARD.encode(personality.as_bytes());
784            self.agent_souls.insert(name.to_string(), (role, b64));
785        }
786    }
787
788    /// Build the agent dispatch prefix for task sending.
789    fn agent_prefix(&self) -> String {
790        if let Some(ref name) = self.active_agent {
791            if let Some((role, b64)) = self.agent_souls.get(name) {
792                return format!("__agent:{}__{}__{}__ ", name, role, b64);
793            }
794        }
795        String::new()
796    }
797
798    pub fn with_channels(
799        mut self,
800        task_tx: mpsc::UnboundedSender<String>,
801        event_rx: mpsc::UnboundedReceiver<Event>,
802    ) -> Self {
803        self.task_tx = Some(task_tx);
804        self.event_rx = Some(event_rx);
805        self
806    }
807
808    /// Format a log line with auto-detected content type.
809    /// Applies syntax highlighting to code, colors to diffs, etc.
810    fn format_line(&self, text: &str) -> String {
811        // Detect content type
812        let trimmed = text.trim();
813
814        // Code blocks (start with ``` or indented 4+ spaces)
815        if trimmed.starts_with("```") || text.lines().all(|l| l.starts_with("    ") || l.is_empty())
816        {
817            return self.term_renderer.render_code(text, "");
818        }
819
820        // Diff output (starts with diff --git, @@, +++, ---)
821        if trimmed.contains("diff --git")
822            || trimmed.starts_with("@@")
823            || trimmed.starts_with("--- a/")
824            || trimmed.starts_with("+++ b/")
825        {
826            return self.term_renderer.render_diff(text);
827        }
828
829        // JSON (starts with { or [)
830        if trimmed.starts_with('{') || trimmed.starts_with('[') {
831            if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
832                return self.term_renderer.render_json(text);
833            }
834        }
835
836        // Markdown headers (# Title, ## Section)
837        if trimmed.starts_with("# ") || trimmed.starts_with("## ") || trimmed.starts_with("### ") {
838            return self.term_renderer.render_markdown(text);
839        }
840
841        // Default: plain text
842        text.to_string()
843    }
844
845    pub fn push_event(&mut self, event: Event) {
846        match &event {
847            Event::RunStarted { task, .. } => {
848                self.think = crate::event::ThinkStripper::new();
849                self.open_group(&format!("started: {}", task), LogStyle::Brand);
850            }
851            Event::RouteSelected { chain, .. } => {
852                self.route = chain.join(" → ");
853                self.add_line(&format!("↳ route: {}", self.route), LogStyle::Dim, 1);
854            }
855            Event::ModelSwitched {
856                from, to, reason, ..
857            } => {
858                self.route = to.clone();
859                // v0.9 simple mode: one plain-language line instead of the
860                // technical "fallback: X → Y (reason)".
861                if self.simple {
862                    if let Some(line) = crate::humanize::humanize(&event, self.lang) {
863                        self.add_line(&line, LogStyle::Warn, 1);
864                    }
865                } else {
866                    let clean = crate::event::friendly_model_switch_reason(reason);
867                    let label = if crate::event::is_local_model_unavailable(reason) {
868                        format!(
869                            "↳ modèle local indisponible → routage modèle cloud ({})",
870                            to
871                        )
872                    } else {
873                        format!("↳ fallback: {} → {} ({})", from, to, clean)
874                    };
875                    self.add_line(&label, LogStyle::Warn, 1);
876                }
877            }
878            Event::ThinkingDelta { text, .. } => {
879                let visible = self.think.feed(text);
880                if !visible.is_empty() {
881                    self.add_line(&visible, LogStyle::Cmd, 1);
882                }
883            }
884            Event::ReasoningDelta { .. } => {}
885            Event::ToolUseProposed { name, .. } => {
886                self.open_group(&format!("tool · {}", name), LogStyle::Steel);
887            }
888            Event::ToolOutput { blocks, .. } => {
889                for b in blocks {
890                    if let crate::event::Block::Text(t) = b {
891                        self.add_line(&format!("  {}", t), LogStyle::Dim, 2);
892                    }
893                }
894            }
895            Event::AgentSpawned { role, model, .. } => {
896                let lanes = self.swarm_lanes.get_or_insert_with(|| SwarmLanesState {
897                    started_at_frame: self.frame,
898                    ..Default::default()
899                });
900                let lane = match role.as_str() {
901                    "planner" => &mut lanes.planner,
902                    "coder" => &mut lanes.coder,
903                    "verifier" => &mut lanes.verifier,
904                    _ => &mut lanes.coder,
905                };
906                lane.status = "Working".into();
907                lane.note = "spawned".into();
908                lane.model = model.clone();
909                let s = match role.as_str() {
910                    "planner" => LogStyle::Planner,
911                    "coder" => LogStyle::Agent,
912                    "verifier" => LogStyle::Verifier,
913                    _ => LogStyle::Dim,
914                };
915                self.open_group(&format!("{} ({})", role, model), s);
916            }
917            Event::AgentStatus {
918                role, note, status, ..
919            } => {
920                if let Some(lanes) = self.swarm_lanes.as_mut() {
921                    let lane = match role.as_str() {
922                        "planner" => &mut lanes.planner,
923                        "coder" => &mut lanes.coder,
924                        "verifier" => &mut lanes.verifier,
925                        _ => &mut lanes.coder,
926                    };
927                    lane.status = format!("{:?}", status);
928                    lane.note = note.clone();
929                }
930                let s = match role.as_str() {
931                    "planner" => LogStyle::Planner,
932                    "coder" => LogStyle::Agent,
933                    "verifier" => LogStyle::Verifier,
934                    _ => LogStyle::Dim,
935                };
936                let icon = match status {
937                    crate::event::AgentStatus::Done => "✓",
938                    crate::event::AgentStatus::Working => "●",
939                    crate::event::AgentStatus::Thinking => "○",
940                    crate::event::AgentStatus::Error => "✗",
941                    _ => "◌",
942                };
943                self.add_line(&format!("{} {} — {}", icon, role, note), s, 1);
944            }
945            Event::CheckpointCreated { id, label, .. } => {
946                for node in &mut self.checkpoints {
947                    node.current = false;
948                }
949                self.checkpoints.push(CheckpointNode {
950                    id: id.0.clone(),
951                    label: label.clone(),
952                    current: true,
953                });
954                self.add_line(&format!("● checkpoint: {}", label), LogStyle::Gold, 0)
955            }
956            Event::SkillLearned { name, .. } => {
957                self.toast = Some(Toast {
958                    text: format!("✦ skill learned · {}", name),
959                    age: 0,
960                    max_age: 90,
961                });
962                self.add_line(&format!("✦ skill learned · {}", name), LogStyle::Agent, 0)
963            }
964            Event::CostUpdate { usd, .. } => {
965                if *usd > self.last_cost {
966                    self.cost_flash_frames = 12;
967                }
968                self.last_cost = *usd;
969                self.cost_usd = *usd;
970            }
971            Event::TokenUsage { input, output, .. } => {
972                self.total_tokens += input + output;
973                if self.total_tokens > self.last_tokens {
974                    self.tok_flash_frames = 12;
975                }
976                self.last_tokens = self.total_tokens;
977            }
978            Event::TokenUsageEstimated { input, output, .. } => {
979                self.total_tokens += input + output;
980                if self.total_tokens > self.last_tokens {
981                    self.tok_flash_frames = 12;
982                }
983                self.last_tokens = self.total_tokens;
984            }
985            Event::AutonomyChanged { level, .. } => {
986                self.autonomy = format!("{:?}", level).to_lowercase()
987            }
988            Event::DiffProposed {
989                file,
990                patch,
991                plus,
992                minus,
993                ..
994            } => {
995                if self.pending_diffs.len() >= 3 {
996                    self.pending_diffs.pop_front();
997                }
998                self.pending_diffs.push_back(DiffEntry {
999                    file: file.clone(),
1000                    plus: *plus,
1001                    minus: *minus,
1002                    lines: parse_diff_patch(patch),
1003                    applied: false,
1004                });
1005                self.add_line(
1006                    &format!("◇ {}  +{} / -{}  · proposed", file, plus, minus),
1007                    LogStyle::Dim,
1008                    0,
1009                )
1010            }
1011            Event::DiffApplied { file, .. } => {
1012                if let Some(entry) = self.pending_diffs.iter_mut().find(|d| d.file == *file) {
1013                    entry.applied = true;
1014                }
1015                while self.pending_diffs.front().is_some_and(|d| d.applied) {
1016                    self.pending_diffs.pop_front();
1017                }
1018            }
1019            Event::TestResult {
1020                passed,
1021                failed,
1022                detail,
1023                ..
1024            } => {
1025                if *failed > 0 {
1026                    self.add_line(
1027                        &format!("⚠ tests  {} passed · {} failed", passed, failed),
1028                        LogStyle::Warn,
1029                        1,
1030                    );
1031                    for line in detail.lines() {
1032                        self.add_line(&format!("  {}", line), LogStyle::Rem, 2);
1033                    }
1034                } else {
1035                    self.add_line(
1036                        &format!("✓ tests  {} passed · no regressions", passed),
1037                        LogStyle::Ok,
1038                        1,
1039                    );
1040                }
1041            }
1042            Event::RunFinished { outcome, .. } => {
1043                // Recover any text held by the think-stripper (unclosed <think>).
1044                let tail = self.think.flush();
1045                if !tail.trim().is_empty() {
1046                    self.add_line(&tail, LogStyle::Cmd, 1);
1047                }
1048                self.close_group();
1049                if self.simple {
1050                    // Human sentence + one cost line in centimes, no token
1051                    // jargon, no competitor table.
1052                    if let Some(line) = crate::humanize::humanize(&event, self.lang) {
1053                        self.add_line(&line, LogStyle::Ok, 0);
1054                    }
1055                    let usd = outcome.cost_usd;
1056                    let cost_line = if usd <= 0.0 {
1057                        "C'était gratuit.".to_string()
1058                    } else if usd < 0.01 {
1059                        "Coût : moins d'un centime.".to_string()
1060                    } else {
1061                        format!("Coût : environ {:.0} centimes.", usd * 100.0)
1062                    };
1063                    self.add_line(&cost_line, LogStyle::Dim, 1);
1064                } else {
1065                    self.add_line(
1066                        &format!(
1067                            "✓ done  status: {}  cost: ${:.4}",
1068                            outcome.status, outcome.cost_usd
1069                        ),
1070                        LogStyle::Ok,
1071                        0,
1072                    );
1073                    // Cost comparison — Sparrow's moat
1074                    if outcome.tokens.input > 0 || outcome.tokens.output > 0 {
1075                        let comparison =
1076                            crate::cost::format_comparison(outcome.cost_usd, &outcome.tokens);
1077                        for line in comparison.lines().skip(1) {
1078                            // skip the "── Cost ──" header, show data lines
1079                            if !line.is_empty() && !line.starts_with("──") {
1080                                let style = if line.contains("Sparrow") {
1081                                    LogStyle::Ok
1082                                } else if line.contains("💡") {
1083                                    LogStyle::Warn
1084                                } else {
1085                                    LogStyle::Rem
1086                                };
1087                                self.add_line(line, style, 1);
1088                            }
1089                        }
1090                    }
1091                }
1092            }
1093            Event::Error { message, .. } => {
1094                if !crate::event::is_local_model_unavailable(message) {
1095                    self.add_line(message, LogStyle::Err, 0);
1096                }
1097            }
1098            Event::UpdateAvailable {
1099                current,
1100                latest,
1101                install_cmd,
1102                ..
1103            } => {
1104                self.add_line(
1105                    &format!(
1106                        "📦 Sparrow v{} available (current: v{}). Run: {}",
1107                        latest, current, install_cmd
1108                    ),
1109                    LogStyle::Warn,
1110                    0,
1111                );
1112            }
1113            _ => {}
1114        }
1115    }
1116
1117    fn add_line(&mut self, text: &str, style: LogStyle, indent: u16) {
1118        let group = self.current_group;
1119        for line in text.lines() {
1120            self.lines.push(LogLine {
1121                text: line.to_string(),
1122                style,
1123                indent,
1124                group,
1125                header_for: None,
1126            });
1127        }
1128    }
1129
1130    /// Open a new collapsible task group; subsequent `add_line` calls attach to it.
1131    fn open_group(&mut self, title: &str, style: LogStyle) {
1132        let id = self.groups.len();
1133        self.groups.push(TaskGroup {
1134            title: title.to_string(),
1135            collapsed: false,
1136            style,
1137        });
1138        self.lines.push(LogLine {
1139            text: title.to_string(),
1140            style,
1141            indent: 0,
1142            group: None,
1143            header_for: Some(id),
1144        });
1145        self.current_group = Some(id);
1146        self.focus_group = Some(id);
1147    }
1148
1149    /// Close the active group (subsequent lines go top-level).
1150    fn close_group(&mut self) {
1151        self.current_group = None;
1152    }
1153
1154    /// Number of child lines belonging to a group (for the "N hidden" hint).
1155    fn group_child_count(&self, id: usize) -> usize {
1156        self.lines.iter().filter(|l| l.group == Some(id)).count()
1157    }
1158
1159    /// Move focus to the previous/next group header.
1160    fn focus_group_step(&mut self, forward: bool) {
1161        if self.groups.is_empty() {
1162            return;
1163        }
1164        let last = self.groups.len() - 1;
1165        self.focus_group = Some(match self.focus_group {
1166            None => last,
1167            Some(i) if forward => (i + 1).min(last),
1168            Some(i) => i.saturating_sub(1),
1169        });
1170    }
1171
1172    /// Toggle collapse on the focused group, or all groups if none focused.
1173    fn toggle_group(&mut self) {
1174        match self.focus_group {
1175            Some(i) if i < self.groups.len() => {
1176                self.groups[i].collapsed = !self.groups[i].collapsed;
1177            }
1178            _ => {
1179                let any_open = self.groups.iter().any(|g| !g.collapsed);
1180                for g in &mut self.groups {
1181                    g.collapsed = any_open;
1182                }
1183            }
1184        }
1185    }
1186
1187    fn boot(&mut self) {
1188        self.add_line(
1189            concat!(
1190                "SPARROW  v",
1191                env!("CARGO_PKG_VERSION"),
1192                " — one cli · grows with you"
1193            ),
1194            LogStyle::Dim,
1195            0,
1196        );
1197        self.add_line("", LogStyle::Normal, 0);
1198
1199        // Honest, platform-aware sandbox status. seccomp/namespaces are Linux-only;
1200        // on other platforms we run with workspace path-boundary enforcement only.
1201        #[cfg(target_os = "linux")]
1202        let sandbox_line = "local-hardened · namespaces + path boundary";
1203        #[cfg(not(target_os = "linux"))]
1204        let sandbox_line = "path-boundary enforcement (namespaces are Linux-only)";
1205
1206        let boot = [
1207            (
1208                "router  ",
1209                "model routing + fallback chain",
1210                LogStyle::Planner,
1211            ),
1212            (
1213                "surfaces",
1214                "cli · tui · webview · gateway",
1215                LogStyle::Planner,
1216            ),
1217            ("sandbox ", sandbox_line, LogStyle::Ok),
1218            (
1219                "skills  ",
1220                "library indexed · self-improving",
1221                LogStyle::Accent,
1222            ),
1223            (
1224                "memory  ",
1225                "sqlite · bounded docs · session search",
1226                LogStyle::Ok,
1227            ),
1228            (
1229                "autonomy",
1230                "dial: supervised → trusted → autonomous",
1231                LogStyle::Accent,
1232            ),
1233        ];
1234        for (k, v, s) in &boot {
1235            self.add_line(&format!("{}  {}", k, v), *s, 1);
1236        }
1237        self.add_line("✓ ready  one binary. no dependencies.", LogStyle::Ok, 0);
1238        self.add_line("", LogStyle::Normal, 0);
1239        self.booted = true;
1240    }
1241
1242    pub fn run(&mut self) -> io::Result<()> {
1243        // Windows: force the console code page to UTF-8 (65001) BEFORE we
1244        // enter the alternate screen. Without this the default CP1252/OEM
1245        // mangles every multi-byte glyph the TUI emits (•, ·, ∘, →, box-
1246        // drawing) into "â", "·" garbage and visibly drops bytes inside
1247        // ASCII strings, producing "binana"/"versioo" output.
1248        ensure_utf8_console();
1249        enable_raw_mode()?;
1250        let mut stdout = io::stdout();
1251        execute!(stdout, EnterAlternateScreen)?;
1252        let backend = ratatui::backend::CrosstermBackend::new(stdout);
1253        let mut terminal = ratatui::Terminal::new(backend)?;
1254        // Wipe any residue from the parent shell so ratatui starts on a
1255        // clean buffer (otherwise stray dots from the previous prompt show
1256        // up over empty panel areas).
1257        terminal.clear()?;
1258        let result = self.main_loop(&mut terminal);
1259        disable_raw_mode()?;
1260        execute!(io::stdout(), LeaveAlternateScreen)?;
1261        result
1262    }
1263
1264    fn main_loop(&mut self, terminal: &mut CrosstermTerminal) -> io::Result<()> {
1265        let start = Instant::now();
1266        if self.replay_events.is_some() {
1267            self.rebuild_replay();
1268        }
1269        loop {
1270            self.drain_engine_events();
1271            self.frame += 1;
1272            self.spinner_idx = (self.spinner_idx + 1) % 10;
1273            self.tick_visuals();
1274            terminal.draw(|f| self.render(f, start.elapsed().as_secs_f64()))?;
1275            if event::poll(std::time::Duration::from_millis(50))? {
1276                if let TermEvent::Key(key) = event::read()? {
1277                    if key.kind != KeyEventKind::Press {
1278                        continue;
1279                    }
1280                    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1281                    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1282                    match key.code {
1283                        KeyCode::Esc => break,
1284                        KeyCode::Char('c') if ctrl => break,
1285
1286                        // ── Replay scrubber (active only in replay mode) ─────
1287                        KeyCode::Char('q') if self.replay_events.is_some() => break,
1288                        KeyCode::Left if self.replay_events.is_some() => {
1289                            self.replay_idx = self.replay_idx.saturating_sub(1);
1290                            self.rebuild_replay();
1291                        }
1292                        KeyCode::Right if self.replay_events.is_some() => {
1293                            let max = self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1294                            self.replay_idx = (self.replay_idx + 1).min(max);
1295                            self.rebuild_replay();
1296                        }
1297                        KeyCode::Home if self.replay_events.is_some() => {
1298                            self.replay_idx = 0;
1299                            self.rebuild_replay();
1300                        }
1301                        KeyCode::End if self.replay_events.is_some() => {
1302                            self.replay_idx =
1303                                self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1304                            self.rebuild_replay();
1305                        }
1306
1307                        // Ctrl+L → clear log buffer and related group state
1308                        KeyCode::Char('l') if ctrl => {
1309                            self.clear_log_state();
1310                        }
1311                        // Ctrl+I → next Enter sends as mid-run injection
1312                        KeyCode::Char('i') if ctrl => {
1313                            self.inject_pending = true;
1314                            self.add_line(
1315                                "[inject] next message will be sent to the running agent",
1316                                LogStyle::Warn,
1317                                0,
1318                            );
1319                        }
1320
1321                        // ── Collapsible task groups ──────────────────────────
1322                        // Ctrl+↑/↓ move focus between task headers; Ctrl+O toggles.
1323                        KeyCode::Up if ctrl => self.focus_group_step(false),
1324                        KeyCode::Down if ctrl => self.focus_group_step(true),
1325                        KeyCode::Char('o') if ctrl => self.toggle_group(),
1326
1327                        // History navigation (only when on first row of input)
1328                        KeyCode::Up if self.cursor_row == 0 && !self.history.is_empty() => {
1329                            let new_idx = match self.history_idx {
1330                                None => self.history.len() - 1,
1331                                Some(0) => 0,
1332                                Some(i) => i - 1,
1333                            };
1334                            self.history_idx = Some(new_idx);
1335                            let entry = self.history[new_idx].clone();
1336                            self.set_input(&entry);
1337                        }
1338                        KeyCode::Down if self.cursor_row == self.input_lines.len() - 1 => {
1339                            match self.history_idx {
1340                                Some(i) if i + 1 < self.history.len() => {
1341                                    self.history_idx = Some(i + 1);
1342                                    let entry = self.history[i + 1].clone();
1343                                    self.set_input(&entry);
1344                                }
1345                                Some(_) => {
1346                                    self.history_idx = None;
1347                                    self.set_input("");
1348                                }
1349                                None => {}
1350                            }
1351                        }
1352
1353                        // Scrollback nav with PgUp/PgDn/Home/End
1354                        KeyCode::PageUp => self.scroll = self.scroll.saturating_add(10),
1355                        KeyCode::PageDown => self.scroll = self.scroll.saturating_sub(10),
1356                        KeyCode::Home => self.scroll = 0,
1357                        KeyCode::End => self.scroll = u16::MAX,
1358
1359                        // Tab → autocomplete or toggle agent
1360                        KeyCode::Tab => {
1361                            let line = self
1362                                .input_lines
1363                                .get(self.cursor_row)
1364                                .map(String::as_str)
1365                                .unwrap_or("");
1366                            // @agent → toggle, not insert
1367                            if let Some(rest) = line.strip_prefix('@') {
1368                                let name = &rest.trim().to_string();
1369                                if !name.is_empty() && self.agent_names.contains(name) {
1370                                    self.toggle_agent(name);
1371                                    self.input_lines = vec![String::new()];
1372                                    self.cursor_row = 0;
1373                                    self.cursor_col = 0;
1374                                }
1375                            } else {
1376                                let matches = self.autocomplete_matches();
1377                                if let Some(first) = matches.first() {
1378                                    if let Some(line) = self.input_lines.get_mut(self.cursor_row) {
1379                                        *line = first.to_string();
1380                                    } else {
1381                                        self.input_lines = vec![first.to_string()];
1382                                        self.cursor_row = 0;
1383                                    }
1384                                    self.cursor_col = first.len();
1385                                }
1386                            }
1387                        }
1388
1389                        // Backspace: handle multiline correctly
1390                        KeyCode::Backspace => {
1391                            if self.cursor_col > 0 {
1392                                let line = &mut self.input_lines[self.cursor_row];
1393                                let new_col = line[..self.cursor_col]
1394                                    .char_indices()
1395                                    .last()
1396                                    .map(|(i, _)| i)
1397                                    .unwrap_or(0);
1398                                line.replace_range(new_col..self.cursor_col, "");
1399                                self.cursor_col = new_col;
1400                            } else if self.cursor_row > 0 {
1401                                // join with previous line
1402                                let curr = self.input_lines.remove(self.cursor_row);
1403                                self.cursor_row -= 1;
1404                                let prev = &mut self.input_lines[self.cursor_row];
1405                                self.cursor_col = prev.len();
1406                                prev.push_str(&curr);
1407                            }
1408                        }
1409
1410                        // Shift+Enter or Alt+Enter → newline
1411                        KeyCode::Enter if shift || key.modifiers.contains(KeyModifiers::ALT) => {
1412                            let line = &mut self.input_lines[self.cursor_row];
1413                            let rest = line.split_off(self.cursor_col);
1414                            self.cursor_row += 1;
1415                            self.cursor_col = 0;
1416                            self.input_lines.insert(self.cursor_row, rest);
1417                        }
1418
1419                        // Enter → submit
1420                        KeyCode::Enter => {
1421                            let task = self.current_input().trim().to_string();
1422                            if !task.is_empty() {
1423                                // Handle in-TUI commands
1424                                match task.as_str() {
1425                                    "/clear" => {
1426                                        self.clear_log_state();
1427                                    }
1428                                    "/collapse" => {
1429                                        for g in &mut self.groups {
1430                                            g.collapsed = true;
1431                                        }
1432                                    }
1433                                    "/expand" => {
1434                                        for g in &mut self.groups {
1435                                            g.collapsed = false;
1436                                        }
1437                                    }
1438                                    "/exit" | "/quit" => break,
1439                                    "/help" => {
1440                                        self.add_line("Commands:", LogStyle::Brand, 0);
1441                                        for c in SLASH_COMMANDS {
1442                                            self.add_line(c, LogStyle::Dim, 1);
1443                                        }
1444                                        self.add_line("Local shortcuts:", LogStyle::Brand, 0);
1445                                        self.add_line(
1446                                            "Esc/Ctrl+C quit · Ctrl+I inject · Ctrl+L clear · Ctrl+↑/↓ focus task · Ctrl+O fold/unfold",
1447                                            LogStyle::Dim,
1448                                            1,
1449                                        );
1450                                        self.add_line(
1451                                            "Shift+Enter newline · Up/Down history · PgUp/PgDn scroll · Tab autocomplete/agent toggle",
1452                                            LogStyle::Dim,
1453                                            1,
1454                                        );
1455                                        self.add_line(
1456                                            "/collapse · /expand — fold/unfold all tasks",
1457                                            LogStyle::Dim,
1458                                            1,
1459                                        );
1460                                    }
1461                                    s if s.starts_with("/plan") => {
1462                                        let planned = s.trim_start_matches("/plan").trim();
1463                                        if planned.is_empty() {
1464                                            self.add_line("Usage: /plan <task>", LogStyle::Warn, 0);
1465                                        } else {
1466                                            let plan =
1467                                                crate::plan::build_read_only_plan(planned, &[]);
1468                                            self.add_line(
1469                                                "Read-only plan · no tools or edits executed",
1470                                                LogStyle::Planner,
1471                                                0,
1472                                            );
1473                                            self.add_line(&plan.summary, LogStyle::Dim, 1);
1474                                            for (idx, step) in plan.steps.iter().enumerate() {
1475                                                self.add_line(
1476                                                    &format!("{}. {}", idx + 1, step),
1477                                                    LogStyle::Cmd,
1478                                                    1,
1479                                                );
1480                                            }
1481                                            self.add_line(
1482                                                "Run the task explicitly when you accept the plan.",
1483                                                LogStyle::Warn,
1484                                                0,
1485                                            );
1486                                        }
1487                                    }
1488                                    _ => {
1489                                        // Send to engine
1490                                        let label = if self.inject_pending {
1491                                            "inject"
1492                                        } else {
1493                                            "sparrow"
1494                                        };
1495                                        self.add_line(
1496                                            &format!("{} › {}", label, task.replace('\n', " ↵ ")),
1497                                            LogStyle::Prompt,
1498                                            0,
1499                                        );
1500                                        self.push_history(&task);
1501                                        let to_send = if self.inject_pending {
1502                                            format!("__inject__:{}", task)
1503                                        } else {
1504                                            let prefix = self.agent_prefix();
1505                                            if prefix.is_empty() {
1506                                                task.clone()
1507                                            } else {
1508                                                format!("{}{}", prefix, task)
1509                                            }
1510                                        };
1511                                        self.inject_pending = false;
1512                                        if let Some(tx) = &self.task_tx {
1513                                            if tx.send(to_send).is_err() {
1514                                                self.add_line(
1515                                                    "runtime channel disconnected",
1516                                                    LogStyle::Err,
1517                                                    0,
1518                                                );
1519                                            }
1520                                        }
1521                                    }
1522                                }
1523                                self.set_input("");
1524                                self.history_idx = None;
1525                            }
1526                        }
1527
1528                        // Regular character → insert at cursor
1529                        KeyCode::Char(c) => {
1530                            let line = &mut self.input_lines[self.cursor_row];
1531                            line.insert(self.cursor_col, c);
1532                            self.cursor_col += c.len_utf8();
1533                        }
1534
1535                        // Cursor movement
1536                        KeyCode::Left => {
1537                            if self.scroll == 0
1538                                && self.cursor_col == 0
1539                                && self.checkpoints.len() > 1
1540                            {
1541                                let previous = self
1542                                    .checkpoints
1543                                    .iter()
1544                                    .rev()
1545                                    .skip(1)
1546                                    .find(|node| !node.id.is_empty())
1547                                    .map(|node| node.id.clone());
1548                                if let (Some(id), Some(tx)) = (previous, &self.task_tx) {
1549                                    let _ = tx.send(format!("__rewind__:{}", id));
1550                                    self.add_line(
1551                                        "rewind requested from checkpoint timeline",
1552                                        LogStyle::Gold,
1553                                        0,
1554                                    );
1555                                }
1556                            } else if self.cursor_col > 0 {
1557                                self.cursor_col = self.input_lines[self.cursor_row]
1558                                    [..self.cursor_col]
1559                                    .char_indices()
1560                                    .last()
1561                                    .map(|(i, _)| i)
1562                                    .unwrap_or(0);
1563                            } else if self.cursor_row > 0 {
1564                                self.cursor_row -= 1;
1565                                self.cursor_col = self.input_lines[self.cursor_row].len();
1566                            }
1567                        }
1568                        KeyCode::Right => {
1569                            let line = &self.input_lines[self.cursor_row];
1570                            if self.cursor_col < line.len() {
1571                                let next = line[self.cursor_col..]
1572                                    .chars()
1573                                    .next()
1574                                    .map(|c| c.len_utf8())
1575                                    .unwrap_or(0);
1576                                self.cursor_col += next;
1577                            } else if self.cursor_row + 1 < self.input_lines.len() {
1578                                self.cursor_row += 1;
1579                                self.cursor_col = 0;
1580                            }
1581                        }
1582
1583                        _ => {}
1584                    }
1585                }
1586            }
1587        }
1588        Ok(())
1589    }
1590
1591    fn tick_visuals(&mut self) {
1592        if !self.booted {
1593            self.boot_progress = self.boot_progress.saturating_add(1);
1594            if self.boot_progress >= 70 {
1595                self.boot();
1596            }
1597        }
1598        if self.cost_flash_frames > 0 {
1599            self.cost_flash_frames -= 1;
1600        }
1601        if self.tok_flash_frames > 0 {
1602            self.tok_flash_frames -= 1;
1603        }
1604        if let Some(toast) = self.toast.as_mut() {
1605            toast.age = toast.age.saturating_add(1);
1606            if toast.age >= toast.max_age {
1607                self.toast = None;
1608            }
1609        }
1610    }
1611
1612    fn drain_engine_events(&mut self) {
1613        let mut disconnected = false;
1614        let mut events = Vec::new();
1615        if let Some(rx) = self.event_rx.as_mut() {
1616            loop {
1617                match rx.try_recv() {
1618                    Ok(event) => events.push(event),
1619                    Err(mpsc::error::TryRecvError::Empty) => break,
1620                    Err(mpsc::error::TryRecvError::Disconnected) => {
1621                        disconnected = true;
1622                        break;
1623                    }
1624                }
1625            }
1626        }
1627        for event in events {
1628            self.push_event(event);
1629        }
1630        if disconnected {
1631            self.event_rx = None;
1632            self.add_line("runtime event stream disconnected", LogStyle::Warn, 0);
1633        }
1634    }
1635
1636    fn render(&self, f: &mut Frame, _elapsed: f64) {
1637        let area = f.area();
1638        if !self.booted {
1639            self.render_boot(f, area);
1640            return;
1641        }
1642        // Input height = lines + 1 (top rail) + 1 (autocomplete row if any)
1643        let suggestions = self.autocomplete_matches();
1644        let input_height = (self.input_lines.len() as u16 + 1).max(2)
1645            + if !suggestions.is_empty() { 1 } else { 0 };
1646        let swarm_height = if self.swarm_lanes.is_some() { 4 } else { 0 };
1647        let diff_height = if self.pending_diffs.is_empty() { 0 } else { 12 };
1648        let checkpoint_height = if self.checkpoints.is_empty() { 0 } else { 2 };
1649        let chunks = Layout::default()
1650            .direction(Direction::Vertical)
1651            .constraints([
1652                Constraint::Length(2),
1653                Constraint::Length(swarm_height),
1654                Constraint::Min(0),
1655                Constraint::Length(diff_height),
1656                Constraint::Length(checkpoint_height),
1657                Constraint::Length(1), // keyboard hints
1658                Constraint::Length(input_height),
1659            ])
1660            .split(area);
1661        self.render_cockpit(f, chunks[0]);
1662        if swarm_height > 0 {
1663            self.render_swarm_lanes(f, chunks[1]);
1664        }
1665        self.render_scroll(f, chunks[2]);
1666        if diff_height > 0 {
1667            self.render_diff(f, chunks[3]);
1668        }
1669        if checkpoint_height > 0 {
1670            self.render_checkpoint_timeline(f, chunks[4]);
1671        }
1672        self.render_keyboard_hints(f, chunks[5]);
1673        self.render_input(f, chunks[6]);
1674        self.render_toast(f, area);
1675    }
1676
1677    fn render_boot(&self, f: &mut Frame, area: Rect) {
1678        let mut lines = Vec::new();
1679        let bird_lines: Vec<&str> = theme::ASCII_SPARROW.lines().collect();
1680        let bird_count = ((self.boot_progress / 5) as usize).min(bird_lines.len());
1681        for line in bird_lines.iter().take(bird_count) {
1682            lines.push(Line::from(Span::styled(
1683                *line,
1684                Style::default().fg(self.theme.brand),
1685            )));
1686        }
1687        if self.boot_progress >= 25 {
1688            let wordmark = if self.boot_progress < 35 {
1689                "S  P  A  R  R  O  W"
1690            } else if self.boot_progress < 45 {
1691                "S P A R R O W"
1692            } else {
1693                "SPARROW"
1694            };
1695            lines.push(Line::from(Span::styled(
1696                wordmark,
1697                Style::default()
1698                    .fg(self.theme.brand)
1699                    .add_modifier(Modifier::BOLD),
1700            )));
1701        }
1702        #[cfg(target_os = "linux")]
1703        let sandbox_boot = "sandbox    local-hardened · namespaces armed";
1704        #[cfg(not(target_os = "linux"))]
1705        let sandbox_boot = "sandbox    path-boundary enforcement";
1706        let boot_log = [
1707            "router     warming provider graph",
1708            "surfaces   cli · webview · gateway",
1709            sandbox_boot,
1710            "skills     library indexed",
1711            "memory     sqlite profile loaded",
1712            "autonomy   dial ready",
1713        ];
1714        if self.boot_progress >= 45 {
1715            let count = (((self.boot_progress - 45) / 4) as usize).min(boot_log.len());
1716            for item in boot_log.iter().take(count) {
1717                lines.push(Line::from(Span::styled(
1718                    *item,
1719                    Style::default().fg(self.theme.dim),
1720                )));
1721            }
1722        }
1723        if self.boot_progress >= 68 {
1724            lines.push(Line::from(Span::styled(
1725                "✓ ready",
1726                Style::default()
1727                    .fg(self.theme.add)
1728                    .add_modifier(Modifier::BOLD),
1729            )));
1730        }
1731        let height = lines.len() as u16;
1732        let width = area.width.min(72);
1733        let rect = Rect {
1734            x: area.x + area.width.saturating_sub(width) / 2,
1735            y: area.y + area.height.saturating_sub(height.max(1)) / 2,
1736            width,
1737            height: height.max(1),
1738        };
1739        f.render_widget(Paragraph::new(Text::from(lines)), rect);
1740    }
1741
1742    fn render_cockpit(&self, f: &mut Frame, area: Rect) {
1743        let aut_color = match self.autonomy.as_str() {
1744            "autonomous" => self.theme.autonomous,
1745            "trusted" => self.theme.trusted,
1746            _ => self.theme.supervised,
1747        };
1748
1749        // Spinner frame + flight verb cycling every ~25 frames (~1.25 s at 50 ms)
1750        let spinner = self.theme.spinner_frame(self.spinner_idx);
1751        let verb = self.theme.flight_verb(self.frame as usize / 25);
1752
1753        // LED for autonomy pill: pulse between ● and ◉ every 8 frames
1754        let led = if self.frame / 8 % 2 == 0 {
1755            "●"
1756        } else {
1757            "◉"
1758        };
1759
1760        // ── Right HUD zone (cost · tokens · autonomy pill) ────────────────
1761        // This block carries the load-bearing numbers — spend, token burn and
1762        // the autonomy level. It is laid out in its own right-aligned chunk so
1763        // it stays visible even on an 80-column terminal; only the route (left
1764        // zone) truncates when space is tight, never the budget readout.
1765        let cost_str = if self.cost_usd > 0.0 {
1766            format!("${:.4} ▲  ", self.cost_usd)
1767        } else {
1768            format!("${:.4}  ", self.cost_usd)
1769        };
1770        let tok_str = format!("{} tok  ", self.total_tokens);
1771        let aut_upper = self.autonomy.to_uppercase();
1772        // Reserve the plain-text width of the right zone (+1 leading gap).
1773        let right_w = (cost_str.chars().count()
1774            + tok_str.chars().count()
1775            + 2 // led + space
1776            + aut_upper.chars().count()
1777            + 1) as u16;
1778
1779        let right = Line::from(vec![
1780            Span::styled(
1781                cost_str,
1782                if self.cost_flash_frames > 0 {
1783                    Style::default()
1784                        .fg(self.theme.gold)
1785                        .add_modifier(Modifier::BOLD)
1786                } else {
1787                    Style::default().fg(self.theme.brand)
1788                },
1789            ),
1790            // tokens
1791            Span::styled(
1792                tok_str,
1793                if self.tok_flash_frames > 0 {
1794                    Style::default()
1795                        .fg(self.theme.gold)
1796                        .add_modifier(Modifier::BOLD)
1797                } else {
1798                    Style::default().fg(self.theme.steel)
1799                },
1800            ),
1801            // autonomy pill with pulsing LED
1802            Span::styled(
1803                format!("{} ", led),
1804                Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1805            ),
1806            Span::styled(
1807                aut_upper,
1808                Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1809            ),
1810        ]);
1811
1812        // Draw a single rail instead of a boxed panel so the TUI reads like a
1813        // code assistant cockpit rather than a decorative terminal frame.
1814        let block = Block::default()
1815            .borders(Borders::BOTTOM)
1816            .border_style(Style::default().fg(self.theme.line));
1817        let inner = block.inner(area);
1818        f.render_widget(block, area);
1819        let zones = Layout::default()
1820            .direction(Direction::Horizontal)
1821            .constraints([Constraint::Min(0), Constraint::Length(right_w)])
1822            .split(inner);
1823
1824        // ── Left zone (spinner · wordmark · verb · agent · route) ─────────
1825        // The route is the flexible element: rather than let the paragraph clip
1826        // it mid-word, pre-truncate it with an ellipsis to the space the left
1827        // zone actually has, so narrow terminals read "…coder" not "qwen2.5-cod".
1828        let agent_badge = match &self.active_agent {
1829            // 🐦 renders two cells wide; count it as 2 for the width budget.
1830            Some(agent) => format!("🐦 {}  ", agent.to_uppercase()),
1831            None => String::new(),
1832        };
1833        let agent_w = if agent_badge.is_empty() {
1834            0
1835        } else {
1836            agent_badge.chars().count() + 1 // +1 for the wide bird glyph
1837        };
1838        // Fixed left prefix: spinner(2) + "SPARROW  "(9) + verb field(11) +
1839        // agent badge + "route: "(7).
1840        let prefix_w = 2 + 9 + 11 + agent_w + 7;
1841        let route_budget = (zones[0].width as usize).saturating_sub(prefix_w);
1842        let route_disp = truncate_for_width(&self.route, route_budget);
1843        let left = Line::from(vec![
1844            Span::styled(
1845                format!("{} ", spinner),
1846                Style::default()
1847                    .fg(self.theme.brand)
1848                    .add_modifier(Modifier::BOLD),
1849            ),
1850            Span::styled(
1851                "SPARROW  ",
1852                Style::default()
1853                    .fg(self.theme.brand)
1854                    .add_modifier(Modifier::BOLD),
1855            ),
1856            Span::styled(
1857                format!("{:<9}  ", verb),
1858                Style::default().fg(self.theme.dim),
1859            ),
1860            Span::styled(
1861                agent_badge,
1862                Style::default()
1863                    .fg(self.theme.gold)
1864                    .add_modifier(Modifier::BOLD),
1865            ),
1866            Span::styled(
1867                format!("route: {}", route_disp),
1868                Style::default().fg(self.theme.planner),
1869            ),
1870        ]);
1871        f.render_widget(Paragraph::new(left), zones[0]);
1872        f.render_widget(
1873            Paragraph::new(right).alignment(ratatui::layout::Alignment::Right),
1874            zones[1],
1875        );
1876    }
1877
1878    fn render_swarm_lanes(&self, f: &mut Frame, area: Rect) {
1879        let Some(lanes) = &self.swarm_lanes else {
1880            return;
1881        };
1882        let age = self.frame.saturating_sub(lanes.started_at_frame);
1883        let items = [
1884            ("planner", &lanes.planner, self.theme.planner),
1885            ("coder", &lanes.coder, self.theme.agent),
1886            ("verifier", &lanes.verifier, self.theme.verifier),
1887        ];
1888        let mut lines = vec![Line::from(vec![
1889            Span::styled("agents", Style::default().fg(self.theme.dimmer)),
1890            Span::styled(" · ", Style::default().fg(self.theme.dimmer)),
1891            Span::styled(
1892                format!("swarm {}s", age.min(99)),
1893                Style::default().fg(self.theme.dimmer),
1894            ),
1895        ])];
1896
1897        for (role, lane, color) in items {
1898            let working = lane.status == "Working" || lane.status == "Thinking";
1899            let icon = match lane.status.as_str() {
1900                "Done" => "✓",
1901                "Error" => "✗",
1902                "Idle" => "◌",
1903                _ if self.frame / 8 % 2 == 0 => "●",
1904                _ => "○",
1905            };
1906            let caret = if working && self.frame / 8 % 2 == 0 {
1907                " ▌"
1908            } else {
1909                ""
1910            };
1911            let fixed_width = 2 + 10 + 20 + 14;
1912            let note_width = area.width.saturating_sub(fixed_width) as usize;
1913            let note = truncate_for_width(&lane.note, note_width);
1914            lines.push(Line::from(vec![
1915                Span::styled("  ", Style::default().fg(self.theme.dimmer)),
1916                Span::styled(
1917                    format!("{} ", icon),
1918                    Style::default().fg(if working { self.theme.gold } else { color }),
1919                ),
1920                Span::styled(
1921                    format!("{:<9}", role),
1922                    Style::default().fg(color).add_modifier(Modifier::BOLD),
1923                ),
1924                Span::styled(
1925                    truncate_for_width(&lane.model, 18),
1926                    Style::default().fg(self.theme.dim),
1927                ),
1928                Span::styled(" · ", Style::default().fg(self.theme.dimmer)),
1929                Span::styled(
1930                    format!("{}{}", lane.status.to_lowercase(), caret),
1931                    Style::default().fg(if working {
1932                        self.theme.gold
1933                    } else {
1934                        self.theme.dim
1935                    }),
1936                ),
1937                Span::styled(" · ", Style::default().fg(self.theme.dimmer)),
1938                Span::styled(note, Style::default().fg(self.theme.fg)),
1939            ]));
1940        }
1941
1942        f.render_widget(
1943            Paragraph::new(Text::from(lines)).block(Block::default().padding(
1944                ratatui::widgets::Padding {
1945                    left: 1,
1946                    right: 1,
1947                    top: 0,
1948                    bottom: 0,
1949                },
1950            )),
1951            area,
1952        );
1953    }
1954
1955    fn render_scroll(&self, f: &mut Frame, area: Rect) {
1956        let max_lines = area.height.saturating_sub(1) as usize;
1957        if max_lines == 0 {
1958            return;
1959        }
1960        // Filter out child lines of collapsed groups; render headers as toggles.
1961        let rendered: Vec<Line> = self
1962            .lines
1963            .iter()
1964            .filter_map(|log| {
1965                // Hide children of collapsed groups
1966                if let Some(g) = log.group {
1967                    if self.groups.get(g).map(|gr| gr.collapsed).unwrap_or(false) {
1968                        return None;
1969                    }
1970                }
1971                if let Some(gid) = log.header_for {
1972                    // Collapsible header: ▾ expanded / ▸ collapsed + child count + focus mark
1973                    let gr = self.groups.get(gid);
1974                    let collapsed = gr.map(|g| g.collapsed).unwrap_or(false);
1975                    let title = gr.map(|g| g.title.as_str()).unwrap_or(log.text.as_str());
1976                    let log_style = gr.map(|g| g.style).unwrap_or(log.style);
1977                    let arrow = if collapsed { "▸" } else { "▾" };
1978                    let focused = self.focus_group == Some(gid);
1979                    let n = self.group_child_count(gid);
1980                    let hint = if collapsed && n > 0 {
1981                        format!("  ({} hidden)", n)
1982                    } else {
1983                        String::new()
1984                    };
1985                    let marker = if focused { "‣ " } else { "  " };
1986                    let mut style = Style::default().fg(log_style.color(&self.theme));
1987                    if focused {
1988                        style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
1989                    }
1990                    Some(Line::from(Span::styled(
1991                        format!("{}{} {}{}", marker, arrow, title, hint),
1992                        style,
1993                    )))
1994                } else {
1995                    let formatted = self.format_line(&log.text);
1996                    let prefix = "  ".repeat(log.indent as usize);
1997                    let rendered_line = crate::tui::ansi_bridge::render_line(
1998                        &formatted,
1999                        Style::default().fg(log.style.color(&self.theme)),
2000                    );
2001                    // Prepend indent prefix
2002                    let mut final_spans =
2003                        vec![Span::styled(prefix, Style::default().fg(self.theme.dim))];
2004                    final_spans.extend(rendered_line.spans);
2005                    Some(Line::from(final_spans))
2006                }
2007            })
2008            .collect();
2009
2010        let total = rendered.len();
2011        let skip = (self.scroll as usize).min(total.saturating_sub(1));
2012        let mut text_lines: Vec<Line> = Vec::new();
2013        text_lines.push(Line::from(vec![
2014            Span::styled("SPARROW", Style::default().fg(self.theme.brand)),
2015            Span::styled(" › ", Style::default().fg(self.theme.dimmer)),
2016            Span::styled("session", Style::default().fg(self.theme.dim)),
2017        ]));
2018        let remaining = max_lines.saturating_sub(text_lines.len());
2019        let start = total.saturating_sub(skip).saturating_sub(remaining);
2020        let end = total.saturating_sub(skip);
2021        text_lines.extend(rendered[start..end].iter().cloned());
2022        f.render_widget(
2023            Paragraph::new(Text::from(text_lines)).block(Block::default().padding(
2024                ratatui::widgets::Padding {
2025                    left: 1,
2026                    right: 1,
2027                    top: 0,
2028                    bottom: 0,
2029                },
2030            )),
2031            area,
2032        );
2033    }
2034
2035    fn render_diff(&self, f: &mut Frame, area: Rect) {
2036        let Some(diff) = self.pending_diffs.back() else {
2037            return;
2038        };
2039        let mut lines = vec![Line::from(vec![
2040            Span::styled("◇ ", Style::default().fg(self.theme.gold)),
2041            Span::styled(
2042                truncate_for_width(&diff.file, area.width.saturating_sub(20) as usize),
2043                Style::default()
2044                    .fg(self.theme.brand)
2045                    .add_modifier(Modifier::BOLD),
2046            ),
2047            Span::styled(
2048                format!("  +{} / -{}  · proposed", diff.plus, diff.minus),
2049                Style::default().fg(self.theme.dim),
2050            ),
2051        ])];
2052        for (idx, line) in diff
2053            .lines
2054            .iter()
2055            .take(area.height.saturating_sub(3) as usize)
2056            .enumerate()
2057        {
2058            let color = match line.kind {
2059                DiffLineKind::Plus => self.theme.add,
2060                DiffLineKind::Minus => self.theme.rem,
2061                DiffLineKind::Hunk => self.theme.gold,
2062                DiffLineKind::Context => self.theme.dim,
2063            };
2064            let mut spans = vec![Span::styled(
2065                format!("{:>4} ", idx + 1),
2066                Style::default().fg(self.theme.dimmer),
2067            )];
2068            spans.extend(syntax_spans(&line.text, &self.theme, color));
2069            lines.push(Line::from(spans));
2070        }
2071        f.render_widget(
2072            Paragraph::new(Text::from(lines)).block(
2073                Block::default()
2074                    .borders(Borders::TOP)
2075                    .border_style(Style::default().fg(self.theme.line)),
2076            ),
2077            area,
2078        );
2079    }
2080
2081    fn render_checkpoint_timeline(&self, f: &mut Frame, area: Rect) {
2082        let mut spans = Vec::new();
2083        for (idx, node) in self
2084            .checkpoints
2085            .iter()
2086            .rev()
2087            .take(8)
2088            .collect::<Vec<_>>()
2089            .iter()
2090            .rev()
2091            .enumerate()
2092        {
2093            if idx > 0 {
2094                spans.push(Span::styled("──", Style::default().fg(self.theme.dimmer)));
2095            }
2096            spans.push(Span::styled(
2097                if node.current { "●" } else { "◆" },
2098                Style::default().fg(if node.current {
2099                    self.theme.gold
2100                } else {
2101                    self.theme.dim
2102                }),
2103            ));
2104        }
2105        if let Some(current) = self.checkpoints.iter().find(|n| n.current) {
2106            spans.push(Span::styled(
2107                format!(
2108                    "  {} · {}",
2109                    truncate_for_width(&current.label, 36),
2110                    current.id.chars().take(8).collect::<String>()
2111                ),
2112                Style::default().fg(self.theme.dim),
2113            ));
2114        }
2115        spans.push(Span::styled(
2116            "    rewind ← · snapshot before each batch",
2117            Style::default().fg(self.theme.dimmer),
2118        ));
2119        f.render_widget(Paragraph::new(Line::from(spans)), area);
2120    }
2121
2122    fn render_toast(&self, f: &mut Frame, area: Rect) {
2123        let Some(toast) = &self.toast else {
2124            return;
2125        };
2126        let width = (toast.text.chars().count() as u16 + 6).min(area.width.saturating_sub(2));
2127        if width < 8 || area.height < 5 {
2128            return;
2129        }
2130        let rect = Rect {
2131            x: area.x + area.width.saturating_sub(width) / 2,
2132            y: area.y + area.height.saturating_sub(3) / 2,
2133            width,
2134            height: 3,
2135        };
2136        let border = if toast.age / 20 % 2 == 0 {
2137            Style::default()
2138                .fg(self.theme.gold)
2139                .add_modifier(Modifier::BOLD)
2140        } else {
2141            Style::default().fg(self.theme.gold)
2142        };
2143        f.render_widget(
2144            Paragraph::new(Line::from(Span::styled(
2145                toast.text.as_str(),
2146                Style::default()
2147                    .fg(self.theme.gold)
2148                    .add_modifier(Modifier::BOLD),
2149            )))
2150            .block(Block::default().borders(Borders::ALL).border_style(border)),
2151            rect,
2152        );
2153    }
2154
2155    fn render_keyboard_hints(&self, f: &mut Frame, area: Rect) {
2156        let hints = if area.width < 72 {
2157            "Esc quit  Tab complete  / commands  Ctrl+L clear"
2158        } else if area.width < 112 {
2159            "Esc/Ctrl+C quit  Tab complete  / commands  Ctrl+L clear  PgUp/PgDn scroll"
2160        } else {
2161            "Esc/Ctrl+C quit  Tab complete/agents  / commands  Ctrl+L clear  Ctrl+I inject  PgUp/PgDn scroll"
2162        };
2163        let line = Line::from(Span::styled(hints, Style::default().fg(self.theme.dimmer)));
2164        f.render_widget(
2165            Paragraph::new(line).alignment(ratatui::layout::Alignment::Left),
2166            area,
2167        );
2168    }
2169
2170    fn render_input(&self, f: &mut Frame, area: Rect) {
2171        let cursor_char = if self.frame / 8 % 2 == 0 { "▌" } else { " " };
2172        let prompt = if self.inject_pending {
2173            "◆ inject › "
2174        } else {
2175            "◆ sparrow › "
2176        };
2177        let prompt_color = if self.inject_pending {
2178            self.theme.coral
2179        } else {
2180            self.theme.brand
2181        };
2182
2183        let mut text_lines: Vec<Line> = Vec::new();
2184        for (row_idx, line) in self.input_lines.iter().enumerate() {
2185            let mut spans: Vec<Span> = Vec::new();
2186            if row_idx == 0 {
2187                spans.push(Span::styled(
2188                    prompt,
2189                    Style::default()
2190                        .fg(prompt_color)
2191                        .add_modifier(Modifier::BOLD),
2192                ));
2193            } else {
2194                spans.push(Span::styled(
2195                    "          › ",
2196                    Style::default().fg(self.theme.dimmer),
2197                ));
2198            }
2199            if row_idx == self.cursor_row {
2200                let (before, after) = line.split_at(self.cursor_col.min(line.len()));
2201                spans.push(Span::styled(before, Style::default().fg(self.theme.fg)));
2202                spans.push(Span::styled(cursor_char, Style::default().fg(prompt_color)));
2203                spans.push(Span::styled(after, Style::default().fg(self.theme.fg)));
2204            } else {
2205                spans.push(Span::styled(
2206                    line.as_str(),
2207                    Style::default().fg(self.theme.fg),
2208                ));
2209            }
2210            text_lines.push(Line::from(spans));
2211        }
2212
2213        // Autocomplete row (suggestions)
2214        let suggestions = self.autocomplete_matches();
2215        if !suggestions.is_empty() {
2216            let mut s: Vec<Span> = vec![Span::styled(
2217                "  ⇥  ",
2218                Style::default().fg(self.theme.dimmer),
2219            )];
2220            for (i, cmd) in suggestions.iter().enumerate() {
2221                if i == 0 {
2222                    s.push(Span::styled(
2223                        *cmd,
2224                        Style::default()
2225                            .fg(self.theme.brand)
2226                            .add_modifier(Modifier::BOLD),
2227                    ));
2228                } else {
2229                    s.push(Span::styled(*cmd, Style::default().fg(self.theme.dim)));
2230                }
2231                s.push(Span::raw("  "));
2232            }
2233            text_lines.push(Line::from(s));
2234        }
2235
2236        f.render_widget(
2237            Paragraph::new(Text::from(text_lines)).block(
2238                Block::default()
2239                    .borders(Borders::TOP)
2240                    .border_style(Style::default().fg(self.theme.line)),
2241            ),
2242            area,
2243        );
2244    }
2245}
2246
2247impl Default for Tui {
2248    fn default() -> Self {
2249        Self::new()
2250    }
2251}
2252
2253#[cfg(test)]
2254mod v09_tests {
2255    use super::*;
2256    use crate::event::{Event, OutcomeSummary, RunId, TokenUsage};
2257
2258    fn run() -> RunId {
2259        RunId("t".into())
2260    }
2261
2262    #[test]
2263    fn simple_mode_renders_human_model_switch() {
2264        let mut tui = Tui::new().with_experience(true, crate::humanize::Lang::Fr);
2265        let before = tui.lines.len();
2266        tui.push_event(Event::ModelSwitched {
2267            run: run(),
2268            from: "a".into(),
2269            to: "b".into(),
2270            reason: "escalation".into(),
2271        });
2272        let added: Vec<String> = tui.lines[before..].iter().map(|l| l.text.clone()).collect();
2273        let joined = added.join("\n");
2274        assert!(
2275            joined.contains("vitesse supérieure") || joined.contains("Je change de modèle"),
2276            "simple mode should show a human switch line, got: {joined}"
2277        );
2278        // No technical "fallback: a → b" jargon.
2279        assert!(!joined.contains("fallback:"), "jargon leaked: {joined}");
2280    }
2281
2282    #[test]
2283    fn pro_mode_keeps_technical_model_switch() {
2284        let mut tui = Tui::new().with_experience(false, crate::humanize::Lang::Fr);
2285        let before = tui.lines.len();
2286        tui.push_event(Event::ModelSwitched {
2287            run: run(),
2288            from: "a".into(),
2289            to: "b".into(),
2290            reason: "x".into(),
2291        });
2292        let joined: String = tui.lines[before..]
2293            .iter()
2294            .map(|l| l.text.clone())
2295            .collect::<Vec<_>>()
2296            .join("\n");
2297        assert!(joined.contains("a") && joined.contains("b"));
2298    }
2299
2300    #[test]
2301    fn builder_mode_renders_builder_menu() {
2302        let tui = Tui::new()
2303            .with_experience(false, crate::humanize::Lang::Fr)
2304            .with_experience_mode("builder");
2305        let text = tui
2306            .lines
2307            .iter()
2308            .map(|line| line.text.as_str())
2309            .collect::<Vec<_>>()
2310            .join("\n");
2311        assert!(
2312            text.contains("Builder menu"),
2313            "missing builder header:\n{text}"
2314        );
2315        for item in ["Run", "Test", "Refactor", "Git", "Debug", "Replay"] {
2316            assert!(text.contains(item), "missing builder item {item}:\n{text}");
2317        }
2318    }
2319
2320    #[test]
2321    fn simple_mode_run_finished_has_no_dollar_jargon() {
2322        let mut tui = Tui::new().with_experience(true, crate::humanize::Lang::Fr);
2323        let before = tui.lines.len();
2324        tui.push_event(Event::RunFinished {
2325            run: run(),
2326            outcome: OutcomeSummary {
2327                status: "completed".into(),
2328                diffs: vec![],
2329                cost_usd: 0.0,
2330                tokens: TokenUsage {
2331                    input: 0,
2332                    output: 0,
2333                },
2334                cost_comparison: String::new(),
2335                duration_ms: None,
2336            },
2337        });
2338        let joined: String = tui.lines[before..]
2339            .iter()
2340            .map(|l| l.text.clone())
2341            .collect::<Vec<_>>()
2342            .join("\n");
2343        assert!(
2344            joined.contains("Terminé") || joined.contains("gratuit"),
2345            "got: {joined}"
2346        );
2347        assert!(
2348            !joined.contains("status:"),
2349            "technical status leaked: {joined}"
2350        );
2351    }
2352}