use crate::flight_log::{clock, Entry, State, Summary};
use crate::frame::Frame;
use std::collections::HashSet;
const OUTPUT_LINES: usize = 12;
pub struct Colors {
pub fg: [u8; 3],
pub bg: [u8; 3],
pub dim: [u8; 3],
pub accent: [u8; 3],
pub ok: [u8; 3],
pub bad: [u8; 3],
pub warn: [u8; 3],
pub selected: [u8; 3],
}
pub struct View<'a> {
pub title: &'a str,
pub branch: Option<&'a str>,
pub running: bool,
pub summary: Summary,
pub entries: &'a [Entry],
pub selected: Option<usize>,
pub expanded: &'a HashSet<usize>,
pub focused: bool,
pub root: Option<&'a str>,
pub key: &'a str,
}
type Line = (Vec<(String, [u8; 3])>, Option<usize>);
fn short_path<'a>(path: &'a str, root: Option<&str>) -> &'a str {
root.and_then(|r| path.strip_prefix(r))
.map(|p| p.trim_start_matches('/'))
.filter(|p| !p.is_empty())
.unwrap_or(path)
}
fn first_line(s: &str) -> &str {
s.lines().next().unwrap_or_default()
}
fn entry_lines(i: usize, e: &Entry, v: &View<'_>, c: &Colors, out: &mut Vec<Line>) {
let time = |t: u64| (format!(" {} ", &clock(t)[..5]), c.dim);
let mut push = |spans: Vec<(String, [u8; 3])>| out.push((spans, Some(i)));
match e {
Entry::Prompt { t, text } => push(vec![
time(*t),
("» ".into(), c.accent),
(first_line(text).into(), c.accent),
]),
Entry::Command {
t,
command,
exit,
failed,
duration_ms,
output,
running,
..
} => {
let bad = *failed || exit.is_some_and(|x| x != 0);
let mark = match (exit, bad, running) {
(Some(x), true, _) if *x != 0 => (format!("✗ {x} "), c.bad),
(_, true, _) => ("✗ ".into(), c.bad),
(Some(_), false, _) => ("✓ ".into(), c.ok),
(None, false, true) if v.running => ("… ".into(), c.warn),
(None, false, _) => ("· ".into(), c.dim),
};
let mut spans = vec![time(*t), mark, (first_line(command).into(), c.fg)];
if let Some(d) = duration_ms {
spans.push((format!(" {}", crate::blocks::format_duration(*d)), c.dim));
}
push(spans);
let lines: Vec<&str> = output
.as_deref()
.map(|o| o.lines().filter(|l| !l.trim().is_empty()).collect())
.unwrap_or_default();
let shown = if v.expanded.contains(&i) {
OUTPUT_LINES
} else if bad {
1
} else {
0
};
let from = lines.len().saturating_sub(shown);
for l in &lines[from..] {
out.push((vec![(format!(" {l}"), c.dim)], Some(i)));
}
}
Entry::Edit { t, path, tool } => {
let mut spans = vec![
time(*t),
("✎ ".into(), c.accent),
(short_path(path, v.root).into(), c.fg),
];
if let Some(tool) = tool {
spans.push((format!(" {tool}"), c.dim));
}
push(spans);
}
Entry::Tool { t, tool, text } => {
let mut spans = vec![time(*t), ("○ ".into(), c.dim), (tool.clone(), c.dim)];
if let Some(text) = text {
spans.push((format!(" {}", short_path(text, v.root)), c.dim));
}
push(spans);
}
Entry::Waiting { t, text } => push(vec![
time(*t),
("◆ needs you".into(), c.warn),
(
text.as_deref()
.map(|x| format!(": {}", first_line(x)))
.unwrap_or_default(),
c.warn,
),
]),
Entry::Done { t } => push(vec![time(*t), ("■ done".into(), c.ok)]),
}
}
fn put_line(f: &mut Frame, row: usize, spans: &[(String, [u8; 3])], bg: [u8; 3]) {
let cols = f.cols;
let mut col = 0;
let total: usize = spans.iter().map(|(t, _)| t.chars().count()).sum();
let cut = total > cols;
for (text, fg) in spans {
if col >= cols {
break;
}
let room = cols - col - usize::from(cut);
let piece: String = text.chars().take(room).collect();
col = f.put(row, col, &piece, *fg, bg);
}
if cut && cols > 0 {
let fg = spans.last().map(|s| s.1).unwrap_or([128; 3]);
f.put(row, cols - 1, "…", fg, bg);
}
}
pub fn build(v: &View<'_>, cols: usize, rows: usize, c: &Colors) -> Frame {
let mut f = Frame::blank(cols, rows, c.fg, c.bg);
if cols < 8 || rows < 6 {
return f;
}
let s = &v.summary;
let (state, state_color) = match (s.state, v.running) {
(State::Waiting, true) => ("NEEDS YOU", c.warn),
(State::Done, _) => ("DONE", c.ok),
(_, false) => ("STOPPED", c.dim),
(State::Idle, true) => ("STARTING", c.dim),
(State::Working, true) => ("WORKING", c.accent),
};
put_line(&mut f, 0, &[(" ◆ FLIGHT LOG".into(), c.accent)], c.bg);
let state_col = cols.saturating_sub(state.chars().count() + 1);
f.put(0, state_col, state, state_color, c.bg);
put_line(&mut f, 1, &[(format!(" {}", v.title), c.fg)], c.bg);
if let Some(b) = v.branch {
put_line(&mut f, 2, &[(format!(" ⎇ {b}"), c.dim)], c.bg);
}
let mut counts = vec![(
format!(
" {} command{}",
s.commands,
if s.commands == 1 { "" } else { "s" }
),
c.dim,
)];
if s.failed > 0 {
counts.push((format!(" · {} failed", s.failed), c.bad));
}
counts.push((
format!(" · {} edit{}", s.edits, if s.edits == 1 { "" } else { "s" }),
c.dim,
));
put_line(&mut f, 3, &counts, c.bg);
put_line(&mut f, 4, &[("─".repeat(cols), c.dim)], c.bg);
let footer = if v.focused {
" ↑↓ Enter output · y copy · c changes · Esc back".to_string()
} else {
format!(" {} focus · again to close", v.key)
};
put_line(&mut f, rows - 1, &[(footer, c.dim)], c.bg);
let top = 5;
let room = rows.saturating_sub(top + 1);
let mut lines: Vec<Line> = Vec::new();
for (i, e) in v.entries.iter().enumerate() {
entry_lines(i, e, v, c, &mut lines);
}
if lines.is_empty() {
put_line(
&mut f,
top + 1,
&[(
" Nothing yet. What the agent does shows up here.".into(),
c.dim,
)],
c.bg,
);
return f;
}
let mut start = lines.len().saturating_sub(room);
if let Some(sel) = v.selected {
let first = lines.iter().position(|l| l.1 == Some(sel));
let last = lines.iter().rposition(|l| l.1 == Some(sel));
if let (Some(first), Some(last)) = (first, last) {
if first < start {
start = first;
}
if last >= start + room {
start = last + 1 - room.min(last + 1);
}
}
}
let selected = v
.selected
.or_else(|| v.focused.then(|| v.entries.len().saturating_sub(1)));
for (row, (spans, owner)) in lines.iter().skip(start).take(room).enumerate() {
let bg = if v.focused && *owner == selected {
c.selected
} else {
c.bg
};
if bg != c.bg {
f.fill(top + row, 0, c.fg, bg);
}
put_line(&mut f, top + row, spans, bg);
}
f
}
#[cfg(test)]
mod tests {
use super::*;
use crate::flight_log::summary;
fn colors() -> Colors {
Colors {
fg: [200; 3],
bg: [0; 3],
dim: [100; 3],
accent: [0, 200, 200],
ok: [0, 200, 0],
bad: [200, 0, 0],
warn: [200, 200, 0],
selected: [40; 3],
}
}
fn cmd(command: &str, exit: Option<i32>, output: Option<&str>) -> Entry {
Entry::Command {
t: 0,
command: command.into(),
cwd: None,
exit,
failed: false,
duration_ms: Some(1200),
output: output.map(str::to_string),
running: exit.is_none(),
}
}
fn text(f: &Frame) -> Vec<String> {
(0..f.rows)
.map(|r| f.row_text(r).0.trim_end().to_string())
.collect()
}
fn view<'a>(entries: &'a [Entry], expanded: &'a HashSet<usize>) -> View<'a> {
View {
title: "Claude Code · fix-test",
branch: Some("agent/fix-test"),
running: true,
summary: summary(entries),
entries,
selected: None,
expanded,
focused: false,
root: Some("/w/api-fix-test"),
key: "Ctrl+Shift+L",
}
}
#[test]
fn header_counts_and_entries_show() {
let entries = vec![
Entry::Prompt {
t: 0,
text: "fix the test".into(),
},
cmd(
"cargo test",
Some(101),
Some("running 3 tests\ntest result: FAILED"),
),
Entry::Edit {
t: 0,
path: "/w/api-fix-test/src/a.rs".into(),
tool: Some("Edit".into()),
},
cmd("cargo test", Some(0), Some("ok")),
Entry::Waiting {
t: 0,
text: Some("permission to use Bash".into()),
},
];
let none = HashSet::new();
let t = text(&build(&view(&entries, &none), 60, 16, &colors()));
assert!(
t[0].starts_with(" ◆ FLIGHT LOG") && t[0].ends_with("NEEDS YOU"),
"{t:#?}"
);
assert_eq!(t[2], " ⎇ agent/fix-test");
assert_eq!(t[3], " 2 commands · 1 failed · 1 edit");
let all = t.join("\n");
assert!(all.contains("» fix the test"), "{all}");
assert!(all.contains("✗ 101 cargo test 1.2s"), "{all}");
assert!(all.contains("test result: FAILED"), "{all}");
assert!(!all.contains(" ok"), "{all}");
assert!(all.contains("✎ src/a.rs Edit"), "{all}");
assert!(all.contains("◆ needs you: permission to use Bash"), "{all}");
assert!(t[15].contains("Ctrl+Shift+L focus"));
}
#[test]
fn opened_output_and_scrolling_follow_the_selection() {
let mut entries: Vec<Entry> = (0..30)
.map(|i| cmd(&format!("step {i}"), Some(0), Some("a\nb\nc")))
.collect();
entries.push(Entry::Done { t: 0 });
let none = HashSet::new();
let t = text(&build(&view(&entries, &none), 50, 12, &colors()));
assert!(t.iter().any(|l| l.contains("■ done")), "{t:#?}");
assert!(!t.iter().any(|l| l.contains("step 0 ")), "{t:#?}");
let open: HashSet<usize> = [3].into();
let mut v = view(&entries, &open);
v.selected = Some(3);
v.focused = true;
let t = text(&build(&v, 50, 12, &colors()));
let at = t
.iter()
.position(|l| l.contains("step 3"))
.expect("selected in view");
assert_eq!(t[at + 1].trim(), "a");
assert!(t[11].contains("Esc back"));
}
#[test]
fn long_lines_are_cut_and_states_follow_the_agent() {
let entries = vec![cmd(&"x".repeat(200), None, None)];
let none = HashSet::new();
let mut v = view(&entries, &none);
let f = build(&v, 30, 8, &colors());
assert!(text(&f).iter().all(|l| l.chars().count() <= 30));
assert!(text(&f)[0].ends_with("WORKING"));
v.running = false;
assert!(text(&build(&v, 30, 8, &colors()))[0].ends_with("STOPPED"));
let empty: Vec<Entry> = Vec::new();
let t = text(&build(&view(&empty, &none), 60, 8, &colors()));
assert!(t.join("\n").contains("Nothing yet"));
}
}