This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-tui/src/structured_pane.rs
T

1654 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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:?}"
);
}
}