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