chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "jcode-tui-render"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
jcode-tui-style = { path = "../jcode-tui-style" }
ratatui = "0.30"
unicode-width = "0.2"
@@ -0,0 +1,395 @@
//! Interactive, animated demo of the inline swarm gallery.
//!
//! This runs as a real full-screen terminal app so you can *see* the inline
//! gallery the way it looks live in the jcode TUI, without touching any running
//! agents. It simulates a handful of mock swarm workers that stream output,
//! change status, and finish over time, rendering them through the exact same
//! `render_swarm_gallery` layout the real TUI uses.
//!
//! Run with:
//! cargo run --profile selfdev -p jcode-tui-render --example swarm_gallery_live
//!
//! Controls:
//! q / Esc quit
//! + / - more / fewer agents
//! [ / ] shrink / grow the gallery band (the max_pct knob)
//! space pause / resume the animation
use std::io::{self, Stdout};
use std::time::{Duration, Instant};
use ratatui::crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use jcode_tui_render::swarm_gallery::{GalleryMember, humanize_age, render_gallery};
/// A simulated worker, mirroring the fields the real adapter reads from a
/// `SwarmMemberStatus` (name, role, status, streamed output tail).
struct MockWorker {
name: String,
role: Option<&'static str>,
status: String,
/// Full streamed transcript so far; the tile shows the tail.
transcript: Vec<String>,
/// Scripted lines this worker will "stream" over time.
script: Vec<String>,
next_line: usize,
/// Ticks until the next line is appended.
cooldown: u16,
started: Instant,
}
impl MockWorker {
fn new(name: &str, role: Option<&'static str>, script: Vec<&str>) -> Self {
Self {
name: name.to_string(),
role,
status: "spawned".to_string(),
transcript: Vec::new(),
script: script.into_iter().map(|s| s.to_string()).collect(),
next_line: 0,
cooldown: 2,
started: Instant::now(),
}
}
fn tick(&mut self) {
if self.next_line >= self.script.len() {
if self.status != "completed" {
self.status = "completed".to_string();
}
return;
}
if self.cooldown > 0 {
self.cooldown -= 1;
// While waiting, alternate running/thinking to show the badge change.
if self.transcript.is_empty() {
self.status = "thinking".to_string();
}
return;
}
let line = self.script[self.next_line].clone();
self.status = if line.starts_with("! ") {
"blocked".to_string()
} else {
"running".to_string()
};
self.transcript
.push(line.trim_start_matches("! ").to_string());
self.next_line += 1;
self.cooldown = 2 + (self.next_line as u16 % 3);
}
fn age_secs(&self) -> u64 {
self.started.elapsed().as_secs()
}
}
fn workers_to_members(workers: &[MockWorker]) -> Vec<GalleryMember> {
workers
.iter()
.map(|w| {
let mut body: Vec<String> = w.transcript.clone();
body.push(format!("· {} ago", humanize_age(w.age_secs())));
GalleryMember {
label: w.name.clone(),
icon: None,
status: w.status.clone(),
task: None,
role: w.role.map(str::to_string),
body,
sort_key: w.name.clone(),
todo: None,
todo_items: Vec::new(),
model: None,
provider: None,
auth_method: None,
effort: None,
elapsed_secs: Some(w.age_secs()),
}
})
.collect()
}
fn make_workers(n: usize) -> Vec<MockWorker> {
let pool: Vec<(&str, Option<&'static str>, Vec<&str>)> = vec![
(
"researcher",
Some("coordinator"),
vec![
"Searching the codebase for the auth flow...",
"Found 12 candidate files.",
"Reading crates/jcode-app-core/src/auth.rs",
"The OAuth callback is handled in handle_login()",
"Now cross-referencing the token refresh path.",
"Refresh happens in refresh_session() (line 412).",
"Summarizing findings for the implementer.",
],
),
(
"implementer",
None,
vec![
"Editing crates/jcode-base/src/config.rs",
"Added swarm_spawn_mode = inline",
"Running cargo check...",
"warning: unused import `Foo`",
"Fixing the import.",
"cargo check clean.",
"Wiring the gallery into the draw path.",
],
),
(
"reviewer",
None,
vec![
"Waiting for the implementer to finish.",
"Reviewing the diff...",
"Reviewed 4 files.",
"! Blocked: needs a test for the new branch.",
"Test added, re-reviewing.",
"No blocking issues found.",
"LGTM",
],
),
(
"tester",
None,
vec![
"Building the selfdev profile...",
"Compiling jcode-tui-render",
"Running 5 tests",
"test cells_are_width_bounded ... ok",
"test many_agents_form_multiple_columns ... ok",
"All tests passed.",
],
),
(
"doc-writer",
None,
vec![
"Drafting the config docs.",
"Documenting swarm_gallery_max_pct.",
"Adding an example to default_file.rs.",
"Proofreading.",
"Docs ready.",
],
),
(
"packager",
None,
vec![
"Preparing the release worktree.",
"Bumping version.",
"Staging changes.",
"Commit drafted.",
],
),
(
"benchmarker",
None,
vec![
"Warming up the bench harness.",
"Running memory_recall_bench...",
"p50 latency 18ms",
"p99 latency 64ms",
"Results recorded.",
],
),
(
"linter",
None,
vec![
"Running clippy...",
"warning: needless clone (3)",
"Auto-fixing.",
"clippy clean.",
],
),
];
pool.into_iter()
.take(n.max(1))
.map(|(name, role, script)| MockWorker::new(name, role, script))
.collect()
}
fn render_gallery_lines(
workers: &[MockWorker],
width: usize,
max_height: usize,
) -> Vec<Line<'static>> {
// Delegate to the exact same shared renderer the live TUI uses; only the
// member data (built from mock workers) differs.
render_gallery(&workers_to_members(workers), width, max_height)
}
fn draw(f: &mut Frame, workers: &[MockWorker], max_pct: usize, paused: bool) {
let area = f.area();
// Reserve a top band like the real TUI does: a configurable share of the
// chat column height, capped, with a >=5 row floor before it shows.
let budget = ((area.height as usize * max_pct) / 100).clamp(0, 18);
let lines = if budget >= 5 && area.width >= 24 {
render_gallery_lines(workers, area.width as usize, budget)
} else {
Vec::new()
};
let band_h = if lines.is_empty() {
0u16
} else {
((lines.len() as u16) + 1).min(area.height / 2)
};
let band = Rect {
x: area.x,
y: area.y,
width: area.width,
height: band_h,
};
let chat = Rect {
x: area.x,
y: area.y + band_h,
width: area.width,
height: area.height.saturating_sub(band_h),
};
if band_h > 0 {
f.render_widget(Clear, band);
f.render_widget(Paragraph::new(lines), band);
}
// Mock chat / instructions below the band.
let help = vec![
Line::from(vec![Span::styled(
" inline swarm gallery — live demo (no real agents touched)",
Style::default()
.fg(Color::Rgb(200, 200, 210))
.add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(vec![Span::styled(
" The band above is the same renderer the jcode TUI shows above your chat",
Style::default().fg(Color::Rgb(150, 150, 160)),
)]),
Line::from(vec![Span::styled(
" when you run a swarm with swarm_spawn_mode = \"inline\".",
Style::default().fg(Color::Rgb(150, 150, 160)),
)]),
Line::from(""),
Line::from(vec![
Span::styled(
" band size: ",
Style::default().fg(Color::Rgb(150, 150, 160)),
),
Span::styled(
format!("{max_pct}% "),
Style::default().fg(Color::Rgb(255, 200, 100)),
),
Span::styled(
"(agents.swarm_gallery_max_pct)",
Style::default().fg(Color::Rgb(110, 110, 120)),
),
Span::styled(
if paused { " [PAUSED]" } else { "" },
Style::default().fg(Color::Rgb(255, 170, 80)),
),
]),
Line::from(""),
Line::from(vec![Span::styled(
" keys: q/Esc quit +/- agents [ / ] band size space pause",
Style::default().fg(Color::Rgb(120, 120, 130)),
)]),
];
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::Rgb(70, 70, 80)))
.title(Span::styled(
" chat ",
Style::default().fg(Color::Rgb(120, 120, 130)),
));
f.render_widget(
Paragraph::new(help).block(block).wrap(Wrap { trim: false }),
chat,
);
}
fn main() -> io::Result<()> {
let mut stdout = io::stdout();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = run(&mut terminal);
disable_raw_mode()?;
execute!(
terminal.backend_mut() as &mut CrosstermBackend<Stdout>,
LeaveAlternateScreen
)?;
terminal.show_cursor()?;
res
}
fn run(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
let mut n_agents = 4usize;
let mut workers = make_workers(n_agents);
let mut max_pct = 40usize;
let mut paused = false;
let tick = Duration::from_millis(350);
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| draw(f, &workers, max_pct, paused))?;
let timeout = tick.saturating_sub(last_tick.elapsed());
if event::poll(timeout)?
&& let Event::Key(key) = event::read()?
{
let ctrl_c =
key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c');
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
_ if ctrl_c => break,
KeyCode::Char('+') | KeyCode::Char('=') => {
n_agents = (n_agents + 1).min(8);
workers = make_workers(n_agents);
}
KeyCode::Char('-') | KeyCode::Char('_') => {
n_agents = n_agents.saturating_sub(1).max(1);
workers = make_workers(n_agents);
}
KeyCode::Char(']') => max_pct = (max_pct + 5).min(90),
KeyCode::Char('[') => max_pct = max_pct.saturating_sub(5).max(5),
KeyCode::Char(' ') => paused = !paused,
KeyCode::Char('r') => {
workers = make_workers(n_agents);
}
_ => {}
}
}
if last_tick.elapsed() >= tick {
if !paused {
for w in workers.iter_mut() {
w.tick();
}
// When everything is done, loop the demo after a short beat.
if workers.iter().all(|w| w.status == "completed")
&& workers.iter().all(|w| w.age_secs() > 1)
{
// restart so the demo keeps streaming
workers = make_workers(n_agents);
}
}
last_tick = Instant::now();
}
}
Ok(())
}
@@ -0,0 +1,304 @@
//! Visual preview of the swarm gallery layout against mock streams.
//!
//! Run with: `cargo run --profile selfdev -p jcode-tui-render --example swarm_gallery_preview`
use jcode_tui_render::swarm_gallery::{
GalleryMember, SwarmStripHint, render_swarm_compact, render_swarm_dock, render_swarm_panel,
render_swarm_strip, render_swarm_strip_vertical,
};
use jcode_tui_render::swarm_tiles::{SwarmGalleryConfig, SwarmTile, render_swarm_gallery};
use ratatui::prelude::*;
fn accent(status: &str) -> Color {
match status {
"running" => Color::Rgb(255, 200, 100),
"thinking" => Color::Rgb(140, 180, 255),
"done" => Color::Rgb(100, 200, 100),
"blocked" => Color::Rgb(255, 170, 80),
"failed" => Color::Rgb(255, 100, 100),
_ => Color::Rgb(140, 140, 150),
}
}
fn mk(name: &str, role: Option<&str>, status: &str, body: &[&str]) -> SwarmTile {
let mut t = SwarmTile::new(name, status, accent(status))
.with_body(body.iter().map(|s| s.to_string()).collect());
if let Some(r) = role {
t = t.with_role_glyph(r);
}
t
}
fn print_lines(label: &str, lines: &[Line<'static>]) {
println!("\n=== {label} ===");
for line in lines {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
println!("{text}");
}
}
fn main() {
let header = Line::from(Span::styled(
"🐝 swarm · 3 agents running",
Style::default().fg(Color::Rgb(255, 200, 100)),
));
let three = vec![
mk(
"researcher",
Some(""),
"thinking",
&[
"Searching the codebase for the auth flow...",
"Found 12 candidate files.",
"Reading crates/jcode-app-core/src/auth.rs",
"The OAuth callback is handled in handle_login()",
"Now cross-referencing the token refresh path.",
],
),
mk(
"implementer",
None,
"running",
&[
"Editing crates/jcode-base/src/config.rs",
"Added swarm_spawn_mode = inline",
"Running cargo check...",
"warning: unused import `Foo`",
"Fixing the import.",
],
),
mk(
"reviewer",
None,
"done",
&["Reviewed 4 files.", "No blocking issues found.", "LGTM ✓"],
),
];
let cfg = SwarmGalleryConfig::default();
print_lines(
"3 agents @ width 100",
&render_swarm_gallery(&three, 100, &cfg, Some(header.clone())),
);
print_lines(
"3 agents @ width 60",
&render_swarm_gallery(&three, 60, &cfg, Some(header.clone())),
);
let six: Vec<SwarmTile> = (0..6)
.map(|i| {
let status = ["running", "thinking", "done", "blocked"][i % 4];
mk(
&format!("agent-{i}"),
None,
status,
&[
&format!("step {i}.1 doing work"),
&format!("step {i}.2 still going"),
&format!("step {i}.3 almost there"),
],
)
})
.collect();
print_lines(
"6 agents @ width 120",
&render_swarm_gallery(&six, 120, &cfg, Some(header.clone())),
);
let many: Vec<SwarmTile> = (0..12)
.map(|i| {
mk(
&format!("worker-{i:02}"),
None,
"running",
&["...", "working"],
)
})
.collect();
let tight = SwarmGalleryConfig {
max_height: 12,
..Default::default()
};
print_lines(
"12 agents @ width 120, height 12",
&render_swarm_gallery(&many, 120, &tight, Some(header)),
);
// ---- New list+detail panel ----
let gm = |name: &str, role: Option<&str>, status: &str, body: &[&str]| GalleryMember {
label: name.to_string(),
icon: None,
status: status.to_string(),
task: None,
role: role.map(str::to_string),
body: body.iter().map(|s| s.to_string()).collect(),
sort_key: name.to_string(),
todo: None,
todo_items: Vec::new(),
model: None,
provider: None,
auth_method: None,
effort: None,
elapsed_secs: None,
};
let panel_members = vec![
gm(
"researcher",
Some("coordinator"),
"thinking",
&["Cross-referencing the token refresh path.", "· 2s ago"],
),
gm(
"implementer",
None,
"running",
&[
"Running cargo check...",
"warning: unused import `Foo`",
"· 5s ago",
],
),
gm("reviewer", None, "done", &["LGTM ✓", "· 1m ago"]),
gm(
"doc-writer",
None,
"blocked",
&["waiting on reviewer", "· 12s ago"],
),
];
print_lines(
"PANEL: 4 agents, selected #1 (implementer), focused @ width 70 h 14",
&render_swarm_panel(&panel_members, 1, true, 70, 14),
);
print_lines(
"PANEL: 4 agents, selected #0, unfocused @ width 70 h 14",
&render_swarm_panel(&panel_members, 0, false, 70, 14),
);
print_lines(
"PANEL: narrow @ width 44 h 12",
&render_swarm_panel(&panel_members, 2, true, 44, 12),
);
// ---- New compact strip (above status line) ----
let hints = vec![
SwarmStripHint {
key: "alt+↑/↓".into(),
label: "select".into(),
},
SwarmStripHint {
key: "alt+o".into(),
label: "open".into(),
},
SwarmStripHint {
key: "alt+shift+p".into(),
label: "prompt".into(),
},
SwarmStripHint {
key: "esc".into(),
label: "exit".into(),
},
];
print_lines(
"STRIP: unfocused @ width 90",
&render_swarm_strip(
&panel_members,
1,
false,
&hints,
Some("alt+n controls"),
0,
90,
12,
),
);
print_lines(
"STRIP: focused, selected #1 @ width 90",
&render_swarm_strip(&panel_members, 1, true, &hints, None, 3, 90, 12),
);
print_lines(
"STRIP: focused narrow @ width 54",
&render_swarm_strip(&panel_members, 0, true, &hints, None, 5, 54, 12),
);
// ---- Vertical strip (default layout: one agent per row) ----
let mut vert_members = panel_members.clone();
for (m, icon) in vert_members.iter_mut().zip(["🦊", "🐝", "🐅", "🦉"]) {
m.icon = Some(icon.to_string());
m.task = Some(match m.label.as_str() {
"researcher" => "wire the auth flow".to_string(),
"implementer" => "audit the webhook path".to_string(),
"reviewer" => "support/contact page".to_string(),
_ => "misc background task".to_string(),
});
}
vert_members[1].todo = Some((3, 9));
print_lines(
"VERTICAL: unfocused @ width 90",
&render_swarm_strip_vertical(
&vert_members,
1,
false,
&hints,
Some("alt+n controls"),
0,
90,
4,
12,
),
);
print_lines(
"VERTICAL: focused (accordion), selected #1 @ width 90",
&render_swarm_strip_vertical(&vert_members, 1, true, &hints, None, 3, 90, 4, 12),
);
print_lines(
"VERTICAL: 7 agents overflow @ width 80",
&render_swarm_strip_vertical(
&{
let mut many = Vec::new();
for i in 0..7 {
let mut m = gm(&format!("agent-{i}"), None, "running", &[]);
m.icon = Some("🐜".to_string());
many.push(m);
}
many
},
0,
false,
&hints,
Some("alt+n controls"),
0,
80,
4,
12,
),
);
// ---- Dock (vertical agent list for the info-widget margins) ----
print_lines(
"DOCK: 4 agents, selected #0, unfocused @ width 34 h 12",
&render_swarm_dock(&panel_members, 0, false, Some((3, 7)), 0, 34, 12),
);
print_lines(
"DOCK: 4 agents, selected #1, focused @ width 34 h 14",
&render_swarm_dock(&panel_members, 1, true, Some((3, 7)), 2, 34, 14),
);
print_lines(
"DOCK: narrow @ width 24 h 8",
&render_swarm_dock(&panel_members, 2, false, None, 0, 24, 8),
);
// ---- Compact summary (two lines: tally + plan bar) ----
print_lines(
"COMPACT: 4 agents, nodes 5/12 (3 running) @ width 34",
&render_swarm_compact(&panel_members, Some((5, 3, 12)), 34, 2),
);
print_lines(
"COMPACT: no plan @ width 34",
&render_swarm_compact(&panel_members, None, 34, 2),
);
print_lines(
"COMPACT: narrow @ width 20",
&render_swarm_compact(&panel_members, Some((5, 3, 12)), 20, 2),
);
}
+91
View File
@@ -0,0 +1,91 @@
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
pub fn clear_area(frame: &mut Frame, area: Rect) {
for x in area.left()..area.right() {
for y in area.top()..area.bottom() {
frame.buffer_mut()[(x, y)].reset();
}
}
}
pub fn left_aligned_content_inset(width: u16, centered: bool) -> u16 {
if centered || width <= 1 { 0 } else { 1 }
}
pub fn centered_content_block_width(width: u16, max_width: usize) -> usize {
(width as usize).min(max_width).max(1)
}
pub fn left_pad_lines_to_block_width(lines: &mut [Line<'static>], width: u16, block_width: usize) {
let block_width = block_width.min(width as usize);
let pad = (width as usize).saturating_sub(block_width) / 2;
for line in lines {
if pad > 0 {
line.spans.insert(0, Span::raw(" ".repeat(pad)));
}
line.alignment = Some(Alignment::Left);
}
}
const RIGHT_RAIL_HEADER_HEIGHT: u16 = 1;
pub fn right_rail_border_style(focused: bool, focus_color: Color, dim_color: Color) -> Style {
let border_color = if focused { focus_color } else { dim_color };
Style::default().fg(border_color)
}
fn right_rail_inner(area: Rect) -> Rect {
Block::default().borders(Borders::LEFT).inner(area)
}
fn right_rail_content_area(area: Rect) -> Option<Rect> {
let inner = right_rail_inner(area);
if inner.width == 0 || inner.height <= RIGHT_RAIL_HEADER_HEIGHT {
return None;
}
Some(Rect {
x: inner.x,
y: inner.y + RIGHT_RAIL_HEADER_HEIGHT,
width: inner.width,
height: inner.height - RIGHT_RAIL_HEADER_HEIGHT,
})
}
pub fn draw_right_rail_chrome(
frame: &mut Frame,
area: Rect,
title: Line<'static>,
border_style: Style,
) -> Option<Rect> {
let inner = right_rail_inner(area);
let content_area = right_rail_content_area(area)?;
let block = Block::default()
.borders(Borders::LEFT)
.border_style(border_style);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(title),
Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: RIGHT_RAIL_HEADER_HEIGHT,
},
);
Some(content_area)
}
/// Set alignment on a line only if it doesn't already have one set.
/// This allows markdown rendering to mark code blocks as left-aligned while
/// other content inherits the default alignment (e.g., centered mode).
pub fn align_if_unset(line: Line<'static>, align: Alignment) -> Line<'static> {
if line.alignment.is_some() {
line
} else {
line.alignment(align)
}
}
+64
View File
@@ -0,0 +1,64 @@
use ratatui::layout::Rect;
pub fn rect_contains(outer: Rect, inner: Rect) -> bool {
inner.x >= outer.x
&& inner.y >= outer.y
&& inner.x.saturating_add(inner.width) <= outer.x.saturating_add(outer.width)
&& inner.y.saturating_add(inner.height) <= outer.y.saturating_add(outer.height)
}
pub fn point_in_rect(col: u16, row: u16, rect: Rect) -> bool {
col >= rect.x
&& row >= rect.y
&& col < rect.x.saturating_add(rect.width)
&& row < rect.y.saturating_add(rect.height)
}
pub fn parse_area_spec(spec: &str) -> Option<Rect> {
let mut parts = spec.split('+');
let size = parts.next()?;
let x = parts.next()?;
let y = parts.next()?;
if parts.next().is_some() {
return None;
}
let (w, h) = size.split_once('x')?;
Some(Rect {
width: w.parse::<u16>().ok()?,
height: h.parse::<u16>().ok()?,
x: x.parse::<u16>().ok()?,
y: y.parse::<u16>().ok()?,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rect_contains_requires_full_containment() {
let outer = Rect::new(2, 2, 10, 10);
assert!(rect_contains(outer, Rect::new(4, 4, 2, 2)));
assert!(rect_contains(outer, Rect::new(2, 2, 10, 10)));
assert!(!rect_contains(outer, Rect::new(1, 2, 10, 10)));
assert!(!rect_contains(outer, Rect::new(2, 2, 11, 10)));
}
#[test]
fn point_in_rect_uses_half_open_bounds() {
let rect = Rect::new(10, 20, 5, 4);
assert!(point_in_rect(10, 20, rect));
assert!(point_in_rect(14, 23, rect));
assert!(!point_in_rect(15, 23, rect));
assert!(!point_in_rect(14, 24, rect));
}
#[test]
fn parse_area_spec_parses_geometry() {
assert_eq!(parse_area_spec("80x24+4+2"), Some(Rect::new(4, 2, 80, 24)));
assert_eq!(parse_area_spec("bad"), None);
assert_eq!(parse_area_spec("80x24+4"), None);
}
}
+202
View File
@@ -0,0 +1,202 @@
pub mod chrome;
pub mod layout;
pub mod memory_tiles;
pub mod swarm_gallery;
pub mod swarm_tiles;
use ratatui::prelude::{Line, Span, Style};
pub fn render_rounded_box(
title: &str,
content: Vec<Line<'static>>,
max_width: usize,
border_style: Style,
) -> Vec<Line<'static>> {
if content.is_empty() || max_width < 6 {
return Vec::new();
}
let max_content_width = content
.iter()
.map(|line| line.width())
.max()
.unwrap_or(0)
.min(max_width.saturating_sub(4));
let truncated_title = truncate_line_with_ellipsis_to_width(
&Line::from(Span::raw(format!(" {} ", title))),
max_width.saturating_sub(2).max(1),
);
let title_text = line_plain_text(&truncated_title);
let title_len = truncated_title.width();
let box_content_width = max_content_width.max(title_len.saturating_sub(2));
if box_content_width < 6 {
return Vec::new();
}
let box_width = box_content_width + 4;
let border_chars = box_width.saturating_sub(title_len + 2);
let left_border = "".repeat(border_chars / 2);
let right_border = "".repeat(border_chars - border_chars / 2);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
format!("{}{}{}", left_border, title_text, right_border),
border_style,
)));
for line in content {
let truncated = truncate_line_to_width(&line, box_content_width);
let padding = box_content_width.saturating_sub(truncated.width());
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled("", border_style));
spans.extend(truncated.spans);
if padding > 0 {
spans.push(Span::raw(" ".repeat(padding)));
}
spans.push(Span::styled("", border_style));
lines.push(Line::from(spans));
}
let bottom_border = "".repeat(box_width.saturating_sub(2));
lines.push(Line::from(Span::styled(
format!("{}", bottom_border),
border_style,
)));
lines
}
pub fn truncate_line_to_width(line: &Line<'static>, width: usize) -> Line<'static> {
if width == 0 {
return Line::from("");
}
let mut spans: Vec<Span<'static>> = Vec::new();
let mut remaining = width;
for span in &line.spans {
if remaining == 0 {
break;
}
let text = span.content.as_ref();
let span_width = unicode_width::UnicodeWidthStr::width(text);
if span_width <= remaining {
spans.push(span.clone());
remaining -= span_width;
} else {
let mut clipped = String::new();
let mut used = 0;
for ch in text.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if used + cw > remaining {
break;
}
clipped.push(ch);
used += cw;
}
if !clipped.is_empty() {
spans.push(Span::styled(clipped, span.style));
}
remaining = 0;
}
}
if spans.is_empty() {
Line::from("")
} else {
Line::from(spans)
}
}
pub fn truncate_line_with_ellipsis_to_width(line: &Line<'static>, width: usize) -> Line<'static> {
if width == 0 {
return Line::from("");
}
if line.width() <= width {
return line.clone();
}
if width == 1 {
return Line::from(Span::raw(""));
}
let mut spans: Vec<Span<'static>> = Vec::new();
let mut remaining = width.saturating_sub(1);
let mut ellipsis_style = Style::default();
for span in &line.spans {
if remaining == 0 {
break;
}
let text = span.content.as_ref();
let span_width = unicode_width::UnicodeWidthStr::width(text);
if span_width <= remaining {
spans.push(span.clone());
remaining -= span_width;
ellipsis_style = span.style;
} else {
let mut clipped = String::new();
let mut used = 0;
for ch in text.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if used + cw > remaining {
break;
}
clipped.push(ch);
used += cw;
}
if !clipped.is_empty() {
spans.push(Span::styled(clipped, span.style));
ellipsis_style = span.style;
}
break;
}
}
spans.push(Span::styled("", ellipsis_style));
let mut truncated = Line::from(spans);
truncated.alignment = line.alignment;
truncated
}
pub fn truncate_line_preserving_suffix_to_width(
prefix: &Line<'static>,
suffix: &Line<'static>,
width: usize,
) -> Line<'static> {
if width == 0 {
return Line::from("");
}
if suffix.width() == 0 {
return truncate_line_with_ellipsis_to_width(prefix, width);
}
let mut combined_spans = prefix.spans.clone();
combined_spans.extend(suffix.spans.clone());
let mut combined = Line::from(combined_spans);
combined.alignment = prefix.alignment;
if combined.width() <= width {
return combined;
}
let suffix_width = suffix.width();
if suffix_width >= width {
let mut truncated = truncate_line_with_ellipsis_to_width(suffix, width);
truncated.alignment = prefix.alignment;
return truncated;
}
let prefix_budget = width.saturating_sub(suffix_width);
let mut prefix_part = truncate_line_with_ellipsis_to_width(prefix, prefix_budget);
prefix_part.spans.extend(suffix.spans.clone());
prefix_part.alignment = prefix.alignment;
prefix_part
}
pub fn line_plain_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
+587
View File
@@ -0,0 +1,587 @@
use chrono::{DateTime, Utc};
use ratatui::prelude::*;
#[derive(Clone)]
pub struct MemoryTilePlan {
pub lines: Vec<Line<'static>>,
pub width: usize,
pub height: usize,
pub score: usize,
}
pub struct MemoryTile {
category: String,
items: Vec<MemoryTileItem>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MemoryTileItem {
pub content: String,
pub updated_at: Option<DateTime<Utc>>,
}
impl From<String> for MemoryTileItem {
fn from(content: String) -> Self {
Self {
content,
updated_at: None,
}
}
}
impl From<&str> for MemoryTileItem {
fn from(content: &str) -> Self {
Self::from(content.to_string())
}
}
pub fn parse_memory_display_entries(content: &str) -> Vec<(String, MemoryTileItem)> {
let mut entries: Vec<(String, MemoryTileItem)> = Vec::new();
let mut current_category = String::new();
let mut last_entry_idx: Option<usize> = None;
for raw_line in content.lines() {
let line = raw_line.trim();
if line.starts_with("# ") || line.is_empty() {
continue;
}
if let Some(category) = line.strip_prefix("## ") {
current_category = category.trim().to_string();
continue;
}
if let Some(updated_at_raw) = line
.strip_prefix("<!-- updated_at: ")
.and_then(|value| value.strip_suffix(" -->"))
{
if let (Some(idx), Ok(updated_at)) = (
last_entry_idx,
DateTime::parse_from_rfc3339(updated_at_raw.trim()),
) {
entries[idx].1.updated_at = Some(updated_at.with_timezone(&Utc));
}
continue;
}
let content = if let Some(dot_pos) = line.find(". ") {
let prefix = &line[..dot_pos];
if prefix.trim().chars().all(|c| c.is_ascii_digit()) {
line[dot_pos + 2..].trim()
} else {
line
}
} else {
line
};
if content.is_empty() {
continue;
}
let category = if current_category.is_empty() {
"memory".to_string()
} else {
current_category.clone()
};
entries.push((
category,
MemoryTileItem {
content: content.to_string(),
updated_at: None,
},
));
last_entry_idx = Some(entries.len() - 1);
}
entries
}
pub fn group_into_tiles<T>(entries: Vec<(String, T)>) -> Vec<MemoryTile>
where
T: Into<MemoryTileItem>,
{
let mut order: Vec<String> = Vec::new();
let mut map: std::collections::HashMap<String, Vec<MemoryTileItem>> =
std::collections::HashMap::new();
for (cat, content) in entries {
if !map.contains_key(&cat) {
order.push(cat.clone());
}
map.entry(cat).or_default().push(content.into());
}
order
.into_iter()
.filter_map(|cat| {
map.remove(&cat).map(|items| MemoryTile {
category: cat,
items,
})
})
.collect()
}
/// Split a string into chunks that each fit within `max_width` display columns,
/// respecting multi-column characters (CJK characters take 2 columns, etc.).
pub fn split_by_display_width(s: &str, max_width: usize) -> Vec<String> {
use unicode_width::UnicodeWidthChar;
let mut chunks = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for ch in s.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + cw > max_width && !current.is_empty() {
chunks.push(std::mem::take(&mut current));
current_width = 0;
}
current.push(ch);
current_width += cw;
}
if !current.is_empty() {
chunks.push(current);
}
if chunks.is_empty() {
chunks.push(String::new());
}
chunks
}
fn truncate_to_display_width(s: &str, max_width: usize) -> String {
use unicode_width::UnicodeWidthChar;
if max_width == 0 {
return String::new();
}
let full_width = unicode_width::UnicodeWidthStr::width(s);
if full_width <= max_width {
return s.to_string();
}
let ellipsis = "";
let ellipsis_width = unicode_width::UnicodeWidthStr::width(ellipsis);
if ellipsis_width >= max_width {
return ellipsis.to_string();
}
let target_width = max_width - ellipsis_width;
let mut truncated = String::new();
let mut width = 0usize;
for ch in s.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + ch_width > target_width {
break;
}
truncated.push(ch);
width += ch_width;
}
truncated.push('…');
truncated
}
fn format_memory_updated_age(updated_at: DateTime<Utc>) -> String {
let age = Utc::now().signed_duration_since(updated_at);
if age.num_seconds() < 2 {
"updated now".to_string()
} else if age.num_minutes() < 1 {
format!("updated {}s ago", age.num_seconds().max(1))
} else if age.num_hours() < 1 {
format!("updated {}m ago", age.num_minutes())
} else if age.num_days() < 1 {
format!("updated {}h ago", age.num_hours())
} else if age.num_days() < 7 {
format!("updated {}d ago", age.num_days())
} else if age.num_days() < 30 {
format!("updated {}w ago", (age.num_days() / 7).max(1))
} else {
format!("updated {}mo ago", (age.num_days() / 30).max(1))
}
}
fn memory_age_text_tint(updated_at: Option<DateTime<Utc>>) -> Color {
let Some(updated_at) = updated_at else {
return Color::Rgb(140, 144, 152);
};
let age = Utc::now().signed_duration_since(updated_at);
if age.num_hours() < 1 {
Color::Rgb(146, 156, 149)
} else if age.num_days() < 1 {
Color::Rgb(142, 148, 156)
} else if age.num_days() < 7 {
Color::Rgb(145, 144, 154)
} else if age.num_days() < 30 {
Color::Rgb(150, 143, 147)
} else {
Color::Rgb(154, 144, 144)
}
}
fn memory_tile_content_lines(
items: &[MemoryTileItem],
inner_width: usize,
border_style: Style,
text_style: Style,
) -> Vec<Line<'static>> {
let bullet = "· ";
let bullet_width = unicode_width::UnicodeWidthStr::width(bullet);
let item_width = inner_width.saturating_sub(bullet_width);
let mut content_lines: Vec<Line<'static>> = Vec::new();
for item in items {
let text_fill_style = text_style.fg(memory_age_text_tint(item.updated_at));
let meta_fill_style = Style::default().fg(Color::Rgb(160, 165, 172));
let text_display_width = unicode_width::UnicodeWidthStr::width(item.content.as_str());
if text_display_width <= item_width {
let text = item.content.to_string();
let padding = inner_width.saturating_sub(bullet_width + text_display_width);
let mut spans = vec![
Span::styled("", border_style),
Span::styled(bullet.to_string(), text_fill_style),
Span::styled(text, text_fill_style),
];
if padding > 0 {
spans.push(Span::raw(" ".repeat(padding)));
}
spans.push(Span::styled("", border_style));
content_lines.push(Line::from(spans));
} else {
let indent = bullet_width;
let cont_width = inner_width.saturating_sub(indent);
let first_chunk_width = item_width;
let mut all_chunks: Vec<String> = Vec::new();
let first_chunks = split_by_display_width(&item.content, first_chunk_width);
if let Some(first) = first_chunks.first() {
all_chunks.push(first.clone());
let remainder: String = item.content.chars().skip(first.chars().count()).collect();
if !remainder.is_empty() {
all_chunks.extend(split_by_display_width(&remainder, cont_width));
}
}
for (ci, chunk) in all_chunks.iter().enumerate() {
let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str());
if ci == 0 {
let padding = inner_width.saturating_sub(bullet_width + chunk_width);
let mut spans = vec![
Span::styled("", border_style),
Span::styled(bullet.to_string(), text_fill_style),
Span::styled(chunk.clone(), text_fill_style),
];
if padding > 0 {
spans.push(Span::raw(" ".repeat(padding)));
}
spans.push(Span::styled("", border_style));
content_lines.push(Line::from(spans));
} else {
let padding = inner_width.saturating_sub(indent + chunk_width);
let mut spans = vec![
Span::styled("", border_style),
Span::raw(" ".repeat(indent)),
Span::styled(chunk.clone(), text_fill_style),
];
if padding > 0 {
spans.push(Span::raw(" ".repeat(padding)));
}
spans.push(Span::styled("", border_style));
content_lines.push(Line::from(spans));
}
}
}
if let Some(updated_at) = item.updated_at {
let meta = format_memory_updated_age(updated_at);
let indent = bullet_width;
let meta_width = inner_width.saturating_sub(indent).max(1);
for chunk in split_by_display_width(&meta, meta_width) {
let chunk_width = unicode_width::UnicodeWidthStr::width(chunk.as_str());
let padding = inner_width.saturating_sub(indent + chunk_width);
content_lines.push(Line::from(vec![
Span::styled("", border_style),
Span::raw(" ".repeat(indent)),
Span::styled(chunk, meta_fill_style),
Span::raw(" ".repeat(padding)),
Span::styled("", border_style),
]));
}
}
}
if content_lines.is_empty() {
content_lines.push(Line::from(vec![
Span::styled("", border_style),
Span::raw(" ".repeat(inner_width)),
Span::styled("", border_style),
]));
}
content_lines
}
fn render_memory_tile_box(
tile: &MemoryTile,
box_width: usize,
border_style: Style,
text_style: Style,
) -> Vec<Line<'static>> {
let inner_width = box_width.saturating_sub(4);
if inner_width < 4 {
return Vec::new();
}
let title_max_width = box_width.saturating_sub(4);
let title_label = truncate_to_display_width(&tile.category.to_lowercase(), title_max_width);
let title_text = format!(" {} ", title_label);
let title_len = unicode_width::UnicodeWidthStr::width(title_text.as_str());
let border_chars = box_width.saturating_sub(title_len + 2);
let left_border = "".repeat(border_chars / 2);
let right_border = "".repeat(border_chars - border_chars / 2);
let top = Line::from(Span::styled(
format!("{}{}{}", left_border, title_text, right_border),
border_style,
));
let content_lines =
memory_tile_content_lines(&tile.items, inner_width, border_style, text_style);
let bottom = Line::from(Span::styled(
format!("{}", "".repeat(box_width.saturating_sub(2))),
border_style,
));
let mut lines = Vec::with_capacity(content_lines.len() + 2);
lines.push(top);
lines.extend(content_lines);
lines.push(bottom);
lines
}
pub fn plan_memory_tile(
tile: &MemoryTile,
box_width: usize,
border_style: Style,
text_style: Style,
) -> Option<MemoryTilePlan> {
let lines = render_memory_tile_box(tile, box_width, border_style, text_style);
if lines.is_empty() {
return None;
}
let width = lines.first().map(Line::width).unwrap_or(box_width);
let height = lines.len();
let score = tile.items.len() * 10
+ tile
.items
.iter()
.map(|item| unicode_width::UnicodeWidthStr::width(item.content.as_str()).min(80))
.sum::<usize>();
Some(MemoryTilePlan {
lines,
width,
height,
score,
})
}
pub fn choose_memory_tile_span(
tile: &MemoryTile,
column_width: usize,
gap: usize,
max_span: usize,
border_style: Style,
text_style: Style,
) -> Option<(MemoryTilePlan, usize)> {
let single = plan_memory_tile(tile, column_width, border_style, text_style)?;
let mut best_plan = single.clone();
let mut best_span = 1usize;
for span in 2..=max_span.max(1) {
let width = column_width * span + gap * span.saturating_sub(1);
let Some(plan) = plan_memory_tile(tile, width, border_style, text_style) else {
continue;
};
let single_area = single.width * single.height;
let span_area = plan.width * plan.height;
let height_gain = single.height.saturating_sub(plan.height);
let area_gain = single_area.saturating_sub(span_area);
if height_gain >= 2 || (height_gain >= 1 && area_gain > column_width) {
best_plan = plan;
best_span = span;
break;
}
}
Some((best_plan, best_span))
}
pub fn render_memory_tiles(
tiles: &[MemoryTile],
total_width: usize,
border_style: Style,
text_style: Style,
header_line: Option<Line<'static>>,
) -> Vec<Line<'static>> {
if tiles.is_empty() {
return Vec::new();
}
let mut all_lines: Vec<Line<'static>> = Vec::new();
if let Some(header) = header_line {
all_lines.push(header);
}
let min_box_inner = 16usize;
let min_box_width = min_box_inner + 4;
let gap = 2usize;
let row_gap = 0usize;
let usable_width = total_width.max(min_box_width);
#[derive(Clone)]
struct Placement {
x: usize,
y: usize,
plan: MemoryTilePlan,
}
#[derive(Clone)]
struct PlannedTile {
span: usize,
plan: MemoryTilePlan,
}
let max_cols = ((usable_width + gap) / (min_box_width + gap)).clamp(1, 4);
let mut best_layout: Option<(Vec<Placement>, usize, usize)> = None;
for column_count in 1..=max_cols {
let column_width = (usable_width.saturating_sub((column_count - 1) * gap)) / column_count;
if column_width < min_box_width {
continue;
}
let max_span = if column_count >= 2 { 2 } else { 1 };
let mut planned: Vec<PlannedTile> = tiles
.iter()
.filter_map(|tile| {
let (plan, span) = choose_memory_tile_span(
tile,
column_width,
gap,
max_span,
border_style,
text_style,
)?;
Some(PlannedTile { span, plan })
})
.collect();
if planned.is_empty() {
continue;
}
planned.sort_by(|a, b| {
b.plan
.score
.cmp(&a.plan.score)
.then_with(|| b.span.cmp(&a.span))
.then_with(|| b.plan.height.cmp(&a.plan.height))
.then_with(|| b.plan.width.cmp(&a.plan.width))
});
let mut column_heights = vec![0usize; column_count];
let mut placements: Vec<Placement> = Vec::with_capacity(planned.len());
for planned_tile in planned {
let mut best_start = 0usize;
let mut best_y = usize::MAX;
for start_col in 0..=column_count.saturating_sub(planned_tile.span) {
let y = column_heights[start_col..start_col + planned_tile.span]
.iter()
.copied()
.max()
.unwrap_or(0);
if y < best_y || (y == best_y && start_col < best_start) {
best_start = start_col;
best_y = y;
}
}
let x = best_start * (column_width + gap);
let next_height = best_y + planned_tile.plan.height + row_gap;
for height in &mut column_heights[best_start..best_start + planned_tile.span] {
*height = next_height;
}
placements.push(Placement {
x,
y: best_y,
plan: planned_tile.plan,
});
}
let total_height = column_heights
.iter()
.copied()
.max()
.unwrap_or(0)
.saturating_sub(row_gap);
let imbalance = column_heights.iter().copied().max().unwrap_or(0)
- column_heights.iter().copied().min().unwrap_or(0);
let used_width = column_count * column_width + gap * column_count.saturating_sub(1);
let leftover_width = usable_width.saturating_sub(used_width);
// Vertical centering: if this column arrangement has imbalanced columns,
// center shorter columns' tiles vertically within the available space.
let max_col_height = *column_heights.iter().max().unwrap_or(&0);
for (col_idx, col_height) in column_heights.iter().enumerate() {
if *col_height < max_col_height {
let extra = max_col_height - col_height;
let offset = extra / 2;
if offset > 0 {
for placed in placements.iter_mut() {
let start_col = placed.x / (column_width + gap);
if start_col == col_idx {
placed.y += offset;
}
}
}
}
}
let layout_score = total_height * 100 + imbalance * 3 + leftover_width;
match &best_layout {
Some((_, _, best_score)) if *best_score <= layout_score => {}
_ => best_layout = Some((placements, total_height, layout_score)),
}
}
let Some((mut placements, total_height, _)) = best_layout else {
return all_lines;
};
placements.sort_by(|a, b| a.x.cmp(&b.x).then_with(|| a.y.cmp(&b.y)));
for y in 0..total_height {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut cursor = 0usize;
let mut row_has_content = false;
for placed in placements
.iter()
.filter(|placed| y >= placed.y && y < placed.y + placed.plan.height)
{
if placed.x > cursor {
spans.push(Span::raw(" ".repeat(placed.x - cursor)));
}
spans.extend(placed.plan.lines[y - placed.y].spans.clone());
cursor = placed.x + placed.plan.width;
row_has_content = true;
}
if row_has_content {
if cursor < usable_width {
spans.push(Span::raw(" ".repeat(usable_width - cursor)));
}
all_lines.push(Line::from(spans));
}
}
all_lines
}
File diff suppressed because it is too large Load Diff
+611
View File
@@ -0,0 +1,611 @@
//! Gallery / grid layout for live swarm-agent viewports.
//!
//! Unlike [`crate::memory_tiles`], which is a ragged masonry bin-packer optimized
//! for boxes whose heights are fixed by their content (a memory entry is however
//! many lines it is), this module lays out a set of *streaming viewports* whose
//! heights we control. The goals are different:
//!
//! - Cells should be uniform and scannable (gallery view), not artfully packed.
//! - Each cell shows the *tail* of an agent's output, bottom-anchored like a
//! terminal, filling exactly its height budget.
//! - The total height is a knob: more agents -> shorter viewports each, instead
//! of overflowing.
//!
//! The placement is a simple grid allocator (column count chosen to keep cells
//! near a target aspect ratio), reusing the box-drawing/width helpers shared
//! with the memory tiles.
use ratatui::prelude::*;
use unicode_width::UnicodeWidthStr;
use crate::memory_tiles::split_by_display_width;
/// One agent's viewport to render in the gallery.
#[derive(Clone, Debug)]
pub struct SwarmTile {
/// Title line (e.g. agent friendly name).
pub title: String,
/// Short status badge text (e.g. "running", "done").
pub status: String,
/// Color used for the status badge and accents.
pub accent: Color,
/// Optional role/prefix glyph drawn before the title (e.g. "★").
pub role_glyph: Option<String>,
/// The agent's recent output, oldest first. The renderer shows the tail.
pub body: Vec<String>,
}
impl SwarmTile {
pub fn new(title: impl Into<String>, status: impl Into<String>, accent: Color) -> Self {
Self {
title: title.into(),
status: status.into(),
accent,
role_glyph: None,
body: Vec::new(),
}
}
pub fn with_role_glyph(mut self, glyph: impl Into<String>) -> Self {
self.role_glyph = Some(glyph.into());
self
}
pub fn with_body(mut self, body: Vec<String>) -> Self {
self.body = body;
self
}
}
/// Tunable parameters for the gallery layout.
#[derive(Clone, Copy, Debug)]
pub struct SwarmGalleryConfig {
/// Total height budget for the whole gallery (excluding an optional header).
pub max_height: usize,
/// Minimum inner width per cell (columns) before we reduce the column count.
pub min_cell_inner_width: usize,
/// Minimum cell height (including borders). Cells never shrink below this;
/// if the budget can't fit all agents at this height, we overflow into a
/// "+N more" strip.
pub min_cell_height: usize,
/// Preferred cell height (including borders) when there is room to spare.
pub preferred_cell_height: usize,
/// Horizontal gap between cells.
pub gap: usize,
/// Target width:height ratio for a cell, used to pick the column count.
pub target_aspect: f32,
}
impl Default for SwarmGalleryConfig {
fn default() -> Self {
Self {
max_height: 16,
min_cell_inner_width: 18,
min_cell_height: 4,
preferred_cell_height: 7,
gap: 2,
target_aspect: 4.5,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct GridShape {
cols: usize,
rows: usize,
/// Number of tiles actually shown (rest go into the overflow strip).
shown: usize,
}
/// Choose how many columns/rows to use for `tile_count` agents in the given
/// budget. Picks the column count whose resulting cell aspect ratio is closest
/// to `target_aspect`, subject to width/height minimums.
fn choose_grid(
tile_count: usize,
total_width: usize,
cfg: &SwarmGalleryConfig,
) -> Option<GridShape> {
if tile_count == 0 || total_width == 0 || cfg.max_height == 0 {
return None;
}
// Max columns that still give each cell at least the minimum inner width.
let min_cell_total = cfg.min_cell_inner_width + 2; // borders
let max_cols_by_width =
((total_width + cfg.gap) / (min_cell_total + cfg.gap)).clamp(1, tile_count);
if max_cols_by_width == 0 {
return None;
}
// Max rows that fit at least the minimum cell height. Guard against a
// degenerate `min_cell_height` of 0 (division by zero).
let max_rows_by_height = (cfg.max_height / cfg.min_cell_height.max(1)).max(1);
let max_visible_cells = (max_cols_by_width * max_rows_by_height).max(1);
let mut best: Option<(f32, GridShape)> = None;
for cols in 1..=max_cols_by_width {
let cell_w = (total_width.saturating_sub((cols - 1) * cfg.gap)) / cols;
if cell_w < min_cell_total {
continue;
}
// Rows needed for this column count, capped by what fits vertically.
let rows_needed = tile_count.div_ceil(cols);
let rows = rows_needed.min(max_rows_by_height).max(1);
let shown = (cols * rows).min(tile_count).min(max_visible_cells);
let cell_h = (cfg.max_height.saturating_sub((rows - 1) * cfg.gap)) / rows;
if cell_h == 0 {
continue;
}
let aspect = cell_w as f32 / cell_h as f32;
// Penalize empty slots in the last row (orphan tiles look unbalanced).
let empty_slots = (cols * rows).saturating_sub(shown);
// Prefer aspect close to target; penalize raggedness; tie-break toward
// showing more tiles.
let cost =
(aspect - cfg.target_aspect).abs() + (empty_slots as f32) * 0.6 - (shown as f32) * 0.01;
let shape = GridShape { cols, rows, shown };
match &best {
Some((best_cost, _)) if *best_cost <= cost => {}
_ => best = Some((cost, shape)),
}
}
best.map(|(_, shape)| shape)
}
/// Render a single cell box of the given inner dimensions. `inner_w`/`inner_h`
/// are the content area (excluding borders).
fn render_cell(tile: &SwarmTile, inner_w: usize, inner_h: usize) -> Vec<Line<'static>> {
let border_style = Style::default().fg(Color::Rgb(80, 80, 92));
let accent_style = Style::default().fg(tile.accent);
// ---- Title bar (drawn into the top border line) ----
let glyph = tile.role_glyph.as_deref().unwrap_or("");
let title_raw = if glyph.is_empty() {
tile.title.clone()
} else {
format!("{} {}", glyph, tile.title)
};
let box_width = inner_w + 2;
// Top border, total width == box_width, structured as:
// ╭─ <title> <dashes> <badge> ─╮
// Fixed cost: ╭(1) ─(1) space(1) ... space(1) ─(1) ╮(1) = 6 columns plus
// the dash-fill segment of at least 1. Badge is included only if it fits.
let fixed = 6usize; // corners + two leading dashes/space wrappers
let mut badge = format!("[{}]", tile.status);
let mut badge_w = UnicodeWidthStr::width(badge.as_str());
// Reserve room: at least 1 title char, 1 dash filler, and a space before
// the badge. If the badge can't fit, drop it.
let min_dashes = 1usize;
let title_budget = box_width
.saturating_sub(fixed + min_dashes + badge_w + 1)
.max(1);
let mut title_text = truncate_w(&title_raw, title_budget);
let mut title_w = UnicodeWidthStr::width(title_text.as_str());
if fixed + title_w + min_dashes + badge_w + 1 > box_width {
// No room for the badge; drop it and give the title the space.
badge = String::new();
badge_w = 0;
let title_budget = box_width.saturating_sub(fixed + min_dashes).max(1);
title_text = truncate_w(&title_raw, title_budget);
title_w = UnicodeWidthStr::width(title_text.as_str());
}
// Remaining columns become the dash filler. Subtract: corners(2),
// leading "─ "(2), title, trailing " ─"(2), and "[badge] " if present.
let badge_segment = if badge.is_empty() { 0 } else { badge_w + 1 };
let dashes = box_width
.saturating_sub(2 + 2 + title_w + 2 + badge_segment)
.max(1);
let mut top_spans: Vec<Span<'static>> = vec![
Span::styled("╭─ ".to_string(), border_style),
Span::styled(title_text, accent_style.bold()),
Span::styled(" ".to_string(), border_style),
Span::styled("".repeat(dashes), border_style),
];
if !badge.is_empty() {
top_spans.push(Span::styled(" ".to_string(), border_style));
top_spans.push(Span::styled(badge, accent_style));
}
top_spans.push(Span::styled("─╮".to_string(), border_style));
// Final guard against off-by-one from width rounding and against boxes
// narrower than the fixed title-bar chrome.
normalize_top_width(&mut top_spans, box_width, border_style);
let mut lines: Vec<Line<'static>> = Vec::with_capacity(inner_h + 2);
lines.push(Line::from(top_spans));
// ---- Body: bottom-anchored tail of the stream ----
let body_lines = wrap_tail(&tile.body, inner_w, inner_h);
let blank_top = inner_h.saturating_sub(body_lines.len());
let text_style = Style::default().fg(Color::Rgb(170, 172, 180));
for _ in 0..blank_top {
lines.push(content_line("", inner_w, border_style, text_style));
}
for text in body_lines {
lines.push(content_line(&text, inner_w, border_style, text_style));
}
// ---- Bottom border ----
lines.push(Line::from(Span::styled(
format!("{}", "".repeat(box_width.saturating_sub(2))),
border_style,
)));
lines
}
fn normalize_top_width(spans: &mut Vec<Span<'static>>, box_width: usize, border_style: Style) {
let cur: usize = spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if cur < box_width {
// Pad just before the trailing " ─╮".
let pad = box_width - cur;
let insert_at = spans.len().saturating_sub(1);
spans.insert(insert_at, Span::styled("".repeat(pad), border_style));
} else if cur > box_width {
// The box is too narrow for the titled border (fixed chrome is ~8
// columns). Fall back to a plain border of exactly `box_width`.
let fill = "".repeat(box_width.saturating_sub(2));
spans.clear();
spans.push(Span::styled(format!("{fill}"), border_style));
}
}
fn content_line(
text: &str,
inner_w: usize,
border_style: Style,
text_style: Style,
) -> Line<'static> {
let truncated = truncate_w(text, inner_w);
let w = UnicodeWidthStr::width(truncated.as_str());
let pad = inner_w.saturating_sub(w);
Line::from(vec![
Span::styled("".to_string(), border_style),
Span::styled(truncated, text_style),
Span::raw(" ".repeat(pad)),
Span::styled("".to_string(), border_style),
])
}
/// Take the tail of `body`, wrapping each logical line to `width`, and return at
/// most `height` rendered rows (the most recent ones).
fn wrap_tail(body: &[String], width: usize, height: usize) -> Vec<String> {
if width == 0 || height == 0 {
return Vec::new();
}
let mut rows: Vec<String> = Vec::new();
// Wrap from the end until we have enough rows.
for logical in body.iter().rev() {
let mut wrapped = if logical.is_empty() {
vec![String::new()]
} else {
split_by_display_width(logical, width)
};
// Keep wrapped chunks in natural (top-to-bottom) order, but we are
// walking logical lines bottom-up, so prepend.
let mut prefix = std::mem::take(&mut wrapped);
prefix.extend(rows);
rows = prefix;
if rows.len() >= height {
break;
}
}
if rows.len() > height {
let start = rows.len() - height;
rows = rows.split_off(start);
}
rows
}
fn truncate_w(s: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
let full = UnicodeWidthStr::width(s);
if full <= max_width {
return s.to_string();
}
let ellipsis = "";
let target = max_width.saturating_sub(1);
let mut out = String::new();
let mut w = 0usize;
for ch in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw > target {
break;
}
out.push(ch);
w += cw;
}
out.push_str(ellipsis);
out
}
/// Render a single tile filling exactly `width` x `height` (including borders).
/// Used by the list+detail swarm panel to draw the focused agent's viewport.
/// Returns an empty vec when the area is too small to draw a bordered box.
pub fn render_single_tile(tile: &SwarmTile, width: usize, height: usize) -> Vec<Line<'static>> {
if width < 4 || height < 3 {
return Vec::new();
}
let inner_w = width - 2;
let inner_h = height - 2;
let mut lines = render_cell(tile, inner_w, inner_h);
// render_cell already yields `height` lines, but pad/truncate defensively so
// callers can rely on the exact height.
while lines.len() < height {
lines.push(Line::from(Span::raw(" ".repeat(width))));
}
lines.truncate(height);
lines
}
/// Render the swarm gallery. Returns lines ready to draw, fitting within
/// `total_width` columns and roughly `cfg.max_height` rows (plus header).
pub fn render_swarm_gallery(
tiles: &[SwarmTile],
total_width: usize,
cfg: &SwarmGalleryConfig,
header: Option<Line<'static>>,
) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
if let Some(header) = header {
out.push(header);
}
if tiles.is_empty() {
return out;
}
let Some(shape) = choose_grid(tiles.len(), total_width, cfg) else {
return out;
};
let cols = shape.cols;
let rows = shape.rows;
let shown = shape.shown;
let cell_total_w = (total_width.saturating_sub((cols - 1) * cfg.gap)) / cols;
let cell_inner_w = cell_total_w.saturating_sub(2);
// Distribute the height budget across rows; cap at preferred height.
// Guard against inverted config (preferred < min) which would panic clamp.
let max_cell_h = cfg.preferred_cell_height.max(cfg.min_cell_height);
let cell_total_h = (cfg.max_height / rows.max(1)).clamp(cfg.min_cell_height, max_cell_h);
let cell_inner_h = cell_total_h.saturating_sub(2);
// Render each shown tile into a grid of line buffers.
let visible = &tiles[..shown];
for row in 0..rows {
let row_start = row * cols;
if row_start >= shown {
break;
}
let row_end = (row_start + cols).min(shown);
let row_tiles = &visible[row_start..row_end];
// Render each cell in this row.
let cell_blocks: Vec<Vec<Line<'static>>> = row_tiles
.iter()
.map(|tile| render_cell(tile, cell_inner_w, cell_inner_h))
.collect();
for line_idx in 0..cell_total_h {
let mut spans: Vec<Span<'static>> = Vec::new();
for (ci, block) in cell_blocks.iter().enumerate() {
if ci > 0 {
spans.push(Span::raw(" ".repeat(cfg.gap)));
}
if let Some(line) = block.get(line_idx) {
spans.extend(line.spans.clone());
} else {
spans.push(Span::raw(" ".repeat(cell_total_w)));
}
}
out.push(Line::from(spans));
}
}
let hidden = tiles.len().saturating_sub(shown);
if hidden > 0 {
out.push(Line::from(Span::styled(
format!(
" +{} more agent{}",
hidden,
if hidden == 1 { "" } else { "s" }
),
Style::default().fg(Color::Rgb(140, 140, 150)),
)));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tile(name: &str, body: &[&str]) -> SwarmTile {
SwarmTile::new(name, "running", Color::Rgb(255, 200, 100))
.with_body(body.iter().map(|s| s.to_string()).collect())
}
#[test]
fn single_agent_uses_one_column() {
let tiles = vec![tile("alpha", &["hello", "world"])];
let cfg = SwarmGalleryConfig::default();
let lines = render_swarm_gallery(&tiles, 60, &cfg, None);
assert!(!lines.is_empty());
// Top border present.
let top = plain(&lines[0]);
assert!(top.starts_with("╭─ alpha"), "got: {top}");
assert!(top.contains("[running]"), "got: {top}");
}
#[test]
fn many_agents_form_multiple_columns() {
let tiles: Vec<SwarmTile> = (0..4).map(|i| tile(&format!("a{i}"), &["x"])).collect();
let shape = choose_grid(4, 100, &SwarmGalleryConfig::default()).unwrap();
assert!(shape.cols >= 2, "expected multi-column, got {shape:?}");
let lines = render_swarm_gallery(&tiles, 100, &SwarmGalleryConfig::default(), None);
assert!(!lines.is_empty());
}
#[test]
fn tail_shows_most_recent_lines() {
let body: Vec<String> = (0..20).map(|i| format!("line{i}")).collect();
let rows = wrap_tail(&body, 20, 3);
assert_eq!(rows.len(), 3);
assert_eq!(rows[2], "line19");
assert_eq!(rows[0], "line17");
}
#[test]
fn overflow_emits_more_strip() {
let tiles: Vec<SwarmTile> = (0..50).map(|i| tile(&format!("a{i}"), &["x"])).collect();
let cfg = SwarmGalleryConfig {
max_height: 8,
..Default::default()
};
let lines = render_swarm_gallery(&tiles, 60, &cfg, None);
let last = plain(lines.last().unwrap());
assert!(last.contains("more agent"), "got: {last}");
}
#[test]
fn cells_are_width_bounded() {
let tiles: Vec<SwarmTile> = (0..3)
.map(|i| {
tile(
&format!("a{i}"),
&["a very long line that should be truncated nicely"],
)
})
.collect();
let cfg = SwarmGalleryConfig::default();
let lines = render_swarm_gallery(&tiles, 80, &cfg, None);
for line in &lines {
assert!(line.width() <= 80, "line too wide: {}", plain(line));
}
}
#[test]
fn empty_tiles_render_nothing_but_header() {
let cfg = SwarmGalleryConfig::default();
assert!(render_swarm_gallery(&[], 80, &cfg, None).is_empty());
let lines = render_swarm_gallery(&[], 80, &cfg, Some(Line::from("hdr")));
assert_eq!(lines.len(), 1);
}
#[test]
fn zero_sized_budgets_do_not_panic() {
let tiles = vec![tile("a", &["x"])];
for (w, h) in [(0usize, 0usize), (0, 16), (60, 0), (1, 1), (3, 3)] {
let cfg = SwarmGalleryConfig {
max_height: h,
..Default::default()
};
let _ = render_swarm_gallery(&tiles, w, &cfg, None);
}
}
#[test]
fn degenerate_config_does_not_panic() {
let tiles = vec![tile("a", &["x"])];
// min_cell_height = 0 previously divided by zero in choose_grid.
let cfg = SwarmGalleryConfig {
min_cell_height: 0,
..Default::default()
};
let _ = render_swarm_gallery(&tiles, 60, &cfg, None);
// preferred < min previously panicked in clamp().
let cfg = SwarmGalleryConfig {
min_cell_height: 4,
preferred_cell_height: 3,
..Default::default()
};
let _ = render_swarm_gallery(&tiles, 60, &cfg, None);
}
#[test]
fn hundreds_of_tiles_small_terminal_stay_bounded() {
let tiles: Vec<SwarmTile> = (0..300)
.map(|i| tile(&format!("agent-{i}"), &["node gate-7 running", "ok"]))
.collect();
for width in [1usize, 5, 10, 20, 21, 40, 80] {
for mh in [1usize, 2, 4, 8, 16] {
let cfg = SwarmGalleryConfig {
max_height: mh,
..Default::default()
};
let lines = render_swarm_gallery(&tiles, width, &cfg, None);
for line in &lines {
assert!(
line.width() <= width.max(1),
"width={width} mh={mh}: line {} wider than {width}: {:?}",
line.width(),
plain(line)
);
}
}
}
}
#[test]
fn single_tile_tiny_sizes_stay_bounded() {
let t = SwarmTile::new("日本語のタイトル", "実行中", Color::Cyan).with_body(vec![
"こんにちは世界こんにちは世界".to_string(),
"🐝🐝🐝🐝🐝🐝".to_string(),
"e\u{301}e\u{301} combining".to_string(),
]);
for w in 0..24usize {
for h in 0..8usize {
let lines = render_single_tile(&t, w, h);
if w < 4 || h < 3 {
assert!(lines.is_empty(), "expected empty at {w}x{h}");
continue;
}
assert_eq!(lines.len(), h, "height mismatch at {w}x{h}");
for line in &lines {
assert!(
line.width() <= w,
"{w}x{h}: line {} too wide: {:?}",
line.width(),
plain(line)
);
}
}
}
}
#[test]
fn wrap_tail_handles_zero_and_empty() {
assert!(wrap_tail(&[], 10, 3).is_empty());
assert!(wrap_tail(&["x".to_string()], 0, 3).is_empty());
assert!(wrap_tail(&["x".to_string()], 10, 0).is_empty());
}
#[test]
fn truncate_w_handles_wide_chars() {
assert_eq!(truncate_w("", 5), "");
assert_eq!(truncate_w("abc", 0), "");
// 2-column chars never split mid-glyph and result stays within budget.
for max in 1..8usize {
let out = truncate_w("日本語テスト", max);
assert!(
UnicodeWidthStr::width(out.as_str()) <= max,
"max={max} out={out}"
);
}
}
fn plain(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
}
@@ -0,0 +1,237 @@
//! Audit sweep: panic-safety and width-bound checks for the swarm gallery,
//! panel, and strip renderers across degenerate inputs (empty members, huge
//! member counts, tiny widths/heights, wide glyphs).
use jcode_tui_render::swarm_gallery::{
GalleryMember, SwarmStripHint, render_gallery, render_swarm_dock, render_swarm_panel,
render_swarm_strip,
};
use ratatui::prelude::Line;
use unicode_width::UnicodeWidthStr;
fn member(id: &str, status: &str, role: Option<&str>, body: &[&str]) -> GalleryMember {
GalleryMember {
label: id.to_string(),
icon: None,
status: status.to_string(),
task: None,
role: role.map(str::to_string),
body: body.iter().map(|s| s.to_string()).collect(),
sort_key: id.to_string(),
todo: None,
todo_items: Vec::new(),
model: None,
provider: None,
auth_method: None,
effort: None,
elapsed_secs: None,
}
}
fn plain(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn hints() -> Vec<SwarmStripHint> {
vec![
SwarmStripHint {
key: "alt+w".into(),
label: "focus".into(),
},
SwarmStripHint {
key: "esc".into(),
label: "back".into(),
},
]
}
fn member_sets() -> Vec<Vec<GalleryMember>> {
let wide = {
let mut m = member(
"🐝🦀日本語エージェント",
"running",
Some("coordinator"),
&["全角テキスト🐝🐝🐝 very wide glyph body line", "· 5s ago"],
);
m.todo = Some((3, 8));
m
};
let huge: Vec<GalleryMember> = (0..1500)
.map(|i| {
let mut m = member(
&format!("agent-{i:04}"),
match i % 6 {
0 => "running",
1 => "thinking",
2 => "done",
3 => "failed",
4 => "blocked",
_ => "spawned",
},
if i == 0 { Some("coordinator") } else { None },
&["some output", "· 2m ago"],
);
m.todo = Some((i as u32 % 10, 10));
m
})
.collect();
vec![
vec![],
vec![member("a", "running", None, &[])],
vec![wide.clone()],
vec![
wide,
member("b", "done", Some("mystery_role_2"), &["ok", "· 1h ago"]),
member(
"",
"weird-status-xyz",
Some("unknown-role"),
&["", " ", "·"],
),
],
huge,
]
}
#[test]
fn gallery_never_panics_and_stays_width_bounded() {
for members in member_sets() {
for width in 0..=60 {
for max_height in 0..=20 {
let lines = render_gallery(&members, width, max_height);
for line in &lines {
let w = plain(line).as_str().width();
assert!(
w <= width,
"gallery width={width} h={max_height} n={} overflow {w}: {:?}",
members.len(),
plain(line)
);
}
}
}
// A couple of large widths too.
for width in [80usize, 200, 500] {
let _ = render_gallery(&members, width, 16);
}
}
}
#[test]
fn panel_never_panics_across_degenerate_inputs() {
for members in member_sets() {
for width in 0..=40 {
for max_height in 0..=16 {
for selected in [0usize, 1, 5, usize::MAX] {
for focused in [false, true] {
let _ = render_swarm_panel(&members, selected, focused, width, max_height);
}
}
}
}
}
}
#[test]
fn panel_lines_respect_width_bound() {
let mut violations: Vec<String> = Vec::new();
for members in member_sets() {
if members.is_empty() {
continue;
}
for width in 8..=60 {
for max_height in 3..=16 {
let lines = render_swarm_panel(&members, 0, true, width, max_height);
for line in &lines {
let text = plain(line);
let w = text.as_str().width();
if w > width {
violations.push(format!(
"n={} width={width} h={max_height}: {w} cols: {text:?}",
members.len()
));
}
}
}
}
}
assert!(
violations.is_empty(),
"panel emitted over-wide lines ({}):\n{}",
violations.len(),
violations[..violations.len().min(10)].join("\n")
);
}
#[test]
fn strip_never_panics_and_stays_width_bounded() {
for members in member_sets() {
for width in 0..=60 {
for selected in [0usize, 3, usize::MAX] {
for focused in [false, true] {
for spinner in [0usize, 7, usize::MAX] {
let lines = render_swarm_strip(
&members,
selected,
focused,
&hints(),
Some("ctrl+t controls"),
spinner,
width,
12,
);
for line in &lines {
let text = plain(line);
let w = text.as_str().width();
assert!(
w <= width,
"strip width={width} n={} overflow {w}: {text:?}",
members.len()
);
}
}
}
}
}
}
}
#[test]
fn dock_never_panics_and_respects_width_and_height_bounds() {
for members in member_sets() {
for width in 0..=48 {
for max_height in 0..=16 {
for selected in [0usize, 3, usize::MAX] {
for focused in [false, true] {
for plan in [None, Some((3u32, 7u32))] {
let lines = render_swarm_dock(
&members,
selected,
focused,
plan,
usize::MAX,
width,
max_height,
);
assert!(
lines.len() <= max_height.max(1),
"dock width={width} h={max_height} n={}: {} lines",
members.len(),
lines.len()
);
for line in &lines {
let text = plain(line);
let w = text.as_str().width();
assert!(
w <= width,
"dock width={width} n={} overflow {w}: {text:?}",
members.len()
);
}
}
}
}
}
}
}
}