use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier};
use super::app::SPINNER_FRAMES;
use super::neon_prompt::{color, contrast, mix, rgb, Rgb, Slab, INSET_X};
use super::render_model::SubagentSnap;
use super::text_metrics::char_width;
use super::theme::Theme;
pub(crate) const MAX_ROWS: usize = 6;
pub(crate) fn row_at(tray: Rect, n: usize, x: u16, y: u16) -> Option<usize> {
if tray.height == 0 || x < tray.x || x >= tray.right() || y <= tray.y || y >= tray.bottom() {
return None;
}
let i = usize::from(y - tray.y - 1);
(i < n.min(MAX_ROWS)).then_some(i)
}
const TRAY_DEPTH: f32 = 0.55;
const WATCHED_TINT: f32 = 0.14;
fn watched_bg(t: &Theme, surface: Rgb) -> Rgb {
let d = Theme::default();
mix(surface, rgb(t.prompt_fg, d.prompt_fg), WATCHED_TINT)
}
pub(crate) fn tray_height(n: usize) -> u16 {
if n == 0 {
return 0;
}
n.min(MAX_ROWS) as u16 + u16::from(n > MAX_ROWS) + 1
}
pub(super) fn legible(c: Rgb, toward: Rgb, bg: Rgb, target: f32) -> Rgb {
let mut t = 0.0;
loop {
let out = mix(c, toward, t);
if contrast(out, bg) >= target || t >= 1.0 {
return out;
}
t += 0.04;
}
}
fn readable(c: Rgb, text: Rgb, bg: Rgb, target: f32) -> Rgb {
let out = legible(c, text, bg, target);
if contrast(out, bg) >= target {
return out;
}
let extreme = if contrast((255, 255, 255), bg) >= contrast((0, 0, 0), bg) {
(255, 255, 255)
} else {
(0, 0, 0)
};
legible(out, extreme, bg, target)
}
struct Ink {
name: Rgb,
step: Rgb,
dim: Rgb,
count: Rgb,
run: Rgb,
ok: Rgb,
err: Rgb,
warn: Rgb,
}
impl Ink {
fn on(t: &Theme, bg: Rgb) -> Self {
let d = Theme::default();
let text = rgb(t.claude_text, d.claude_text);
let muted = rgb(t.muted, d.muted);
let dim = readable(muted, text, bg, 4.5);
Self {
name: readable(rgb(t.subagent_name, d.subagent_name), text, bg, 4.5),
step: readable(mix(dim, text, 0.45), text, bg, 4.5),
dim,
count: legible(mix(bg, muted, 0.55), dim, bg, 3.0),
run: legible(rgb(t.status_streaming, d.status_streaming), text, bg, 3.0),
ok: legible(rgb(t.subagent_done, d.subagent_done), text, bg, 3.0),
err: legible(rgb(t.error_color, d.error_color), text, bg, 3.0),
warn: legible(rgb(t.warning_color, d.warning_color), text, bg, 3.0),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum State {
Running,
Cancelling,
Ok,
Failed,
TimedOut,
}
fn state(s: &SubagentSnap) -> State {
let st = s.status.as_str();
if !s.done {
if st.contains("cancelling") {
State::Cancelling
} else {
State::Running
}
} else if st.starts_with('\u{2718}') {
State::Failed
} else if st.starts_with('\u{26a0}') {
State::TimedOut
} else {
State::Ok
}
}
fn step(s: &SubagentSnap) -> String {
let st = s.status.trim();
match state(s) {
State::Ok | State::Failed => s
.result
.clone()
.filter(|r| !r.is_empty())
.unwrap_or_else(|| st.chars().skip(2).collect()),
State::TimedOut => st.chars().skip(2).collect::<String>().trim().to_string(),
State::Cancelling => "cancelling\u{2026}".into(),
State::Running => {
if st.starts_with("starting") || st == "running" {
"starting\u{2026}".into()
} else if let Some(rest) = st.strip_prefix("\u{2699} ") {
rest.rsplit_once(" (tool #")
.map(|(tool, _)| tool.to_string())
.unwrap_or_else(|| rest.to_string())
} else if st.contains("thinking") {
"thinking\u{2026}".into()
} else {
st.to_string()
}
}
}
}
fn glyph(s: &SubagentSnap, spinner_frame: usize, ink: &Ink) -> (&'static str, Rgb) {
match state(s) {
State::Running => (
SPINNER_FRAMES[(spinner_frame / 3) % SPINNER_FRAMES.len()],
ink.run,
),
State::Cancelling => ("\u{25cc}", ink.warn), State::Ok => ("\u{2713}", ink.ok), State::Failed => ("\u{2717}", ink.err), State::TimedOut => ("!", ink.warn),
}
}
fn step_ink(s: &SubagentSnap, ink: &Ink) -> Rgb {
match state(s) {
State::Running => ink.step,
State::Cancelling | State::TimedOut => mix(ink.dim, ink.warn, 0.6),
State::Failed => mix(ink.dim, ink.err, 0.7),
State::Ok => ink.dim,
}
}
fn clock(secs: f64) -> String {
let s = secs.max(0.0) as u64;
if s < 60 {
format!("{s}s")
} else {
format!("{}m{:02}s", s / 60, s % 60)
}
}
fn tools_label(n: u32) -> String {
match n {
0 => String::new(),
1 => "1 tool".into(),
n => format!("{n} tools"),
}
}
fn width(s: &str) -> u16 {
s.chars().map(|c| char_width(c) as u16).sum()
}
fn put(buf: &mut Buffer, x: u16, y: u16, sym: &str, fg: Rgb, bg: Rgb) {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.reset();
cell.set_symbol(sym).set_fg(color(fg)).set_bg(color(bg));
}
}
#[allow(clippy::too_many_arguments)]
fn text(
buf: &mut Buffer,
x: u16,
y: u16,
s: &str,
fg: Rgb,
bg: Color,
bold: bool,
max_x: u16,
) -> u16 {
let cut = x + width(s) > max_x;
let limit = if cut { max_x.saturating_sub(1) } else { max_x };
let mut cx = x;
let mut tmp = [0u8; 4];
for ch in s.chars() {
let w = char_width(ch) as u16;
if cx + w > limit {
break;
}
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_symbol(ch.encode_utf8(&mut tmp))
.set_fg(color(fg))
.set_bg(bg);
cell.modifier = if bold {
Modifier::BOLD
} else {
Modifier::empty()
};
}
cx += w;
}
if cut && cx < max_x {
if let Some(cell) = buf.cell_mut((cx, y)) {
cell.set_symbol("\u{2026}").set_fg(color(fg)).set_bg(bg);
cell.modifier = Modifier::empty();
}
cx += 1;
}
cx
}
fn text_right(buf: &mut Buffer, right: u16, y: u16, s: &str, fg: Rgb, bg: Color) -> u16 {
let x = right.saturating_sub(width(s));
text(buf, x, y, s, fg, bg, false, right);
x
}
#[allow(clippy::too_many_arguments)]
fn row(
buf: &mut Buffer,
x: u16,
right: u16,
y: u16,
s: &SubagentSnap,
name_w: u16,
spinner_frame: usize,
ink: &Ink,
bg: Color,
) {
let (g, gc) = glyph(s, spinner_frame, ink);
text(buf, x, y, g, gc, bg, true, right);
let name_x = x + 2;
let name_ink = if s.done { ink.dim } else { ink.name };
text(
buf,
name_x,
y,
&s.name,
name_ink,
bg,
!s.done,
name_x + name_w,
);
let step_x = name_x + name_w + 2;
let time_x = text_right(buf, right, y, &clock(s.elapsed_secs), ink.dim, bg);
let mut end = time_x.saturating_sub(2);
if right.saturating_sub(x) >= 64 {
let tools = tools_label(s.tools);
if !tools.is_empty() {
end = text_right(buf, end, y, &tools, ink.count, bg).saturating_sub(2);
}
}
if step_x + 4 < end {
text(buf, step_x, y, &step(s), step_ink(s, ink), bg, false, end);
}
}
pub(crate) fn paint_tray(
buf: &mut Buffer,
tray: Rect,
input: Rect,
slab: &Slab,
theme: &Theme,
snaps: &[SubagentSnap],
spinner_frame: usize,
) {
if snaps.is_empty()
|| tray.height < 2
|| tray.width < 12
|| input.height < 3
|| tray.bottom() != input.y
{
return;
}
let backdrop = slab.backdrop();
let (l, r) = (2u16, tray.width - 3);
let tray_at = |rx: u16| mix(backdrop, slab.fill_at(rx), TRAY_DEPTH);
let top = tray.y;
for y in tray.top()..tray.bottom() {
put(buf, tray.x, y, " ", backdrop, backdrop);
put(buf, tray.right() - 1, y, " ", backdrop, backdrop);
put(buf, tray.x + 1, y, " ", slab.halo_at(1), slab.halo_at(1));
let rr = tray.width - 2;
put(buf, tray.x + rr, y, " ", slab.halo_at(rr), slab.halo_at(rr));
}
for rx in l..=r {
put(
buf,
tray.x + rx,
top,
"\u{2584}",
tray_at(rx),
slab.halo_at(rx),
); }
for y in top + 1..tray.bottom() {
for rx in l..=r {
put(buf, tray.x + rx, y, " ", tray_at(rx), tray_at(rx));
}
}
for rx in l..=r {
put(
buf,
input.x + rx,
input.y,
"\u{2584}",
slab.fill_at(rx),
tray_at(rx),
);
}
let surface = tray_at(tray.width / 2);
let ink = Ink::on(theme, surface);
let bg = color(surface);
let x = tray.x + INSET_X;
let right = tray.right().saturating_sub(INSET_X + 1);
let name_w = snaps
.iter()
.map(|s| width(&s.name))
.max()
.unwrap_or(4)
.clamp(4, 16);
let sel_at = |rx: u16| watched_bg(theme, tray_at(rx));
let sel = sel_at(tray.width / 2);
let sel_ink = Ink::on(theme, sel);
let shown = snaps.len().min(MAX_ROWS);
let mut y = top + 1;
for (i, s) in snaps.iter().take(MAX_ROWS).enumerate() {
if y >= tray.bottom() {
return;
}
if s.watched {
row(
buf,
x,
right,
y,
s,
name_w,
spinner_frame,
&sel_ink,
color(sel),
);
for rx in l..=r {
if let Some(cell) = buf.cell_mut((tray.x + rx, y)) {
cell.set_bg(color(sel_at(rx)));
}
}
if i == 0 {
for rx in l..=r {
put(buf, tray.x + rx, top, "\u{2584}", sel_at(rx), slab.halo_at(rx));
}
}
if i + 1 == shown && snaps.len() <= MAX_ROWS {
for rx in l..=r {
put(buf, input.x + rx, input.y, "\u{2584}", slab.fill_at(rx), sel_at(rx));
}
}
} else {
row(buf, x, right, y, s, name_w, spinner_frame, &ink, bg);
}
y += 1;
}
if snaps.len() > MAX_ROWS && y < tray.bottom() {
let more = format!("+{} more", snaps.len() - MAX_ROWS);
text(buf, x + 2, y, &more, ink.dim, bg, false, right);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::neon_prompt::PromptFx;
fn snap(name: &str, status: &str, done: bool) -> SubagentSnap {
SubagentSnap {
name: name.into(),
status: status.into(),
elapsed_secs: 12.0,
done,
tools: 0,
result: None,
watched: false,
}
}
#[test]
fn rows_are_found_under_the_rim_and_not_past_the_shown_ones() {
let tray = Rect::new(0, 10, 80, tray_height(8));
assert_eq!(row_at(tray, 8, 5, 10), None, "the rim");
assert_eq!(row_at(tray, 8, 5, 11), Some(0));
assert_eq!(row_at(tray, 8, 5, 16), Some(5));
assert_eq!(row_at(tray, 8, 5, 17), None, "the +N more row");
assert_eq!(row_at(tray, 2, 5, 13), None, "past the last agent");
assert_eq!(row_at(Rect::default(), 2, 5, 11), None, "no tray");
}
#[test]
fn height_is_rim_plus_rows_capped() {
assert_eq!(tray_height(0), 0);
assert_eq!(tray_height(1), 2, "top rim, one row");
assert_eq!(tray_height(6), 7);
assert_eq!(tray_height(9), 8, "top rim, six rows, +N more");
}
#[test]
fn step_drops_the_engine_prefixes() {
assert_eq!(
step(&snap("a", "starting: do it", false)),
"starting\u{2026}"
);
assert_eq!(step(&snap("a", "\u{2699} bash (tool #3)", false)), "bash");
assert_eq!(step(&snap("a", "$ cargo test", false)), "$ cargo test");
assert_eq!(
step(&snap("a", "\u{1F4AD} thinking...", false)),
"thinking\u{2026}"
);
assert_eq!(step(&snap("a", "\u{26a0} timed out", true)), "timed out");
let mut done = snap("a", "\u{2714} short preview", true);
assert_eq!(step(&done), "short preview");
done.result = Some("the full first line".into());
assert_eq!(step(&done), "the full first line");
}
#[test]
fn clock_reads_like_the_web_tray() {
assert_eq!(clock(8.4), "8s");
assert_eq!(clock(64.0), "1m04s");
}
const BUILTINS: &[&str] = &[
"default",
"night-city",
"neon-rain",
"amber",
"phosphor",
"solarized-dark",
"blood",
"ocean",
"rose-pine",
"nord",
"dracula",
"monokai",
"myx",
"gruvbox",
"catppuccin",
"tokyo-night",
"sunset",
"ice",
"forest",
"lavender",
"paper",
];
#[test]
fn tray_is_recessed_and_legible_on_every_builtin() {
for &name in BUILTINS {
let theme = Theme::builtin_for_test(name);
let slab = Slab::new(&theme, PromptFx::default(), 80, None);
let backdrop = slab.backdrop();
let body = slab.fill_at(40);
let tray = mix(backdrop, body, TRAY_DEPTH);
let (lb, lt, lo) = (
crate::tui::neon_prompt::luminance(backdrop),
crate::tui::neon_prompt::luminance(tray),
crate::tui::neon_prompt::luminance(body),
);
assert!(
lb.min(lo) <= lt && lt <= lb.max(lo),
"{name}: tray between chrome and body"
);
let sel = watched_bg(&theme, tray);
assert!(
contrast(sel, tray) > 1.08,
"{name}: the watched row stands out ({:.2})",
contrast(sel, tray)
);
let sel_ink = Ink::on(&theme, sel);
for (what, c, min) in [
("name", sel_ink.name, 4.5),
("step", sel_ink.step, 4.5),
("dim", sel_ink.dim, 4.5),
] {
let got = contrast(c, sel);
assert!(got >= min - 0.01, "{name}: watched {what} {got:.2} < {min}");
}
let ink = Ink::on(&theme, tray);
for (what, c, min) in [
("name", ink.name, 4.5),
("step", ink.step, 4.5),
("dim", ink.dim, 4.5),
("count", ink.count, 3.0),
("run", ink.run, 3.0),
("ok", ink.ok, 3.0),
("err", ink.err, 3.0),
("warn", ink.warn, 3.0),
] {
let got = contrast(c, tray);
assert!(got >= min - 0.01, "{name}: {what} {got:.2} < {min}");
}
}
}
}
#[cfg(test)]
mod render_tests {
use crate::tui::app::SPINNER_FRAMES;
use crate::tui::testing::TestHarness;
use super::{watched_bg, Theme};
use crate::tui::neon_prompt::{contrast, rgb};
use crate::tui::theme::THEME;
use agent_engine::session::SessionEventWire;
use ratatui::buffer::Buffer;
use synaps_cli::{AgentEvent, StreamEvent};
const W: u16 = 80;
const H: u16 = 24;
fn sym(buf: &Buffer, x: u16, y: u16) -> &str {
buf[(x, y)].symbol()
}
fn row(buf: &Buffer, y: u16) -> String {
(0..buf.area().width).map(|x| sym(buf, x, y)).collect()
}
fn agent(h: &mut TestHarness, ev: AgentEvent) {
h.feed_event(SessionEventWire::Stream(StreamEvent::Agent(ev)));
}
fn start(h: &mut TestHarness, id: u64, name: &str) {
agent(
h,
AgentEvent::SubagentStart {
subagent_id: id,
agent_name: name.into(),
task_preview: format!("task for {name}"),
},
);
}
fn update(h: &mut TestHarness, id: u64, status: &str) {
agent(
h,
AgentEvent::SubagentUpdate {
subagent_id: id,
agent_name: String::new(),
status: status.into(),
},
);
}
fn done(h: &mut TestHarness, id: u64, preview: &str) {
agent(
h,
AgentEvent::SubagentDone {
subagent_id: id,
agent_name: String::new(),
result_preview: preview.into(),
duration_secs: 10.5,
},
);
}
fn prompt_row(buf: &Buffer) -> u16 {
(0..buf.area().height)
.rev()
.find(|&y| sym(buf, 3, y) == "\u{276f}")
.expect("prompt ❯")
}
fn tray_rows(buf: &Buffer) -> Option<(u16, u16)> {
let rim = prompt_row(buf) - 1;
assert_eq!(sym(buf, 1, rim), "\u{2597}", "the input's own corner");
let tray_bg = buf[(2, rim)].bg;
if tray_bg == buf[(0, rim)].bg {
return None;
}
let mut top = rim - 1;
while buf[(2, top)].symbol() == " " && buf[(2, top)].bg == tray_bg {
top -= 1;
}
assert_eq!(sym(buf, 2, top), "\u{2584}", "the tray's top rim");
assert_eq!(buf[(2, top)].fg, tray_bg, "the top rim is tray-coloured");
Some((top, rim - 1))
}
fn cell(
buf: &Buffer,
x: u16,
y: u16,
) -> (String, ratatui::style::Color, ratatui::style::Color) {
let c = &buf[(x, y)];
(c.symbol().to_string(), c.fg, c.bg)
}
#[test]
fn agents_ride_on_a_tray_resting_on_the_prompt() {
let mut h = TestHarness::boot_with_size(W, H);
start(&mut h, 1, "chrollo");
update(&mut h, 1, "\u{2699} read (tool #4)");
update(&mut h, 1, "reading oneshot.rs");
start(&mut h, 2, "spike");
update(&mut h, 2, "$ cargo test -p synaps-tui");
let buf = h.render().clone();
let (top, last) = tray_rows(&buf).expect("a tray");
assert_eq!(last, top + 2, "top rim, two agent rows");
assert_eq!(prompt_row(&buf), last + 2, "the input's rim, then ❯");
for (y, name, what) in [
(top + 1, "chrollo", "reading oneshot.rs"),
(top + 2, "spike", "$ cargo test -p synaps-tui"),
] {
let line = row(&buf, y);
assert!(
SPINNER_FRAMES.contains(&sym(&buf, 3, y)),
"running glyph under ❯: {line:?}"
);
assert!(line.contains(name) && line.contains(what), "{line:?}");
}
assert!(row(&buf, top + 1).contains("4 tools"));
for y in top..=prompt_row(&buf) {
let line = row(&buf, y);
for glyph in ['\u{256d}', '\u{256e}', '\u{2570}', '\u{256f}', '\u{2502}'] {
assert!(!line.contains(glyph), "box glyph {glyph:?} in {line:?}");
}
}
}
#[test]
fn the_watched_worker_row_is_a_band_in_the_prompt_accent() {
use crossterm::event::{KeyCode, KeyModifiers};
let mut h = TestHarness::boot_with_size(W, H);
start(&mut h, 1, "chrollo");
start(&mut h, 2, "spike");
let before = h.render().clone();
let (top, _) = tray_rows(&before).expect("a tray");
let tray_bg = before[(2, top + 1)].bg;
assert_eq!(
before[(2, top + 2)].bg,
tray_bg,
"nothing watched: one surface"
);
h.key(KeyCode::Up, KeyModifiers::CONTROL);
let buf = h.render().clone();
let y = (top + 1..=top + 2)
.find(|&y| row(&buf, y).contains("spike"))
.expect("spike's row");
let other = if y == top + 1 { top + 2 } else { top + 1 };
let band = buf[(2, y)].bg;
assert_ne!(band, tray_bg, "the watched row is a band");
for x in [2, W / 2, W - 3] {
assert_eq!(buf[(x, y)].bg, band, "across the tray at x={x}");
}
assert_eq!(buf[(2, other)].bg, tray_bg, "the other row is unchanged");
let rgb_of = |c: ratatui::style::Color| match c {
ratatui::style::Color::Rgb(r, g, b) => (r, g, b),
other => panic!("not rgb: {other:?}"),
};
assert!(
contrast(rgb_of(band), rgb_of(tray_bg)) > 1.08,
"the band stands off the tray"
);
let accent = rgb(
THEME.load().prompt_fg,
Theme::default().prompt_fg,
);
assert_eq!(
rgb_of(band),
watched_bg(&THEME.load(), rgb_of(tray_bg)),
"the tray tinted toward ❯ ({accent:?})"
);
assert_ne!(
buf[(2, y)].symbol(),
"\u{2503}",
"no selection bar"
);
let x = (0..W)
.find(|&x| buf[(x, y)].symbol() == "s" && buf[(x + 1, y)].symbol() == "p")
.unwrap();
assert_eq!(buf[(x, y)].bg, band, "the name sits on the band");
assert!(
!buf[(x, y)]
.modifier
.contains(ratatui::style::Modifier::UNDERLINED),
"the band marks it; no underline"
);
let rim = prompt_row(&buf) - 1;
assert_eq!(y, rim - 1, "spike is the last row");
for x in [2, W / 2, W - 3] {
assert_eq!(buf[(x, rim)].symbol(), "\u{2584}");
assert_eq!(buf[(x, rim)].bg, buf[(x, y)].bg, "bottom padding at x={x}");
assert_eq!(buf[(x, top)].fg, before[(x, top)].fg, "top padding unchanged");
}
h.key(KeyCode::Up, KeyModifiers::CONTROL);
let buf = h.render().clone();
let first = top + 1;
assert!(row(&buf, first).contains("chrollo"));
assert_ne!(buf[(2, first)].bg, tray_bg, "chrollo is watched");
for x in [2, W / 2, W - 3] {
assert_eq!(buf[(x, top)].symbol(), "\u{2584}");
assert_eq!(buf[(x, top)].fg, buf[(x, first)].bg, "top padding at x={x}");
assert_eq!(buf[(x, top)].bg, before[(x, top)].bg, "above it is unchanged");
assert_eq!(buf[(x, rim)].bg, before[(x, rim)].bg, "bottom padding back to the tray");
}
}
#[test]
fn the_input_is_unchanged_and_the_tray_is_a_clean_rectangle() {
let mut h = TestHarness::boot_with_size(W, H);
start(&mut h, 1, "sleeper-1");
start(&mut h, 2, "sleeper-2");
let buf = h.render().clone();
let mut plain = TestHarness::boot_with_size(W, H);
let plain = plain.render().clone();
assert!(tray_rows(&plain).is_none());
let prompt = prompt_row(&buf);
assert_eq!(prompt_row(&plain), prompt, "the input doesn't move");
let rim = prompt - 1;
let bottom = (prompt..H)
.find(|&y| sym(&buf, 1, y) == "\u{259D}")
.expect("bottom rim");
for y in prompt..=bottom {
for x in 0..W {
assert_eq!(cell(&buf, x, y), cell(&plain, x, y), "input ({x},{y})");
}
}
let (top, _) = tray_rows(&buf).expect("a tray");
let tray_bg = buf[(2, top + 1)].bg;
for x in 0..W {
let (got, want) = (cell(&buf, x, rim), cell(&plain, x, rim));
if (2..=W - 3).contains(&x) {
assert_eq!((&got.0, got.1), (&want.0, want.1), "rim ({x})");
assert_eq!(got.2, tray_bg, "tray padding above the rim ({x})");
} else {
assert_eq!(got, want, "rim corner/outside ({x})");
}
}
let chrome = buf[(0, top + 1)].bg;
for y in top + 1..rim {
assert_eq!(buf[(2, y)].bg, tray_bg, "left edge at {y}");
assert_eq!(buf[(W - 3, y)].bg, tray_bg, "right edge at {y}");
assert_eq!(buf[(1, y)].bg, chrome, "chrome outside at {y}");
assert_eq!(buf[(W - 2, y)].bg, chrome, "chrome outside at {y}");
}
for x in 2..=W - 3 {
assert_eq!(
cell(&buf, x, top),
("\u{2584}".into(), tray_bg, chrome),
"top rim ({x})"
);
}
assert_eq!(buf[(1, top)].bg, chrome);
assert_eq!(buf[(1, top)].symbol(), " ", "no tray outside its columns");
}
#[test]
fn finished_agents_show_their_result_and_state() {
let mut h = TestHarness::boot_with_size(W, H);
start(&mut h, 1, "shady");
done(&mut h, 1, "ERROR: provider request failed [rate_limit]");
start(&mut h, 2, "inline");
done(&mut h, 2, "## Summary\n\nRelease checklist found.\nmore");
start(&mut h, 3, "gif-recorder");
done(&mut h, 3, "[TIMED OUT after 30s — partial results below]");
let buf = h.render().clone();
let (rim, _) = tray_rows(&buf).expect("a tray");
let failed = row(&buf, rim + 1);
assert_eq!(sym(&buf, 3, rim + 1), "\u{2717}", "{failed:?}");
assert!(
failed.contains("provider request failed [rate_limit]"),
"{failed:?}"
);
assert!(!failed.contains("ERROR"), "{failed:?}");
let ok = row(&buf, rim + 2);
assert_eq!(sym(&buf, 3, rim + 2), "\u{2713}", "{ok:?}");
assert!(
ok.contains("Release checklist found."),
"first real line: {ok:?}"
);
let timed = row(&buf, rim + 3);
assert_eq!(sym(&buf, 3, rim + 3), "!", "{timed:?}");
assert!(timed.contains("timed out"), "{timed:?}");
assert!(ok.contains("10s"), "duration: {ok:?}");
}
#[test]
fn more_than_six_agents_collapse_into_a_count() {
let mut h = TestHarness::boot_with_size(W, H);
for id in 1..=8 {
start(&mut h, id, &format!("agent-{id}"));
}
let buf = h.render().clone();
let (rim, last) = tray_rows(&buf).expect("a tray");
assert_eq!(last, rim + 7, "top rim, six rows, +N more");
assert_eq!(prompt_row(&buf), last + 2);
assert!(row(&buf, rim + 6).contains("agent-6"));
assert!(row(&buf, rim + 7).contains("+2 more"));
assert!(!row(&buf, rim + 7).contains("agent-7"));
}
#[test]
fn no_agents_no_tray() {
let mut h = TestHarness::boot_with_size(W, H);
let buf = h.render().clone();
assert!(tray_rows(&buf).is_none(), "the plain slab only");
}
#[test]
fn the_tray_meets_the_prompt_even_with_a_download_row() {
let areas = crate::tui::draw::AppAreas::from_heights(
ratatui::layout::Rect::new(0, 0, W, H),
3,
1,
3,
);
assert_eq!(
areas.subagent.bottom(),
areas.input.y,
"tray right on the prompt"
);
assert_eq!(
areas.download.bottom(),
areas.subagent.y,
"download above it"
);
}
fn rows_named(buf: &Buffer, needle: &str) -> Vec<String> {
(0..buf.area().height)
.map(|y| row(buf, y))
.filter(|l| l.contains(needle))
.collect()
}
#[test]
fn a_daemon_reload_ends_running_rows_as_stopped() {
let mut h = TestHarness::boot_with_size(W, H);
start(&mut h, 1, "integrator");
start(&mut h, 2, "qwen3-builder");
update(&mut h, 2, "$ sleep 290");
h.feed_event(SessionEventWire::Reloading {
generation: 10,
retry_after_ms: 500,
});
let buf = h.render().clone();
for name in ["integrator", "qwen3-builder"] {
let line = rows_named(&buf, name)
.pop()
.unwrap_or_else(|| panic!("{name} missing"));
assert!(line.contains("stopped by daemon reload"), "{line:?}");
assert!(
!line.contains("sleep 290"),
"no longer shown as working: {line:?}"
);
}
}
#[test]
fn rows_stopped_by_a_reload_flash_out() {
use super::super::app::SubagentState;
use super::super::stream_handler::{
mark_workers_stopped_by_reload, reconcile_subagents, SUBAGENT_DONE_FLASH_SECS,
};
let t0 = std::time::Instant::now();
let row = |id: u64, done: bool| SubagentState {
id,
name: format!("w{id}"),
status: "$ sleep 290".into(),
start_time: t0,
done,
duration_secs: done.then_some(1.0),
done_at: done.then_some(t0),
tools: 1,
result: None,
};
let mut hud = vec![row(1, false), row(2, true)];
assert!(mark_workers_stopped_by_reload(&mut hud, t0));
assert!(hud[0].done && hud[0].status.contains("stopped by daemon reload"));
assert_eq!(
hud[1].status, "$ sleep 290",
"a finished row keeps its ending"
);
assert!(!mark_workers_stopped_by_reload(&mut hud, t0), "idempotent");
let later = t0 + std::time::Duration::from_secs_f64(SUBAGENT_DONE_FLASH_SECS + 1.0);
reconcile_subagents(&mut hud, &[], later);
assert!(
hud.is_empty(),
"{:?}",
hud.iter().map(|s| &s.status).collect::<Vec<_>>()
);
}
#[test]
fn a_reused_worker_id_replaces_its_row() {
let mut h = TestHarness::boot_with_size(W, H);
start(&mut h, 1, "integrator");
h.feed_event(SessionEventWire::Reloading {
generation: 10,
retry_after_ms: 500,
});
start(&mut h, 1, "integrator");
let buf = h.render().clone();
let lines = rows_named(&buf, "integrator");
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(lines[0].contains("starting"), "{lines:?}");
}
#[test]
fn a_background_agent_keeps_moving_after_the_turn() {
use synaps_cli::runtime::subagent::SubagentStatus;
use synaps_cli::tools::SubagentDisplayRow;
let agent_row = |buf: &Buffer| {
(0..buf.area().height)
.map(|y| row(buf, y))
.find(|l| l.contains("sleeper-1"))
.expect("the agent's tray row")
};
let mut h = TestHarness::boot_with_size(W, H);
start(&mut h, 37, "sleeper-1");
let line = agent_row(&h.render().clone());
assert!(line.contains("starting"), "{line:?}");
let rows = |step: &str, tools: u32| {
SessionEventWire::SubagentRows(vec![SubagentDisplayRow {
subagent_id: 37,
agent_name: "sleeper-1".into(),
status: SubagentStatus::Running,
cancel_requested: false,
elapsed_secs: 3.0,
finished_elapsed: None,
step: step.into(),
tools,
handle_id: "sa_37".into(),
model: String::new(),
}])
};
h.feed_event(rows("$ date +%T; sleep 20; date +%T", 1));
let line = agent_row(&h.render().clone());
assert!(line.contains("$ date +%T; sleep 20"), "{line:?}");
assert!(line.contains("1 tool"), "{line:?}");
assert!(!line.contains("starting"), "{line:?}");
}
fn registry_row(
id: u64,
name: &str,
status: synaps_cli::runtime::subagent::SubagentStatus,
cancel_requested: bool,
) -> synaps_cli::tools::SubagentDisplayRow {
synaps_cli::tools::SubagentDisplayRow {
subagent_id: id,
agent_name: name.into(),
status,
cancel_requested,
elapsed_secs: 42.0,
finished_elapsed: None,
step: "$ cargo test -p synaps-tui".into(),
tools: 3,
handle_id: format!("sa_{id}"),
model: String::new(),
}
}
fn line_with(buf: &Buffer, needle: &str) -> Option<String> {
(0..buf.area().height)
.map(|y| row(buf, y))
.find(|l| l.contains(needle))
}
#[test]
fn reattaching_shows_the_workers_that_kept_running() {
use synaps_cli::runtime::subagent::SubagentStatus::Running;
let mut h = TestHarness::boot_with_size(W, H);
assert!(tray_rows(&h.render().clone()).is_none(), "fresh client");
h.feed_event(SessionEventWire::SubagentRows(vec![
registry_row(7, "chrollo", Running, false),
registry_row(8, "spike", Running, false),
]));
let buf = h.render().clone();
assert!(tray_rows(&buf).is_some(), "the tray is back");
for name in ["chrollo", "spike"] {
let line = line_with(&buf, name).unwrap_or_else(|| panic!("{name} missing"));
assert!(line.contains("$ cargo test -p synaps-tui"), "{line:?}");
assert!(line.contains("3 tools"), "{line:?}");
}
}
#[test]
fn an_abort_does_not_hide_a_background_worker() {
use synaps_cli::runtime::subagent::SubagentStatus::Running;
let mut h = TestHarness::boot_with_size(W, H);
start(&mut h, 37, "sleeper-1");
assert!(line_with(&h.render().clone(), "sleeper-1").is_some());
h.feed_event(SessionEventWire::Aborted {
context_saved: true,
});
h.feed_event(SessionEventWire::SubagentRows(vec![registry_row(
37,
"sleeper-1",
Running,
false,
)]));
let buf = h.render().clone();
let line = line_with(&buf, "sleeper-1").expect("the worker survives the abort");
assert!(line.contains("$ cargo test -p synaps-tui"), "{line:?}");
}
#[test]
fn rows_do_not_bring_back_finished_or_cancelling_workers() {
use synaps_cli::runtime::subagent::SubagentStatus::{Completed, Running};
let mut h = TestHarness::boot_with_size(W, H);
h.feed_event(SessionEventWire::SubagentRows(vec![
registry_row(1, "finished-one", Completed, false),
registry_row(2, "cancelling-one", Running, true),
]));
let buf = h.render().clone();
assert!(tray_rows(&buf).is_none(), "nothing to show");
assert!(line_with(&buf, "finished-one").is_none());
assert!(line_with(&buf, "cancelling-one").is_none());
}
}