Skip to main content

sparrow/tui/
mod.rs

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