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
24type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>;
25
26#[derive(Debug, Clone)]
27struct LogLine {
28    text: String,
29    style: LogStyle,
30    indent: u16,
31    /// If set, this line is a child of collapsible group N (hidden when collapsed).
32    group: Option<usize>,
33    /// If set, this line IS the collapsible header for group N.
34    header_for: Option<usize>,
35}
36
37/// A collapsible task group in the scroll log (a run, an agent phase, a tool call).
38#[derive(Debug, Clone)]
39struct TaskGroup {
40    title: String,
41    collapsed: bool,
42    style: LogStyle,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq)]
46enum LogStyle {
47    Normal,
48    Dim,
49    Brand,
50    Agent,
51    Planner,
52    Verifier,
53    Rem,
54    Steel,
55    Gold,
56    Prompt,
57    Cmd,
58    Ok,
59    Warn,
60    Err,
61    Accent,
62}
63
64impl LogStyle {
65    fn color(&self, theme: &Theme) -> Color {
66        match self {
67            LogStyle::Normal => theme.fg,
68            LogStyle::Dim => theme.dim,
69            LogStyle::Brand => theme.brand,
70            LogStyle::Agent => theme.agent,
71            LogStyle::Planner => theme.planner,
72            LogStyle::Verifier => theme.verifier,
73            LogStyle::Rem => theme.rem,
74            LogStyle::Steel => theme.steel,
75            LogStyle::Gold => theme.gold,
76            LogStyle::Prompt => theme.brand,
77            LogStyle::Cmd => theme.fg,
78            LogStyle::Ok => theme.add,
79            LogStyle::Warn => theme.verifier,
80            LogStyle::Err => theme.rem,
81            LogStyle::Accent => theme.brand,
82        }
83    }
84}
85
86const SLASH_COMMANDS: &[&str] = &[
87    "/help",
88    "/plan",
89    "/permissions",
90    "/memory",
91    "/compact",
92    "/model",
93    "/agents",
94    "/sessions",
95    "/export",
96    "/run",
97    "/chat",
98    "/swarm",
99    "/agent",
100    "/skills",
101    "/checkpoint",
102    "/rewind",
103    "/replay",
104    "/auth",
105    "/clear",
106    "/collapse",
107    "/expand",
108    "/exit",
109];
110
111const HISTORY_MAX: usize = 100;
112
113// ─── Swarm lanes ─────────────────────────────────────────────────────────────
114
115#[derive(Debug, Clone)]
116struct LaneState {
117    /// AgentStatus name (Idle/Thinking/Working/Done/Error/WaitingForApproval)
118    status: String,
119    /// last note text
120    note: String,
121    /// Brain id
122    model: String,
123}
124
125impl Default for LaneState {
126    fn default() -> Self {
127        Self {
128            status: "Idle".into(),
129            note: "".into(),
130            model: "".into(),
131        }
132    }
133}
134
135#[derive(Debug, Clone, Default)]
136struct SwarmLanesState {
137    planner: LaneState,
138    coder: LaneState,
139    verifier: LaneState,
140    /// Frame at which the swarm started; used to fade-in lanes.
141    started_at_frame: u64,
142}
143
144// ─── Diff panel ──────────────────────────────────────────────────────────────
145
146#[derive(Debug, Clone)]
147enum DiffLineKind {
148    Context,
149    Plus,
150    Minus,
151    Hunk,
152}
153
154#[derive(Debug, Clone)]
155struct DiffLineEntry {
156    kind: DiffLineKind,
157    text: String,
158}
159
160#[derive(Debug, Clone)]
161struct DiffEntry {
162    file: String,
163    plus: u32,
164    minus: u32,
165    lines: Vec<DiffLineEntry>,
166    applied: bool,
167}
168
169fn parse_diff_patch(patch: &str) -> Vec<DiffLineEntry> {
170    let mut out = Vec::new();
171    for line in patch.lines().take(40) {
172        let kind = if line.starts_with("+++") || line.starts_with("---") {
173            DiffLineKind::Context
174        } else if line.starts_with("@@") {
175            DiffLineKind::Hunk
176        } else if line.starts_with('+') {
177            DiffLineKind::Plus
178        } else if line.starts_with('-') {
179            DiffLineKind::Minus
180        } else {
181            DiffLineKind::Context
182        };
183        out.push(DiffLineEntry {
184            kind,
185            text: line.to_string(),
186        });
187    }
188    out
189}
190
191fn truncate_for_width(text: &str, width: usize) -> String {
192    if width == 0 {
193        return String::new();
194    }
195    let mut out = String::new();
196    for ch in text.chars().take(width) {
197        out.push(ch);
198    }
199    if text.chars().count() > width && width > 1 {
200        out.pop();
201        out.push('…');
202    }
203    out
204}
205
206fn syntax_spans(text: &str, theme: &Theme, base: Color) -> Vec<Span<'static>> {
207    const KEYWORDS: &[&str] = &[
208        "fn", "pub", "if", "else", "return", "let", "mut", "const", "struct", "impl", "trait",
209        "use", "as", "match",
210    ];
211    let violet = Color::Rgb(0xb4, 0x8e, 0xff);
212    let mut spans = Vec::new();
213    let mut buf = String::new();
214    let chars = text.chars();
215    let mut in_string = false;
216
217    let flush_word = |word: &mut String, spans: &mut Vec<Span<'static>>, next_is_call: bool| {
218        if word.is_empty() {
219            return;
220        }
221        let style = if KEYWORDS.contains(&word.as_str()) {
222            Style::default().fg(violet).add_modifier(Modifier::BOLD)
223        } else if next_is_call {
224            Style::default().fg(theme.gold)
225        } else {
226            Style::default().fg(base)
227        };
228        spans.push(Span::styled(std::mem::take(word), style));
229    };
230
231    for ch in chars {
232        if ch == '"' {
233            if in_string {
234                buf.push(ch);
235                spans.push(Span::styled(
236                    std::mem::take(&mut buf),
237                    Style::default().fg(theme.add),
238                ));
239                in_string = false;
240            } else {
241                flush_word(&mut buf, &mut spans, false);
242                buf.push(ch);
243                in_string = true;
244            }
245            continue;
246        }
247        if in_string {
248            buf.push(ch);
249            continue;
250        }
251        if ch.is_alphanumeric() || ch == '_' {
252            buf.push(ch);
253            continue;
254        }
255        let next_is_call = ch == '(';
256        flush_word(&mut buf, &mut spans, next_is_call);
257        spans.push(Span::styled(ch.to_string(), Style::default().fg(base)));
258    }
259    if in_string {
260        spans.push(Span::styled(buf, Style::default().fg(theme.add)));
261    } else {
262        flush_word(&mut buf, &mut spans, false);
263    }
264    spans
265}
266
267// ─── Checkpoint timeline ─────────────────────────────────────────────────────
268
269#[derive(Debug, Clone)]
270struct CheckpointNode {
271    id: String,
272    label: String,
273    current: bool,
274}
275
276// ─── Embers (background particles) ───────────────────────────────────────────
277
278#[derive(Debug, Clone)]
279struct Ember {
280    x: u16,
281    y: f32,
282    vy: f32,
283    /// true = amber, false = coral
284    amber: bool,
285    life: u32,
286    max_life: u32,
287    /// char from the bird theme
288    glyph: char,
289}
290
291// ─── Toast (skill learned, etc.) ─────────────────────────────────────────────
292
293#[derive(Debug, Clone)]
294struct Toast {
295    text: String,
296    /// frames since spawn
297    age: u32,
298    /// total lifetime in frames
299    max_age: u32,
300}
301
302pub struct Tui {
303    theme: Theme,
304    lines: Vec<LogLine>,
305    route: String,
306    cost_usd: f64,
307    total_tokens: u64,
308    autonomy: String,
309    /// Multi-line input. input_lines[0] = first row of the prompt.
310    input_lines: Vec<String>,
311    /// Cursor row within input_lines.
312    cursor_row: usize,
313    /// Cursor col (byte index) within input_lines[cursor_row].
314    cursor_col: usize,
315    /// Command history, oldest first.
316    history: Vec<String>,
317    /// When navigating history, index into history; None = fresh editing.
318    history_idx: Option<usize>,
319    /// Pending injection mode: next Enter sends as injection, not new task.
320    inject_pending: bool,
321    scroll: u16,
322    frame: u64,
323    spinner_idx: usize,
324    booted: bool,
325    boot_progress: u32,
326    event_rx: Option<mpsc::UnboundedReceiver<Event>>,
327    task_tx: Option<mpsc::UnboundedSender<String>>,
328    history_path: Option<std::path::PathBuf>,
329
330    // ── Batch 3 additions ─────────────────────────────────────────────────
331    /// Active swarm lanes (None when not in swarm mode).
332    swarm_lanes: Option<SwarmLanesState>,
333    /// Pending diffs (cap = 3, FIFO).
334    pending_diffs: std::collections::VecDeque<DiffEntry>,
335    /// Checkpoint timeline nodes.
336    checkpoints: Vec<CheckpointNode>,
337    /// Drifting embers in the scroll area.
338    embers: Vec<Ember>,
339    /// Centered overlay toast (skill learned, etc.).
340    toast: Option<Toast>,
341    /// Cost flash counter (frames remaining of bold cost).
342    cost_flash_frames: u32,
343    last_cost: f64,
344    /// Token flash counter.
345    tok_flash_frames: u32,
346    last_tokens: u64,
347
348    // ── Collapsible task groups ───────────────────────────────────────────
349    /// Collapsible task groups; child lines reference these by index.
350    groups: Vec<TaskGroup>,
351    /// Group that new lines are attached to (None = top level).
352    current_group: Option<usize>,
353    /// Group header currently focused for collapse/expand (Ctrl+↑/↓, Ctrl+O).
354    focus_group: Option<usize>,
355
356    // ── Replay scrubber ───────────────────────────────────────────────────
357    /// When set, the TUI is in replay mode: scrub events with ←/→.
358    replay_events: Option<Vec<Event>>,
359    replay_idx: usize,
360    /// Strips <think> reasoning blocks from streamed deltas.
361    think: crate::event::ThinkStripper,
362    /// Known agent names for `@<name>` autocomplete; populated by the host.
363    agent_names: Vec<String>,
364    /// Currently active agent (toggled via @picker). None = default pipeline.
365    active_agent: Option<String>,
366    /// Cached agent souls: name → (role, personality_b64).
367    agent_souls: std::collections::HashMap<String, (String, String)>,
368}
369
370impl Tui {
371    pub fn new() -> Self {
372        // Resolve history path: ~/.local/state/sparrow/tui_history.txt
373        let history_path = dirs::state_dir()
374            .or_else(dirs::data_local_dir)
375            .or_else(dirs::data_dir)
376            .map(|d| d.join("sparrow").join("tui_history.txt"));
377        let history = history_path
378            .as_ref()
379            .and_then(|p| std::fs::read_to_string(p).ok())
380            .map(|s| s.lines().map(String::from).collect())
381            .unwrap_or_default();
382
383        // Pick theme from $SPARROW_THEME or default to `captain`.
384        let theme = std::env::var("SPARROW_THEME")
385            .ok()
386            .map(|n| crate::tui::theme::by_name(&n))
387            .unwrap_or_default();
388        Self {
389            theme,
390            lines: Vec::new(),
391            route: "idle".into(),
392            cost_usd: 0.0,
393            total_tokens: 0,
394            autonomy: "supervised".into(),
395            input_lines: vec![String::new()],
396            cursor_row: 0,
397            cursor_col: 0,
398            history,
399            history_idx: None,
400            inject_pending: false,
401            scroll: 0,
402            frame: 0,
403            spinner_idx: 0,
404            booted: false,
405            boot_progress: 0,
406            event_rx: None,
407            task_tx: None,
408            history_path,
409            swarm_lanes: None,
410            pending_diffs: std::collections::VecDeque::new(),
411            checkpoints: Vec::new(),
412            embers: Self::spawn_embers(),
413            toast: None,
414            cost_flash_frames: 0,
415            last_cost: 0.0,
416            tok_flash_frames: 0,
417            last_tokens: 0,
418            groups: Vec::new(),
419            current_group: None,
420            focus_group: None,
421            replay_events: None,
422            replay_idx: 0,
423            think: crate::event::ThinkStripper::new(),
424            agent_names: Vec::new(),
425            active_agent: None,
426            agent_souls: std::collections::HashMap::new(),
427        }
428    }
429
430    /// Launch the TUI as a replay scrubber over a recorded transcript.
431    /// ←/→ step through events; Home/End jump to start/end.
432    pub fn with_replay(mut self, events: Vec<Event>) -> Self {
433        self.replay_events = Some(events);
434        self.replay_idx = 0;
435        self.booted = true; // skip boot animation in replay mode
436        self
437    }
438
439    /// Rebuild the log from replay events up to `replay_idx`.
440    fn rebuild_replay(&mut self) {
441        let Some(events) = self.replay_events.clone() else {
442            return;
443        };
444        self.lines.clear();
445        self.groups.clear();
446        self.current_group = None;
447        self.focus_group = None;
448        self.cost_usd = 0.0;
449        self.total_tokens = 0;
450        let upto = self.replay_idx.min(events.len());
451        for ev in events.iter().take(upto) {
452            self.push_event(ev.clone());
453        }
454        let total = events.len();
455        self.add_line(
456            &format!(
457                "── replay {}/{}  (←/→ step · Home/End jump · q quit) ──",
458                upto, total
459            ),
460            LogStyle::Accent,
461            0,
462        );
463    }
464
465    fn spawn_embers() -> Vec<Ember> {
466        // Deterministic-ish initial spread (no rand dep): use position + idx as seed.
467        let glyphs = ['·', '•', '∘', '◦'];
468        (0..10u16)
469            .map(|i| Ember {
470                x: 4 + (i * 13) % 90,
471                y: 4.0 + ((i as f32) * 2.7) % 20.0,
472                vy: 0.10 + ((i as f32) * 0.037) % 0.25,
473                amber: i % 2 == 0,
474                life: ((i as u32) * 17) % 180,
475                max_life: 180 + ((i as u32) * 11) % 90,
476                glyph: glyphs[(i as usize) % glyphs.len()],
477            })
478            .collect()
479    }
480
481    /// Snapshot current input as a single joined string.
482    fn current_input(&self) -> String {
483        self.input_lines.join("\n")
484    }
485
486    /// Replace current input with a single-line snapshot (used by history nav).
487    fn set_input(&mut self, s: &str) {
488        self.input_lines = s.split('\n').map(String::from).collect();
489        if self.input_lines.is_empty() {
490            self.input_lines.push(String::new());
491        }
492        self.cursor_row = self.input_lines.len() - 1;
493        self.cursor_col = self.input_lines[self.cursor_row].len();
494    }
495
496    /// Append current input to history (de-dup against last entry) and persist.
497    fn push_history(&mut self, entry: &str) {
498        if entry.trim().is_empty() {
499            return;
500        }
501        if self.history.last().map(|s| s.as_str()) == Some(entry) {
502            return;
503        }
504        self.history.push(entry.to_string());
505        if self.history.len() > HISTORY_MAX {
506            let excess = self.history.len() - HISTORY_MAX;
507            self.history.drain(..excess);
508        }
509        if let Some(path) = &self.history_path {
510            if let Some(parent) = path.parent() {
511                let _ = std::fs::create_dir_all(parent);
512            }
513            let _ = std::fs::write(path, self.history.join("\n"));
514        }
515    }
516
517    /// Match autocomplete candidates for the current input.
518    fn autocomplete_matches(&self) -> Vec<&'static str> {
519        let line = &self.input_lines[0];
520        if line.starts_with('/') {
521            return SLASH_COMMANDS
522                .iter()
523                .filter(|c| c.starts_with(line.as_str()) && **c != line.as_str())
524                .copied()
525                .take(5)
526                .collect();
527        }
528        vec![]
529    }
530
531    /// Test hook: mutable access to the first input line.
532    #[doc(hidden)]
533    pub fn debug_first_line_mut(&mut self) -> &mut String {
534        if self.input_lines.is_empty() {
535            self.input_lines.push(String::new());
536        }
537        &mut self.input_lines[0]
538    }
539
540    /// Test hook: set the cursor column.
541    #[doc(hidden)]
542    pub fn debug_set_cursor_col(&mut self, col: usize) {
543        self.cursor_row = 0;
544        self.cursor_col = col;
545    }
546
547    /// `@<name>` agent picker: returns owned strings prefixed with `@`. Separate
548    /// from the slash autocomplete because the candidate list is dynamic.
549    pub fn agent_matches(&self) -> Vec<String> {
550        // Find the last `@` token on the current line.
551        let line = &self.input_lines[self.cursor_row];
552        let upto = line.get(..self.cursor_col).unwrap_or(line);
553        let Some(at_pos) = upto.rfind('@') else {
554            return vec![];
555        };
556        // Don't trigger when `@` is preceded by a non-whitespace char (so e-mails
557        // like foo@example don't fire the picker).
558        if at_pos > 0
559            && !upto[..at_pos]
560                .chars()
561                .last()
562                .map(|c| c.is_whitespace())
563                .unwrap_or(true)
564        {
565            return vec![];
566        }
567        let prefix = &upto[at_pos + 1..];
568        // Bail if the fragment already contains whitespace — picker is over.
569        if prefix.contains(char::is_whitespace) {
570            return vec![];
571        }
572        self.agent_names
573            .iter()
574            .filter(|n| n.starts_with(prefix))
575            .take(5)
576            .map(|n| format!("@{}", n))
577            .collect()
578    }
579
580    /// Populate the `@<name>` agent picker with the agents the host knows about.
581    pub fn with_agents(mut self, names: Vec<String>) -> Self {
582        self.agent_names = names;
583        self
584    }
585
586    /// Toggle an agent on/off. When toggled on, all subsequent tasks run with
587    /// that agent's identity. Toggle again (or toggle another agent) to switch.
588    pub fn toggle_agent(&mut self, name: &str) {
589        if self.active_agent.as_deref() == Some(name) {
590            // Deselect
591            self.active_agent = None;
592        } else {
593            // Select — cache the agent soul
594            self.active_agent = Some(name.to_string());
595            if !self.agent_souls.contains_key(name) {
596                self.cache_agent_soul(name);
597            }
598        }
599    }
600
601    /// Load and cache an agent's soul (role + base64 personality).
602    fn cache_agent_soul(&mut self, name: &str) {
603        let path = dirs::config_dir()
604            .unwrap_or_default()
605            .join("sparrow")
606            .join("agents")
607            .join(format!("{}.soul.toml", name));
608        if let Ok(content) = std::fs::read_to_string(&path) {
609            let role = content.lines()
610                .find(|l| l.starts_with("role"))
611                .and_then(|l| l.split('=').nth(1))
612                .map(|s| s.trim().trim_matches('"').to_string())
613                .unwrap_or_default();
614            let personality = content.lines()
615                .find(|l| l.starts_with("personality"))
616                .and_then(|l| l.split('=').nth(1))
617                .map(|s| s.trim().trim_matches('"').to_string())
618                .unwrap_or_default();
619            use base64::{Engine as _, engine::general_purpose::STANDARD};
620            let b64 = STANDARD.encode(personality.as_bytes());
621            self.agent_souls.insert(name.to_string(), (role, b64));
622        }
623    }
624
625    /// Build the agent dispatch prefix for task sending.
626    fn agent_prefix(&self) -> String {
627        if let Some(ref name) = self.active_agent {
628            if let Some((role, b64)) = self.agent_souls.get(name) {
629                return format!("__agent:{}__{}__{}__ ", name, role, b64);
630            }
631        }
632        String::new()
633    }
634
635    pub fn with_channels(
636        mut self,
637        task_tx: mpsc::UnboundedSender<String>,
638        event_rx: mpsc::UnboundedReceiver<Event>,
639    ) -> Self {
640        self.task_tx = Some(task_tx);
641        self.event_rx = Some(event_rx);
642        self
643    }
644
645    pub fn push_event(&mut self, event: Event) {
646        match &event {
647            Event::RunStarted { task, .. } => {
648                self.think = crate::event::ThinkStripper::new();
649                self.open_group(&format!("started: {}", task), LogStyle::Brand);
650            }
651            Event::RouteSelected { chain, .. } => {
652                self.route = chain.join(" → ");
653                self.add_line(&format!("↳ route: {}", self.route), LogStyle::Dim, 1);
654            }
655            Event::ModelSwitched {
656                from, to, reason, ..
657            } => {
658                self.route = to.clone();
659                let clean = crate::event::friendly_model_switch_reason(reason);
660                let label = if crate::event::is_local_model_unavailable(reason) {
661                    format!(
662                        "↳ modèle local indisponible → routage modèle cloud ({})",
663                        to
664                    )
665                } else {
666                    format!("↳ fallback: {} → {} ({})", from, to, clean)
667                };
668                self.add_line(&label, LogStyle::Warn, 1);
669            }
670            Event::ThinkingDelta { text, .. } => {
671                let visible = self.think.feed(text);
672                if !visible.is_empty() {
673                    self.add_line(&visible, LogStyle::Cmd, 1);
674                }
675            }
676            Event::ReasoningDelta { .. } => {}
677            Event::ToolUseProposed { name, .. } => {
678                self.open_group(&format!("tool · {}", name), LogStyle::Steel);
679            }
680            Event::ToolOutput { blocks, .. } => {
681                for b in blocks {
682                    if let crate::event::Block::Text(t) = b {
683                        self.add_line(&format!("  {}", t), LogStyle::Dim, 2);
684                    }
685                }
686            }
687            Event::AgentSpawned { role, model, .. } => {
688                let lanes = self.swarm_lanes.get_or_insert_with(|| SwarmLanesState {
689                    started_at_frame: self.frame,
690                    ..Default::default()
691                });
692                let lane = match role.as_str() {
693                    "planner" => &mut lanes.planner,
694                    "coder" => &mut lanes.coder,
695                    "verifier" => &mut lanes.verifier,
696                    _ => &mut lanes.coder,
697                };
698                lane.status = "Working".into();
699                lane.note = "spawned".into();
700                lane.model = model.clone();
701                let s = match role.as_str() {
702                    "planner" => LogStyle::Planner,
703                    "coder" => LogStyle::Agent,
704                    "verifier" => LogStyle::Verifier,
705                    _ => LogStyle::Dim,
706                };
707                self.open_group(&format!("{} ({})", role, model), s);
708            }
709            Event::AgentStatus {
710                role, note, status, ..
711            } => {
712                if let Some(lanes) = self.swarm_lanes.as_mut() {
713                    let lane = match role.as_str() {
714                        "planner" => &mut lanes.planner,
715                        "coder" => &mut lanes.coder,
716                        "verifier" => &mut lanes.verifier,
717                        _ => &mut lanes.coder,
718                    };
719                    lane.status = format!("{:?}", status);
720                    lane.note = note.clone();
721                }
722                let s = match role.as_str() {
723                    "planner" => LogStyle::Planner,
724                    "coder" => LogStyle::Agent,
725                    "verifier" => LogStyle::Verifier,
726                    _ => LogStyle::Dim,
727                };
728                let icon = match status {
729                    crate::event::AgentStatus::Done => "✓",
730                    crate::event::AgentStatus::Working => "●",
731                    crate::event::AgentStatus::Thinking => "○",
732                    crate::event::AgentStatus::Error => "✗",
733                    _ => "◌",
734                };
735                self.add_line(&format!("{} {} — {}", icon, role, note), s, 1);
736            }
737            Event::CheckpointCreated { id, label, .. } => {
738                for node in &mut self.checkpoints {
739                    node.current = false;
740                }
741                self.checkpoints.push(CheckpointNode {
742                    id: id.0.clone(),
743                    label: label.clone(),
744                    current: true,
745                });
746                self.add_line(&format!("● checkpoint: {}", label), LogStyle::Gold, 0)
747            }
748            Event::SkillLearned { name, .. } => {
749                self.toast = Some(Toast {
750                    text: format!("✦ skill learned · {}", name),
751                    age: 0,
752                    max_age: 90,
753                });
754                self.add_line(&format!("✦ skill learned · {}", name), LogStyle::Agent, 0)
755            }
756            Event::CostUpdate { usd, .. } => {
757                if *usd > self.last_cost {
758                    self.cost_flash_frames = 12;
759                }
760                self.last_cost = *usd;
761                self.cost_usd = *usd;
762            }
763            Event::TokenUsage { input, output, .. } => {
764                self.total_tokens += input + output;
765                if self.total_tokens > self.last_tokens {
766                    self.tok_flash_frames = 12;
767                }
768                self.last_tokens = self.total_tokens;
769            }
770            Event::TokenUsageEstimated { input, output, .. } => {
771                self.total_tokens += input + output;
772                if self.total_tokens > self.last_tokens {
773                    self.tok_flash_frames = 12;
774                }
775                self.last_tokens = self.total_tokens;
776            }
777            Event::AutonomyChanged { level, .. } => {
778                self.autonomy = format!("{:?}", level).to_lowercase()
779            }
780            Event::DiffProposed {
781                file,
782                patch,
783                plus,
784                minus,
785                ..
786            } => {
787                if self.pending_diffs.len() >= 3 {
788                    self.pending_diffs.pop_front();
789                }
790                self.pending_diffs.push_back(DiffEntry {
791                    file: file.clone(),
792                    plus: *plus,
793                    minus: *minus,
794                    lines: parse_diff_patch(patch),
795                    applied: false,
796                });
797                self.add_line(
798                    &format!("◇ {}  +{} / -{}  · proposed", file, plus, minus),
799                    LogStyle::Dim,
800                    0,
801                )
802            }
803            Event::DiffApplied { file, .. } => {
804                if let Some(entry) = self.pending_diffs.iter_mut().find(|d| d.file == *file) {
805                    entry.applied = true;
806                }
807                while self.pending_diffs.front().is_some_and(|d| d.applied) {
808                    self.pending_diffs.pop_front();
809                }
810            }
811            Event::TestResult {
812                passed,
813                failed,
814                detail,
815                ..
816            } => {
817                if *failed > 0 {
818                    self.add_line(
819                        &format!("⚠ tests  {} passed · {} failed", passed, failed),
820                        LogStyle::Warn,
821                        1,
822                    );
823                    for line in detail.lines() {
824                        self.add_line(&format!("  {}", line), LogStyle::Rem, 2);
825                    }
826                } else {
827                    self.add_line(
828                        &format!("✓ tests  {} passed · no regressions", passed),
829                        LogStyle::Ok,
830                        1,
831                    );
832                }
833            }
834            Event::RunFinished { outcome, .. } => {
835                // Recover any text held by the think-stripper (unclosed <think>).
836                let tail = self.think.flush();
837                if !tail.trim().is_empty() {
838                    self.add_line(&tail, LogStyle::Cmd, 1);
839                }
840                self.close_group();
841                self.add_line(
842                    &format!(
843                        "✓ done  status: {}  cost: ${:.4}",
844                        outcome.status, outcome.cost_usd
845                    ),
846                    LogStyle::Ok,
847                    0,
848                );
849            }
850            Event::Error { message, .. } => {
851                if !crate::event::is_local_model_unavailable(message) {
852                    self.add_line(message, LogStyle::Err, 0);
853                }
854            }
855            _ => {}
856        }
857    }
858
859    fn add_line(&mut self, text: &str, style: LogStyle, indent: u16) {
860        let group = self.current_group;
861        for line in text.lines() {
862            self.lines.push(LogLine {
863                text: line.to_string(),
864                style,
865                indent,
866                group,
867                header_for: None,
868            });
869        }
870    }
871
872    /// Open a new collapsible task group; subsequent `add_line` calls attach to it.
873    fn open_group(&mut self, title: &str, style: LogStyle) {
874        let id = self.groups.len();
875        self.groups.push(TaskGroup {
876            title: title.to_string(),
877            collapsed: false,
878            style,
879        });
880        self.lines.push(LogLine {
881            text: title.to_string(),
882            style,
883            indent: 0,
884            group: None,
885            header_for: Some(id),
886        });
887        self.current_group = Some(id);
888        self.focus_group = Some(id);
889    }
890
891    /// Close the active group (subsequent lines go top-level).
892    fn close_group(&mut self) {
893        self.current_group = None;
894    }
895
896    /// Number of child lines belonging to a group (for the "N hidden" hint).
897    fn group_child_count(&self, id: usize) -> usize {
898        self.lines.iter().filter(|l| l.group == Some(id)).count()
899    }
900
901    /// Move focus to the previous/next group header.
902    fn focus_group_step(&mut self, forward: bool) {
903        if self.groups.is_empty() {
904            return;
905        }
906        let last = self.groups.len() - 1;
907        self.focus_group = Some(match self.focus_group {
908            None => last,
909            Some(i) if forward => (i + 1).min(last),
910            Some(i) => i.saturating_sub(1),
911        });
912    }
913
914    /// Toggle collapse on the focused group, or all groups if none focused.
915    fn toggle_group(&mut self) {
916        match self.focus_group {
917            Some(i) if i < self.groups.len() => {
918                self.groups[i].collapsed = !self.groups[i].collapsed;
919            }
920            _ => {
921                let any_open = self.groups.iter().any(|g| !g.collapsed);
922                for g in &mut self.groups {
923                    g.collapsed = any_open;
924                }
925            }
926        }
927    }
928
929    fn boot(&mut self) {
930        self.add_line(
931            "SPARROW  v0.1.0 — one cli · grows with you",
932            LogStyle::Dim,
933            0,
934        );
935        self.add_line("", LogStyle::Normal, 0);
936
937        // Honest, platform-aware sandbox status. seccomp/namespaces are Linux-only;
938        // on other platforms we run with workspace path-boundary enforcement only.
939        #[cfg(target_os = "linux")]
940        let sandbox_line = "local-hardened · namespaces + path boundary";
941        #[cfg(not(target_os = "linux"))]
942        let sandbox_line = "path-boundary enforcement (namespaces are Linux-only)";
943
944        let boot = [
945            (
946                "router  ",
947                "model routing + fallback chain",
948                LogStyle::Planner,
949            ),
950            (
951                "surfaces",
952                "cli · tui · webview · gateway",
953                LogStyle::Planner,
954            ),
955            ("sandbox ", sandbox_line, LogStyle::Ok),
956            (
957                "skills  ",
958                "library indexed · self-improving",
959                LogStyle::Accent,
960            ),
961            (
962                "memory  ",
963                "sqlite · bounded docs · session search",
964                LogStyle::Ok,
965            ),
966            (
967                "autonomy",
968                "dial: supervised → trusted → autonomous",
969                LogStyle::Accent,
970            ),
971        ];
972        for (k, v, s) in &boot {
973            self.add_line(&format!("{}  {}", k, v), *s, 1);
974        }
975        self.add_line("✓ ready  one binary. no dependencies.", LogStyle::Ok, 0);
976        self.add_line("", LogStyle::Normal, 0);
977        self.booted = true;
978    }
979
980    pub fn run(&mut self) -> io::Result<()> {
981        enable_raw_mode()?;
982        let mut stdout = io::stdout();
983        execute!(stdout, EnterAlternateScreen)?;
984        let backend = ratatui::backend::CrosstermBackend::new(stdout);
985        let mut terminal = ratatui::Terminal::new(backend)?;
986        let result = self.main_loop(&mut terminal);
987        disable_raw_mode()?;
988        execute!(io::stdout(), LeaveAlternateScreen)?;
989        result
990    }
991
992    fn main_loop(&mut self, terminal: &mut CrosstermTerminal) -> io::Result<()> {
993        let start = Instant::now();
994        if self.replay_events.is_some() {
995            self.rebuild_replay();
996        }
997        loop {
998            self.drain_engine_events();
999            self.frame += 1;
1000            self.spinner_idx = (self.spinner_idx + 1) % 10;
1001            self.tick_visuals();
1002            terminal.draw(|f| self.render(f, start.elapsed().as_secs_f64()))?;
1003            if event::poll(std::time::Duration::from_millis(50))? {
1004                if let TermEvent::Key(key) = event::read()? {
1005                    if key.kind != KeyEventKind::Press {
1006                        continue;
1007                    }
1008                    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
1009                    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
1010                    match key.code {
1011                        KeyCode::Esc => break,
1012                        KeyCode::Char('c') if ctrl => break,
1013
1014                        // ── Replay scrubber (active only in replay mode) ─────
1015                        KeyCode::Char('q') if self.replay_events.is_some() => break,
1016                        KeyCode::Left if self.replay_events.is_some() => {
1017                            self.replay_idx = self.replay_idx.saturating_sub(1);
1018                            self.rebuild_replay();
1019                        }
1020                        KeyCode::Right if self.replay_events.is_some() => {
1021                            let max = self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1022                            self.replay_idx = (self.replay_idx + 1).min(max);
1023                            self.rebuild_replay();
1024                        }
1025                        KeyCode::Home if self.replay_events.is_some() => {
1026                            self.replay_idx = 0;
1027                            self.rebuild_replay();
1028                        }
1029                        KeyCode::End if self.replay_events.is_some() => {
1030                            self.replay_idx =
1031                                self.replay_events.as_ref().map(|e| e.len()).unwrap_or(0);
1032                            self.rebuild_replay();
1033                        }
1034
1035                        // Ctrl+L → clear log buffer
1036                        KeyCode::Char('l') if ctrl => {
1037                            self.lines.clear();
1038                        }
1039                        // Ctrl+I → next Enter sends as mid-run injection
1040                        KeyCode::Char('i') if ctrl => {
1041                            self.inject_pending = true;
1042                            self.add_line(
1043                                "[inject] next message will be sent to the running agent",
1044                                LogStyle::Warn,
1045                                0,
1046                            );
1047                        }
1048
1049                        // ── Collapsible task groups ──────────────────────────
1050                        // Ctrl+↑/↓ move focus between task headers; Ctrl+O toggles.
1051                        KeyCode::Up if ctrl => self.focus_group_step(false),
1052                        KeyCode::Down if ctrl => self.focus_group_step(true),
1053                        KeyCode::Char('o') if ctrl => self.toggle_group(),
1054
1055                        // History navigation (only when on first row of input)
1056                        KeyCode::Up if self.cursor_row == 0 && !self.history.is_empty() => {
1057                            let new_idx = match self.history_idx {
1058                                None => self.history.len() - 1,
1059                                Some(0) => 0,
1060                                Some(i) => i - 1,
1061                            };
1062                            self.history_idx = Some(new_idx);
1063                            let entry = self.history[new_idx].clone();
1064                            self.set_input(&entry);
1065                        }
1066                        KeyCode::Down if self.cursor_row == self.input_lines.len() - 1 => {
1067                            match self.history_idx {
1068                                Some(i) if i + 1 < self.history.len() => {
1069                                    self.history_idx = Some(i + 1);
1070                                    let entry = self.history[i + 1].clone();
1071                                    self.set_input(&entry);
1072                                }
1073                                Some(_) => {
1074                                    self.history_idx = None;
1075                                    self.set_input("");
1076                                }
1077                                None => {}
1078                            }
1079                        }
1080
1081                        // Scrollback nav with PgUp/PgDn/Home/End
1082                        KeyCode::PageUp => self.scroll = self.scroll.saturating_add(10),
1083                        KeyCode::PageDown => self.scroll = self.scroll.saturating_sub(10),
1084                        KeyCode::Home => self.scroll = 0,
1085                        KeyCode::End => self.scroll = u16::MAX,
1086
1087                        // Tab → autocomplete or toggle agent
1088                        KeyCode::Tab => {
1089                            let line = &self.input_lines[0];
1090                            // @agent → toggle, not insert
1091                            if line.starts_with('@') {
1092                                let name = &line[1..].trim().to_string();
1093                                if !name.is_empty() && self.agent_names.contains(name) {
1094                                    self.toggle_agent(name);
1095                                    self.input_lines = vec![String::new()];
1096                                    self.cursor_row = 0;
1097                                    self.cursor_col = 0;
1098                                }
1099                            } else {
1100                                let matches = self.autocomplete_matches();
1101                                if let Some(first) = matches.first() {
1102                                    self.input_lines = vec![first.to_string()];
1103                                    self.cursor_row = 0;
1104                                    self.cursor_col = first.len();
1105                                }
1106                            }
1107                        }
1108
1109                        // Backspace: handle multiline correctly
1110                        KeyCode::Backspace => {
1111                            if self.cursor_col > 0 {
1112                                let line = &mut self.input_lines[self.cursor_row];
1113                                let new_col = line[..self.cursor_col]
1114                                    .char_indices()
1115                                    .last()
1116                                    .map(|(i, _)| i)
1117                                    .unwrap_or(0);
1118                                line.replace_range(new_col..self.cursor_col, "");
1119                                self.cursor_col = new_col;
1120                            } else if self.cursor_row > 0 {
1121                                // join with previous line
1122                                let curr = self.input_lines.remove(self.cursor_row);
1123                                self.cursor_row -= 1;
1124                                let prev = &mut self.input_lines[self.cursor_row];
1125                                self.cursor_col = prev.len();
1126                                prev.push_str(&curr);
1127                            }
1128                        }
1129
1130                        // Shift+Enter or Alt+Enter → newline
1131                        KeyCode::Enter if shift || key.modifiers.contains(KeyModifiers::ALT) => {
1132                            let line = &mut self.input_lines[self.cursor_row];
1133                            let rest = line.split_off(self.cursor_col);
1134                            self.cursor_row += 1;
1135                            self.cursor_col = 0;
1136                            self.input_lines.insert(self.cursor_row, rest);
1137                        }
1138
1139                        // Enter → submit
1140                        KeyCode::Enter => {
1141                            let task = self.current_input().trim().to_string();
1142                            if !task.is_empty() {
1143                                // Handle in-TUI commands
1144                                match task.as_str() {
1145                                    "/clear" => {
1146                                        self.lines.clear();
1147                                        self.groups.clear();
1148                                        self.current_group = None;
1149                                        self.focus_group = None;
1150                                    }
1151                                    "/collapse" => {
1152                                        for g in &mut self.groups {
1153                                            g.collapsed = true;
1154                                        }
1155                                    }
1156                                    "/expand" => {
1157                                        for g in &mut self.groups {
1158                                            g.collapsed = false;
1159                                        }
1160                                    }
1161                                    "/exit" | "/quit" => break,
1162                                    "/help" => {
1163                                        self.add_line("Commands:", LogStyle::Brand, 0);
1164                                        for c in SLASH_COMMANDS {
1165                                            self.add_line(c, LogStyle::Dim, 1);
1166                                        }
1167                                        self.add_line(
1168                                            "Ctrl+I inject · Ctrl+L clear · Ctrl+↑/↓ focus task · Ctrl+O fold/unfold · Shift+Enter newline · Up/Down history",
1169                                            LogStyle::Dim, 0,
1170                                        );
1171                                        self.add_line(
1172                                            "/collapse · /expand — fold/unfold all tasks",
1173                                            LogStyle::Dim,
1174                                            1,
1175                                        );
1176                                    }
1177                                    s if s.starts_with("/plan") => {
1178                                        let planned = s.trim_start_matches("/plan").trim();
1179                                        if planned.is_empty() {
1180                                            self.add_line("Usage: /plan <task>", LogStyle::Warn, 0);
1181                                        } else {
1182                                            let plan =
1183                                                crate::plan::build_read_only_plan(planned, &[]);
1184                                            self.add_line(
1185                                                "Read-only plan · no tools or edits executed",
1186                                                LogStyle::Planner,
1187                                                0,
1188                                            );
1189                                            self.add_line(&plan.summary, LogStyle::Dim, 1);
1190                                            for (idx, step) in plan.steps.iter().enumerate() {
1191                                                self.add_line(
1192                                                    &format!("{}. {}", idx + 1, step),
1193                                                    LogStyle::Cmd,
1194                                                    1,
1195                                                );
1196                                            }
1197                                            self.add_line(
1198                                                "Run the task explicitly when you accept the plan.",
1199                                                LogStyle::Warn,
1200                                                0,
1201                                            );
1202                                        }
1203                                    }
1204                                    _ => {
1205                                        // Send to engine
1206                                        let label = if self.inject_pending {
1207                                            "inject"
1208                                        } else {
1209                                            "sparrow"
1210                                        };
1211                                        self.add_line(
1212                                            &format!("{} › {}", label, task.replace('\n', " ↵ ")),
1213                                            LogStyle::Prompt,
1214                                            0,
1215                                        );
1216                                        self.push_history(&task);
1217                                        let to_send = if self.inject_pending {
1218                                            format!("__inject__:{}", task)
1219                                        } else {
1220                                            let prefix = self.agent_prefix();
1221                                            if prefix.is_empty() {
1222                                                task.clone()
1223                                            } else {
1224                                                format!("{}{}", prefix, task)
1225                                            }
1226                                        };
1227                                        self.inject_pending = false;
1228                                        if let Some(tx) = &self.task_tx {
1229                                            if tx.send(to_send).is_err() {
1230                                                self.add_line(
1231                                                    "runtime channel disconnected",
1232                                                    LogStyle::Err,
1233                                                    0,
1234                                                );
1235                                            }
1236                                        }
1237                                    }
1238                                }
1239                                self.set_input("");
1240                                self.history_idx = None;
1241                            }
1242                        }
1243
1244                        // Regular character → insert at cursor
1245                        KeyCode::Char(c) => {
1246                            let line = &mut self.input_lines[self.cursor_row];
1247                            line.insert(self.cursor_col, c);
1248                            self.cursor_col += c.len_utf8();
1249                        }
1250
1251                        // Cursor movement
1252                        KeyCode::Left => {
1253                            if self.scroll == 0
1254                                && self.cursor_col == 0
1255                                && self.checkpoints.len() > 1
1256                            {
1257                                let previous = self
1258                                    .checkpoints
1259                                    .iter()
1260                                    .rev()
1261                                    .skip(1)
1262                                    .find(|node| !node.id.is_empty())
1263                                    .map(|node| node.id.clone());
1264                                if let (Some(id), Some(tx)) = (previous, &self.task_tx) {
1265                                    let _ = tx.send(format!("__rewind__:{}", id));
1266                                    self.add_line(
1267                                        "rewind requested from checkpoint timeline",
1268                                        LogStyle::Gold,
1269                                        0,
1270                                    );
1271                                }
1272                            } else if self.cursor_col > 0 {
1273                                self.cursor_col = self.input_lines[self.cursor_row]
1274                                    [..self.cursor_col]
1275                                    .char_indices()
1276                                    .last()
1277                                    .map(|(i, _)| i)
1278                                    .unwrap_or(0);
1279                            } else if self.cursor_row > 0 {
1280                                self.cursor_row -= 1;
1281                                self.cursor_col = self.input_lines[self.cursor_row].len();
1282                            }
1283                        }
1284                        KeyCode::Right => {
1285                            let line = &self.input_lines[self.cursor_row];
1286                            if self.cursor_col < line.len() {
1287                                let next = line[self.cursor_col..]
1288                                    .chars()
1289                                    .next()
1290                                    .map(|c| c.len_utf8())
1291                                    .unwrap_or(0);
1292                                self.cursor_col += next;
1293                            } else if self.cursor_row + 1 < self.input_lines.len() {
1294                                self.cursor_row += 1;
1295                                self.cursor_col = 0;
1296                            }
1297                        }
1298
1299                        _ => {}
1300                    }
1301                }
1302            }
1303        }
1304        Ok(())
1305    }
1306
1307    fn tick_visuals(&mut self) {
1308        if !self.booted {
1309            self.boot_progress = self.boot_progress.saturating_add(1);
1310            if self.boot_progress >= 70 {
1311                self.boot();
1312            }
1313        }
1314        if self.cost_flash_frames > 0 {
1315            self.cost_flash_frames -= 1;
1316        }
1317        if self.tok_flash_frames > 0 {
1318            self.tok_flash_frames -= 1;
1319        }
1320        if let Some(toast) = self.toast.as_mut() {
1321            toast.age = toast.age.saturating_add(1);
1322            if toast.age >= toast.max_age {
1323                self.toast = None;
1324            }
1325        }
1326        for ember in &mut self.embers {
1327            ember.y -= ember.vy;
1328            ember.life = ember.life.saturating_add(1);
1329            if ember.life >= ember.max_life || ember.y < 0.0 {
1330                ember.y = 28.0 + (ember.x % 7) as f32;
1331                ember.life = 0;
1332            }
1333        }
1334    }
1335
1336    fn drain_engine_events(&mut self) {
1337        let mut disconnected = false;
1338        let mut events = Vec::new();
1339        if let Some(rx) = self.event_rx.as_mut() {
1340            loop {
1341                match rx.try_recv() {
1342                    Ok(event) => events.push(event),
1343                    Err(mpsc::error::TryRecvError::Empty) => break,
1344                    Err(mpsc::error::TryRecvError::Disconnected) => {
1345                        disconnected = true;
1346                        break;
1347                    }
1348                }
1349            }
1350        }
1351        for event in events {
1352            self.push_event(event);
1353        }
1354        if disconnected {
1355            self.event_rx = None;
1356            self.add_line("runtime event stream disconnected", LogStyle::Warn, 0);
1357        }
1358    }
1359
1360    fn render(&self, f: &mut Frame, _elapsed: f64) {
1361        let area = f.area();
1362        if !self.booted {
1363            self.render_boot(f, area);
1364            return;
1365        }
1366        // Input height = lines + 2 (border) + 1 (autocomplete row if any)
1367        let suggestions = self.autocomplete_matches();
1368        let input_height = (self.input_lines.len() as u16 + 2).max(3)
1369            + if !suggestions.is_empty() { 1 } else { 0 };
1370        let swarm_height = if self.swarm_lanes.is_some() { 5 } else { 0 };
1371        let diff_height = if self.pending_diffs.is_empty() { 0 } else { 12 };
1372        let checkpoint_height = if self.checkpoints.is_empty() { 0 } else { 2 };
1373        let chunks = Layout::default()
1374            .direction(Direction::Vertical)
1375            .constraints([
1376                Constraint::Length(3),
1377                Constraint::Length(swarm_height),
1378                Constraint::Min(0),
1379                Constraint::Length(diff_height),
1380                Constraint::Length(checkpoint_height),
1381                Constraint::Length(input_height),
1382            ])
1383            .split(area);
1384        self.render_cockpit(f, chunks[0]);
1385        if swarm_height > 0 {
1386            self.render_swarm_lanes(f, chunks[1]);
1387        }
1388        self.render_scroll(f, chunks[2]);
1389        if diff_height > 0 {
1390            self.render_diff(f, chunks[3]);
1391        }
1392        if checkpoint_height > 0 {
1393            self.render_checkpoint_timeline(f, chunks[4]);
1394        }
1395        self.render_input(f, chunks[5]);
1396        self.render_toast(f, area);
1397    }
1398
1399    fn render_boot(&self, f: &mut Frame, area: Rect) {
1400        let mut lines = Vec::new();
1401        let bird_lines: Vec<&str> = theme::ASCII_SPARROW.lines().collect();
1402        let bird_count = ((self.boot_progress / 5) as usize).min(bird_lines.len());
1403        for line in bird_lines.iter().take(bird_count) {
1404            lines.push(Line::from(Span::styled(
1405                *line,
1406                Style::default().fg(self.theme.brand),
1407            )));
1408        }
1409        if self.boot_progress >= 25 {
1410            let wordmark = if self.boot_progress < 35 {
1411                "S  P  A  R  R  O  W"
1412            } else if self.boot_progress < 45 {
1413                "S P A R R O W"
1414            } else {
1415                "SPARROW"
1416            };
1417            lines.push(Line::from(Span::styled(
1418                wordmark,
1419                Style::default()
1420                    .fg(self.theme.brand)
1421                    .add_modifier(Modifier::BOLD),
1422            )));
1423        }
1424        #[cfg(target_os = "linux")]
1425        let sandbox_boot = "sandbox    local-hardened · namespaces armed";
1426        #[cfg(not(target_os = "linux"))]
1427        let sandbox_boot = "sandbox    path-boundary enforcement";
1428        let boot_log = [
1429            "router     warming provider graph",
1430            "surfaces   cli · webview · gateway",
1431            sandbox_boot,
1432            "skills     library indexed",
1433            "memory     sqlite profile loaded",
1434            "autonomy   dial ready",
1435        ];
1436        if self.boot_progress >= 45 {
1437            let count = (((self.boot_progress - 45) / 4) as usize).min(boot_log.len());
1438            for item in boot_log.iter().take(count) {
1439                lines.push(Line::from(Span::styled(
1440                    *item,
1441                    Style::default().fg(self.theme.dim),
1442                )));
1443            }
1444        }
1445        if self.boot_progress >= 68 {
1446            lines.push(Line::from(Span::styled(
1447                "✓ ready",
1448                Style::default()
1449                    .fg(self.theme.add)
1450                    .add_modifier(Modifier::BOLD),
1451            )));
1452        }
1453        let height = lines.len() as u16;
1454        let width = area.width.min(72);
1455        let rect = Rect {
1456            x: area.x + area.width.saturating_sub(width) / 2,
1457            y: area.y + area.height.saturating_sub(height.max(1)) / 2,
1458            width,
1459            height: height.max(1),
1460        };
1461        f.render_widget(Paragraph::new(Text::from(lines)), rect);
1462    }
1463
1464    fn render_cockpit(&self, f: &mut Frame, area: Rect) {
1465        let aut_color = match self.autonomy.as_str() {
1466            "autonomous" => self.theme.autonomous,
1467            "trusted" => self.theme.trusted,
1468            _ => self.theme.supervised,
1469        };
1470
1471        // Spinner frame + flight verb cycling every ~25 frames (~1.25 s at 50 ms)
1472        let spinner = self.theme.spinner_frame(self.spinner_idx);
1473        let verb = self.theme.flight_verb(self.frame as usize / 25);
1474
1475        // LED for autonomy pill: pulse between ● and ◉ every 8 frames
1476        let led = if self.frame / 8 % 2 == 0 {
1477            "●"
1478        } else {
1479            "◉"
1480        };
1481
1482        let line = Line::from(vec![
1483            // braille spinner
1484            Span::styled(
1485                format!("{} ", spinner),
1486                Style::default()
1487                    .fg(self.theme.brand)
1488                    .add_modifier(Modifier::BOLD),
1489            ),
1490            // wordmark
1491            Span::styled(
1492                "SPARROW  ",
1493                Style::default()
1494                    .fg(self.theme.brand)
1495                    .add_modifier(Modifier::BOLD),
1496            ),
1497            // flight verb (cycling, fixed-width so cockpit doesn't jump)
1498            Span::styled(
1499                format!("{:<9}  ", verb),
1500                Style::default().fg(self.theme.dim),
1501            ),
1502            // active agent indicator (when toggled)
1503            Span::styled(
1504                if let Some(ref agent) = self.active_agent {
1505                    format!("🐦 {}  ", agent.to_uppercase())
1506                } else {
1507                    String::new()
1508                },
1509                Style::default()
1510                    .fg(self.theme.gold)
1511                    .add_modifier(Modifier::BOLD),
1512            ),
1513            // route
1514            Span::styled(
1515                format!("route: {}  ", self.route),
1516                Style::default().fg(self.theme.planner),
1517            ),
1518            // cost with ▲ when non-zero
1519            Span::styled(
1520                if self.cost_usd > 0.0 {
1521                    format!("${:.4} ▲  ", self.cost_usd)
1522                } else {
1523                    format!("${:.4}  ", self.cost_usd)
1524                },
1525                if self.cost_flash_frames > 0 {
1526                    Style::default()
1527                        .fg(self.theme.gold)
1528                        .add_modifier(Modifier::BOLD)
1529                } else {
1530                    Style::default().fg(self.theme.brand)
1531                },
1532            ),
1533            // tokens
1534            Span::styled(
1535                format!("{} tok  ", self.total_tokens),
1536                if self.tok_flash_frames > 0 {
1537                    Style::default()
1538                        .fg(self.theme.gold)
1539                        .add_modifier(Modifier::BOLD)
1540                } else {
1541                    Style::default().fg(self.theme.steel)
1542                },
1543            ),
1544            // autonomy pill with pulsing LED
1545            Span::styled(
1546                format!("{} ", led),
1547                Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1548            ),
1549            Span::styled(
1550                self.autonomy.to_uppercase(),
1551                Style::default().fg(aut_color).add_modifier(Modifier::BOLD),
1552            ),
1553        ]);
1554        f.render_widget(
1555            Paragraph::new(line).block(
1556                Block::default()
1557                    .borders(Borders::ALL)
1558                    .border_style(Style::default().fg(self.theme.line)),
1559            ),
1560            area,
1561        );
1562    }
1563
1564    fn render_swarm_lanes(&self, f: &mut Frame, area: Rect) {
1565        let Some(lanes) = &self.swarm_lanes else {
1566            return;
1567        };
1568        let cols = Layout::default()
1569            .direction(Direction::Horizontal)
1570            .constraints([
1571                Constraint::Percentage(33),
1572                Constraint::Percentage(34),
1573                Constraint::Percentage(33),
1574            ])
1575            .split(area);
1576        let age = self.frame.saturating_sub(lanes.started_at_frame);
1577        let items = [
1578            ("planner", &lanes.planner, self.theme.planner),
1579            ("coder", &lanes.coder, self.theme.agent),
1580            ("verifier", &lanes.verifier, self.theme.verifier),
1581        ];
1582        for (idx, (role, lane, color)) in items.iter().enumerate() {
1583            let working = lane.status == "Working" || lane.status == "Thinking";
1584            let icon = match lane.status.as_str() {
1585                "Done" => "✓",
1586                "Error" => "✗",
1587                "Idle" => "◌",
1588                _ if self.frame / 8 % 2 == 0 => "●",
1589                _ => "○",
1590            };
1591            let caret = if working && self.frame / 8 % 2 == 0 {
1592                " ▌"
1593            } else {
1594                ""
1595            };
1596            let note_width = cols[idx].width.saturating_sub(4) as usize;
1597            let note = truncate_for_width(&lane.note, note_width);
1598            let lines = vec![
1599                Line::from(Span::styled(
1600                    format!("{}  {}", role.to_uppercase(), lane.model),
1601                    Style::default().fg(*color).add_modifier(Modifier::BOLD),
1602                )),
1603                Line::from(Span::styled(
1604                    format!("{}  {}{}", icon, lane.status, caret),
1605                    Style::default().fg(if working { self.theme.gold } else { *color }),
1606                )),
1607                Line::from(Span::styled(note, Style::default().fg(self.theme.fg))),
1608            ];
1609            f.render_widget(
1610                Paragraph::new(Text::from(lines)).block(
1611                    Block::default()
1612                        .borders(Borders::ALL)
1613                        .title(format!("swarm {}", age.min(99)))
1614                        .border_style(Style::default().fg(*color)),
1615                ),
1616                cols[idx],
1617            );
1618        }
1619    }
1620
1621    fn render_scroll(&self, f: &mut Frame, area: Rect) {
1622        let max_lines = area.height.saturating_sub(2) as usize;
1623        if max_lines == 0 {
1624            return;
1625        }
1626        // Filter out child lines of collapsed groups; render headers as toggles.
1627        let rendered: Vec<Line> = self
1628            .lines
1629            .iter()
1630            .filter_map(|log| {
1631                // Hide children of collapsed groups
1632                if let Some(g) = log.group {
1633                    if self.groups.get(g).map(|gr| gr.collapsed).unwrap_or(false) {
1634                        return None;
1635                    }
1636                }
1637                if let Some(gid) = log.header_for {
1638                    // Collapsible header: ▾ expanded / ▸ collapsed + child count + focus mark
1639                    let gr = self.groups.get(gid);
1640                    let collapsed = gr.map(|g| g.collapsed).unwrap_or(false);
1641                    let title = gr.map(|g| g.title.as_str()).unwrap_or(log.text.as_str());
1642                    let log_style = gr.map(|g| g.style).unwrap_or(log.style);
1643                    let arrow = if collapsed { "▸" } else { "▾" };
1644                    let focused = self.focus_group == Some(gid);
1645                    let n = self.group_child_count(gid);
1646                    let hint = if collapsed && n > 0 {
1647                        format!("  ({} hidden)", n)
1648                    } else {
1649                        String::new()
1650                    };
1651                    let marker = if focused { "‣ " } else { "  " };
1652                    let mut style = Style::default().fg(log_style.color(&self.theme));
1653                    if focused {
1654                        style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
1655                    }
1656                    Some(Line::from(Span::styled(
1657                        format!("{}{} {}{}", marker, arrow, title, hint),
1658                        style,
1659                    )))
1660                } else {
1661                    let prefix = "  ".repeat(log.indent as usize);
1662                    Some(Line::from(Span::styled(
1663                        format!("{}{}", prefix, log.text),
1664                        Style::default().fg(log.style.color(&self.theme)),
1665                    )))
1666                }
1667            })
1668            .collect();
1669
1670        let total = rendered.len();
1671        let skip = (self.scroll as usize).min(total.saturating_sub(1));
1672        let show_logo = self.frame.saturating_sub(70) < 120 && self.scroll == 0;
1673        let logo_lines: Vec<Line> = if show_logo {
1674            theme::ascii_sparrow_at_frame(self.frame)
1675                .lines()
1676                .map(|line| {
1677                    Line::from(Span::styled(
1678                        line.to_string(),
1679                        Style::default().fg(self.theme.brand),
1680                    ))
1681                })
1682                .collect()
1683        } else {
1684            Vec::new()
1685        };
1686        let remaining = max_lines.saturating_sub(logo_lines.len());
1687        let mut text_lines: Vec<Line> = logo_lines;
1688        let start = total.saturating_sub(skip).saturating_sub(remaining);
1689        let end = total.saturating_sub(skip);
1690        text_lines.extend(rendered[start..end].iter().cloned());
1691        f.render_widget(
1692            Paragraph::new(Text::from(text_lines)).block(
1693                Block::default()
1694                    .borders(Borders::ALL)
1695                    .border_style(Style::default().fg(self.theme.line)),
1696            ),
1697            area,
1698        );
1699        self.render_embers(f, area);
1700    }
1701
1702    fn render_embers(&self, f: &mut Frame, area: Rect) {
1703        if area.width < 3 || area.height < 3 {
1704            return;
1705        }
1706        for ember in &self.embers {
1707            let x = area.x + 1 + (ember.x % area.width.saturating_sub(2));
1708            let y_offset = (ember.y.max(0.0) as u16) % area.height.saturating_sub(2);
1709            let y = area.y + 1 + y_offset;
1710            let color = if ember.amber {
1711                self.theme.gold
1712            } else {
1713                self.theme.rem
1714            };
1715            if let Some(cell) = f.buffer_mut().cell_mut((x, y)) {
1716                cell.set_char(ember.glyph).set_fg(color);
1717            }
1718        }
1719    }
1720
1721    fn render_diff(&self, f: &mut Frame, area: Rect) {
1722        let Some(diff) = self.pending_diffs.back() else {
1723            return;
1724        };
1725        let mut lines = vec![Line::from(vec![
1726            Span::styled("◇ ", Style::default().fg(self.theme.gold)),
1727            Span::styled(
1728                truncate_for_width(&diff.file, area.width.saturating_sub(20) as usize),
1729                Style::default()
1730                    .fg(self.theme.brand)
1731                    .add_modifier(Modifier::BOLD),
1732            ),
1733            Span::styled(
1734                format!("  +{} / -{}  · proposed", diff.plus, diff.minus),
1735                Style::default().fg(self.theme.dim),
1736            ),
1737        ])];
1738        for (idx, line) in diff
1739            .lines
1740            .iter()
1741            .take(area.height.saturating_sub(3) as usize)
1742            .enumerate()
1743        {
1744            let color = match line.kind {
1745                DiffLineKind::Plus => self.theme.add,
1746                DiffLineKind::Minus => self.theme.rem,
1747                DiffLineKind::Hunk => self.theme.gold,
1748                DiffLineKind::Context => self.theme.dim,
1749            };
1750            let mut spans = vec![Span::styled(
1751                format!("{:>4} ", idx + 1),
1752                Style::default().fg(self.theme.dimmer),
1753            )];
1754            spans.extend(syntax_spans(&line.text, &self.theme, color));
1755            lines.push(Line::from(spans));
1756        }
1757        f.render_widget(
1758            Paragraph::new(Text::from(lines)).block(
1759                Block::default()
1760                    .borders(Borders::ALL)
1761                    .title("diff")
1762                    .border_style(Style::default().fg(self.theme.line)),
1763            ),
1764            area,
1765        );
1766    }
1767
1768    fn render_checkpoint_timeline(&self, f: &mut Frame, area: Rect) {
1769        let mut spans = Vec::new();
1770        for (idx, node) in self
1771            .checkpoints
1772            .iter()
1773            .rev()
1774            .take(8)
1775            .collect::<Vec<_>>()
1776            .iter()
1777            .rev()
1778            .enumerate()
1779        {
1780            if idx > 0 {
1781                spans.push(Span::styled("──", Style::default().fg(self.theme.dimmer)));
1782            }
1783            spans.push(Span::styled(
1784                if node.current { "●" } else { "◆" },
1785                Style::default().fg(if node.current {
1786                    self.theme.gold
1787                } else {
1788                    self.theme.dim
1789                }),
1790            ));
1791        }
1792        if let Some(current) = self.checkpoints.iter().find(|n| n.current) {
1793            spans.push(Span::styled(
1794                format!(
1795                    "  {} · {}",
1796                    truncate_for_width(&current.label, 36),
1797                    current.id.chars().take(8).collect::<String>()
1798                ),
1799                Style::default().fg(self.theme.dim),
1800            ));
1801        }
1802        spans.push(Span::styled(
1803            "    rewind ← · snapshot before each batch",
1804            Style::default().fg(self.theme.dimmer),
1805        ));
1806        f.render_widget(Paragraph::new(Line::from(spans)), area);
1807    }
1808
1809    fn render_toast(&self, f: &mut Frame, area: Rect) {
1810        let Some(toast) = &self.toast else {
1811            return;
1812        };
1813        let width = (toast.text.chars().count() as u16 + 6).min(area.width.saturating_sub(2));
1814        if width < 8 || area.height < 5 {
1815            return;
1816        }
1817        let rect = Rect {
1818            x: area.x + area.width.saturating_sub(width) / 2,
1819            y: area.y + area.height.saturating_sub(3) / 2,
1820            width,
1821            height: 3,
1822        };
1823        let border = if toast.age / 20 % 2 == 0 {
1824            Style::default()
1825                .fg(self.theme.gold)
1826                .add_modifier(Modifier::BOLD)
1827        } else {
1828            Style::default().fg(self.theme.gold)
1829        };
1830        f.render_widget(
1831            Paragraph::new(Line::from(Span::styled(
1832                toast.text.as_str(),
1833                Style::default()
1834                    .fg(self.theme.gold)
1835                    .add_modifier(Modifier::BOLD),
1836            )))
1837            .block(Block::default().borders(Borders::ALL).border_style(border)),
1838            rect,
1839        );
1840    }
1841
1842    fn render_input(&self, f: &mut Frame, area: Rect) {
1843        let cursor_char = if self.frame / 8 % 2 == 0 { "▌" } else { " " };
1844        let prompt = if self.inject_pending {
1845            "◆ inject › "
1846        } else {
1847            "◆ sparrow › "
1848        };
1849        let prompt_color = if self.inject_pending {
1850            self.theme.coral
1851        } else {
1852            self.theme.brand
1853        };
1854
1855        let mut text_lines: Vec<Line> = Vec::new();
1856        for (row_idx, line) in self.input_lines.iter().enumerate() {
1857            let mut spans: Vec<Span> = Vec::new();
1858            if row_idx == 0 {
1859                spans.push(Span::styled(
1860                    prompt,
1861                    Style::default()
1862                        .fg(prompt_color)
1863                        .add_modifier(Modifier::BOLD),
1864                ));
1865            } else {
1866                spans.push(Span::styled(
1867                    "          › ",
1868                    Style::default().fg(self.theme.dimmer),
1869                ));
1870            }
1871            if row_idx == self.cursor_row {
1872                let (before, after) = line.split_at(self.cursor_col.min(line.len()));
1873                spans.push(Span::styled(before, Style::default().fg(self.theme.fg)));
1874                spans.push(Span::styled(cursor_char, Style::default().fg(prompt_color)));
1875                spans.push(Span::styled(after, Style::default().fg(self.theme.fg)));
1876            } else {
1877                spans.push(Span::styled(
1878                    line.as_str(),
1879                    Style::default().fg(self.theme.fg),
1880                ));
1881            }
1882            text_lines.push(Line::from(spans));
1883        }
1884
1885        // Autocomplete row (suggestions)
1886        let suggestions = self.autocomplete_matches();
1887        if !suggestions.is_empty() {
1888            let mut s: Vec<Span> = vec![Span::styled(
1889                "  ⇥  ",
1890                Style::default().fg(self.theme.dimmer),
1891            )];
1892            for (i, cmd) in suggestions.iter().enumerate() {
1893                if i == 0 {
1894                    s.push(Span::styled(
1895                        *cmd,
1896                        Style::default()
1897                            .fg(self.theme.brand)
1898                            .add_modifier(Modifier::BOLD),
1899                    ));
1900                } else {
1901                    s.push(Span::styled(*cmd, Style::default().fg(self.theme.dim)));
1902                }
1903                s.push(Span::raw("  "));
1904            }
1905            text_lines.push(Line::from(s));
1906        }
1907
1908        f.render_widget(
1909            Paragraph::new(Text::from(text_lines)).block(
1910                Block::default()
1911                    .borders(Borders::ALL)
1912                    .border_style(Style::default().fg(self.theme.line)),
1913            ),
1914            area,
1915        );
1916    }
1917}
1918
1919impl Default for Tui {
1920    fn default() -> Self {
1921        Self::new()
1922    }
1923}