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 {
#[default]
Legacy,
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),
}
#[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);
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),
}
}
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
}
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, height)?;
} else {
self.differential(frame, height)?;
}
self.prev = frame.to_vec();
self.width = width;
self.height = height;
self.started = true;
Ok(())
}
fn full_redraw(&mut self, frame: &[String], 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 start_row = if frame.len() < height {
height - frame.len() + 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], height: usize) -> std::io::Result<()> {
let Some(diff) = first_diff(&self.prev, frame) else {
return Ok(());
};
let plen = self.prev.len();
let prev_top = plen.saturating_sub(height);
if plen != frame.len() || diff < prev_top {
return self.full_redraw(frame, height);
}
let row = if plen <= height {
(height - plen) + diff + 1
} else {
diff - prev_top + 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()
}
}
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::Legacy);
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 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"));
}
}