1654 lines
53 KiB
Rust
1654 lines
53 KiB
Rust
//! Native ratatui renderer for daemon-projected structured session rows.
|
||
|
||
use maestro_protocol::{SessionStatus, TranscriptRow, TranscriptRowKind};
|
||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||
use ratatui::style::{Modifier, Style};
|
||
use ratatui::text::{Line, Span};
|
||
use ratatui::widgets::Paragraph;
|
||
use ratatui::Frame;
|
||
use unicode_width::UnicodeWidthChar;
|
||
|
||
use crate::keymap::Mode;
|
||
use crate::palette;
|
||
use crate::session_task::SessionState;
|
||
use crate::structured_task::{offered_decisions, StructuredHandle, StructuredState};
|
||
|
||
const DIFF_PREVIEW_LINES: usize = 3;
|
||
const DIFF_CONTEXT_LINES: usize = 3;
|
||
const LCS_CELL_LIMIT: usize = 250_000;
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct StructuredPane {
|
||
pub handle: StructuredHandle,
|
||
pub focused: bool,
|
||
pub mode: Mode,
|
||
pub session_status: SessionStatus,
|
||
pub activity_detail: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct VisualLine {
|
||
text: String,
|
||
row_id: Option<String>,
|
||
rail: bool,
|
||
tone: Tone,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy)]
|
||
enum Tone {
|
||
Normal,
|
||
Muted,
|
||
Active,
|
||
Warn,
|
||
Error,
|
||
}
|
||
|
||
impl StructuredPane {
|
||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||
if area.width == 0 || area.height == 0 {
|
||
return;
|
||
}
|
||
let footer_height = area.height.min(3);
|
||
let parts = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([
|
||
Constraint::Min(0),
|
||
Constraint::Length(footer_height.saturating_sub(1).min(2)),
|
||
Constraint::Length(footer_height.min(1)),
|
||
])
|
||
.split(area);
|
||
let mut state = self.handle.state.lock().expect("structured state mutex");
|
||
self.render_transcript(f, parts[0], &mut state);
|
||
self.render_projection_status(f, parts[1], &state);
|
||
self.render_composer(f, parts[2], &state);
|
||
}
|
||
|
||
fn render_transcript(&self, f: &mut Frame, area: Rect, state: &mut StructuredState) {
|
||
let mut lines = build_lines(state, area.width.max(1) as usize);
|
||
if self.session_status == SessionStatus::Dead {
|
||
let detail = self
|
||
.activity_detail
|
||
.as_deref()
|
||
.unwrap_or("structured session ended");
|
||
lines.push(VisualLine {
|
||
text: format!("error: {detail}"),
|
||
row_id: None,
|
||
rail: true,
|
||
tone: Tone::Error,
|
||
});
|
||
} else {
|
||
match self.handle.connection() {
|
||
SessionState::Connecting => lines.push(system_line("connecting…")),
|
||
SessionState::Reconnecting => lines.push(system_line("reconnecting…")),
|
||
SessionState::Ended => lines.push(error_line("structured connection ended")),
|
||
SessionState::Live => {}
|
||
}
|
||
}
|
||
if let Some(error) = &state.error {
|
||
lines.push(error_line(error));
|
||
}
|
||
|
||
let viewport = usize::from(area.height);
|
||
let max_scroll = lines.len().saturating_sub(viewport);
|
||
state.scroll = reconcile_scroll(&state.scroll, &state.line_rows, &lines);
|
||
if state.follow_selection {
|
||
if let Some(selected) = state.selected.as_deref() {
|
||
let first = lines
|
||
.iter()
|
||
.position(|line| line.row_id.as_deref() == Some(selected));
|
||
let last = lines
|
||
.iter()
|
||
.rposition(|line| line.row_id.as_deref() == Some(selected));
|
||
if let (Some(first), Some(last)) = (first, last) {
|
||
if first < state.scroll {
|
||
state.scroll = first;
|
||
} else if last >= state.scroll.saturating_add(viewport) {
|
||
state.scroll = last.saturating_add(1).saturating_sub(viewport);
|
||
}
|
||
}
|
||
} else {
|
||
state.scroll = max_scroll;
|
||
}
|
||
}
|
||
state.scroll = state.scroll.min(max_scroll);
|
||
|
||
let palette = palette::active();
|
||
for (offset, visual) in lines.iter().skip(state.scroll).take(viewport).enumerate() {
|
||
let selected = visual.row_id.as_ref() == state.selected.as_ref();
|
||
let surface = if selected {
|
||
palette.selection
|
||
} else {
|
||
palette.card
|
||
};
|
||
let foreground = match visual.tone {
|
||
Tone::Normal => surface.fg,
|
||
Tone::Muted => surface.muted,
|
||
Tone::Active => surface.active,
|
||
Tone::Warn => surface.awaiting,
|
||
Tone::Error => surface.error,
|
||
};
|
||
let base = Style::default().bg(surface.bg).fg(foreground);
|
||
let prefix = if visual.rail {
|
||
Span::styled("▌", base.fg(surface.awaiting))
|
||
} else {
|
||
Span::styled(" ", base)
|
||
};
|
||
let line = Line::from(vec![prefix, Span::styled(&visual.text, base)]);
|
||
f.render_widget(
|
||
Paragraph::new(line).style(base),
|
||
Rect::new(area.x, area.y + offset as u16, area.width, 1),
|
||
);
|
||
}
|
||
state.line_rows = lines.iter().map(|line| line.row_id.clone()).collect();
|
||
}
|
||
|
||
fn render_projection_status(&self, f: &mut Frame, area: Rect, state: &StructuredState) {
|
||
if area.height == 0 {
|
||
return;
|
||
}
|
||
let palette = palette::active();
|
||
let plan = plan_summary(state);
|
||
let usage = usage_summary(state);
|
||
let rows = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([Constraint::Length(1), Constraint::Length(1)])
|
||
.split(area);
|
||
f.render_widget(
|
||
Paragraph::new(plan).style(palette.card.muted_style()),
|
||
rows[0],
|
||
);
|
||
if area.height > 1 {
|
||
f.render_widget(
|
||
Paragraph::new(usage).style(palette.card.dim_style()),
|
||
rows[1],
|
||
);
|
||
}
|
||
}
|
||
|
||
fn render_composer(&self, f: &mut Frame, area: Rect, state: &StructuredState) {
|
||
if area.height == 0 {
|
||
return;
|
||
}
|
||
let palette = palette::active();
|
||
let active = state.turn_active();
|
||
let label = if active {
|
||
" working esc: cancel"
|
||
} else {
|
||
" › "
|
||
};
|
||
let label_width = label.chars().count().min(usize::from(area.width)) as u16;
|
||
let parts = Layout::default()
|
||
.direction(Direction::Horizontal)
|
||
.constraints([Constraint::Length(label_width), Constraint::Min(0)])
|
||
.split(area);
|
||
let label_style = if active {
|
||
Style::default().bg(palette.card.bg).fg(palette.card.active)
|
||
} else {
|
||
palette.card.text_style().add_modifier(Modifier::BOLD)
|
||
};
|
||
f.render_widget(Paragraph::new(label).style(label_style), parts[0]);
|
||
if !active {
|
||
let cursor = state
|
||
.composer
|
||
.render(parts[1], f.buffer_mut(), palette.card.text_style());
|
||
if self.focused && self.mode == Mode::Insert && parts[1].width > 0 {
|
||
f.set_cursor_position((parts[1].x + cursor, parts[1].y));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn system_line(text: &str) -> VisualLine {
|
||
VisualLine {
|
||
text: text.into(),
|
||
row_id: None,
|
||
rail: false,
|
||
tone: Tone::Muted,
|
||
}
|
||
}
|
||
|
||
fn error_line(text: &str) -> VisualLine {
|
||
VisualLine {
|
||
text: text.into(),
|
||
row_id: None,
|
||
rail: true,
|
||
tone: Tone::Error,
|
||
}
|
||
}
|
||
|
||
/// Translate `scroll` — a raw line index into `old_rows` (the previous
|
||
/// render's row-per-line layout) — into the equivalent index into `lines`
|
||
/// (this render's freshly rebuilt layout), anchored on row identity rather
|
||
/// than raw position. A row's wrapped lines above or intersecting the
|
||
/// viewport growing, shrinking, or moving index therefore shifts `scroll`
|
||
/// along with it instead of leaving the viewport looking at different
|
||
/// content. Falls back to the nearest surviving neighbour row when the
|
||
/// anchor row itself is gone, and to the raw index (clamped) when nothing
|
||
/// nearby survived either.
|
||
fn reconcile_scroll(scroll: &usize, old_rows: &[Option<String>], lines: &[VisualLine]) -> usize {
|
||
if old_rows.is_empty() {
|
||
return *scroll;
|
||
}
|
||
let old_index = (*scroll).min(old_rows.len() - 1);
|
||
let anchor = old_rows[old_index].clone();
|
||
match anchor {
|
||
Some(id) => match find_row_block(lines, &id) {
|
||
Some((start, len)) => {
|
||
start + (old_index - block_start(old_rows, old_index)).min(len - 1)
|
||
}
|
||
None => fallback_after_removal(old_rows, lines, old_index),
|
||
},
|
||
None => {
|
||
// Untagged tail lines (connecting/dead/error) carry no row identity;
|
||
// approximate by preserving distance from the end of the transcript.
|
||
let distance_from_end = old_rows.len() - old_index;
|
||
lines.len().saturating_sub(distance_from_end)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Index of the first line belonging to `id`, and how many lines it spans.
|
||
/// A row's wrapped lines are always contiguous in `lines` — `build_lines`
|
||
/// emits them as one run per row — so a single forward scan suffices.
|
||
fn find_row_block(lines: &[VisualLine], id: &str) -> Option<(usize, usize)> {
|
||
let start = lines
|
||
.iter()
|
||
.position(|line| line.row_id.as_deref() == Some(id))?;
|
||
let mut end = start;
|
||
while end + 1 < lines.len() && lines[end + 1].row_id.as_deref() == Some(id) {
|
||
end += 1;
|
||
}
|
||
Some((start, end - start + 1))
|
||
}
|
||
|
||
/// First index of the contiguous run that `index` sits in within `rows`.
|
||
fn block_start(rows: &[Option<String>], index: usize) -> usize {
|
||
let mut start = index;
|
||
while start > 0 && rows[start - 1] == rows[index] {
|
||
start -= 1;
|
||
}
|
||
start
|
||
}
|
||
|
||
/// The anchor row is gone (removed, or replaced wholesale by a fresh
|
||
/// snapshot). Search outward from its old position, nearest first, for a
|
||
/// still-live neighbour row and land on its first line; if none of the old
|
||
/// layout's rows survived, fall back to the raw old index clamped into the
|
||
/// new layout.
|
||
fn fallback_after_removal(
|
||
old_rows: &[Option<String>],
|
||
lines: &[VisualLine],
|
||
old_index: usize,
|
||
) -> usize {
|
||
let len = old_rows.len();
|
||
let mut distance = 1usize;
|
||
loop {
|
||
if distance > old_index && old_index + distance >= len {
|
||
return old_index.min(lines.len().saturating_sub(1));
|
||
}
|
||
if distance <= old_index {
|
||
if let Some((start, _)) = old_rows[old_index - distance]
|
||
.as_ref()
|
||
.and_then(|id| find_row_block(lines, id))
|
||
{
|
||
return start;
|
||
}
|
||
}
|
||
if old_index + distance < len {
|
||
if let Some((start, _)) = old_rows[old_index + distance]
|
||
.as_ref()
|
||
.and_then(|id| find_row_block(lines, id))
|
||
{
|
||
return start;
|
||
}
|
||
}
|
||
distance += 1;
|
||
}
|
||
}
|
||
|
||
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
|
||
let mut out = Vec::new();
|
||
for (index, row) in state.rows.iter().enumerate() {
|
||
if row.kind == TranscriptRowKind::Plan {
|
||
continue;
|
||
}
|
||
let row_width = width.saturating_sub(1).max(1);
|
||
let start = out.len();
|
||
match row.kind {
|
||
TranscriptRowKind::User => push_wrapped(
|
||
&mut out,
|
||
row,
|
||
"You: ",
|
||
content_text(row),
|
||
row_width,
|
||
Tone::Normal,
|
||
false,
|
||
),
|
||
TranscriptRowKind::AgentText => push_wrapped(
|
||
&mut out,
|
||
row,
|
||
"",
|
||
content_text(row),
|
||
row_width,
|
||
Tone::Normal,
|
||
false,
|
||
),
|
||
TranscriptRowKind::AgentThought => push_wrapped(
|
||
&mut out,
|
||
row,
|
||
"thought: ",
|
||
content_text(row),
|
||
row_width,
|
||
Tone::Muted,
|
||
false,
|
||
),
|
||
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
|
||
tool_lines(&mut out, state, row, row_width)
|
||
}
|
||
TranscriptRowKind::Approval => approval_lines(&mut out, state, row, row_width),
|
||
TranscriptRowKind::Notice => push_wrapped(
|
||
&mut out,
|
||
row,
|
||
"",
|
||
"[notice]".into(),
|
||
row_width,
|
||
Tone::Muted,
|
||
false,
|
||
),
|
||
TranscriptRowKind::Plan => {}
|
||
}
|
||
if out.len() > start
|
||
&& state.rows[index + 1..]
|
||
.iter()
|
||
.any(|next| next.kind != TranscriptRowKind::Plan)
|
||
{
|
||
// A blank surface line is the vertical padding that keeps adjacent
|
||
// transcript blocks readable at a glance.
|
||
out.push(VisualLine {
|
||
text: String::new(),
|
||
row_id: None,
|
||
rail: false,
|
||
tone: Tone::Normal,
|
||
});
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Text supplied to the terminal clipboard when a transcript block is
|
||
/// clicked. It preserves the full tool detail rather than its compact preview.
|
||
pub fn copy_text(row: &TranscriptRow) -> String {
|
||
match row.kind {
|
||
TranscriptRowKind::User
|
||
| TranscriptRowKind::AgentText
|
||
| TranscriptRowKind::AgentThought => content_text(row),
|
||
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
|
||
let (name, argument) = tool_title(row);
|
||
let mut lines = vec![if argument.is_empty() {
|
||
name
|
||
} else {
|
||
format!("{name} {argument}")
|
||
}];
|
||
lines.extend(tool_content_lines(row, None));
|
||
lines.join("\n")
|
||
}
|
||
TranscriptRowKind::Approval => row
|
||
.data
|
||
.get("toolCall")
|
||
.and_then(|tool| tool.get("title"))
|
||
.and_then(serde_json::Value::as_str)
|
||
.unwrap_or("permission requested")
|
||
.into(),
|
||
TranscriptRowKind::Notice => "[notice]".into(),
|
||
TranscriptRowKind::Plan => String::new(),
|
||
}
|
||
}
|
||
|
||
fn push_wrapped(
|
||
out: &mut Vec<VisualLine>,
|
||
row: &TranscriptRow,
|
||
prefix: &str,
|
||
text: String,
|
||
width: usize,
|
||
tone: Tone,
|
||
rail: bool,
|
||
) {
|
||
let joined = format!("{prefix}{text}");
|
||
for line in wrap_text(&joined, width) {
|
||
out.push(VisualLine {
|
||
text: line,
|
||
row_id: Some(row.id.clone()),
|
||
rail,
|
||
tone,
|
||
});
|
||
}
|
||
}
|
||
|
||
fn tool_lines(
|
||
out: &mut Vec<VisualLine>,
|
||
state: &StructuredState,
|
||
row: &TranscriptRow,
|
||
width: usize,
|
||
) {
|
||
let expanded = state.expanded.contains(&row.id);
|
||
let status = row
|
||
.data
|
||
.get("status")
|
||
.and_then(serde_json::Value::as_str)
|
||
.unwrap_or("pending");
|
||
let status_mark = match status {
|
||
"pending" => "·",
|
||
"in_progress" => "●",
|
||
"failed" => "×",
|
||
_ => "✓",
|
||
};
|
||
let (name, argument) = tool_title(row);
|
||
let affordance = if expanded { "▾" } else { "▸" };
|
||
let mut head = format!("{affordance} {status_mark} {name}");
|
||
if !argument.is_empty() {
|
||
head.push_str(" ");
|
||
head.push_str(&argument);
|
||
}
|
||
out.push(VisualLine {
|
||
text: truncate_cells(&head, width),
|
||
row_id: Some(row.id.clone()),
|
||
rail: false,
|
||
tone: if status == "failed" {
|
||
Tone::Error
|
||
} else {
|
||
Tone::Active
|
||
},
|
||
});
|
||
|
||
if row.kind == TranscriptRowKind::ToolDiff && !expanded {
|
||
let content = tool_content_lines(row, Some(DIFF_PREVIEW_LINES + 1));
|
||
let shown = content.len().min(DIFF_PREVIEW_LINES);
|
||
for text in content.iter().take(shown) {
|
||
push_wrapped(out, row, " ", text.clone(), width, diff_tone(text), false);
|
||
}
|
||
if content.len() > shown {
|
||
push_wrapped(
|
||
out,
|
||
row,
|
||
" ",
|
||
"… Enter to expand".into(),
|
||
width,
|
||
Tone::Muted,
|
||
false,
|
||
);
|
||
}
|
||
} else if expanded {
|
||
let content = tool_content_lines(row, None);
|
||
for text in content {
|
||
push_wrapped(out, row, " ", text.clone(), width, diff_tone(&text), false);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn approval_lines(
|
||
out: &mut Vec<VisualLine>,
|
||
state: &StructuredState,
|
||
row: &TranscriptRow,
|
||
width: usize,
|
||
) {
|
||
let pending = state.pending_nonce().is_some()
|
||
&& state
|
||
.rows
|
||
.iter()
|
||
.rev()
|
||
.find(|candidate| candidate.kind == TranscriptRowKind::Approval)
|
||
.is_some_and(|candidate| candidate.id == row.id);
|
||
let tool = row.data.get("toolCall").unwrap_or(&serde_json::Value::Null);
|
||
let title = tool
|
||
.get("title")
|
||
.and_then(serde_json::Value::as_str)
|
||
.unwrap_or("permission requested");
|
||
push_wrapped(
|
||
out,
|
||
row,
|
||
"permission: ",
|
||
title.into(),
|
||
width,
|
||
Tone::Warn,
|
||
true,
|
||
);
|
||
if let Some(path) = tool
|
||
.pointer("/locations/0/path")
|
||
.and_then(serde_json::Value::as_str)
|
||
{
|
||
push_wrapped(out, row, " ", path.into(), width, Tone::Muted, true);
|
||
}
|
||
let actions = if pending {
|
||
let offered = offered_decisions(&row.data);
|
||
let mut keys = Vec::new();
|
||
if offered.contains(&maestro_protocol::StructuredApprovalDecision::AllowOnce) {
|
||
keys.push("[a] allow once");
|
||
}
|
||
if offered.contains(&maestro_protocol::StructuredApprovalDecision::AllowForSession) {
|
||
keys.push("[A] allow session");
|
||
}
|
||
if offered.contains(&maestro_protocol::StructuredApprovalDecision::Deny) {
|
||
keys.push("[d] deny");
|
||
}
|
||
if keys.is_empty() {
|
||
"no decision offered".into()
|
||
} else {
|
||
keys.join(" ")
|
||
}
|
||
} else {
|
||
"answered".into()
|
||
};
|
||
push_wrapped(out, row, " ", actions, width, Tone::Warn, true);
|
||
}
|
||
|
||
fn content_text(row: &TranscriptRow) -> String {
|
||
let Some(content) = row.data.get("content") else {
|
||
return String::new();
|
||
};
|
||
match content.get("type").and_then(serde_json::Value::as_str) {
|
||
Some("text") => content
|
||
.get("text")
|
||
.and_then(serde_json::Value::as_str)
|
||
.unwrap_or_default()
|
||
.into(),
|
||
Some("image") => "[image]".into(),
|
||
Some("audio") => "[audio]".into(),
|
||
Some("resource_link") => format!(
|
||
"[resource: {}]",
|
||
content
|
||
.get("name")
|
||
.or_else(|| content.get("uri"))
|
||
.and_then(serde_json::Value::as_str)
|
||
.unwrap_or("linked")
|
||
),
|
||
Some("resource") => "[resource]".into(),
|
||
_ => String::new(),
|
||
}
|
||
}
|
||
|
||
fn tool_title(row: &TranscriptRow) -> (String, String) {
|
||
let fallback = row
|
||
.data
|
||
.get("kind")
|
||
.and_then(serde_json::Value::as_str)
|
||
.unwrap_or("tool");
|
||
let explicit = row.data.get("name").and_then(serde_json::Value::as_str);
|
||
let title = row
|
||
.data
|
||
.get("title")
|
||
.and_then(serde_json::Value::as_str)
|
||
.unwrap_or_default();
|
||
if let Some((name, rest)) = title
|
||
.split_once('(')
|
||
.and_then(|(name, rest)| rest.strip_suffix(')').map(|rest| (name, rest)))
|
||
{
|
||
return (explicit.unwrap_or(name).to_string(), rest.to_string());
|
||
}
|
||
(explicit.unwrap_or(fallback).to_string(), title.to_string())
|
||
}
|
||
|
||
fn tool_content_lines(row: &TranscriptRow, limit: Option<usize>) -> Vec<String> {
|
||
let mut out = Vec::new();
|
||
for block in row
|
||
.data
|
||
.get("content")
|
||
.and_then(serde_json::Value::as_array)
|
||
.into_iter()
|
||
.flatten()
|
||
{
|
||
if limit.is_some_and(|limit| out.len() >= limit) {
|
||
break;
|
||
}
|
||
match block.get("type").and_then(serde_json::Value::as_str) {
|
||
Some("content") => {
|
||
let nested = block.get("content").cloned().unwrap_or_default();
|
||
let fake = TranscriptRow {
|
||
id: String::new(),
|
||
kind: TranscriptRowKind::AgentText,
|
||
data: serde_json::json!({"content": nested}),
|
||
};
|
||
let text = content_text(&fake);
|
||
let remaining = limit.map_or(usize::MAX, |limit| limit.saturating_sub(out.len()));
|
||
out.extend(text.lines().take(remaining).map(str::to_string));
|
||
}
|
||
Some("diff") => {
|
||
let path = block
|
||
.get("path")
|
||
.and_then(serde_json::Value::as_str)
|
||
.unwrap_or("file");
|
||
out.push(format!("{path}:"));
|
||
let old = block.get("oldText").and_then(serde_json::Value::as_str);
|
||
if let Some(new) = block.get("newText").and_then(serde_json::Value::as_str) {
|
||
let remaining = limit.map(|limit| limit.saturating_sub(out.len()));
|
||
out.extend(render_diff(old, new, remaining));
|
||
}
|
||
}
|
||
Some("terminal") => out.push("[embedded terminal]".into()),
|
||
_ => {}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
struct SourceLine<'a> {
|
||
text: &'a str,
|
||
terminated: bool,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum DiffKind {
|
||
Add,
|
||
Delete,
|
||
Context,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
struct DiffOp<'a> {
|
||
kind: DiffKind,
|
||
line: SourceLine<'a>,
|
||
}
|
||
|
||
fn split_diff_lines(text: &str) -> Vec<SourceLine<'_>> {
|
||
text.split_inclusive('\n')
|
||
.map(|part| SourceLine {
|
||
text: part.strip_suffix('\n').unwrap_or(part),
|
||
terminated: part.ends_with('\n'),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn lcs_diff<'a>(old: &[SourceLine<'a>], new: &[SourceLine<'a>]) -> Vec<DiffOp<'a>> {
|
||
if old.is_empty() {
|
||
return new
|
||
.iter()
|
||
.copied()
|
||
.map(|line| DiffOp {
|
||
kind: DiffKind::Add,
|
||
line,
|
||
})
|
||
.collect();
|
||
}
|
||
if new.is_empty() {
|
||
return old
|
||
.iter()
|
||
.copied()
|
||
.map(|line| DiffOp {
|
||
kind: DiffKind::Delete,
|
||
line,
|
||
})
|
||
.collect();
|
||
}
|
||
if old.len().saturating_mul(new.len()) > LCS_CELL_LIMIT {
|
||
return old
|
||
.iter()
|
||
.copied()
|
||
.map(|line| DiffOp {
|
||
kind: DiffKind::Delete,
|
||
line,
|
||
})
|
||
.chain(new.iter().copied().map(|line| DiffOp {
|
||
kind: DiffKind::Add,
|
||
line,
|
||
}))
|
||
.collect();
|
||
}
|
||
|
||
let columns = new.len() + 1;
|
||
let mut table = vec![0_u32; (old.len() + 1) * columns];
|
||
for i in (0..old.len()).rev() {
|
||
for j in (0..new.len()).rev() {
|
||
table[i * columns + j] = if old[i] == new[j] {
|
||
table[(i + 1) * columns + j + 1] + 1
|
||
} else {
|
||
table[(i + 1) * columns + j].max(table[i * columns + j + 1])
|
||
};
|
||
}
|
||
}
|
||
|
||
let mut ops = Vec::with_capacity(old.len() + new.len());
|
||
let (mut i, mut j) = (0, 0);
|
||
while i < old.len() && j < new.len() {
|
||
if old[i] == new[j] {
|
||
ops.push(DiffOp {
|
||
kind: DiffKind::Context,
|
||
line: old[i],
|
||
});
|
||
i += 1;
|
||
j += 1;
|
||
} else if table[(i + 1) * columns + j] >= table[i * columns + j + 1] {
|
||
ops.push(DiffOp {
|
||
kind: DiffKind::Delete,
|
||
line: old[i],
|
||
});
|
||
i += 1;
|
||
} else {
|
||
ops.push(DiffOp {
|
||
kind: DiffKind::Add,
|
||
line: new[j],
|
||
});
|
||
j += 1;
|
||
}
|
||
}
|
||
ops.extend(old[i..].iter().copied().map(|line| DiffOp {
|
||
kind: DiffKind::Delete,
|
||
line,
|
||
}));
|
||
ops.extend(new[j..].iter().copied().map(|line| DiffOp {
|
||
kind: DiffKind::Add,
|
||
line,
|
||
}));
|
||
ops
|
||
}
|
||
|
||
fn diff_ops<'a>(old: &'a str, new: &'a str) -> Vec<DiffOp<'a>> {
|
||
let old = split_diff_lines(old);
|
||
let new = split_diff_lines(new);
|
||
let mut prefix = 0;
|
||
while prefix < old.len() && prefix < new.len() && old[prefix] == new[prefix] {
|
||
prefix += 1;
|
||
}
|
||
let mut suffix = 0;
|
||
while suffix < old.len() - prefix
|
||
&& suffix < new.len() - prefix
|
||
&& old[old.len() - 1 - suffix] == new[new.len() - 1 - suffix]
|
||
{
|
||
suffix += 1;
|
||
}
|
||
|
||
let mut ops = old[..prefix]
|
||
.iter()
|
||
.copied()
|
||
.map(|line| DiffOp {
|
||
kind: DiffKind::Context,
|
||
line,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
ops.extend(lcs_diff(
|
||
&old[prefix..old.len() - suffix],
|
||
&new[prefix..new.len() - suffix],
|
||
));
|
||
ops.extend(
|
||
old[old.len() - suffix..]
|
||
.iter()
|
||
.copied()
|
||
.map(|line| DiffOp {
|
||
kind: DiffKind::Context,
|
||
line,
|
||
}),
|
||
);
|
||
ops
|
||
}
|
||
|
||
fn render_diff_ops(ops: &[DiffOp<'_>]) -> Vec<String> {
|
||
let mut rendered = Vec::with_capacity(ops.len());
|
||
let mut index = 0;
|
||
while index < ops.len() {
|
||
if ops[index].kind != DiffKind::Context {
|
||
push_diff_op(&mut rendered, ops[index]);
|
||
index += 1;
|
||
continue;
|
||
}
|
||
|
||
let mut end = index;
|
||
while end < ops.len() && ops[end].kind == DiffKind::Context {
|
||
end += 1;
|
||
}
|
||
let run = &ops[index..end];
|
||
let whole_diff = index == 0 && end == ops.len();
|
||
let keep_head = if whole_diff || index > 0 {
|
||
DIFF_CONTEXT_LINES
|
||
} else {
|
||
0
|
||
};
|
||
let keep_tail = if whole_diff || end < ops.len() {
|
||
DIFF_CONTEXT_LINES
|
||
} else {
|
||
0
|
||
};
|
||
if run.len() <= keep_head + keep_tail {
|
||
for op in run {
|
||
push_diff_op(&mut rendered, *op);
|
||
}
|
||
} else {
|
||
for op in run.iter().take(keep_head) {
|
||
push_diff_op(&mut rendered, *op);
|
||
}
|
||
let collapsed = &run[keep_head..run.len() - keep_tail];
|
||
rendered.push(format!("… {} unchanged lines …", collapsed.len()));
|
||
if collapsed.iter().any(|op| !op.line.terminated) {
|
||
rendered.push("\\ No newline at end of file".into());
|
||
}
|
||
for op in run.iter().skip(run.len() - keep_tail) {
|
||
push_diff_op(&mut rendered, *op);
|
||
}
|
||
}
|
||
index = end;
|
||
}
|
||
rendered
|
||
}
|
||
|
||
fn push_diff_op(rendered: &mut Vec<String>, op: DiffOp<'_>) {
|
||
let prefix = match op.kind {
|
||
DiffKind::Add => "+ ",
|
||
DiffKind::Delete => "- ",
|
||
DiffKind::Context => " ",
|
||
};
|
||
rendered.push(format!("{prefix}{}", op.line.text));
|
||
if !op.line.terminated {
|
||
rendered.push("\\ No newline at end of file".into());
|
||
}
|
||
}
|
||
|
||
fn render_diff(old: Option<&str>, new: &str, limit: Option<usize>) -> Vec<String> {
|
||
let ops = old.map_or_else(
|
||
|| {
|
||
split_diff_lines(new)
|
||
.into_iter()
|
||
.map(|line| DiffOp {
|
||
kind: DiffKind::Add,
|
||
line,
|
||
})
|
||
.collect()
|
||
},
|
||
|old| diff_ops(old, new),
|
||
);
|
||
let mut rendered = render_diff_ops(&ops);
|
||
if let Some(limit) = limit {
|
||
rendered.truncate(limit);
|
||
}
|
||
rendered
|
||
}
|
||
|
||
fn diff_tone(text: &str) -> Tone {
|
||
if text.starts_with("+ ") {
|
||
Tone::Active
|
||
} else if text.starts_with("- ") {
|
||
Tone::Error
|
||
} else {
|
||
Tone::Muted
|
||
}
|
||
}
|
||
|
||
fn plan_summary(state: &StructuredState) -> String {
|
||
let entries = state
|
||
.reduced
|
||
.plan
|
||
.as_ref()
|
||
.and_then(|plan| plan.get("entries"))
|
||
.and_then(serde_json::Value::as_array);
|
||
let Some(entries) = entries else {
|
||
return " plan —".into();
|
||
};
|
||
let current = entries.iter().find(|entry| {
|
||
entry.get("status").and_then(serde_json::Value::as_str) == Some("in_progress")
|
||
});
|
||
let done = entries
|
||
.iter()
|
||
.filter(|entry| {
|
||
entry.get("status").and_then(serde_json::Value::as_str) == Some("completed")
|
||
})
|
||
.count();
|
||
let text = current
|
||
.and_then(|entry| entry.get("content"))
|
||
.and_then(serde_json::Value::as_str)
|
||
.unwrap_or("waiting");
|
||
format!(" plan {done}/{} {text}", entries.len())
|
||
}
|
||
|
||
fn usage_summary(state: &StructuredState) -> String {
|
||
let activity = if state.reduced.turn.thinking {
|
||
"thinking"
|
||
} else if state.reduced.turn.responding {
|
||
"responding"
|
||
} else {
|
||
"idle"
|
||
};
|
||
let usage = state
|
||
.reduced
|
||
.usage
|
||
.as_ref()
|
||
.or(state.reduced.context.as_ref());
|
||
let used = usage
|
||
.and_then(|value| value.get("used"))
|
||
.and_then(serde_json::Value::as_u64);
|
||
let size = usage
|
||
.and_then(|value| value.get("size"))
|
||
.and_then(serde_json::Value::as_u64);
|
||
let context = match (used, size) {
|
||
(Some(used), Some(size)) => format!(" context {used}/{size}"),
|
||
_ => String::new(),
|
||
};
|
||
let model = state
|
||
.reduced
|
||
.model
|
||
.as_deref()
|
||
.map(|model| format!(" {model}"))
|
||
.unwrap_or_default();
|
||
let mode = state
|
||
.reduced
|
||
.mode
|
||
.as_deref()
|
||
.map(|mode| format!(" · {mode}"))
|
||
.unwrap_or_default();
|
||
format!(" {activity}{context}{model}{mode}")
|
||
}
|
||
|
||
fn wrap_text(text: &str, width: usize) -> Vec<String> {
|
||
let width = width.max(1);
|
||
let mut out = Vec::new();
|
||
for source in text.split('\n') {
|
||
let mut line = String::new();
|
||
let mut cells = 0;
|
||
for ch in source.chars() {
|
||
let char_width = ch.width().unwrap_or(0);
|
||
if cells > 0 && cells + char_width > width {
|
||
out.push(std::mem::take(&mut line));
|
||
cells = 0;
|
||
}
|
||
line.push(ch);
|
||
cells += char_width;
|
||
}
|
||
out.push(line);
|
||
}
|
||
if out.is_empty() {
|
||
out.push(String::new());
|
||
}
|
||
out
|
||
}
|
||
|
||
fn truncate_cells(text: &str, width: usize) -> String {
|
||
let width = width.max(1);
|
||
let total: usize = text.chars().map(|ch| ch.width().unwrap_or(0)).sum();
|
||
if total <= width {
|
||
return text.to_string();
|
||
}
|
||
if width == 1 {
|
||
return "…".into();
|
||
}
|
||
let mut out = String::new();
|
||
let mut cells = 0;
|
||
for ch in text.chars() {
|
||
let char_width = ch.width().unwrap_or(0);
|
||
if cells + char_width > width - 1 {
|
||
break;
|
||
}
|
||
out.push(ch);
|
||
cells += char_width;
|
||
}
|
||
out.push('…');
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use maestro_protocol::{ReducedState, TranscriptRow};
|
||
use ratatui::backend::TestBackend;
|
||
use ratatui::Terminal;
|
||
use serde_json::json;
|
||
|
||
fn row(id: &str, kind: TranscriptRowKind, data: serde_json::Value) -> TranscriptRow {
|
||
TranscriptRow {
|
||
id: id.into(),
|
||
kind,
|
||
data,
|
||
}
|
||
}
|
||
|
||
fn render_state(
|
||
state: StructuredState,
|
||
width: u16,
|
||
height: u16,
|
||
status: SessionStatus,
|
||
detail: Option<&str>,
|
||
) -> (String, ratatui::buffer::Buffer) {
|
||
let pane = StructuredPane {
|
||
handle: StructuredHandle::test_handle(state),
|
||
focused: true,
|
||
mode: Mode::Normal,
|
||
session_status: status,
|
||
activity_detail: detail.map(str::to_string),
|
||
};
|
||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||
terminal
|
||
.draw(|frame| pane.render(frame, frame.area()))
|
||
.unwrap();
|
||
let buffer = terminal.backend().buffer().clone();
|
||
let text = (0..height)
|
||
.map(|y| {
|
||
(0..width)
|
||
.map(|x| buffer[(x, y)].symbol())
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
(text, buffer)
|
||
}
|
||
|
||
#[test]
|
||
fn prose_wraps_to_the_pane_width() {
|
||
let mut state = StructuredState::default();
|
||
state.rows.push(row(
|
||
"a",
|
||
TranscriptRowKind::AgentText,
|
||
json!({"content":{"type":"text","text":"abcdefghij"}}),
|
||
));
|
||
let lines = build_lines(&state, 5);
|
||
assert_eq!(
|
||
lines
|
||
.iter()
|
||
.map(|line| line.text.as_str())
|
||
.collect::<Vec<_>>(),
|
||
["abcd", "efgh", "ij"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn transcript_blocks_have_a_padding_line_between_them() {
|
||
let mut state = StructuredState::default();
|
||
state.rows = vec![
|
||
row(
|
||
"one",
|
||
TranscriptRowKind::User,
|
||
json!({"content":{"type":"text","text":"first"}}),
|
||
),
|
||
row(
|
||
"two",
|
||
TranscriptRowKind::AgentText,
|
||
json!({"content":{"type":"text","text":"second"}}),
|
||
),
|
||
];
|
||
|
||
let lines = build_lines(&state, 80);
|
||
assert_eq!(lines.len(), 3);
|
||
assert_eq!(lines[1].text, "");
|
||
assert!(lines[1].row_id.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn copying_a_tool_keeps_its_full_detail() {
|
||
let tool = row(
|
||
"t",
|
||
TranscriptRowKind::ToolCall,
|
||
json!({"title":"Bash(cargo test)","content":[{"type":"content","content":{"type":"text","text":"all green"}}]}),
|
||
);
|
||
|
||
assert_eq!(copy_text(&tool), "Bash cargo test\nall green");
|
||
}
|
||
|
||
#[test]
|
||
fn tool_is_compact_until_selected_row_expands() {
|
||
let mut state = StructuredState::default();
|
||
state.rows.push(row("t", TranscriptRowKind::ToolCall, json!({"title":"Bash(cargo test)","status":"completed","content":[{"type":"content","content":{"type":"text","text":"output"}}]})));
|
||
let collapsed = build_lines(&state, 80);
|
||
assert_eq!(collapsed.len(), 1);
|
||
assert!(collapsed[0].text.contains("Bash cargo test"));
|
||
assert_eq!(build_lines(&state, 8).len(), 1);
|
||
state.selected = Some("t".into());
|
||
state.toggle_selected();
|
||
assert!(build_lines(&state, 80)
|
||
.iter()
|
||
.any(|line| line.text.contains("output")));
|
||
}
|
||
|
||
#[test]
|
||
fn compact_tool_header_omits_client_invented_duration() {
|
||
let mut state = StructuredState::default();
|
||
state.rows.push(row(
|
||
"t",
|
||
TranscriptRowKind::ToolCall,
|
||
json!({"title":"Bash(cargo test)","status":"completed"}),
|
||
));
|
||
|
||
assert_eq!(build_lines(&state, 80)[0].text, "▸ ✓ Bash cargo test");
|
||
}
|
||
|
||
#[test]
|
||
fn serialized_tool_without_status_defaults_to_pending() {
|
||
let tool: TranscriptRow = serde_json::from_value(json!({
|
||
"id": "tool:1",
|
||
"kind": "tool_call",
|
||
"data": {"title": "Read(src/lib.rs)"}
|
||
}))
|
||
.unwrap();
|
||
let mut state = StructuredState::default();
|
||
state.rows.push(tool);
|
||
|
||
assert_eq!(build_lines(&state, 80)[0].text, "▸ · Read src/lib.rs");
|
||
}
|
||
|
||
#[test]
|
||
fn collapsed_diff_previews_only_a_few_lines_with_affordance() {
|
||
let mut state = StructuredState::default();
|
||
state.rows.push(row("d", TranscriptRowKind::ToolDiff, json!({"title":"Edit(x)","content":[{"type":"diff","path":"x","oldText":"a\nb","newText":"c\nd"}]})));
|
||
let lines = build_lines(&state, 80);
|
||
assert!(lines.len() <= 5);
|
||
assert!(lines.last().unwrap().text.contains("Enter to expand"));
|
||
}
|
||
|
||
fn diff_content(old_text: Option<&str>, new_text: &str) -> Vec<String> {
|
||
let old_text = old_text.map_or(serde_json::Value::Null, |text| json!(text));
|
||
let row = row(
|
||
"d",
|
||
TranscriptRowKind::ToolDiff,
|
||
json!({"content":[{"type":"diff","path":"x","oldText":old_text,"newText":new_text}]}),
|
||
);
|
||
tool_content_lines(&row, None)
|
||
}
|
||
|
||
#[test]
|
||
fn structured_diff_preserves_unchanged_lines() {
|
||
assert_eq!(diff_content(Some("a\nb\n"), "a\nb\n"), ["x:", " a", " b"]);
|
||
}
|
||
|
||
#[test]
|
||
fn structured_diff_handles_insert_delete_replace_and_empty_files() {
|
||
assert_eq!(
|
||
diff_content(Some("a\nc\n"), "a\nb\nc\n"),
|
||
["x:", " a", "+ b", " c"]
|
||
);
|
||
assert_eq!(
|
||
diff_content(Some("a\nb\nc\n"), "a\nc\n"),
|
||
["x:", " a", "- b", " c"]
|
||
);
|
||
assert_eq!(
|
||
diff_content(Some("a\nb\n"), "a\nc\n"),
|
||
["x:", " a", "- b", "+ c"]
|
||
);
|
||
assert_eq!(diff_content(Some(""), ""), ["x:"]);
|
||
assert_eq!(diff_content(None, "a\n"), ["x:", "+ a"]);
|
||
assert_eq!(diff_content(Some("a\n"), ""), ["x:", "- a"]);
|
||
}
|
||
|
||
#[test]
|
||
fn structured_diff_marks_added_and_removed_final_newlines() {
|
||
assert_eq!(
|
||
diff_content(Some("a"), "a\n"),
|
||
["x:", "- a", "\\ No newline at end of file", "+ a"]
|
||
);
|
||
assert_eq!(
|
||
diff_content(Some("a\n"), "a"),
|
||
["x:", "- a", "+ a", "\\ No newline at end of file"]
|
||
);
|
||
assert_eq!(
|
||
diff_content(None, "a"),
|
||
["x:", "+ a", "\\ No newline at end of file"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn structured_diff_marks_unchanged_unterminated_final_context() {
|
||
assert_eq!(
|
||
diff_content(Some("a\nb\nc"), "a\nX\nc"),
|
||
[
|
||
"x:",
|
||
" a",
|
||
"- b",
|
||
"+ X",
|
||
" c",
|
||
"\\ No newline at end of file"
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn structured_diff_collapses_long_context_around_changes() {
|
||
let common = (0..20)
|
||
.map(|index| format!("line{index}"))
|
||
.collect::<Vec<_>>();
|
||
let old = common[..10]
|
||
.iter()
|
||
.cloned()
|
||
.chain(["OLD".into()])
|
||
.chain(common[10..].iter().cloned())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let new = common[..10]
|
||
.iter()
|
||
.cloned()
|
||
.chain(["NEW".into()])
|
||
.chain(common[10..].iter().cloned())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
|
||
assert_eq!(
|
||
render_diff(Some(&old), &new, None),
|
||
[
|
||
"… 7 unchanged lines …",
|
||
" line7",
|
||
" line8",
|
||
" line9",
|
||
"- OLD",
|
||
"+ NEW",
|
||
" line10",
|
||
" line11",
|
||
" line12",
|
||
"… 7 unchanged lines …",
|
||
"\\ No newline at end of file",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn structured_diff_preserves_eof_marker_through_trailing_collapse() {
|
||
let trailing = (0..10).map(|index| format!("t{index}")).collect::<Vec<_>>();
|
||
let old = ["OLD".into()]
|
||
.into_iter()
|
||
.chain(trailing.iter().cloned())
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let new = ["NEW".into()]
|
||
.into_iter()
|
||
.chain(trailing)
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
|
||
assert_eq!(
|
||
render_diff(Some(&old), &new, None),
|
||
[
|
||
"- OLD",
|
||
"+ NEW",
|
||
" t0",
|
||
" t1",
|
||
" t2",
|
||
"… 7 unchanged lines …",
|
||
"\\ No newline at end of file",
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn structured_diff_collapses_only_long_interior_context() {
|
||
let middle = (0..10).map(|index| format!("c{index}")).collect::<Vec<_>>();
|
||
let old = ["lead0", "lead1", "OLD1"]
|
||
.into_iter()
|
||
.map(str::to_string)
|
||
.chain(middle.iter().cloned())
|
||
.chain(["OLD2", "trail0", "trail1"].into_iter().map(str::to_string))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let new = ["lead0", "lead1", "NEW1"]
|
||
.into_iter()
|
||
.map(str::to_string)
|
||
.chain(middle.iter().cloned())
|
||
.chain(["NEW2", "trail0", "trail1"].into_iter().map(str::to_string))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let rendered = render_diff(Some(&old), &new, None);
|
||
|
||
assert_eq!(
|
||
rendered
|
||
.iter()
|
||
.filter(|line| line.contains("unchanged lines"))
|
||
.count(),
|
||
1
|
||
);
|
||
assert_eq!(&rendered[..4], [" lead0", " lead1", "- OLD1", "+ NEW1"]);
|
||
assert_eq!(
|
||
&rendered[4..11],
|
||
[
|
||
" c0",
|
||
" c1",
|
||
" c2",
|
||
"… 4 unchanged lines …",
|
||
" c7",
|
||
" c8",
|
||
" c9"
|
||
]
|
||
);
|
||
assert_eq!(
|
||
&rendered[11..],
|
||
[
|
||
"- OLD2",
|
||
"+ NEW2",
|
||
" trail0",
|
||
" trail1",
|
||
"\\ No newline at end of file"
|
||
]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn structured_diff_preview_remains_bounded_after_hunking() {
|
||
let common = (0..10)
|
||
.map(|index| format!("line{index}"))
|
||
.collect::<Vec<_>>();
|
||
let old = common
|
||
.iter()
|
||
.cloned()
|
||
.chain(["OLD".into()])
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let new = common
|
||
.iter()
|
||
.cloned()
|
||
.chain(["NEW".into()])
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let expanded = render_diff(Some(&old), &new, None);
|
||
|
||
assert_eq!(render_diff(Some(&old), &new, Some(4)), expanded[..4]);
|
||
assert_eq!(expanded[0], "… 7 unchanged lines …");
|
||
assert!(expanded.contains(&"- OLD".into()));
|
||
assert!(expanded.contains(&"+ NEW".into()));
|
||
}
|
||
|
||
#[test]
|
||
fn structured_diff_bounds_pathological_changed_middles() {
|
||
let old_lines = (0..501)
|
||
.map(|index| format!("line{index}"))
|
||
.collect::<Vec<_>>();
|
||
let mut new_lines = old_lines[1..].to_vec();
|
||
new_lines.push(old_lines[0].clone());
|
||
let old = format!("{}\n", old_lines.join("\n"));
|
||
let new = format!("{}\n", new_lines.join("\n"));
|
||
let rendered = render_diff(Some(&old), &new, None);
|
||
|
||
assert_eq!(rendered.len(), 1_002);
|
||
assert!(rendered[..501].iter().all(|line| line.starts_with("- ")));
|
||
assert!(rendered[501..].iter().all(|line| line.starts_with("+ ")));
|
||
assert_eq!(render_diff(Some(&old), &new, Some(4)), rendered[..4]);
|
||
}
|
||
|
||
#[test]
|
||
fn plan_is_not_a_transcript_row_and_status_is_two_lines() {
|
||
let mut state = StructuredState::default();
|
||
state
|
||
.rows
|
||
.push(row("plan", TranscriptRowKind::Plan, json!({})));
|
||
state.reduced = ReducedState {
|
||
plan: Some(json!({"entries":[{"status":"in_progress","content":"ship"}]})),
|
||
usage: Some(json!({"used":12,"size":100})),
|
||
..ReducedState::default()
|
||
};
|
||
assert!(build_lines(&state, 80).is_empty());
|
||
assert!(plan_summary(&state).contains("ship"));
|
||
assert!(usage_summary(&state).contains("12/100"));
|
||
}
|
||
|
||
#[test]
|
||
fn approval_is_inline_railed_and_lists_only_offered_keys() {
|
||
let mut state = StructuredState::default();
|
||
state.rows.push(row("p", TranscriptRowKind::Approval, json!({"toolCall":{"title":"Bash(rm x)"},"options":[{"kind":"allow_once"},{"kind":"reject_once"}]})));
|
||
state.reduced.pending_approval = Some(json!({"nonce":"n"}));
|
||
let lines = build_lines(&state, 80);
|
||
assert!(lines.iter().all(|line| line.rail));
|
||
assert!(lines
|
||
.iter()
|
||
.any(|line| line.text.contains("[a] allow once")));
|
||
assert!(!lines.iter().any(|line| line.text.contains("[A]")));
|
||
}
|
||
|
||
#[test]
|
||
fn one_column_panes_do_not_panic() {
|
||
let mut state = StructuredState::default();
|
||
state.rows.push(row(
|
||
"a",
|
||
TranscriptRowKind::AgentText,
|
||
json!({"content":{"type":"text","text":"hi"}}),
|
||
));
|
||
assert!(!build_lines(&state, 1).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn native_pane_renders_projection_footer_and_composer_together() {
|
||
let mut state = StructuredState::default();
|
||
state.rows.push(row(
|
||
"a",
|
||
TranscriptRowKind::AgentText,
|
||
json!({"content":{"type":"text","text":"projected transcript"}}),
|
||
));
|
||
state.reduced.plan =
|
||
Some(json!({"entries":[{"status":"in_progress","content":"render native pane"}]}));
|
||
state.reduced.usage = Some(json!({"used":42,"size":1000}));
|
||
let (text, _) = render_state(state, 50, 8, SessionStatus::Running, None);
|
||
assert!(text.contains("projected transcript"), "{text}");
|
||
assert!(text.contains("render native pane"), "{text}");
|
||
assert!(text.contains("context 42/1000"), "{text}");
|
||
assert!(text.contains('›'), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn selected_row_scrolls_into_view_and_uses_the_selection_surface() {
|
||
let mut state = StructuredState::default();
|
||
for n in 0..12 {
|
||
state.rows.push(row(
|
||
&format!("r{n}"),
|
||
TranscriptRowKind::AgentText,
|
||
json!({"content":{"type":"text","text":format!("line {n}")}}),
|
||
));
|
||
}
|
||
state.selected = Some("r11".into());
|
||
let (text, buffer) = render_state(state, 30, 7, SessionStatus::Running, None);
|
||
assert!(text.contains("line 11"), "{text}");
|
||
let selected_y = (0..4)
|
||
.find(|y| {
|
||
(0..30)
|
||
.map(|x| buffer[(x, *y)].symbol())
|
||
.collect::<String>()
|
||
.contains("line 11")
|
||
})
|
||
.unwrap();
|
||
assert_eq!(buffer[(2, selected_y)].bg, palette::active().selection.bg);
|
||
}
|
||
|
||
#[test]
|
||
fn dead_session_reason_and_connection_error_are_rendered_inline() {
|
||
let mut state = StructuredState::default();
|
||
state.error = Some("request failed".into());
|
||
let (text, _) = render_state(state, 50, 7, SessionStatus::Dead, Some("adapter exited"));
|
||
assert!(text.contains("adapter exited"), "{text}");
|
||
assert!(text.contains("request failed"), "{text}");
|
||
}
|
||
|
||
fn test_pane() -> StructuredPane {
|
||
StructuredPane {
|
||
handle: StructuredHandle::test_handle(StructuredState::default()),
|
||
focused: true,
|
||
mode: Mode::Normal,
|
||
session_status: SessionStatus::Running,
|
||
activity_detail: None,
|
||
}
|
||
}
|
||
|
||
fn numbered_rows(state: &mut StructuredState, count: usize) {
|
||
for n in 0..count {
|
||
state.rows.push(row(
|
||
&format!("r{n}"),
|
||
TranscriptRowKind::AgentText,
|
||
json!({"content":{"type":"text","text":format!("line {n}")}}),
|
||
));
|
||
}
|
||
}
|
||
|
||
fn grow_row(state: &mut StructuredState, id: &str) {
|
||
let index = state.rows.iter().position(|r| r.id == id).unwrap();
|
||
state.rows[index] = row(
|
||
id,
|
||
TranscriptRowKind::AgentText,
|
||
json!({"content":{"type":"text","text":"a very long streaming reply that keeps growing and will wrap across several lines now"}}),
|
||
);
|
||
}
|
||
|
||
fn render_transcript_lines(
|
||
pane: &StructuredPane,
|
||
state: &mut StructuredState,
|
||
width: u16,
|
||
height: u16,
|
||
) -> Vec<String> {
|
||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||
terminal
|
||
.draw(|frame| pane.render_transcript(frame, frame.area(), state))
|
||
.unwrap();
|
||
let buffer = terminal.backend().buffer().clone();
|
||
(0..height)
|
||
.map(|y| {
|
||
(0..width)
|
||
.map(|x| buffer[(x, y)].symbol())
|
||
.collect::<String>()
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn row_growing_above_the_viewport_keeps_the_visual_anchor() {
|
||
let pane = test_pane();
|
||
let mut state = StructuredState::default();
|
||
state.follow_selection = false;
|
||
numbered_rows(&mut state, 20);
|
||
state.scroll = 20;
|
||
|
||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(before[0].contains("line 10"), "{before:?}");
|
||
|
||
grow_row(&mut state, "r3");
|
||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(
|
||
after[0].contains("line 10"),
|
||
"a row growing above the viewport must not shift what's on screen: {after:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn appending_a_row_below_the_viewport_keeps_the_visual_anchor() {
|
||
let pane = test_pane();
|
||
let mut state = StructuredState::default();
|
||
state.follow_selection = false;
|
||
numbered_rows(&mut state, 20);
|
||
state.scroll = 20;
|
||
|
||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(before[0].contains("line 10"), "{before:?}");
|
||
|
||
state.rows.push(row(
|
||
"r20",
|
||
TranscriptRowKind::AgentText,
|
||
json!({"content":{"type":"text","text":"line 20"}}),
|
||
));
|
||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert_eq!(before, after, "growth below the viewport must not move it");
|
||
}
|
||
|
||
#[test]
|
||
fn removing_a_row_above_the_viewport_keeps_the_visual_anchor() {
|
||
let pane = test_pane();
|
||
let mut state = StructuredState::default();
|
||
state.follow_selection = false;
|
||
numbered_rows(&mut state, 20);
|
||
state.scroll = 20;
|
||
|
||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(before[0].contains("line 10"), "{before:?}");
|
||
|
||
state.rows.retain(|r| r.id != "r3");
|
||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(
|
||
after[0].contains("line 10"),
|
||
"a row removed above the viewport must not shift what's on screen: {after:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn removing_the_anchor_row_falls_back_to_the_nearest_surviving_neighbor() {
|
||
let pane = test_pane();
|
||
let mut state = StructuredState::default();
|
||
state.follow_selection = false;
|
||
numbered_rows(&mut state, 20);
|
||
state.scroll = 20;
|
||
|
||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(before[0].contains("line 10"), "{before:?}");
|
||
|
||
state.rows.retain(|r| r.id != "r10");
|
||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(
|
||
after[0].contains("line 9"),
|
||
"removing the anchor row should fall back to its nearest surviving neighbor: {after:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn patching_a_row_above_the_viewport_keeps_the_visual_anchor() {
|
||
let pane = test_pane();
|
||
let mut state = StructuredState::default();
|
||
state.follow_selection = false;
|
||
numbered_rows(&mut state, 20);
|
||
state.scroll = 20;
|
||
|
||
render_transcript_lines(&pane, &mut state, 30, 5);
|
||
// Simulate a `TranscriptDelta::Patch` on a collapsed tool row above the
|
||
// viewport expanding into a multi-line diff.
|
||
let index = state.rows.iter().position(|r| r.id == "r5").unwrap();
|
||
state.rows[index] = row(
|
||
"r5",
|
||
TranscriptRowKind::ToolDiff,
|
||
json!({"title":"Edit(x)","content":[{"type":"diff","path":"x","oldText":"a","newText":"a\nb\nc\nd"}]}),
|
||
);
|
||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(
|
||
after[0].contains("line 10"),
|
||
"a patched row above the viewport must not shift what's on screen: {after:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn narrow_width_reconciles_without_panicking() {
|
||
let pane = test_pane();
|
||
let mut state = StructuredState::default();
|
||
state.follow_selection = false;
|
||
numbered_rows(&mut state, 20);
|
||
state.scroll = 10;
|
||
|
||
render_transcript_lines(&pane, &mut state, 1, 3);
|
||
grow_row(&mut state, "r3");
|
||
render_transcript_lines(&pane, &mut state, 1, 3);
|
||
assert!(state.scroll <= state.line_rows.len());
|
||
}
|
||
|
||
#[test]
|
||
fn follow_selection_without_a_selection_stays_pinned_to_the_live_edge() {
|
||
let pane = test_pane();
|
||
let mut state = StructuredState::default();
|
||
state.follow_selection = true;
|
||
state.selected = None;
|
||
numbered_rows(&mut state, 20);
|
||
|
||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(before.last().unwrap().contains("line 19"), "{before:?}");
|
||
|
||
state.rows.push(row(
|
||
"r20",
|
||
TranscriptRowKind::AgentText,
|
||
json!({"content":{"type":"text","text":"line 20"}}),
|
||
));
|
||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(
|
||
after.last().unwrap().contains("line 20"),
|
||
"bottom-follow must track the live edge as rows are appended: {after:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn follow_selection_keeps_the_selected_row_visible_when_a_row_above_it_grows() {
|
||
let pane = test_pane();
|
||
let mut state = StructuredState::default();
|
||
state.follow_selection = true;
|
||
numbered_rows(&mut state, 20);
|
||
state.selected = Some("r15".into());
|
||
|
||
let before = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(
|
||
before.iter().any(|line| line.contains("line 15")),
|
||
"{before:?}"
|
||
);
|
||
|
||
grow_row(&mut state, "r3");
|
||
let after = render_transcript_lines(&pane, &mut state, 30, 5);
|
||
assert!(
|
||
after.iter().any(|line| line.contains("line 15")),
|
||
"the selected row must stay visible while follow_selection is on: {after:?}"
|
||
);
|
||
}
|
||
}
|