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}
400
401impl Tui {
402    pub fn new() -> Self {
403        // Resolve history path: ~/.local/state/sparrow/tui_history.txt
404        let history_path = dirs::state_dir()
405            .or_else(dirs::data_local_dir)
406            .or_else(dirs::data_dir)
407            .map(|d| d.join("sparrow").join("tui_history.txt"));
408        let history = history_path
409            .as_ref()
410            .and_then(|p| std::fs::read_to_string(p).ok())
411            .map(|s| s.lines().map(String::from).collect())
412            .unwrap_or_default();
413
414        // Pick theme from $SPARROW_THEME or default to `captain`.
415        let theme = std::env::var("SPARROW_THEME")
416            .ok()
417            .map(|n| crate::tui::theme::by_name(&n))
418            .unwrap_or_default();
419        let mut tui = Self {
420            theme,
421            lines: Vec::new(),
422            route: "idle".into(),
423            cost_usd: 0.0,
424            total_tokens: 0,
425            autonomy: "supervised".into(),
426            input_lines: vec![String::new()],
427            cursor_row: 0,
428            cursor_col: 0,
429            history,
430            history_idx: None,
431            inject_pending: false,
432            scroll: 0,
433            frame: 0,
434            spinner_idx: 0,
435            booted: false,
436            boot_progress: 0,
437            event_rx: None,
438            task_tx: None,
439            history_path,
440            swarm_lanes: None,
441            pending_diffs: std::collections::VecDeque::new(),
442            checkpoints: Vec::new(),
443            embers: Self::spawn_embers(),
444            toast: None,
445            cost_flash_frames: 0,
446            last_cost: 0.0,
447            tok_flash_frames: 0,
448            last_tokens: 0,
449            groups: Vec::new(),
450            current_group: None,
451            focus_group: None,
452            replay_events: None,
453            replay_idx: 0,
454            think: crate::event::ThinkStripper::new(),
455            agent_names: Vec::new(),
456            active_agent: None,
457            agent_souls: std::collections::HashMap::new(),
458            term_renderer: crate::tui::renderer::TermRenderer::new(
459                crate::tui::renderer::RenderConfig::default(),
460            ),
461        };
462        tui.show_splash();
463        tui
464    }
465
466    /// Show a rich-formatted splash screen demonstrating TUI capabilities.
467    fn show_splash(&mut self) {
468        self.add_line("══════════════════════════════════════", LogStyle::Brand, 0);
469        self.add_line("  🐦 SPARROW — one cli · grows with you", LogStyle::Brand, 0);
470        self.add_line("══════════════════════════════════════", LogStyle::Brand, 0);
471        self.add_line("", LogStyle::Cmd, 0);
472        self.add_line("Try these (type in the input below):", LogStyle::Cmd, 0);
473        self.add_line("  @nova     → Tab to toggle Nova agent", LogStyle::Dim, 0);
474        self.add_line("  /help     → list all slash commands", LogStyle::Dim, 0);
475        self.add_line("  Ctrl+R    → rewind to last checkpoint", LogStyle::Dim, 0);
476        self.add_line("", LogStyle::Cmd, 0);
477        // Demo: formatted code
478        self.add_line("# RICH RENDERING DEMO", LogStyle::Gold, 0);
479        self.add_line("", LogStyle::Cmd, 0);
480        self.add_line("Code blocks get syntax highlighting:", LogStyle::Cmd, 0);
481        self.add_line("```rust", LogStyle::Dim, 0);
482        self.add_line("fn main() {", LogStyle::Cmd, 0);
483        self.add_line("    println!(\"Hello, Sparrow!\");", LogStyle::Cmd, 0);
484        self.add_line("}", LogStyle::Cmd, 0);
485        self.add_line("```", LogStyle::Dim, 0);
486        self.add_line("", LogStyle::Cmd, 0);
487        self.add_line("Diffs are colored (additions in green, deletions in red):", LogStyle::Cmd, 0);
488        self.add_line("--- a/src/main.rs", LogStyle::Dim, 0);
489        self.add_line("+++ b/src/main.rs", LogStyle::Dim, 0);
490        self.add_line("@@ -10,6 +10,8 @@ fn main() {", LogStyle::Dim, 0);
491        self.add_line("+    let config = load_config()?;", LogStyle::Ok, 0);
492        self.add_line("     let engine = Engine::new();", LogStyle::Cmd, 0);
493        self.add_line("-    engine.run_old();", LogStyle::Err, 0);
494        self.add_line("+    engine.run_with_config(&config);", LogStyle::Ok, 0);
495        self.add_line("", LogStyle::Cmd, 0);
496        self.add_line("JSON is pretty-printed:", LogStyle::Cmd, 0);
497        self.add_line("{", LogStyle::Dim, 0);
498        self.add_line("  \"status\": \"ready\",", LogStyle::Ok, 0);
499        self.add_line("  \"version\": \"0.5.9\",", LogStyle::Gold, 0);
500        self.add_line("  \"agents\": [\"nova\", \"planner\", \"coder\"]", LogStyle::Cmd, 0);
501        self.add_line("}", LogStyle::Dim, 0);
502        self.add_line("", LogStyle::Cmd, 0);
503        self.add_line("→ Type a task or /command to begin.", LogStyle::Brand, 0);
504    }
505
506    /// Launch the TUI as a replay scrubber over a recorded transcript.
507    /// ←/→ step through events; Home/End jump to start/end.
508    pub fn with_replay(mut self, events: Vec<Event>) -> Self {
509        self.replay_events = Some(events);
510        self.replay_idx = 0;
511        self.booted = true; // skip boot animation in replay mode
512        self
513    }
514
515    /// Rebuild the log from replay events up to `replay_idx`.
516    fn rebuild_replay(&mut self) {
517        let Some(events) = self.replay_events.clone() else {
518            return;
519        };
520        self.lines.clear();
521        self.groups.clear();
522        self.current_group = None;
523        self.focus_group = None;
524        self.cost_usd = 0.0;
525        self.total_tokens = 0;
526        let upto = self.replay_idx.min(events.len());
527        for ev in events.iter().take(upto) {
528            self.push_event(ev.clone());
529        }
530        let total = events.len();
531        self.add_line(
532            &format!(
533                "── replay {}/{}  (←/→ step · Home/End jump · q quit) ──",
534                upto, total
535            ),
536            LogStyle::Accent,
537            0,
538        );
539    }
540
541    fn spawn_embers() -> Vec<Ember> {
542        // Deterministic-ish initial spread (no rand dep): use position + idx as seed.
543        let glyphs = ['·', '•', '∘', '◦'];
544        (0..10u16)
545            .map(|i| Ember {
546                x: 4 + (i * 13) % 90,
547                y: 4.0 + ((i as f32) * 2.7) % 20.0,
548                vy: 0.10 + ((i as f32) * 0.037) % 0.25,
549                amber: i % 2 == 0,
550                life: ((i as u32) * 17) % 180,
551                max_life: 180 + ((i as u32) * 11) % 90,
552                glyph: glyphs[(i as usize) % glyphs.len()],
553            })
554            .collect()
555    }
556
557    /// Snapshot current input as a single joined string.
558    fn current_input(&self) -> String {
559        self.input_lines.join("\n")
560    }
561
562    /// Replace current input with a single-line snapshot (used by history nav).
563    fn set_input(&mut self, s: &str) {
564        self.input_lines = s.split('\n').map(String::from).collect();
565        if self.input_lines.is_empty() {
566            self.input_lines.push(String::new());
567        }
568        self.cursor_row = self.input_lines.len() - 1;
569        self.cursor_col = self.input_lines[self.cursor_row].len();
570    }
571
572    /// Append current input to history (de-dup against last entry) and persist.
573    fn push_history(&mut self, entry: &str) {
574        if entry.trim().is_empty() {
575            return;
576        }
577        if self.history.last().map(|s| s.as_str()) == Some(entry) {
578            return;
579        }
580        self.history.push(entry.to_string());
581        if self.history.len() > HISTORY_MAX {
582            let excess = self.history.len() - HISTORY_MAX;
583            self.history.drain(..excess);
584        }
585        if let Some(path) = &self.history_path {
586            if let Some(parent) = path.parent() {
587                let _ = std::fs::create_dir_all(parent);
588            }
589            let _ = std::fs::write(path, self.history.join("\n"));
590        }
591    }
592
593    /// Match autocomplete candidates for the current input.
594    fn autocomplete_matches(&self) -> Vec<&'static str> {
595        let line = &self.input_lines[0];
596        if line.starts_with('/') {
597            return SLASH_COMMANDS
598                .iter()
599                .filter(|c| c.starts_with(line.as_str()) && **c != line.as_str())
600                .copied()
601                .take(5)
602                .collect();
603        }
604        vec![]
605    }
606
607    /// Test hook: mutable access to the first input line.
608    #[doc(hidden)]
609    pub fn debug_first_line_mut(&mut self) -> &mut String {
610        if self.input_lines.is_empty() {
611            self.input_lines.push(String::new());
612        }
613        &mut self.input_lines[0]
614    }
615
616    /// Test hook: set the cursor column.
617    #[doc(hidden)]
618    pub fn debug_set_cursor_col(&mut self, col: usize) {
619        self.cursor_row = 0;
620        self.cursor_col = col;
621    }
622
623    /// `@<name>` agent picker: returns owned strings prefixed with `@`. Separate
624    /// from the slash autocomplete because the candidate list is dynamic.
625    pub fn agent_matches(&self) -> Vec<String> {
626        // Find the last `@` token on the current line.
627        let line = &self.input_lines[self.cursor_row];
628        let upto = line.get(..self.cursor_col).unwrap_or(line);
629        let Some(at_pos) = upto.rfind('@') else {
630            return vec![];
631        };
632        // Don't trigger when `@` is preceded by a non-whitespace char (so e-mails
633        // like foo@example don't fire the picker).
634        if at_pos > 0
635            && !upto[..at_pos]
636                .chars()
637                .last()
638                .map(|c| c.is_whitespace())
639                .unwrap_or(true)
640        {
641            return vec![];
642        }
643        let prefix = &upto[at_pos + 1..];
644        // Bail if the fragment already contains whitespace — picker is over.
645        if prefix.contains(char::is_whitespace) {
646            return vec![];
647        }
648        self.agent_names
649            .iter()
650            .filter(|n| n.starts_with(prefix))
651            .take(5)
652            .map(|n| format!("@{}", n))
653            .collect()
654    }
655
656    /// Populate the `@<name>` agent picker with the agents the host knows about.
657    pub fn with_agents(mut self, names: Vec<String>) -> Self {
658        self.agent_names = names;
659        self
660    }
661
662    /// Toggle an agent on/off. When toggled on, all subsequent tasks run with
663    /// that agent's identity. Toggle again (or toggle another agent) to switch.
664    pub fn toggle_agent(&mut self, name: &str) {
665        if self.active_agent.as_deref() == Some(name) {
666            // Deselect
667            self.active_agent = None;
668        } else {
669            // Select — cache the agent soul
670            self.active_agent = Some(name.to_string());
671            if !self.agent_souls.contains_key(name) {
672                self.cache_agent_soul(name);
673            }
674        }
675    }
676
677    /// Load and cache an agent's soul (role + base64 personality).
678    fn cache_agent_soul(&mut self, name: &str) {
679        let path = dirs::config_dir()
680            .unwrap_or_default()
681            .join("sparrow")
682            .join("agents")
683            .join(format!("{}.soul.toml", name));
684        if let Ok(content) = std::fs::read_to_string(&path) {
685            let role = content.lines()
686                .find(|l| l.starts_with("role"))
687                .and_then(|l| l.split('=').nth(1))
688                .map(|s| s.trim().trim_matches('"').to_string())
689                .unwrap_or_default();
690            let personality = content.lines()
691                .find(|l| l.starts_with("personality"))
692                .and_then(|l| l.split('=').nth(1))
693                .map(|s| s.trim().trim_matches('"').to_string())
694                .unwrap_or_default();
695            use base64::{Engine as _, engine::general_purpose::STANDARD};
696            let b64 = STANDARD.encode(personality.as_bytes());
697            self.agent_souls.insert(name.to_string(), (role, b64));
698        }
699    }
700
701    /// Build the agent dispatch prefix for task sending.
702    fn agent_prefix(&self) -> String {
703        if let Some(ref name) = self.active_agent {
704            if let Some((role, b64)) = self.agent_souls.get(name) {
705                return format!("__agent:{}__{}__{}__ ", name, role, b64);
706            }
707        }
708        String::new()
709    }
710
711    pub fn with_channels(
712        mut self,
713        task_tx: mpsc::UnboundedSender<String>,
714        event_rx: mpsc::UnboundedReceiver<Event>,
715    ) -> Self {
716        self.task_tx = Some(task_tx);
717        self.event_rx = Some(event_rx);
718        self
719    }
720
721    /// Format a log line with auto-detected content type.
722    /// Applies syntax highlighting to code, colors to diffs, etc.
723    fn format_line(&self, text: &str) -> String {
724        // Detect content type
725        let trimmed = text.trim();
726
727        // Code blocks (start with ``` or indented 4+ spaces)
728        if trimmed.starts_with("```") || text.lines().all(|l| l.starts_with("    ") || l.is_empty()) {
729            return self.term_renderer.render_code(text, "");
730        }
731
732        // Diff output (starts with diff --git, @@, +++, ---)
733        if trimmed.contains("diff --git") || trimmed.starts_with("@@") || trimmed.starts_with("--- a/") || trimmed.starts_with("+++ b/") {
734            return self.term_renderer.render_diff(text);
735        }
736
737        // JSON (starts with { or [)
738        if trimmed.starts_with('{') || trimmed.starts_with('[') {
739            if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
740                return self.term_renderer.render_json(text);
741            }
742        }
743
744        // Markdown headers (# Title, ## Section)
745        if trimmed.starts_with("# ") || trimmed.starts_with("## ") || trimmed.starts_with("### ") {
746            return self.term_renderer.render_markdown(text);
747        }
748
749        // Default: plain text
750        text.to_string()
751    }
752
753    pub fn push_event(&mut self, event: Event) {
754        match &event {
755            Event::RunStarted { task, .. } => {
756                self.think = crate::event::ThinkStripper::new();
757                self.open_group(&format!("started: {}", task), LogStyle::Brand);
758            }
759            Event::RouteSelected { chain, .. } => {
760                self.route = chain.join(" → ");
761                self.add_line(&format!("↳ route: {}", self.route), LogStyle::Dim, 1);
762            }
763            Event::ModelSwitched {
764                from, to, reason, ..
765            } => {
766                self.route = to.clone();
767                let clean = crate::event::friendly_model_switch_reason(reason);
768                let label = if crate::event::is_local_model_unavailable(reason) {
769                    format!(
770                        "↳ modèle local indisponible → routage modèle cloud ({})",
771                        to
772                    )
773                } else {
774                    format!("↳ fallback: {} → {} ({})", from, to, clean)
775                };
776                self.add_line(&label, LogStyle::Warn, 1);
777            }
778            Event::ThinkingDelta { text, .. } => {
779                let visible = self.think.feed(text);
780                if !visible.is_empty() {
781                    self.add_line(&visible, LogStyle::Cmd, 1);
782                }
783            }
784            Event::ReasoningDelta { .. } => {}
785            Event::ToolUseProposed { name, .. } => {
786                self.open_group(&format!("tool · {}", name), LogStyle::Steel);
787            }
788            Event::ToolOutput { blocks, .. } => {
789                for b in blocks {
790                    if let crate::event::Block::Text(t) = b {
791                        self.add_line(&format!("  {}", t), LogStyle::Dim, 2);
792                    }
793                }
794            }
795            Event::AgentSpawned { role, model, .. } => {
796                let lanes = self.swarm_lanes.get_or_insert_with(|| SwarmLanesState {
797                    started_at_frame: self.frame,
798                    ..Default::default()
799                });
800                let lane = match role.as_str() {
801                    "planner" => &mut lanes.planner,
802                    "coder" => &mut lanes.coder,
803                    "verifier" => &mut lanes.verifier,
804                    _ => &mut lanes.coder,
805                };
806                lane.status = "Working".into();
807                lane.note = "spawned".into();
808                lane.model = model.clone();
809                let s = match role.as_str() {
810                    "planner" => LogStyle::Planner,
811                    "coder" => LogStyle::Agent,
812                    "verifier" => LogStyle::Verifier,
813                    _ => LogStyle::Dim,
814                };
815                self.open_group(&format!("{} ({})", role, model), s);
816            }
817            Event::AgentStatus {
818                role, note, status, ..
819            } => {
820                if let Some(lanes) = self.swarm_lanes.as_mut() {
821                    let lane = match role.as_str() {
822                        "planner" => &mut lanes.planner,
823                        "coder" => &mut lanes.coder,
824                        "verifier" => &mut lanes.verifier,
825                        _ => &mut lanes.coder,
826                    };
827                    lane.status = format!("{:?}", status);
828                    lane.note = note.clone();
829                }
830                let s = match role.as_str() {
831                    "planner" => LogStyle::Planner,
832                    "coder" => LogStyle::Agent,
833                    "verifier" => LogStyle::Verifier,
834                    _ => LogStyle::Dim,
835                };
836                let icon = match status {
837                    crate::event::AgentStatus::Done => "✓",
838                    crate::event::AgentStatus::Working => "●",
839                    crate::event::AgentStatus::Thinking => "○",
840                    crate::event::AgentStatus::Error => "✗",
841                    _ => "◌",
842                };
843                self.add_line(&format!("{} {} — {}", icon, role, note), s, 1);
844            }
845            Event::CheckpointCreated { id, label, .. } => {
846                for node in &mut self.checkpoints {
847                    node.current = false;
848                }
849                self.checkpoints.push(CheckpointNode {
850                    id: id.0.clone(),
851                    label: label.clone(),
852                    current: true,
853                });
854                self.add_line(&format!("● checkpoint: {}", label), LogStyle::Gold, 0)
855            }
856            Event::SkillLearned { name, .. } => {
857                self.toast = Some(Toast {
858                    text: format!("✦ skill learned · {}", name),
859                    age: 0,
860                    max_age: 90,
861                });
862                self.add_line(&format!("✦ skill learned · {}", name), LogStyle::Agent, 0)
863            }
864            Event::CostUpdate { usd, .. } => {
865                if *usd > self.last_cost {
866                    self.cost_flash_frames = 12;
867                }
868                self.last_cost = *usd;
869                self.cost_usd = *usd;
870            }
871            Event::TokenUsage { input, output, .. } => {
872                self.total_tokens += input + output;
873                if self.total_tokens > self.last_tokens {
874                    self.tok_flash_frames = 12;
875                }
876                self.last_tokens = self.total_tokens;
877            }
878            Event::TokenUsageEstimated { input, output, .. } => {
879                self.total_tokens += input + output;
880                if self.total_tokens > self.last_tokens {
881                    self.tok_flash_frames = 12;
882                }
883                self.last_tokens = self.total_tokens;
884            }
885            Event::AutonomyChanged { level, .. } => {
886                self.autonomy = format!("{:?}", level).to_lowercase()
887            }
888            Event::DiffProposed {
889                file,
890                patch,
891                plus,
892                minus,
893                ..
894            } => {
895                if self.pending_diffs.len() >= 3 {
896                    self.pending_diffs.pop_front();
897                }
898                self.pending_diffs.push_back(DiffEntry {
899                    file: file.clone(),
900                    plus: *plus,
901                    minus: *minus,
902                    lines: parse_diff_patch(patch),
903                    applied: false,
904                });
905                self.add_line(
906                    &format!("◇ {}  +{} / -{}  · proposed", file, plus, minus),
907                    LogStyle::Dim,
908                    0,
909                )
910            }
911            Event::DiffApplied { file, .. } => {
912                if let Some(entry) = self.pending_diffs.iter_mut().find(|d| d.file == *file) {
913                    entry.applied = true;
914                }
915                while self.pending_diffs.front().is_some_and(|d| d.applied) {
916                    self.pending_diffs.pop_front();
917                }
918            }
919            Event::TestResult {
920                passed,
921                failed,
922                detail,
923                ..
924            } => {
925                if *failed > 0 {
926                    self.add_line(
927                        &format!("⚠ tests  {} passed · {} failed", passed, failed),
928                        LogStyle::Warn,
929                        1,
930                    );
931                    for line in detail.lines() {
932                        self.add_line(&format!("  {}", line), LogStyle::Rem, 2);
933                    }
934                } else {
935                    self.add_line(
936                        &format!("✓ tests  {} passed · no regressions", passed),
937                        LogStyle::Ok,
938                        1,
939                    );
940                }
941            }
942            Event::RunFinished { outcome, .. } => {
943                // Recover any text held by the think-stripper (unclosed <think>).
944                let tail = self.think.flush();
945                if !tail.trim().is_empty() {
946                    self.add_line(&tail, LogStyle::Cmd, 1);
947                }
948                self.close_group();
949                self.add_line(
950                    &format!(
951                        "✓ done  status: {}  cost: ${:.4}",
952                        outcome.status, outcome.cost_usd
953                    ),
954                    LogStyle::Ok,
955                    0,
956                );
957            }
958            Event::Error { message, .. } => {
959                if !crate::event::is_local_model_unavailable(message) {
960                    self.add_line(message, LogStyle::Err, 0);
961                }
962            }
963            _ => {}
964        }
965    }
966
967    fn add_line(&mut self, text: &str, style: LogStyle, indent: u16) {
968        let group = self.current_group;
969        for line in text.lines() {
970            self.lines.push(LogLine {
971                text: line.to_string(),
972                style,
973                indent,
974                group,
975                header_for: None,
976            });
977        }
978    }
979
980    /// Open a new collapsible task group; subsequent `add_line` calls attach to it.
981    fn open_group(&mut self, title: &str, style: LogStyle) {
982        let id = self.groups.len();
983        self.groups.push(TaskGroup {
984            title: title.to_string(),
985            collapsed: false,
986            style,
987        });
988        self.lines.push(LogLine {
989            text: title.to_string(),
990            style,
991            indent: 0,
992            group: None,
993            header_for: Some(id),
994        });
995        self.current_group = Some(id);
996        self.focus_group = Some(id);
997    }
998
999    /// Close the active group (subsequent lines go top-level).
1000    fn close_group(&mut self) {
1001        self.current_group = None;
1002    }
1003
1004    /// Number of child lines belonging to a group (for the "N hidden" hint).
1005    fn group_child_count(&self, id: usize) -> usize {
1006        self.lines.iter().filter(|l| l.group == Some(id)).count()
1007    }
1008
1009    /// Move focus to the previous/next group header.
1010    fn focus_group_step(&mut self, forward: bool) {
1011        if self.groups.is_empty() {
1012            return;
1013        }
1014        let last = self.groups.len() - 1;
1015        self.focus_group = Some(match self.focus_group {
1016            None => last,
1017            Some(i) if forward => (i + 1).min(last),
1018            Some(i) => i.saturating_sub(1),
1019        });
1020    }
1021
1022    /// Toggle collapse on the focused group, or all groups if none focused.
1023    fn toggle_group(&mut self) {
1024        match self.focus_group {
1025            Some(i) if i < self.groups.len() => {
1026                self.groups[i].collapsed = !self.groups[i].collapsed;
1027            }
1028            _ => {
1029                let any_open = self.groups.iter().any(|g| !g.collapsed);
1030                for g in &mut self.groups {
1031                    g.collapsed = any_open;
1032                }
1033            }
1034        }
1035    }
1036
1037    fn boot(&mut self) {
1038        self.add_line(
1039            concat!("SPARROW  v", env!("CARGO_PKG_VERSION"), " — one cli · grows with you"),
1040            LogStyle::Dim,
1041            0,
1042        );
1043        self.add_line("", LogStyle::Normal, 0);
1044
1045        // Honest, platform-aware sandbox status. seccomp/namespaces are Linux-only;
1046        // on other platforms we run with workspace path-boundary enforcement only.
1047        #[cfg(target_os = "linux")]
1048        let sandbox_line = "local-hardened · namespaces + path boundary";
1049        #[cfg(not(target_os = "linux"))]
1050        let sandbox_line = "path-boundary enforcement (namespaces are Linux-only)";
1051
1052        let boot = [
1053            (
1054                "router  ",
1055                "model routing + fallback chain",
1056                LogStyle::Planner,
1057            ),
1058            (
1059                "surfaces",
1060                "cli · tui · webview · gateway",
1061                LogStyle::Planner,
1062            ),
1063            ("sandbox ", sandbox_line, LogStyle::Ok),
1064            (
1065                "skills  ",
1066                "library indexed · self-improving",
1067                LogStyle::Accent,
1068            ),
1069            (
1070                "memory  ",
1071                "sqlite · bounded docs · session search",
1072                LogStyle::Ok,
1073            ),
1074            (
1075                "autonomy",
1076                "dial: supervised → trusted → autonomous",
1077                LogStyle::Accent,
1078            ),
1079        ];
1080        for (k, v, s) in &boot {
1081            self.add_line(&format!("{}  {}", k, v), *s, 1);
1082        }
1083        self.add_line("✓ ready  one binary. no dependencies.", LogStyle::Ok, 0);
1084        self.add_line("", LogStyle::Normal, 0);
1085        self.booted = true;
1086    }
1087
1088    pub fn run(&mut self) -> io::Result<()> {
1089        // Windows: force the console code page to UTF-8 (65001) BEFORE we
1090        // enter the alternate screen. Without this the default CP1252/OEM
1091        // mangles every multi-byte glyph the TUI emits (•, ·, ∘, →, box-
1092        // drawing) into "â", "·" garbage and visibly drops bytes inside
1093        // ASCII strings, producing "binana"/"versioo" output.
1094        ensure_utf8_console();
1095        enable_raw_mode()?;
1096        let mut stdout = io::stdout();
1097        execute!(stdout, EnterAlternateScreen)?;
1098        let backend = ratatui::backend::CrosstermBackend::new(stdout);
1099        let mut terminal = ratatui::Terminal::new(backend)?;
1100        // Wipe any residue from the parent shell so ratatui starts on a
1101        // clean buffer (otherwise stray dots from the previous prompt show
1102        // up over empty panel areas).
1103        terminal.clear()?;
1104        let result = self.main_loop(&mut terminal);
1105        disable_raw_mode()?;
1106        execute!(io::stdout(), LeaveAlternateScreen)?;
1107        result
1108    }
1109
1110    fn main_loop(&mut self, terminal: &mut CrosstermTerminal) -> io::Result<()> {
1111        let start = Instant::now();
1112        if self.replay_events.is_some() {
1113            self.rebuild_replay();
1114        }
1115        loop {
1116            self.drain_engine_events();
1117            self.frame += 1;
1118            self.spinner_idx = (self.spinner_idx + 1) % 10;
1119            self.tick_visuals();
1120            terminal.draw(|f| self.render(f, start.elapsed().as_secs_f64()))?;
1121            if event::poll(std::time::Duration::from_millis(50))? {
1122                if let TermEvent::Key(key) = event::read()? {
1123                    if key.kind != KeyEventKind::Press {
1124                        continue;
1125                    }
1126                    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1127                    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1128                    match key.code {
1129                        KeyCode::Esc => break,
1130                        KeyCode::Char('c') if ctrl => break,
1131
1132                        // ── Replay scrubber (active only in replay mode) ─────
1133                        KeyCode::Char('q') if self.replay_events.is_some() => break,
1134                        KeyCode::Left if self.replay_events.is_some() => {
1135                            self.replay_idx = self.replay_idx.saturating_sub(1);
1136                            self.rebuild_replay();
1137                        }
1138                        KeyCode::Right if self.replay_events.is_some() => {
1139                            let max = self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1140                            self.replay_idx = (self.replay_idx + 1).min(max);
1141                            self.rebuild_replay();
1142                        }
1143                        KeyCode::Home if self.replay_events.is_some() => {
1144                            self.replay_idx = 0;
1145                            self.rebuild_replay();
1146                        }
1147                        KeyCode::End if self.replay_events.is_some() => {
1148                            self.replay_idx =
1149                                self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1150                            self.rebuild_replay();
1151                        }
1152
1153                        // Ctrl+L → clear log buffer
1154                        KeyCode::Char('l') if ctrl => {
1155                            self.lines.clear();
1156                        }
1157                        // Ctrl+I → next Enter sends as mid-run injection
1158                        KeyCode::Char('i') if ctrl => {
1159                            self.inject_pending = true;
1160                            self.add_line(
1161                                "[inject] next message will be sent to the running agent",
1162                                LogStyle::Warn,
1163                                0,
1164                            );
1165                        }
1166
1167                        // ── Collapsible task groups ──────────────────────────
1168                        // Ctrl+↑/↓ move focus between task headers; Ctrl+O toggles.
1169                        KeyCode::Up if ctrl => self.focus_group_step(false),
1170                        KeyCode::Down if ctrl => self.focus_group_step(true),
1171                        KeyCode::Char('o') if ctrl => self.toggle_group(),
1172
1173                        // History navigation (only when on first row of input)
1174                        KeyCode::Up if self.cursor_row == 0 && !self.history.is_empty() => {
1175                            let new_idx = match self.history_idx {
1176                                None => self.history.len() - 1,
1177                                Some(0) => 0,
1178                                Some(i) => i - 1,
1179                            };
1180                            self.history_idx = Some(new_idx);
1181                            let entry = self.history[new_idx].clone();
1182                            self.set_input(&entry);
1183                        }
1184                        KeyCode::Down if self.cursor_row == self.input_lines.len() - 1 => {
1185                            match self.history_idx {
1186                                Some(i) if i + 1 < self.history.len() => {
1187                                    self.history_idx = Some(i + 1);
1188                                    let entry = self.history[i + 1].clone();
1189                                    self.set_input(&entry);
1190                                }
1191                                Some(_) => {
1192                                    self.history_idx = None;
1193                                    self.set_input("");
1194                                }
1195                                None => {}
1196                            }
1197                        }
1198
1199                        // Scrollback nav with PgUp/PgDn/Home/End
1200                        KeyCode::PageUp => self.scroll = self.scroll.saturating_add(10),
1201                        KeyCode::PageDown => self.scroll = self.scroll.saturating_sub(10),
1202                        KeyCode::Home => self.scroll = 0,
1203                        KeyCode::End => self.scroll = u16::MAX,
1204
1205                        // Tab → autocomplete or toggle agent
1206                        KeyCode::Tab => {
1207                            let line = &self.input_lines[0];
1208                            // @agent → toggle, not insert
1209                            if let Some(rest) = line.strip_prefix('@') {
1210                                let name = &rest.trim().to_string();
1211                                if !name.is_empty() && self.agent_names.contains(name) {
1212                                    self.toggle_agent(name);
1213                                    self.input_lines = vec![String::new()];
1214                                    self.cursor_row = 0;
1215                                    self.cursor_col = 0;
1216                                }
1217                            } else {
1218                                let matches = self.autocomplete_matches();
1219                                if let Some(first) = matches.first() {
1220                                    self.input_lines = vec![first.to_string()];
1221                                    self.cursor_row = 0;
1222                                    self.cursor_col = first.len();
1223                                }
1224                            }
1225                        }
1226
1227                        // Backspace: handle multiline correctly
1228                        KeyCode::Backspace => {
1229                            if self.cursor_col > 0 {
1230                                let line = &mut self.input_lines[self.cursor_row];
1231                                let new_col = line[..self.cursor_col]
1232                                    .char_indices()
1233                                    .last()
1234                                    .map(|(i, _)| i)
1235                                    .unwrap_or(0);
1236                                line.replace_range(new_col..self.cursor_col, "");
1237                                self.cursor_col = new_col;
1238                            } else if self.cursor_row > 0 {
1239                                // join with previous line
1240                                let curr = self.input_lines.remove(self.cursor_row);
1241                                self.cursor_row -= 1;
1242                                let prev = &mut self.input_lines[self.cursor_row];
1243                                self.cursor_col = prev.len();
1244                                prev.push_str(&curr);
1245                            }
1246                        }
1247
1248                        // Shift+Enter or Alt+Enter → newline
1249                        KeyCode::Enter if shift || key.modifiers.contains(KeyModifiers::ALT) => {
1250                            let line = &mut self.input_lines[self.cursor_row];
1251                            let rest = line.split_off(self.cursor_col);
1252                            self.cursor_row += 1;
1253                            self.cursor_col = 0;
1254                            self.input_lines.insert(self.cursor_row, rest);
1255                        }
1256
1257                        // Enter → submit
1258                        KeyCode::Enter => {
1259                            let task = self.current_input().trim().to_string();
1260                            if !task.is_empty() {
1261                                // Handle in-TUI commands
1262                                match task.as_str() {
1263                                    "/clear" => {
1264                                        self.lines.clear();
1265                                        self.groups.clear();
1266                                        self.current_group = None;
1267                                        self.focus_group = None;
1268                                    }
1269                                    "/collapse" => {
1270                                        for g in &mut self.groups {
1271                                            g.collapsed = true;
1272                                        }
1273                                    }
1274                                    "/expand" => {
1275                                        for g in &mut self.groups {
1276                                            g.collapsed = false;
1277                                        }
1278                                    }
1279                                    "/exit" | "/quit" => break,
1280                                    "/help" => {
1281                                        self.add_line("Commands:", LogStyle::Brand, 0);
1282                                        for c in SLASH_COMMANDS {
1283                                            self.add_line(c, LogStyle::Dim, 1);
1284                                        }
1285                                        self.add_line(
1286                                            "Ctrl+I inject · Ctrl+L clear · Ctrl+↑/↓ focus task · Ctrl+O fold/unfold · Shift+Enter newline · Up/Down history",
1287                                            LogStyle::Dim, 0,
1288                                        );
1289                                        self.add_line(
1290                                            "/collapse · /expand — fold/unfold all tasks",
1291                                            LogStyle::Dim,
1292                                            1,
1293                                        );
1294                                    }
1295                                    s if s.starts_with("/plan") => {
1296                                        let planned = s.trim_start_matches("/plan").trim();
1297                                        if planned.is_empty() {
1298                                            self.add_line("Usage: /plan <task>", LogStyle::Warn, 0);
1299                                        } else {
1300                                            let plan =
1301                                                crate::plan::build_read_only_plan(planned, &[]);
1302                                            self.add_line(
1303                                                "Read-only plan · no tools or edits executed",
1304                                                LogStyle::Planner,
1305                                                0,
1306                                            );
1307                                            self.add_line(&plan.summary, LogStyle::Dim, 1);
1308                                            for (idx, step) in plan.steps.iter().enumerate() {
1309                                                self.add_line(
1310                                                    &format!("{}. {}", idx + 1, step),
1311                                                    LogStyle::Cmd,
1312                                                    1,
1313                                                );
1314                                            }
1315                                            self.add_line(
1316                                                "Run the task explicitly when you accept the plan.",
1317                                                LogStyle::Warn,
1318                                                0,
1319                                            );
1320                                        }
1321                                    }
1322                                    _ => {
1323                                        // Send to engine
1324                                        let label = if self.inject_pending {
1325                                            "inject"
1326                                        } else {
1327                                            "sparrow"
1328                                        };
1329                                        self.add_line(
1330                                            &format!("{} › {}", label, task.replace('\n', " ↵ ")),
1331                                            LogStyle::Prompt,
1332                                            0,
1333                                        );
1334                                        self.push_history(&task);
1335                                        let to_send = if self.inject_pending {
1336                                            format!("__inject__:{}", task)
1337                                        } else {
1338                                            let prefix = self.agent_prefix();
1339                                            if prefix.is_empty() {
1340                                                task.clone()
1341                                            } else {
1342                                                format!("{}{}", prefix, task)
1343                                            }
1344                                        };
1345                                        self.inject_pending = false;
1346                                        if let Some(tx) = &self.task_tx {
1347                                            if tx.send(to_send).is_err() {
1348                                                self.add_line(
1349                                                    "runtime channel disconnected",
1350                                                    LogStyle::Err,
1351                                                    0,
1352                                                );
1353                                            }
1354                                        }
1355                                    }
1356                                }
1357                                self.set_input("");
1358                                self.history_idx = None;
1359                            }
1360                        }
1361
1362                        // Regular character → insert at cursor
1363                        KeyCode::Char(c) => {
1364                            let line = &mut self.input_lines[self.cursor_row];
1365                            line.insert(self.cursor_col, c);
1366                            self.cursor_col += c.len_utf8();
1367                        }
1368
1369                        // Cursor movement
1370                        KeyCode::Left => {
1371                            if self.scroll == 0
1372                                && self.cursor_col == 0
1373                                && self.checkpoints.len() > 1
1374                            {
1375                                let previous = self
1376                                    .checkpoints
1377                                    .iter()
1378                                    .rev()
1379                                    .skip(1)
1380                                    .find(|node| !node.id.is_empty())
1381                                    .map(|node| node.id.clone());
1382                                if let (Some(id), Some(tx)) = (previous, &self.task_tx) {
1383                                    let _ = tx.send(format!("__rewind__:{}", id));
1384                                    self.add_line(
1385                                        "rewind requested from checkpoint timeline",
1386                                        LogStyle::Gold,
1387                                        0,
1388                                    );
1389                                }
1390                            } else if self.cursor_col > 0 {
1391                                self.cursor_col = self.input_lines[self.cursor_row]
1392                                    [..self.cursor_col]
1393                                    .char_indices()
1394                                    .last()
1395                                    .map(|(i, _)| i)
1396                                    .unwrap_or(0);
1397                            } else if self.cursor_row > 0 {
1398                                self.cursor_row -= 1;
1399                                self.cursor_col = self.input_lines[self.cursor_row].len();
1400                            }
1401                        }
1402                        KeyCode::Right => {
1403                            let line = &self.input_lines[self.cursor_row];
1404                            if self.cursor_col < line.len() {
1405                                let next = line[self.cursor_col..]
1406                                    .chars()
1407                                    .next()
1408                                    .map(|c| c.len_utf8())
1409                                    .unwrap_or(0);
1410                                self.cursor_col += next;
1411                            } else if self.cursor_row + 1 < self.input_lines.len() {
1412                                self.cursor_row += 1;
1413                                self.cursor_col = 0;
1414                            }
1415                        }
1416
1417                        _ => {}
1418                    }
1419                }
1420            }
1421        }
1422        Ok(())
1423    }
1424
1425    fn tick_visuals(&mut self) {
1426        if !self.booted {
1427            self.boot_progress = self.boot_progress.saturating_add(1);
1428            if self.boot_progress >= 70 {
1429                self.boot();
1430            }
1431        }
1432        if self.cost_flash_frames > 0 {
1433            self.cost_flash_frames -= 1;
1434        }
1435        if self.tok_flash_frames > 0 {
1436            self.tok_flash_frames -= 1;
1437        }
1438        if let Some(toast) = self.toast.as_mut() {
1439            toast.age = toast.age.saturating_add(1);
1440            if toast.age >= toast.max_age {
1441                self.toast = None;
1442            }
1443        }
1444        for ember in &mut self.embers {
1445            ember.y -= ember.vy;
1446            ember.life = ember.life.saturating_add(1);
1447            if ember.life >= ember.max_life || ember.y < 0.0 {
1448                ember.y = 28.0 + (ember.x % 7) as f32;
1449                ember.life = 0;
1450            }
1451        }
1452    }
1453
1454    fn drain_engine_events(&mut self) {
1455        let mut disconnected = false;
1456        let mut events = Vec::new();
1457        if let Some(rx) = self.event_rx.as_mut() {
1458            loop {
1459                match rx.try_recv() {
1460                    Ok(event) => events.push(event),
1461                    Err(mpsc::error::TryRecvError::Empty) => break,
1462                    Err(mpsc::error::TryRecvError::Disconnected) => {
1463                        disconnected = true;
1464                        break;
1465                    }
1466                }
1467            }
1468        }
1469        for event in events {
1470            self.push_event(event);
1471        }
1472        if disconnected {
1473            self.event_rx = None;
1474            self.add_line("runtime event stream disconnected", LogStyle::Warn, 0);
1475        }
1476    }
1477
1478    fn render(&self, f: &mut Frame, _elapsed: f64) {
1479        let area = f.area();
1480        if !self.booted {
1481            self.render_boot(f, area);
1482            return;
1483        }
1484        // Input height = lines + 2 (border) + 1 (autocomplete row if any)
1485        let suggestions = self.autocomplete_matches();
1486        let input_height = (self.input_lines.len() as u16 + 2).max(3)
1487            + if !suggestions.is_empty() { 1 } else { 0 };
1488        let swarm_height = if self.swarm_lanes.is_some() { 5 } else { 0 };
1489        let diff_height = if self.pending_diffs.is_empty() { 0 } else { 12 };
1490        let checkpoint_height = if self.checkpoints.is_empty() { 0 } else { 2 };
1491        let chunks = Layout::default()
1492            .direction(Direction::Vertical)
1493            .constraints([
1494                Constraint::Length(3),
1495                Constraint::Length(swarm_height),
1496                Constraint::Min(0),
1497                Constraint::Length(diff_height),
1498                Constraint::Length(checkpoint_height),
1499                Constraint::Length(input_height),
1500            ])
1501            .split(area);
1502        self.render_cockpit(f, chunks[0]);
1503        if swarm_height > 0 {
1504            self.render_swarm_lanes(f, chunks[1]);
1505        }
1506        self.render_scroll(f, chunks[2]);
1507        if diff_height > 0 {
1508            self.render_diff(f, chunks[3]);
1509        }
1510        if checkpoint_height > 0 {
1511            self.render_checkpoint_timeline(f, chunks[4]);
1512        }
1513        self.render_input(f, chunks[5]);
1514        self.render_toast(f, area);
1515    }
1516
1517    fn render_boot(&self, f: &mut Frame, area: Rect) {
1518        let mut lines = Vec::new();
1519        let bird_lines: Vec<&str> = theme::ASCII_SPARROW.lines().collect();
1520        let bird_count = ((self.boot_progress / 5) as usize).min(bird_lines.len());
1521        for line in bird_lines.iter().take(bird_count) {
1522            lines.push(Line::from(Span::styled(
1523                *line,
1524                Style::default().fg(self.theme.brand),
1525            )));
1526        }
1527        if self.boot_progress >= 25 {
1528            let wordmark = if self.boot_progress < 35 {
1529                "S  P  A  R  R  O  W"
1530            } else if self.boot_progress < 45 {
1531                "S P A R R O W"
1532            } else {
1533                "SPARROW"
1534            };
1535            lines.push(Line::from(Span::styled(
1536                wordmark,
1537                Style::default()
1538                    .fg(self.theme.brand)
1539                    .add_modifier(Modifier::BOLD),
1540            )));
1541        }
1542        #[cfg(target_os = "linux")]
1543        let sandbox_boot = "sandbox    local-hardened · namespaces armed";
1544        #[cfg(not(target_os = "linux"))]
1545        let sandbox_boot = "sandbox    path-boundary enforcement";
1546        let boot_log = [
1547            "router     warming provider graph",
1548            "surfaces   cli · webview · gateway",
1549            sandbox_boot,
1550            "skills     library indexed",
1551            "memory     sqlite profile loaded",
1552            "autonomy   dial ready",
1553        ];
1554        if self.boot_progress >= 45 {
1555            let count = (((self.boot_progress - 45) / 4) as usize).min(boot_log.len());
1556            for item in boot_log.iter().take(count) {
1557                lines.push(Line::from(Span::styled(
1558                    *item,
1559                    Style::default().fg(self.theme.dim),
1560                )));
1561            }
1562        }
1563        if self.boot_progress >= 68 {
1564            lines.push(Line::from(Span::styled(
1565                "✓ ready",
1566                Style::default()
1567                    .fg(self.theme.add)
1568                    .add_modifier(Modifier::BOLD),
1569            )));
1570        }
1571        let height = lines.len() as u16;
1572        let width = area.width.min(72);
1573        let rect = Rect {
1574            x: area.x + area.width.saturating_sub(width) / 2,
1575            y: area.y + area.height.saturating_sub(height.max(1)) / 2,
1576            width,
1577            height: height.max(1),
1578        };
1579        f.render_widget(Paragraph::new(Text::from(lines)), rect);
1580    }
1581
1582    fn render_cockpit(&self, f: &mut Frame, area: Rect) {
1583        let aut_color = match self.autonomy.as_str() {
1584            "autonomous" => self.theme.autonomous,
1585            "trusted" => self.theme.trusted,
1586            _ => self.theme.supervised,
1587        };
1588
1589        // Spinner frame + flight verb cycling every ~25 frames (~1.25 s at 50 ms)
1590        let spinner = self.theme.spinner_frame(self.spinner_idx);
1591        let verb = self.theme.flight_verb(self.frame as usize / 25);
1592
1593        // LED for autonomy pill: pulse between ● and ◉ every 8 frames
1594        let led = if self.frame / 8 % 2 == 0 {
1595            "●"
1596        } else {
1597            "◉"
1598        };
1599
1600        let line = Line::from(vec![
1601            // braille spinner
1602            Span::styled(
1603                format!("{} ", spinner),
1604                Style::default()
1605                    .fg(self.theme.brand)
1606                    .add_modifier(Modifier::BOLD),
1607            ),
1608            // wordmark
1609            Span::styled(
1610                "SPARROW  ",
1611                Style::default()
1612                    .fg(self.theme.brand)
1613                    .add_modifier(Modifier::BOLD),
1614            ),
1615            // flight verb (cycling, fixed-width so cockpit doesn't jump)
1616            Span::styled(
1617                format!("{:<9}  ", verb),
1618                Style::default().fg(self.theme.dim),
1619            ),
1620            // active agent indicator (when toggled)
1621            Span::styled(
1622                if let Some(ref agent) = self.active_agent {
1623                    format!("🐦 {}  ", agent.to_uppercase())
1624                } else {
1625                    String::new()
1626                },
1627                Style::default()
1628                    .fg(self.theme.gold)
1629                    .add_modifier(Modifier::BOLD),
1630            ),
1631            // route
1632            Span::styled(
1633                format!("route: {}  ", self.route),
1634                Style::default().fg(self.theme.planner),
1635            ),
1636            // cost with ▲ when non-zero
1637            Span::styled(
1638                if self.cost_usd > 0.0 {
1639                    format!("${:.4} ▲  ", self.cost_usd)
1640                } else {
1641                    format!("${:.4}  ", self.cost_usd)
1642                },
1643                if self.cost_flash_frames > 0 {
1644                    Style::default()
1645                        .fg(self.theme.gold)
1646                        .add_modifier(Modifier::BOLD)
1647                } else {
1648                    Style::default().fg(self.theme.brand)
1649                },
1650            ),
1651            // tokens
1652            Span::styled(
1653                format!("{} tok  ", self.total_tokens),
1654                if self.tok_flash_frames > 0 {
1655                    Style::default()
1656                        .fg(self.theme.gold)
1657                        .add_modifier(Modifier::BOLD)
1658                } else {
1659                    Style::default().fg(self.theme.steel)
1660                },
1661            ),
1662            // autonomy pill with pulsing LED
1663            Span::styled(
1664                format!("{} ", led),
1665                Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1666            ),
1667            Span::styled(
1668                self.autonomy.to_uppercase(),
1669                Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1670            ),
1671        ]);
1672        f.render_widget(
1673            Paragraph::new(line).block(
1674                Block::default()
1675                    .borders(Borders::ALL)
1676                    .border_style(Style::default().fg(self.theme.line)),
1677            ),
1678            area,
1679        );
1680    }
1681
1682    fn render_swarm_lanes(&self, f: &mut Frame, area: Rect) {
1683        let Some(lanes) = &self.swarm_lanes else {
1684            return;
1685        };
1686        let cols = Layout::default()
1687            .direction(Direction::Horizontal)
1688            .constraints([
1689                Constraint::Percentage(33),
1690                Constraint::Percentage(34),
1691                Constraint::Percentage(33),
1692            ])
1693            .split(area);
1694        let age = self.frame.saturating_sub(lanes.started_at_frame);
1695        let items = [
1696            ("planner", &lanes.planner, self.theme.planner),
1697            ("coder", &lanes.coder, self.theme.agent),
1698            ("verifier", &lanes.verifier, self.theme.verifier),
1699        ];
1700        for (idx, (role, lane, color)) in items.iter().enumerate() {
1701            let working = lane.status == "Working" || lane.status == "Thinking";
1702            let icon = match lane.status.as_str() {
1703                "Done" => "✓",
1704                "Error" => "✗",
1705                "Idle" => "◌",
1706                _ if self.frame / 8 % 2 == 0 => "●",
1707                _ => "○",
1708            };
1709            let caret = if working && self.frame / 8 % 2 == 0 {
1710                " ▌"
1711            } else {
1712                ""
1713            };
1714            let note_width = cols[idx].width.saturating_sub(4) as usize;
1715            let note = truncate_for_width(&lane.note, note_width);
1716            let lines = vec![
1717                Line::from(Span::styled(
1718                    format!("{}  {}", role.to_uppercase(), lane.model),
1719                    Style::default().fg(*color).add_modifier(Modifier::BOLD),
1720                )),
1721                Line::from(Span::styled(
1722                    format!("{}  {}{}", icon, lane.status, caret),
1723                    Style::default().fg(if working { self.theme.gold } else { *color }),
1724                )),
1725                Line::from(Span::styled(note, Style::default().fg(self.theme.fg))),
1726            ];
1727            f.render_widget(
1728                Paragraph::new(Text::from(lines)).block(
1729                    Block::default()
1730                        .borders(Borders::ALL)
1731                        .title(format!("swarm {}", age.min(99)))
1732                        .border_style(Style::default().fg(*color)),
1733                ),
1734                cols[idx],
1735            );
1736        }
1737    }
1738
1739    fn render_scroll(&self, f: &mut Frame, area: Rect) {
1740        let max_lines = area.height.saturating_sub(2) as usize;
1741        if max_lines == 0 {
1742            return;
1743        }
1744        // Filter out child lines of collapsed groups; render headers as toggles.
1745        let rendered: Vec<Line> = self
1746            .lines
1747            .iter()
1748            .filter_map(|log| {
1749                // Hide children of collapsed groups
1750                if let Some(g) = log.group {
1751                    if self.groups.get(g).map(|gr| gr.collapsed).unwrap_or(false) {
1752                        return None;
1753                    }
1754                }
1755                if let Some(gid) = log.header_for {
1756                    // Collapsible header: ▾ expanded / ▸ collapsed + child count + focus mark
1757                    let gr = self.groups.get(gid);
1758                    let collapsed = gr.map(|g| g.collapsed).unwrap_or(false);
1759                    let title = gr.map(|g| g.title.as_str()).unwrap_or(log.text.as_str());
1760                    let log_style = gr.map(|g| g.style).unwrap_or(log.style);
1761                    let arrow = if collapsed { "▸" } else { "▾" };
1762                    let focused = self.focus_group == Some(gid);
1763                    let n = self.group_child_count(gid);
1764                    let hint = if collapsed && n > 0 {
1765                        format!("  ({} hidden)", n)
1766                    } else {
1767                        String::new()
1768                    };
1769                    let marker = if focused { "‣ " } else { "  " };
1770                    let mut style = Style::default().fg(log_style.color(&self.theme));
1771                    if focused {
1772                        style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
1773                    }
1774                    Some(Line::from(Span::styled(
1775                        format!("{}{} {}{}", marker, arrow, title, hint),
1776                        style,
1777                    )))
1778                } else {
1779                    let formatted = self.format_line(&log.text);
1780                    let prefix = "  ".repeat(log.indent as usize);
1781                    let rendered_line = crate::tui::ansi_bridge::render_line(
1782                        &formatted,
1783                        Style::default().fg(log.style.color(&self.theme)),
1784                    );
1785                    // Prepend indent prefix
1786                    let mut final_spans = vec![Span::styled(
1787                        prefix,
1788                        Style::default().fg(self.theme.dim),
1789                    )];
1790                    final_spans.extend(rendered_line.spans);
1791                    Some(Line::from(final_spans))
1792                }
1793            })
1794            .collect();
1795
1796        let total = rendered.len();
1797        let skip = (self.scroll as usize).min(total.saturating_sub(1));
1798        let show_logo = self.frame.saturating_sub(70) < 120 && self.scroll == 0;
1799        let logo_lines: Vec<Line> = if show_logo {
1800            theme::ascii_sparrow_at_frame(self.frame)
1801                .lines()
1802                .map(|line| {
1803                    Line::from(Span::styled(
1804                        line.to_string(),
1805                        Style::default().fg(self.theme.brand),
1806                    ))
1807                })
1808                .collect()
1809        } else {
1810            Vec::new()
1811        };
1812        let remaining = max_lines.saturating_sub(logo_lines.len());
1813        let mut text_lines: Vec<Line> = logo_lines;
1814        let start = total.saturating_sub(skip).saturating_sub(remaining);
1815        let end = total.saturating_sub(skip);
1816        text_lines.extend(rendered[start..end].iter().cloned());
1817        f.render_widget(
1818            Paragraph::new(Text::from(text_lines)).block(
1819                Block::default()
1820                    .borders(Borders::ALL)
1821                    .border_style(Style::default().fg(self.theme.line)),
1822            ),
1823            area,
1824        );
1825        self.render_embers(f, area);
1826    }
1827
1828    fn render_embers(&self, f: &mut Frame, area: Rect) {
1829        if area.width < 3 || area.height < 3 {
1830            return;
1831        }
1832        for ember in &self.embers {
1833            let x = area.x + 1 + (ember.x % area.width.saturating_sub(2));
1834            let y_offset = (ember.y.max(0.0) as u16) % area.height.saturating_sub(2);
1835            let y = area.y + 1 + y_offset;
1836            let color = if ember.amber {
1837                self.theme.gold
1838            } else {
1839                self.theme.rem
1840            };
1841            if let Some(cell) = f.buffer_mut().cell_mut((x, y)) {
1842                cell.set_char(ember.glyph).set_fg(color);
1843            }
1844        }
1845    }
1846
1847    fn render_diff(&self, f: &mut Frame, area: Rect) {
1848        let Some(diff) = self.pending_diffs.back() else {
1849            return;
1850        };
1851        let mut lines = vec![Line::from(vec![
1852            Span::styled("◇ ", Style::default().fg(self.theme.gold)),
1853            Span::styled(
1854                truncate_for_width(&diff.file, area.width.saturating_sub(20) as usize),
1855                Style::default()
1856                    .fg(self.theme.brand)
1857                    .add_modifier(Modifier::BOLD),
1858            ),
1859            Span::styled(
1860                format!("  +{} / -{}  · proposed", diff.plus, diff.minus),
1861                Style::default().fg(self.theme.dim),
1862            ),
1863        ])];
1864        for (idx, line) in diff
1865            .lines
1866            .iter()
1867            .take(area.height.saturating_sub(3) as usize)
1868            .enumerate()
1869        {
1870            let color = match line.kind {
1871                DiffLineKind::Plus => self.theme.add,
1872                DiffLineKind::Minus => self.theme.rem,
1873                DiffLineKind::Hunk => self.theme.gold,
1874                DiffLineKind::Context => self.theme.dim,
1875            };
1876            let mut spans = vec![Span::styled(
1877                format!("{:>4} ", idx + 1),
1878                Style::default().fg(self.theme.dimmer),
1879            )];
1880            spans.extend(syntax_spans(&line.text, &self.theme, color));
1881            lines.push(Line::from(spans));
1882        }
1883        f.render_widget(
1884            Paragraph::new(Text::from(lines)).block(
1885                Block::default()
1886                    .borders(Borders::ALL)
1887                    .title("diff")
1888                    .border_style(Style::default().fg(self.theme.line)),
1889            ),
1890            area,
1891        );
1892    }
1893
1894    fn render_checkpoint_timeline(&self, f: &mut Frame, area: Rect) {
1895        let mut spans = Vec::new();
1896        for (idx, node) in self
1897            .checkpoints
1898            .iter()
1899            .rev()
1900            .take(8)
1901            .collect::<Vec<_>>()
1902            .iter()
1903            .rev()
1904            .enumerate()
1905        {
1906            if idx > 0 {
1907                spans.push(Span::styled("──", Style::default().fg(self.theme.dimmer)));
1908            }
1909            spans.push(Span::styled(
1910                if node.current { "●" } else { "◆" },
1911                Style::default().fg(if node.current {
1912                    self.theme.gold
1913                } else {
1914                    self.theme.dim
1915                }),
1916            ));
1917        }
1918        if let Some(current) = self.checkpoints.iter().find(|n| n.current) {
1919            spans.push(Span::styled(
1920                format!(
1921                    "  {} · {}",
1922                    truncate_for_width(&current.label, 36),
1923                    current.id.chars().take(8).collect::<String>()
1924                ),
1925                Style::default().fg(self.theme.dim),
1926            ));
1927        }
1928        spans.push(Span::styled(
1929            "    rewind ← · snapshot before each batch",
1930            Style::default().fg(self.theme.dimmer),
1931        ));
1932        f.render_widget(Paragraph::new(Line::from(spans)), area);
1933    }
1934
1935    fn render_toast(&self, f: &mut Frame, area: Rect) {
1936        let Some(toast) = &self.toast else {
1937            return;
1938        };
1939        let width = (toast.text.chars().count() as u16 + 6).min(area.width.saturating_sub(2));
1940        if width < 8 || area.height < 5 {
1941            return;
1942        }
1943        let rect = Rect {
1944            x: area.x + area.width.saturating_sub(width) / 2,
1945            y: area.y + area.height.saturating_sub(3) / 2,
1946            width,
1947            height: 3,
1948        };
1949        let border = if toast.age / 20 % 2 == 0 {
1950            Style::default()
1951                .fg(self.theme.gold)
1952                .add_modifier(Modifier::BOLD)
1953        } else {
1954            Style::default().fg(self.theme.gold)
1955        };
1956        f.render_widget(
1957            Paragraph::new(Line::from(Span::styled(
1958                toast.text.as_str(),
1959                Style::default()
1960                    .fg(self.theme.gold)
1961                    .add_modifier(Modifier::BOLD),
1962            )))
1963            .block(Block::default().borders(Borders::ALL).border_style(border)),
1964            rect,
1965        );
1966    }
1967
1968    fn render_input(&self, f: &mut Frame, area: Rect) {
1969        let cursor_char = if self.frame / 8 % 2 == 0 { "▌" } else { " " };
1970        let prompt = if self.inject_pending {
1971            "◆ inject › "
1972        } else {
1973            "◆ sparrow › "
1974        };
1975        let prompt_color = if self.inject_pending {
1976            self.theme.coral
1977        } else {
1978            self.theme.brand
1979        };
1980
1981        let mut text_lines: Vec<Line> = Vec::new();
1982        for (row_idx, line) in self.input_lines.iter().enumerate() {
1983            let mut spans: Vec<Span> = Vec::new();
1984            if row_idx == 0 {
1985                spans.push(Span::styled(
1986                    prompt,
1987                    Style::default()
1988                        .fg(prompt_color)
1989                        .add_modifier(Modifier::BOLD),
1990                ));
1991            } else {
1992                spans.push(Span::styled(
1993                    "          › ",
1994                    Style::default().fg(self.theme.dimmer),
1995                ));
1996            }
1997            if row_idx == self.cursor_row {
1998                let (before, after) = line.split_at(self.cursor_col.min(line.len()));
1999                spans.push(Span::styled(before, Style::default().fg(self.theme.fg)));
2000                spans.push(Span::styled(cursor_char, Style::default().fg(prompt_color)));
2001                spans.push(Span::styled(after, Style::default().fg(self.theme.fg)));
2002            } else {
2003                spans.push(Span::styled(
2004                    line.as_str(),
2005                    Style::default().fg(self.theme.fg),
2006                ));
2007            }
2008            text_lines.push(Line::from(spans));
2009        }
2010
2011        // Autocomplete row (suggestions)
2012        let suggestions = self.autocomplete_matches();
2013        if !suggestions.is_empty() {
2014            let mut s: Vec<Span> = vec![Span::styled(
2015                "  ⇥  ",
2016                Style::default().fg(self.theme.dimmer),
2017            )];
2018            for (i, cmd) in suggestions.iter().enumerate() {
2019                if i == 0 {
2020                    s.push(Span::styled(
2021                        *cmd,
2022                        Style::default()
2023                            .fg(self.theme.brand)
2024                            .add_modifier(Modifier::BOLD),
2025                    ));
2026                } else {
2027                    s.push(Span::styled(*cmd, Style::default().fg(self.theme.dim)));
2028                }
2029                s.push(Span::raw("  "));
2030            }
2031            text_lines.push(Line::from(s));
2032        }
2033
2034        f.render_widget(
2035            Paragraph::new(Text::from(text_lines)).block(
2036                Block::default()
2037                    .borders(Borders::ALL)
2038                    .border_style(Style::default().fg(self.theme.line)),
2039            ),
2040            area,
2041        );
2042    }
2043}
2044
2045impl Default for Tui {
2046    fn default() -> Self {
2047        Self::new()
2048    }
2049}