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