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