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