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 group: Option<usize>,
33 header_for: Option<usize>,
35}
36
37#[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#[derive(Debug, Clone)]
116struct LaneState {
117 status: String,
119 note: String,
121 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 started_at_frame: u64,
142}
143
144#[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#[derive(Debug, Clone)]
270struct CheckpointNode {
271 id: String,
272 label: String,
273 current: bool,
274}
275
276#[derive(Debug, Clone)]
279struct Ember {
280 x: u16,
281 y: f32,
282 vy: f32,
283 amber: bool,
285 life: u32,
286 max_life: u32,
287 glyph: char,
289}
290
291#[derive(Debug, Clone)]
294struct Toast {
295 text: String,
296 age: u32,
298 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 input_lines: Vec<String>,
311 cursor_row: usize,
313 cursor_col: usize,
315 history: Vec<String>,
317 history_idx: Option<usize>,
319 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 swarm_lanes: Option<SwarmLanesState>,
333 pending_diffs: std::collections::VecDeque<DiffEntry>,
335 checkpoints: Vec<CheckpointNode>,
337 embers: Vec<Ember>,
339 toast: Option<Toast>,
341 cost_flash_frames: u32,
343 last_cost: f64,
344 tok_flash_frames: u32,
346 last_tokens: u64,
347
348 groups: Vec<TaskGroup>,
351 current_group: Option<usize>,
353 focus_group: Option<usize>,
355
356 replay_events: Option<Vec<Event>>,
359 replay_idx: usize,
360 think: crate::event::ThinkStripper,
362 agent_names: Vec<String>,
364}
365
366impl Tui {
367 pub fn new() -> Self {
368 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 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 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; self
431 }
432
433 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 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 fn current_input(&self) -> String {
477 self.input_lines.join("\n")
478 }
479
480 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 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 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 #[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 #[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 pub fn agent_matches(&self) -> Vec<String> {
544 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 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 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 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 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 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 fn close_group(&mut self) {
838 self.current_group = None;
839 }
840
841 fn group_child_count(&self, id: usize) -> usize {
843 self.lines.iter().filter(|l| l.group == Some(id)).count()
844 }
845
846 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 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 #[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 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 KeyCode::Char('l') if ctrl => {
982 self.lines.clear();
983 }
984 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 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 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 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 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 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 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 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 KeyCode::Enter => {
1074 let task = self.current_input().trim().to_string();
1075 if !task.is_empty() {
1076 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 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 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 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 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 let spinner = self.theme.spinner_frame(self.spinner_idx);
1401 let verb = self.theme.flight_verb(self.frame as usize / 25);
1402
1403 let led = if self.frame / 8 % 2 == 0 {
1405 "●"
1406 } else {
1407 "◉"
1408 };
1409
1410 let line = Line::from(vec![
1411 Span::styled(
1413 format!("{} ", spinner),
1414 Style::default()
1415 .fg(self.theme.brand)
1416 .add_modifier(Modifier::BOLD),
1417 ),
1418 Span::styled(
1420 "SPARROW ",
1421 Style::default()
1422 .fg(self.theme.brand)
1423 .add_modifier(Modifier::BOLD),
1424 ),
1425 Span::styled(
1427 format!("{:<9} ", verb),
1428 Style::default().fg(self.theme.dim),
1429 ),
1430 Span::styled(
1432 format!("route: {} ", self.route),
1433 Style::default().fg(self.theme.planner),
1434 ),
1435 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 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 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 let rendered: Vec<Line> = self
1545 .lines
1546 .iter()
1547 .filter_map(|log| {
1548 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 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(¤t.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 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}