use std::io::Write;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use unicode_width::UnicodeWidthChar;
use crate::plan::{Plan, Status};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RendererMode {
Legacy,
#[default]
Frame,
}
impl RendererMode {
pub const ALL: [RendererMode; 2] = [RendererMode::Legacy, RendererMode::Frame];
pub fn describe(self) -> &'static str {
match self {
RendererMode::Legacy => "terminal owns scrollback and reflow (scroll region)",
RendererMode::Frame => "app re-renders the frame on a width change",
}
}
}
impl std::fmt::Display for RendererMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
RendererMode::Legacy => "legacy",
RendererMode::Frame => "frame",
})
}
}
impl std::str::FromStr for RendererMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
RendererMode::ALL
.into_iter()
.find(|m| m.to_string() == s.trim().to_lowercase())
.ok_or_else(|| format!("unknown renderer {s:?} (legacy, frame)"))
}
}
const SYNC_START: &str = "\x1b[?2026h";
const SYNC_END: &str = "\x1b[?2026l";
const DIM: &str = "\x1b[2m";
const BOLD: &str = "\x1b[1m";
const THINK: &str = "\x1b[38;5;245m";
const GREEN: &str = "\x1b[38;5;114m";
const RED: &str = "\x1b[38;5;203m";
const RESET: &str = "\x1b[0m";
const PREVIEW_LINES: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
User,
Assistant,
}
#[derive(Debug, Clone)]
pub enum Item {
Message { role: Role, text: String },
Thinking { chars: usize, seconds: f64 },
ToolCall { name: String, summary: String },
ToolResult { ok: bool, output: String, verbose: bool },
Plan(Plan),
Note(String),
Output(String),
Raw(String),
}
#[derive(Debug, Clone)]
pub struct StampedItem {
pub stamp: String,
pub item: Item,
}
pub fn render_stamped(si: &StampedItem, width: usize) -> Vec<String> {
let width = width.max(1);
if let Item::Raw(text) = &si.item {
return text.split('\n').map(str::to_string).collect();
}
let stamp_w = visible_width(&si.stamp);
let inner = width.saturating_sub(stamp_w).max(1);
let pad = " ".repeat(stamp_w);
render_item(&si.item, inner)
.into_iter()
.enumerate()
.map(|(i, line)| {
let row = if i == 0 { format!("{}{line}", si.stamp) } else { format!("{pad}{line}") };
fit(&row, width)
})
.collect()
}
pub fn render_item(item: &Item, width: usize) -> Vec<String> {
let width = width.max(1);
match item {
Item::Message { role, text } => {
let prefix = match role {
Role::User => "› ",
Role::Assistant => "",
};
let inner = width.saturating_sub(prefix.chars().count()).max(1);
let body = wrap_block(text, inner);
body.into_iter()
.enumerate()
.map(
|(i, line)| {
if i == 0 && !prefix.is_empty() { format!("{DIM}{prefix}{RESET}{line}") } else { line }
},
)
.collect()
}
Item::Thinking { chars, seconds } => {
vec![fit(&format!("{THINK}∴ Thought for {seconds:.1}s{RESET}{DIM} · {chars} chars{RESET}"), width)]
}
Item::ToolCall { name, summary } => {
let used = name.chars().count() + 4;
let summary = fit(summary, width.saturating_sub(used));
vec![fit(&format!("{GREEN}●{RESET} {BOLD}{name}{RESET} {DIM}{summary}{RESET}"), width)]
}
Item::ToolResult { ok, output, verbose } => tool_result_lines(*ok, output, *verbose, width),
Item::Plan(plan) => plan_lines(plan, width),
Item::Note(text) => wrap_block(text, width).into_iter().map(|line| format!("{DIM}{line}{RESET}")).collect(),
Item::Output(text) => wrap_block(text, width),
Item::Raw(text) => text.split('\n').map(str::to_string).collect(),
}
}
fn tool_result_lines(ok: bool, output: &str, verbose: bool, width: usize) -> Vec<String> {
let lines: Vec<&str> = output.trim_end().lines().collect();
let (mark, color) = if ok { ("⎿", DIM) } else { ("⎿ error:", RED) };
let body = width.saturating_sub(mark.chars().count() + 3).max(1);
if lines.is_empty() {
return vec![fit(&format!(" {color}{mark} (no output){RESET}"), width)];
}
if verbose {
let mut out = Vec::new();
for (i, line) in lines.iter().take(PREVIEW_LINES).enumerate() {
let lead = if i == 0 { mark } else { " " };
out.push(fit(&format!(" {color}{lead} {}{RESET}", fit(line, body)), width));
}
if lines.len() > PREVIEW_LINES {
out.push(fit(&format!(" {DIM} … +{} lines{RESET}", lines.len() - PREVIEW_LINES), width));
}
return out;
}
let more = if lines.len() > 1 { format!(" (+{} lines)", lines.len() - 1) } else { String::new() };
vec![fit(&format!(" {color}{mark} {}{more}{RESET}", fit(lines[0], body.saturating_sub(more.len()))), width)]
}
const PLAN_LINES: usize = 12;
fn plan_lines(plan: &Plan, width: usize) -> Vec<String> {
let body = width.saturating_sub(4).max(1);
let (done, total) = plan.progress();
let mut out = vec![fit(&format!("{GREEN}●{RESET} {BOLD}Plan{RESET} {DIM}{done}/{total} done{RESET}"), width)];
let live: Vec<&crate::plan::PlanItem> = plan.items.iter().filter(|i| i.status != Status::Dropped).collect();
let collapse = live.len() > PLAN_LINES && done > 0;
if collapse {
out.push(fit(&format!(" {GREEN}✔{RESET} {DIM}{done} done{RESET}"), width));
}
let visible: Vec<&&crate::plan::PlanItem> =
live.iter().filter(|i| !(collapse && i.status == Status::Done)).collect();
for (shown, item) in visible.iter().enumerate() {
if shown == PLAN_LINES {
out.push(fit(&format!(" {DIM}… +{} more{RESET}", visible.len() - shown), width));
break;
}
let title = fit(&item.title, body);
let line = match item.status {
Status::Done => format!("{GREEN}✔{RESET} {DIM}{title}{RESET}"),
Status::InProgress => format!("{BOLD}◼ {title}{RESET}"),
Status::Blocked => format!("{RED}! {title}{RESET}"),
_ => format!("{DIM}☐{RESET} {title}"),
};
out.push(format!(" {line}"));
}
out
}
pub fn transcript_lines(items: &[StampedItem], width: usize) -> Vec<String> {
items.iter().flat_map(|item| render_stamped(item, width)).collect()
}
pub fn editor_lines(prompt: &str, text: &str, cursor: usize, width: usize) -> Vec<String> {
let width = width.max(1);
let cursor = cursor.min(text.chars().count());
const MARK: char = CURSOR_MARK;
let mut marked = String::new();
for (i, c) in text.chars().enumerate() {
if i == cursor {
marked.push(MARK);
}
marked.push(c);
}
if cursor >= text.chars().count() {
marked.push(MARK);
}
let inner = width.saturating_sub(visible_width(prompt)).max(1);
let mut lines: Vec<String> = Vec::new();
for (i, logical) in marked.split('\n').enumerate() {
let wrapped = if i == 0 { wrap_ansi(logical, inner) } else { wrap_ansi(logical, width) };
for line in wrapped {
lines.push(line);
}
}
if lines.is_empty() {
lines.push(String::new());
}
lines[0] = format!("{BOLD}{prompt}{RESET}{}", lines[0]);
lines
.into_iter()
.map(|line| fit(&line, width).replace(MARK, "\x1b[7m \x1b[27m"))
.collect()
}
pub fn compose(transcript: &[String], editor: &[String], status: &str) -> Vec<String> {
let mut frame = Vec::with_capacity(transcript.len() + editor.len() + 1);
frame.extend(transcript.iter().cloned());
frame.extend(editor.iter().cloned());
frame.push(status.to_string());
frame
}
pub fn queue_indicator(queued: usize, width: usize) -> Option<String> {
(queued > 0).then(|| fit(&format!("{DIM}⏸{queued} queued · /queue to edit{RESET}"), width.max(1)))
}
pub fn transient_status(text: &str, width: usize) -> String {
fit(&format!("{DIM}{text}{RESET}"), width.max(1))
}
pub fn first_diff(prev: &[String], next: &[String]) -> Option<usize> {
let common = prev.len().min(next.len());
for i in 0..common {
if prev[i] != next[i] {
return Some(i);
}
}
(prev.len() != next.len()).then_some(common)
}
const CURSOR_MARK: char = '\u{0}';
fn cell_width(c: char) -> usize {
if c == CURSOR_MARK { 1 } else { UnicodeWidthChar::width(c).unwrap_or(0) }
}
fn visible_width(text: &str) -> usize {
let mut count = 0;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
for n in chars.by_ref() {
if ('@'..='~').contains(&n) {
break;
}
}
}
continue;
}
count += cell_width(c);
}
count
}
pub fn fit_line(text: &str, width: usize) -> String {
fit(text, width)
}
fn fit(text: &str, width: usize) -> String {
let expanded = sanitize(text);
let text = expanded.as_str();
if visible_width(text) <= width {
return text.to_string();
}
let keep = width.saturating_sub(1);
let mut out = String::new();
let mut seen = 0;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
out.push(c);
if chars.peek() == Some(&'[') {
out.push(chars.next().unwrap());
while let Some(&n) = chars.peek() {
out.push(chars.next().unwrap());
if ('@'..='~').contains(&n) {
break;
}
}
}
continue;
}
let w = cell_width(c);
if seen + w > keep {
break;
}
out.push(c);
seen += w;
}
out.push('…');
out.push_str(RESET);
out
}
fn wrap_block(text: &str, width: usize) -> Vec<String> {
text.split('\n').flat_map(|line| wrap_ansi(line, width)).collect()
}
fn sanitize(line: &str) -> String {
const TAB: usize = 8;
let mut out = String::new();
let mut col = 0usize;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
let mut seq = String::from(c);
seq.push(chars.next().unwrap()); let mut final_byte = None;
while let Some(&n) = chars.peek() {
seq.push(chars.next().unwrap());
if ('@'..='~').contains(&n) {
final_byte = Some(n);
break;
}
}
if final_byte == Some('m') {
out.push_str(&seq);
}
}
continue;
}
if c == '\t' {
let spaces = TAB - (col % TAB);
for _ in 0..spaces {
out.push(' ');
}
col += spaces;
continue;
}
if c == CURSOR_MARK {
out.push(c);
col += 1;
continue;
}
if c.is_control() {
continue;
}
out.push(c);
col += cell_width(c);
}
out
}
fn wrap_ansi(line: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let line = sanitize(line);
let mut out = Vec::new();
let mut cur = String::new();
let mut vis = 0usize;
let mut brk: Option<usize> = None;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
cur.push(c);
if chars.peek() == Some(&'[') {
cur.push(chars.next().unwrap());
while let Some(&n) = chars.peek() {
cur.push(chars.next().unwrap());
if ('@'..='~').contains(&n) {
break;
}
}
}
continue;
}
if c == ' ' {
if vis >= width {
out.push(cur.trim_end().to_string());
cur = String::new();
vis = 0;
brk = None;
continue;
}
cur.push(' ');
vis += 1;
brk = Some(cur.len());
continue;
}
if vis >= width {
match brk {
Some(byte) => {
let rest = cur.split_off(byte);
out.push(std::mem::take(&mut cur).trim_end().to_string());
cur = rest.trim_start_matches(' ').to_string();
vis = visible_width(&cur);
}
None => {
out.push(std::mem::take(&mut cur));
vis = 0;
}
}
brk = None;
}
let w = cell_width(c);
if w > 1 && vis + w > width && !cur.is_empty() {
match brk {
Some(byte) => {
let rest = cur.split_off(byte);
out.push(std::mem::take(&mut cur).trim_end().to_string());
cur = rest.trim_start_matches(' ').to_string();
vis = visible_width(&cur);
}
None => {
out.push(std::mem::take(&mut cur));
vis = 0;
}
}
brk = None;
}
cur.push(c);
vis += w;
}
out.push(cur);
out
}
pub struct FrameRenderer<W: Write> {
out: W,
prev: Vec<String>,
width: usize,
height: usize,
started: bool,
}
impl<W: Write> FrameRenderer<W> {
pub fn new(out: W) -> Self {
Self { out, prev: Vec::new(), width: 0, height: 0, started: false }
}
pub fn invalidate(&mut self) {
self.started = false;
}
pub fn render(&mut self, frame: &[String], width: usize, height: usize) -> std::io::Result<()> {
let width = width.max(1);
let height = height.max(1);
if !self.started || width != self.width || height != self.height {
self.full_redraw(frame, width, height)?;
} else {
self.differential(frame, width, height)?;
}
self.prev = frame.to_vec();
self.width = width;
self.height = height;
self.started = true;
Ok(())
}
fn full_redraw(&mut self, frame: &[String], width: usize, height: usize) -> std::io::Result<()> {
let mut buf = String::from(SYNC_START);
buf.push_str("\x1b[r");
buf.push_str("\x1b[H\x1b[2J\x1b[3J");
let physical = physical_rows(frame, width);
let start_row = if physical < height { height - physical + 1 } else { 1 };
buf.push_str(&format!("\x1b[{start_row};1H"));
for (i, line) in frame.iter().enumerate() {
if i > 0 {
buf.push_str("\r\n");
}
buf.push_str(line);
}
buf.push_str(SYNC_END);
self.out.write_all(buf.as_bytes())?;
self.out.flush()
}
fn differential(&mut self, frame: &[String], width: usize, height: usize) -> std::io::Result<()> {
let Some(diff) = first_diff(&self.prev, frame) else {
return Ok(());
};
let plen = self.prev.len();
let prev_physical = physical_rows(&self.prev, width);
let new_physical = physical_rows(frame, width);
let prev_top = plen.saturating_sub(height);
if prev_physical != new_physical || diff < prev_top {
return self.full_redraw(frame, width, height);
}
let before: usize = frame[..diff].iter().map(|l| line_physical_rows(l, width)).sum();
let row = if new_physical <= height {
(height - new_physical) + before + 1
} else {
before - (new_physical - height) + 1
};
let mut buf = String::from(SYNC_START);
buf.push_str(&format!("\x1b[{row};1H"));
for (i, line) in frame[diff..].iter().enumerate() {
if i > 0 {
buf.push_str("\r\n");
}
buf.push_str("\x1b[2K");
buf.push_str(line);
}
buf.push_str(SYNC_END);
self.out.write_all(buf.as_bytes())?;
self.out.flush()
}
}
fn line_physical_rows(line: &str, width: usize) -> usize {
let width = width.max(1);
let mut rows = 1usize;
let mut col = 0usize;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
for n in chars.by_ref() {
if ('@'..='~').contains(&n) {
break;
}
}
}
continue;
}
let w = cell_width(c);
if w == 0 {
continue;
}
if col + w > width && col > 0 {
rows += 1;
col = 0;
}
col += w;
}
rows
}
fn physical_rows(frame: &[String], width: usize) -> usize {
frame.iter().map(|l| line_physical_rows(l, width)).sum()
}
pub struct Debouncer {
last: Option<Instant>,
quiet: Duration,
}
impl Debouncer {
pub fn new(quiet: Duration) -> Self {
Self { last: None, quiet }
}
pub fn record(&mut self, now: Instant) {
self.last = Some(now);
}
pub fn ready(&self, now: Instant) -> bool {
self.last.is_none_or(|last| now.duration_since(last) >= self.quiet)
}
pub fn clear(&mut self) {
self.last = None;
}
pub fn pending(&self) -> bool {
self.last.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plan::PlanItem;
fn strip(text: &str) -> String {
let mut out = String::new();
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
for n in chars.by_ref() {
if ('@'..='~').contains(&n) {
break;
}
}
}
continue;
}
out.push(c);
}
out
}
#[test]
fn parses_and_displays_renderer_mode() {
assert_eq!("frame".parse::<RendererMode>(), Ok(RendererMode::Frame));
assert_eq!("Legacy".parse::<RendererMode>(), Ok(RendererMode::Legacy));
assert!("fancy".parse::<RendererMode>().is_err());
assert_eq!(RendererMode::default(), RendererMode::Frame);
assert_eq!(RendererMode::Frame.to_string(), "frame");
}
#[test]
fn wraps_words_at_the_width() {
assert_eq!(wrap_ansi("the quick brown fox", 9), vec!["the quick", "brown fox"]);
assert_eq!(wrap_ansi("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
for line in wrap_ansi("one two three four five six seven", 7) {
assert!(visible_width(&line) <= 7, "{line:?}");
}
}
#[test]
fn width_counts_terminal_cells_not_scalars() {
assert_eq!(visible_width("你好"), 4);
assert_eq!(visible_width("e\u{0301}"), 1);
for line in wrap_ansi("你好世界你好世界", 5) {
assert!(visible_width(&line) <= 5, "{line:?}");
}
assert!(visible_width(&fit("你好世界", 5)) <= 5);
}
#[test]
fn cursor_marker_reserves_a_cell_on_a_full_row() {
let width = 12;
let text: String = "abcdefghijklmnopqrstuvwxyz".chars().take(40).collect();
for cursor in 0..text.chars().count() {
for line in editor_lines("› ", &text, cursor, width) {
assert!(
visible_width(&line) <= width,
"row overflows at cursor {cursor}: {line:?} (w={})",
visible_width(&line)
);
}
}
}
#[test]
fn tabs_are_expanded_so_rows_never_overflow_the_width() {
let text = "a\tb\tcategory\tvalue\tand some more trailing words here";
for width in 3..40 {
for line in wrap_ansi(text, width) {
assert!(
visible_width(&line) <= width,
"row overflows at width {width}: {line:?} (w={})",
visible_width(&line)
);
assert!(!line.contains('\t'), "tab survived layout: {line:?}");
}
}
assert_eq!(sanitize("a\tb"), "a b");
assert_eq!(sanitize("\tx"), " x");
assert_eq!(sanitize("no tabs"), "no tabs");
}
#[test]
fn unsafe_terminal_controls_are_stripped_from_layout() {
assert_eq!(sanitize("\x1b[31mred\x1b[0m"), "\x1b[31mred\x1b[0m");
assert_eq!(sanitize("a\x1b[2Jb"), "ab"); assert_eq!(sanitize("a\x1b[Hb"), "ab"); assert_eq!(sanitize("a\rb"), "ab"); assert_eq!(sanitize("a\x08b"), "ab"); assert_eq!(sanitize("a\x07b"), "ab"); for line in wrap_ansi("evil\x1b[2K\x1b[1;1Hclear", 40) {
assert!(!line.contains('\x1b') || !line.contains('H'));
assert!(!line.contains('\x1b') || !line.contains('K'));
assert!(!line.contains('\r'));
}
}
#[test]
fn message_stamp_is_shown_and_rows_stay_within_width() {
let stamp = format!("{DIM}12:34:56{RESET} ");
let item = StampedItem {
stamp: stamp.clone(),
item: Item::Message {
role: Role::User,
text: "a fairly long user question that wraps onto several rows".into(),
},
};
for width in 4..40 {
let lines = render_stamped(&item, width);
for line in &lines {
assert!(
visible_width(line) <= width,
"row overflows at width {width}: {line:?} (w={})",
visible_width(line)
);
}
}
let lines = render_stamped(&item, 30);
assert!(strip(&lines[0]).starts_with("12:34:56 › "), "{:?}", lines[0]);
let bare = StampedItem { stamp: String::new(), item: Item::Message { role: Role::User, text: "hi".into() } };
assert_eq!(strip(&render_stamped(&bare, 30)[0]), "› hi");
}
#[test]
fn plan_header_is_bounded_to_the_width() {
let plan = Plan::default();
for width in 3..20 {
for line in plan_lines(&plan, width) {
assert!(visible_width(&line) <= width, "row overflows at {width}: {line:?}");
}
}
}
#[test]
fn tool_result_count_rows_are_bounded_to_the_width() {
let output = (0..40).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
for width in 3..24 {
for verbose in [false, true] {
for line in tool_result_lines(true, &output, verbose, width) {
assert!(visible_width(&line) <= width, "row overflows at {width}: {line:?}");
}
}
}
}
#[test]
fn queue_indicator_shows_only_when_queued() {
assert_eq!(queue_indicator(0, 40), None);
let line = queue_indicator(2, 40).unwrap();
assert!(strip(&line).contains("⏸2 queued · /queue to edit"), "{line:?}");
assert!(visible_width(&queue_indicator(3, 6).unwrap()) <= 6);
}
#[test]
fn wrapping_preserves_ansi_but_not_its_width() {
let coloured = format!("{GREEN}hello world again{RESET}");
let lines = wrap_ansi(&coloured, 11);
assert_eq!(lines.iter().map(|l| strip(l)).collect::<Vec<_>>(), vec!["hello world", "again"]);
assert!(lines[0].contains(GREEN));
}
#[test]
fn composes_transcript_editor_then_status_last() {
let transcript = vec!["a".to_string(), "b".to_string()];
let editor = vec!["› hi".to_string()];
let frame = compose(&transcript, &editor, "STATUS");
assert_eq!(frame, vec!["a", "b", "› hi", "STATUS"]);
assert_eq!(frame.last().unwrap(), "STATUS", "the bar must be the last line");
}
#[test]
fn diffs_frames_line_by_line() {
let a = vec!["one".into(), "two".into(), "three".into()];
let b = vec!["one".into(), "TWO".into(), "three".into()];
assert_eq!(first_diff(&a, &b), Some(1));
assert_eq!(first_diff(&a, &a), None);
let c = vec!["one".into(), "two".into(), "three".into(), "four".into()];
assert_eq!(first_diff(&a, &c), Some(3));
}
#[test]
fn raw_items_render_byte_exact() {
let json = "{\n \"session_id\": \"s\",\n \"long\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}";
let items = vec![StampedItem { stamp: format!("{DIM}12:34:56{RESET} "), item: Item::Raw(json.to_string()) }];
let lines = transcript_lines(&items, 20);
assert_eq!(lines, json.split('\n').collect::<Vec<_>>(), "raw lines must be byte-exact");
assert_eq!(render_item(&Item::Raw(json.to_string()), 20), lines);
}
#[test]
fn renders_items_within_the_width() {
let items = vec![
StampedItem {
stamp: format!("{DIM}12:34:56{RESET} "),
item: Item::Message {
role: Role::Assistant,
text: "a fairly long assistant answer that should wrap".into(),
},
},
StampedItem {
stamp: format!("{DIM}12:34:56{RESET} "),
item: Item::ToolCall {
name: "bash".into(),
summary: "ls -la /some/very/long/path/that/overflows".into(),
},
},
StampedItem {
stamp: format!("{DIM}12:34:56{RESET} "),
item: Item::ToolResult { ok: true, output: "line1\nline2\nline3".into(), verbose: false },
},
StampedItem {
stamp: String::new(),
item: Item::Note("a note that is also rather long and needs wrapping".into()),
},
];
for line in transcript_lines(&items, 20) {
assert!(visible_width(&line) <= 20, "{line:?} ({} cols)", visible_width(&line));
}
let result = render_item(&Item::ToolResult { ok: true, output: "a\nb\nc".into(), verbose: false }, 40);
assert_eq!(result.len(), 1);
assert!(strip(&result[0]).contains("(+2 lines)"));
}
#[test]
fn editor_marks_the_cursor_and_wraps() {
let lines = editor_lines("› ", "hello", 5, 20);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("\x1b[7m"), "cursor marker present");
let long = editor_lines("› ", &"x".repeat(50), 50, 10);
assert!(long.len() > 1);
for line in &long {
assert!(visible_width(line) <= 10, "{line:?}");
}
}
#[test]
fn plan_item_wraps_and_marks_status() {
let plan = Plan {
goal: String::new(),
items: vec![
PlanItem {
id: 1,
title: "do the first thing".into(),
status: Status::Done,
notes: vec![],
after: vec![],
},
PlanItem {
id: 2,
title: "do the second thing".into(),
status: Status::InProgress,
notes: vec![],
after: vec![],
},
],
};
let lines = plan_lines(&plan, 30);
assert!(strip(&lines[0]).contains("1/2 done"));
assert!(lines.len() >= 3);
}
#[test]
fn debouncer_waits_for_quiet() {
let mut d = Debouncer::new(Duration::from_millis(40));
let t0 = Instant::now();
d.record(t0);
assert!(d.pending());
assert!(!d.ready(t0 + Duration::from_millis(10)));
assert!(d.ready(t0 + Duration::from_millis(40)));
d.clear();
assert!(!d.pending());
assert!(d.ready(t0));
}
}
#[cfg(test)]
mod emulator {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Clone)]
struct Shared(Rc<RefCell<Vec<u8>>>);
impl Write for Shared {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.borrow_mut().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
struct Emu {
buf: Shared,
renderer: FrameRenderer<Shared>,
parser: vt100::Parser,
rows: u16,
cols: u16,
}
impl Emu {
fn new(rows: u16, cols: u16) -> Self {
let buf = Shared(Rc::new(RefCell::new(Vec::new())));
Self {
renderer: FrameRenderer::new(buf.clone()),
parser: vt100::Parser::new(rows, cols, 0),
buf,
rows,
cols,
}
}
fn resize(&mut self, rows: u16, cols: u16) {
self.rows = rows;
self.cols = cols;
self.parser.set_size(rows, cols);
}
fn render(&mut self, frame: &[String]) {
self.renderer.render(frame, self.cols as usize, self.rows as usize).unwrap();
let bytes: Vec<u8> = self.buf.0.borrow_mut().drain(..).collect();
self.parser.process(&bytes);
}
fn screen(&self) -> Vec<String> {
self.parser.screen().rows(0, self.cols).map(|row| row.trim_end().to_string()).collect()
}
}
fn sample_frame(width: usize) -> Vec<String> {
let transcript = transcript_lines(
&[
StampedItem {
stamp: String::new(),
item: Item::Message { role: Role::User, text: "please summarise the plan".into() },
},
StampedItem {
stamp: String::new(),
item: Item::Message {
role: Role::Assistant,
text: "here is a fairly long answer that is meant to wrap onto more than one row when the terminal is narrow".into(),
},
},
],
width,
);
let editor = editor_lines("› ", "my next question", 16, width);
compose(&transcript, &editor, "MODEL ctx 10% ")
}
#[test]
fn bar_is_the_last_row_with_the_editor_above_and_no_duplicates() {
let mut emu = Emu::new(24, 80);
emu.render(&sample_frame(80));
let screen = emu.screen();
assert_eq!(screen.last().unwrap(), "MODEL ctx 10%", "bar on the last row");
let editor_row = &screen[screen.len() - 2];
assert!(editor_row.contains("my next question"), "editor above the bar: {editor_row:?}");
let bars = screen.iter().filter(|r| r.contains("ctx 10%")).count();
assert_eq!(bars, 1, "no duplicate bars: {screen:?}");
}
#[test]
fn a_width_change_reflows_history() {
let mut emu = Emu::new(24, 80);
emu.render(&sample_frame(80));
let wide_answer_rows = emu.screen().iter().filter(|r| r.contains("fairly long answer")).count();
emu.resize(24, 30);
emu.render(&sample_frame(30));
let narrow = emu.screen();
assert_eq!(narrow.last().unwrap(), "MODEL ctx 10%");
assert_eq!(narrow.iter().filter(|r| r.contains("ctx 10%")).count(), 1);
for row in &narrow {
assert!(row.chars().count() <= 30, "row wider than 30: {row:?}");
}
let narrow_answer_rows =
narrow.iter().filter(|r| r.contains("fairly") || r.contains("answer") || r.contains("wrap")).count();
assert!(
narrow_answer_rows > wide_answer_rows,
"history did not reflow ({wide_answer_rows} -> {narrow_answer_rows})"
);
}
#[test]
fn a_resize_drag_ends_on_a_stable_correct_frame() {
let mut emu = Emu::new(24, 80);
emu.render(&sample_frame(80));
for i in 0..20 {
let cols = 40 + (i % 40) as u16;
emu.resize(24, cols);
emu.render(&sample_frame(cols as usize));
}
emu.resize(24, 64);
emu.render(&sample_frame(64));
let first = emu.screen();
emu.render(&sample_frame(64));
let second = emu.screen();
assert_eq!(first, second, "frame not stable after the drag");
assert_eq!(second.last().unwrap(), "MODEL ctx 10%");
assert_eq!(second.iter().filter(|r| r.contains("ctx 10%")).count(), 1);
assert!(second[second.len() - 2].contains("my next question"));
}
fn wide_raw_frame(width: usize, marker: &str) -> Vec<String> {
let long = "x".repeat(width * 3);
let transcript = transcript_lines(
&[
StampedItem { stamp: String::new(), item: Item::Raw(format!("{{\"k\":\"{long}\"}}")) },
StampedItem {
stamp: String::new(),
item: Item::Message { role: Role::Assistant, text: format!("answer {marker}") },
},
],
width,
);
let editor = editor_lines("› ", "next", 4, width);
compose(&transcript, &editor, "BAR")
}
#[test]
fn a_wrapping_raw_line_keeps_the_bar_on_the_last_row() {
let mut emu = Emu::new(24, 40);
emu.render(&wide_raw_frame(40, "a"));
let screen = emu.screen();
assert_eq!(screen.last().unwrap(), "BAR", "bar pushed off the last row: {screen:?}");
assert_eq!(screen.iter().filter(|r| r.contains("BAR")).count(), 1, "duplicate bars: {screen:?}");
assert!(screen[screen.len() - 2].contains("next"), "editor not above the bar: {screen:?}");
}
#[test]
fn a_differential_render_after_a_wrapping_raw_line_addresses_real_rows() {
let mut emu = Emu::new(24, 40);
emu.render(&wide_raw_frame(40, "a"));
emu.render(&wide_raw_frame(40, "b"));
let screen = emu.screen();
assert_eq!(screen.last().unwrap(), "BAR");
assert_eq!(screen.iter().filter(|r| r.contains("BAR")).count(), 1, "bar duplicated: {screen:?}");
assert!(screen.iter().any(|r| r.contains("answer b")), "update lost: {screen:?}");
assert!(!screen.iter().any(|r| r.contains("answer a")), "stale row left behind: {screen:?}");
}
#[test]
fn physical_row_helpers_count_native_wraps() {
assert_eq!(line_physical_rows("", 10), 1);
assert_eq!(line_physical_rows("1234567890", 10), 1);
assert_eq!(line_physical_rows("12345678901", 10), 2);
assert_eq!(line_physical_rows(&"x".repeat(25), 10), 3);
assert_eq!(line_physical_rows("\x1b[2m12345\x1b[0m", 10), 1);
let frame = vec!["abc".to_string(), "x".repeat(21), String::new()];
assert_eq!(physical_rows(&frame, 10), 1 + 3 + 1);
}
#[test]
fn line_physical_rows_counts_wide_glyphs_that_cannot_start_in_the_last_column() {
let line = "1\u{4e16}\u{4e16}\u{4e16}\u{4e16}\u{4e16}2\u{4e16}\u{4e16}\u{4e16}\u{4e16}";
assert_eq!(visible_width(line), 20);
assert_eq!(visible_width(line).div_ceil(10), 2);
assert_eq!(line_physical_rows(line, 10), 3);
let styled = format!("\x1b[2m{line}\x1b[0m");
assert_eq!(line_physical_rows(&styled, 10), 3);
assert_eq!(line_physical_rows("\u{4e16}", 1), 1);
}
}