Compare commits

...
10 changed files with 900 additions and 54 deletions
+120 -11
View File
@@ -41,8 +41,10 @@ use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use maestro_config::{builtin_presets, custom_preset};
use maestro_protocol::display::abbreviate_dir;
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
use maestro_protocol::display::{abbreviate_dir, command_tool};
use maestro_protocol::{
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
@@ -86,9 +88,11 @@ pub enum DraftField {
/// apply immediately, mirroring the web form's per-click choice.
WorktreeChoice,
Name,
/// Disclosure collapsing the worktree/branch controls below (#93).
/// Disclosure collapsing the optional view and worktree controls below.
/// Closed by default; Enter / Space toggles.
Advanced,
/// ACP-backed transcript instead of a terminal. Claude Code only.
StructuredToggle,
/// Worktree on/off for this session (#65). Enter / Space toggles.
WorktreeToggle,
/// New-vs-existing branch source; Left / Right switches.
@@ -251,6 +255,9 @@ pub struct DraftPane {
/// "Advanced options" disclosure (#93). Closed by default — most spawns
/// take the default target.
pub advanced_collapsed: bool,
/// ACP-backed structured transcript. Off by default and supported only
/// when the resolved command is Claude Code.
pub structured_view: bool,
/// Enclosing repository of the chosen target — from the project scan for
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
pub base_repo: Option<PathBuf>,
@@ -311,6 +318,7 @@ impl Default for DraftPane {
name_touched: false,
worktree_enabled: true,
advanced_collapsed: true,
structured_view: false,
base_repo: None,
probed_cwd: None,
probe_seq: 0,
@@ -348,6 +356,7 @@ impl DraftPane {
.position(|p| !p.command.is_empty())
.unwrap_or(0);
self.apply_seed_command();
self.clear_unsupported_structured_view();
}
/// Land a seeded command on the tile that runs it: its own preset when the
@@ -663,6 +672,22 @@ impl DraftPane {
}
}
fn supports_structured_view(&self) -> bool {
command_tool(self.command()) == Some("claude")
}
fn clear_unsupported_structured_view(&mut self) {
if !self.supports_structured_view() {
self.structured_view = false;
}
}
pub fn toggle_structured_view(&mut self) {
if self.supports_structured_view() {
self.structured_view = !self.structured_view;
}
}
/// The chosen working directory: a picked project row, else the raw path
/// input when it holds something.
pub fn cwd(&self) -> Option<PathBuf> {
@@ -694,7 +719,11 @@ impl DraftPane {
}
let worktree = self.build_worktree()?;
Ok(CreateSessionRequest {
view: Default::default(),
view: if self.structured_view && self.supports_structured_view() {
SessionView::Structured
} else {
SessionView::Terminal
},
cwd,
command: command.to_string(),
env: Default::default(),
@@ -754,6 +783,9 @@ impl DraftPane {
order.push(DraftField::Name);
order.push(DraftField::Advanced);
if !self.advanced_collapsed {
if self.supports_structured_view() {
order.push(DraftField::StructuredToggle);
}
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
order.push(DraftField::WorktreeChoice);
}
@@ -795,6 +827,7 @@ impl DraftPane {
}
self.selected_preset =
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
self.clear_unsupported_structured_view();
}
/// Step-one boxes surviving the type-to-filter, in display order. A
@@ -1001,9 +1034,9 @@ impl DraftPane {
// Two fields keep their own Enter instead: `DirPicker`, whose
// highlighted row isn't the picked one until a first Enter chooses
// it (the next Enter, now that it's active, falls through to
// submit); and the disclosure/toggle pair (`Advanced`,
// `WorktreeToggle`), which activate on Enter like a button rather
// than submitting, same as the web form's own `<button>` controls.
// submit); and the disclosure/checkbox fields, which activate on
// Enter like buttons rather than submitting, matching the web
// form controls.
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
let is_other_row = self.selection() >= self.visible_groups().len();
let choose_only =
@@ -1023,6 +1056,10 @@ impl DraftPane {
self.toggle_worktree();
DraftInputResult::Consumed
}
InputKey::Enter if self.focused_field == DraftField::StructuredToggle => {
self.toggle_structured_view();
DraftInputResult::Consumed
}
InputKey::Enter => DraftInputResult::Submit,
InputKey::Char {
c: ' ',
@@ -1040,6 +1077,14 @@ impl DraftPane {
self.toggle_worktree();
DraftInputResult::Consumed
}
InputKey::Char {
c: ' ',
ctrl: false,
..
} if self.focused_field == DraftField::StructuredToggle => {
self.toggle_structured_view();
DraftInputResult::Consumed
}
InputKey::Up if self.focused_field == DraftField::DirPicker => {
self.move_selection(-1);
DraftInputResult::Consumed
@@ -1083,7 +1128,10 @@ impl DraftPane {
other => {
match self.focused_field {
DraftField::Preset | DraftField::Submit => {}
DraftField::CustomCommand => self.custom_command.handle_key(other),
DraftField::CustomCommand => {
self.custom_command.handle_key(other);
self.clear_unsupported_structured_view();
}
// Typing filters the list; the highlight resets to the top
// so a narrowed search always starts at its best match.
DraftField::DirPicker => {
@@ -1121,9 +1169,9 @@ impl DraftPane {
let show_custom = self.is_custom();
let show_raw = self.cwd_raw_visible;
// The worktree choice step, the worktree toggle and its body all stay
// behind the "Advanced options" disclosure (#93): collapsed hides
// them, regardless of what they'd otherwise show.
let show_structured = !self.advanced_collapsed && self.supports_structured_view();
// Optional view and worktree controls stay behind the "Advanced
// options" disclosure (#93), regardless of what they'd otherwise show.
let show_wt_toggle = !self.advanced_collapsed;
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
let show_wt_choice =
@@ -1142,6 +1190,7 @@ impl DraftPane {
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
+ u16::from(show_custom)
+ u16::from(show_raw)
+ u16::from(show_structured)
+ u16::from(show_wt_toggle)
+ u16::from(show_wt_choice) * wt_choice_capacity
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
@@ -1161,6 +1210,9 @@ impl DraftPane {
}
constraints.push(Constraint::Length(1)); // name
constraints.push(Constraint::Length(1)); // advanced header
if show_structured {
constraints.push(Constraint::Length(1));
}
if show_wt_choice {
constraints.push(Constraint::Length(wt_choice_capacity));
}
@@ -1359,6 +1411,24 @@ impl DraftPane {
advanced_row,
);
if show_structured {
let row = next_row();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(format!("{:<GUTTER$}", "view:")),
Span::styled(
if self.structured_view {
"[x] structured view"
} else {
"[ ] structured view"
},
field_style(DraftField::StructuredToggle),
),
])),
row,
);
}
if show_wt_choice {
// Step two (#96 rule 3): "+ new" first, then the repository's
// worktrees newest-first.
@@ -1817,6 +1887,45 @@ mod tests {
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
}
#[test]
fn structured_view_is_default_off_and_only_visible_for_claude_code() {
let mut draft = DraftPane {
advanced_collapsed: false,
..DraftPane::default()
};
assert!(!draft.structured_view);
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(rendered.contains("[ ] structured view"), "{rendered}");
draft.toggle_structured_view();
assert!(draft.structured_view);
draft.cycle_preset(1);
assert_eq!(draft.command(), "opencode");
assert!(
!draft.structured_view,
"a hidden value must not stay checked"
);
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
assert!(!rendered.contains("structured view"), "{rendered}");
}
#[test]
fn structured_view_request_is_guarded_against_a_stale_command() {
let mut draft = DraftPane {
picked_cwd: Some(PathBuf::from("/srv/work")),
worktree_enabled: false,
structured_view: true,
..DraftPane::default()
};
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
draft.selected_preset = 1;
assert_eq!(draft.command(), "opencode");
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
}
#[test]
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
let mut draft = DraftPane {
+467 -7
View File
@@ -14,6 +14,8 @@ 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 {
@@ -322,8 +324,8 @@ fn tool_lines(
},
});
let content = tool_content_lines(row);
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);
@@ -340,6 +342,7 @@ fn tool_lines(
);
}
} 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);
}
@@ -448,7 +451,7 @@ fn tool_title(row: &TranscriptRow) -> (String, String) {
(explicit.unwrap_or(fallback).to_string(), title.to_string())
}
fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
fn tool_content_lines(row: &TranscriptRow, limit: Option<usize>) -> Vec<String> {
let mut out = Vec::new();
for block in row
.data
@@ -457,6 +460,9 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
.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();
@@ -466,7 +472,8 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
data: serde_json::json!({"content": nested}),
};
let text = content_text(&fake);
out.extend(text.lines().map(str::to_string));
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
@@ -474,11 +481,10 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
.and_then(serde_json::Value::as_str)
.unwrap_or("file");
out.push(format!("{path}:"));
if let Some(old) = block.get("oldText").and_then(serde_json::Value::as_str) {
out.extend(old.lines().map(|line| format!("- {line}")));
}
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) {
out.extend(new.lines().map(|line| format!("+ {line}")));
let remaining = limit.map(|limit| limit.saturating_sub(out.len()));
out.extend(render_diff(old, new, remaining));
}
}
Some("terminal") => out.push("[embedded terminal]".into()),
@@ -488,6 +494,236 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
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
@@ -722,6 +958,230 @@ mod tests {
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();
+17
View File
@@ -6,6 +6,7 @@ const base: SpawnFormValues = {
command: "claude",
cwd: "/home/user/project",
name: "amber-fox",
structuredView: false,
worktreeEnabled: false,
worktreeBranch: "",
worktreeBranchMode: "new",
@@ -25,6 +26,22 @@ describe("buildSpawnRequest", () => {
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
});
it("requests structured view only for Claude Code", () => {
expect(buildSpawnRequest({ ...base, structuredView: true }, null)).toEqual({
req: {
cwd: base.cwd,
command: "claude",
metadata: { name: "amber-fox" },
view: "Structured",
},
});
expect(
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
).toEqual({
req: { cwd: base.cwd, command: "opencode", metadata: { name: "amber-fox" } },
});
});
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
const res = buildSpawnRequest(
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
+28 -3
View File
@@ -11,6 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { parentDir } from "./paths.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js";
import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
@@ -40,6 +41,7 @@ export interface SpawnFormValues {
command: string;
cwd: string;
name: string;
structuredView: boolean;
worktreeEnabled: boolean;
worktreeBranch: string;
worktreeBranchEdited: boolean;
@@ -66,6 +68,10 @@ export function buildSpawnRequest(
metadata,
};
if (values.structuredView && splitCommand(values.command).tool === "claude") {
req.view = "Structured";
}
if (values.worktreeEnabled) {
const mode = values.worktreeBranchMode ?? "new";
const branch = values.worktreeBranch.trim();
@@ -153,6 +159,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
<span class="wt-summary"></span>
</button>
<div class="wt-collapsible" hidden>
<label class="row-inline structured-view" hidden>
<input type="checkbox" name="structured_view" />
Structured view
</label>
<div class="field wt-step" hidden>
<span class="field-label">Worktree</span>
<div class="picker wt-choices"></div>
@@ -203,6 +213,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
const structuredWrap = form.querySelector(".structured-view") as HTMLElement;
const structuredView = form.querySelector("input[name=structured_view]") as HTMLInputElement;
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
@@ -248,6 +260,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
command,
cwd,
name: nameInput.value,
structuredView: structuredView.checked,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
@@ -317,6 +330,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
} else {
command = preset!.command;
}
updateStructuredAvailability();
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
}
@@ -354,10 +368,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// operator has already touched (#76).
let commandTouched = false;
function updateStructuredAvailability(): void {
const supported = splitCommand(command).tool === "claude";
structuredWrap.hidden = !supported;
if (!supported) structuredView.checked = false;
}
structuredView.addEventListener("change", emit);
cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
commandTouched = true;
command = cmdRaw.value.trim();
updateStructuredAvailability();
emit();
}
});
@@ -844,10 +867,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
if (e.key !== "Enter" || e.defaultPrevented) return;
const target = e.target as HTMLElement;
if (target === wtEnabled) {
if (target === wtEnabled || target === structuredView) {
e.preventDefault();
wtEnabled.checked = !wtEnabled.checked;
wtEnabled.dispatchEvent(new Event("change"));
const checkbox = target as HTMLInputElement;
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event("change"));
return;
}
@@ -885,6 +909,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
command,
cwd,
name: nameInput.value,
structuredView: structuredView.checked,
worktreeEnabled: wtEnabled.checked,
worktreeBranch: branchValue(),
worktreeBranchEdited: branchEdited,
+108
View File
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
function stubApi(): RequestInit[] {
const sessionRequests: RequestInit[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes("/api/sessions") && init?.method === "POST") {
sessionRequests.push(init);
return new Response(JSON.stringify({ id: "s1", command: "claude", cwd: "/srv/work" }), {
status: 200,
});
}
if (url.includes("/api/info")) {
return new Response(
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user" }),
{ status: 200 },
);
}
if (url.includes("/api/names")) {
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
}
return new Response(JSON.stringify([]), { status: 200 });
}),
);
return sessionRequests;
}
describe("spawn form structured view", () => {
let handle: SpawnFormHandle | null = null;
async function drain(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
afterEach(async () => {
handle?.destroy();
handle = null;
await drain();
vi.unstubAllGlobals();
});
it("is default-off and shown only for the resolved Claude Code command", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
const wrap = handle.el.querySelector<HTMLElement>(".structured-view")!;
const checkbox = wrap.querySelector<HTMLInputElement>("input")!;
const disclosure = handle.el.querySelector<HTMLElement>(".wt-collapsible")!;
expect(disclosure.hidden).toBe(true);
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
expect(disclosure.hidden).toBe(false);
expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(false);
handle.el.querySelector<HTMLButtonElement>('[data-id="opencode"]')!.click();
expect(wrap.hidden).toBe(true);
handle.el.querySelector<HTMLButtonElement>('[data-id="claude"]')!.click();
expect(wrap.hidden).toBe(false);
});
it("clears a checked value when the command changes away from Claude Code", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
checkbox.click();
expect(checkbox.checked).toBe(true);
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "opencode";
command.dispatchEvent(new Event("input"));
expect(checkbox.closest<HTMLElement>(".structured-view")!.hidden).toBe(true);
expect(checkbox.checked).toBe(false);
});
it("emits Structured in the submitted request when selected", async () => {
const requests = stubApi();
handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
onSuccess() {},
});
document.body.appendChild(handle.el);
await drain();
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
handle.el.requestSubmit();
await drain();
expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body))).toMatchObject({
command: "claude",
view: "Structured",
});
});
});
+86 -2
View File
@@ -42,6 +42,61 @@ describe("computeDiffLines", () => {
]);
});
it("handles an insertion without changing surrounding lines", () => {
expect(texts(computeDiffLines("a\nc\n", "a\nb\nc\n"))).toEqual([
["ctx", "a"],
["add", "b"],
["ctx", "c"],
]);
});
it("handles a deletion without changing surrounding lines", () => {
expect(texts(computeDiffLines("a\nb\nc\n", "a\nc\n"))).toEqual([
["ctx", "a"],
["del", "b"],
["ctx", "c"],
]);
});
it("renders two empty files without invented lines", () => {
expect(computeDiffLines("", "")).toEqual([]);
});
it("shows when a final newline is added", () => {
expect(texts(computeDiffLines("a", "a\n"))).toEqual([
["del", "a"],
["eof", "\\ No newline at end of file"],
["add", "a"],
]);
});
it("shows when a final newline is removed", () => {
expect(texts(computeDiffLines("a\n", "a"))).toEqual([
["del", "a"],
["add", "a"],
["eof", "\\ No newline at end of file"],
]);
});
it("marks an unchanged unterminated final context line", () => {
expect(texts(computeDiffLines("a\nb\nc", "a\nX\nc"))).toEqual([
["ctx", "a"],
["del", "b"],
["add", "X"],
["ctx", "c"],
["eof", "\\ No newline at end of file"],
]);
});
it("marks an unterminated new file only once", () => {
const lines = computeDiffLines(null, "a");
expect(texts(lines)).toEqual([
["add", "a"],
["eof", "\\ No newline at end of file"],
]);
expect(lines.filter((line) => line.kind === "eof")).toHaveLength(1);
});
it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => {
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
@@ -59,6 +114,22 @@ describe("computeDiffLines", () => {
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2);
});
it("preserves the EOF marker when a long unterminated trailing run collapses", () => {
const trailing = Array.from({ length: 10 }, (_, i) => `t${i}`);
const oldText = ["OLD", ...trailing].join("\n");
const newText = ["NEW", ...trailing].join("\n");
expect(texts(computeDiffLines(oldText, newText))).toEqual([
["del", "OLD"],
["add", "NEW"],
["ctx", "t0"],
["ctx", "t1"],
["ctx", "t2"],
["hunk", "⋯ 7 unchanged lines ⋯"],
["eof", "\\ No newline at end of file"],
]);
});
it("produces one hunk for the long middle run, none for the short edges", () => {
// Edge runs (2 lines) fit within their one-sided 3-line context budget;
// only the interior run (10 lines, budgeted 3+3=6) needs to collapse.
@@ -75,9 +146,10 @@ describe("computeDiffLines", () => {
["ctx", "lead0"],
["ctx", "lead1"],
]);
expect(texts(lines).slice(-2)).toEqual([
expect(texts(lines).slice(-3)).toEqual([
["ctx", "trail0"],
["ctx", "trail1"],
["eof", "\\ No newline at end of file"],
]);
});
@@ -85,10 +157,22 @@ describe("computeDiffLines", () => {
const lines = computeDiffLines("a\nb", "a\nb\nc");
expect(texts(lines)).toEqual([
["ctx", "a"],
["ctx", "b"],
["del", "b"],
["eof", "\\ No newline at end of file"],
["add", "b"],
["add", "c"],
["eof", "\\ No newline at end of file"],
]);
});
it("falls back to a bounded coarse diff for a pathological changed middle", () => {
const oldLines = Array.from({ length: 501 }, (_, index) => `line${index}`);
const newLines = [...oldLines.slice(1), oldLines[0]];
const lines = computeDiffLines(`${oldLines.join("\n")}\n`, `${newLines.join("\n")}\n`);
expect(lines).toHaveLength(1_002);
expect(lines.slice(0, 501).every((line) => line.kind === "del")).toBe(true);
expect(lines.slice(501).every((line) => line.kind === "add")).toBe(true);
});
});
describe("diffStats", () => {
+57 -31
View File
@@ -2,7 +2,7 @@
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
// coloured hunks the design calls for have to be computed here.
export type DiffLineKind = "add" | "del" | "ctx" | "hunk";
export type DiffLineKind = "add" | "del" | "ctx" | "hunk" | "eof";
export interface DiffLine {
kind: DiffLineKind;
@@ -19,66 +19,82 @@ const CONTEXT = 3;
* still correct, just not minimal. */
const LCS_CELL_LIMIT = 250_000;
function splitLines(text: string): string[] {
if (text === "") return [];
const body = text.endsWith("\n") ? text.slice(0, -1) : text;
return body.split("\n");
interface SourceLine {
text: string;
terminated: boolean;
}
type Op = { kind: "add" | "del" | "ctx"; text: string };
function splitLines(text: string): SourceLine[] {
if (text === "") return [];
const parts = text.split("\n");
const hasFinalNewline = text.endsWith("\n");
if (hasFinalNewline) parts.pop();
return parts.map((line, index) => ({
text: line,
terminated: index < parts.length - 1 || hasFinalNewline,
}));
}
type Op = { kind: "add" | "del" | "ctx"; line: SourceLine };
function sameLine(a: SourceLine, b: SourceLine): boolean {
return a.text === b.text && a.terminated === b.terminated;
}
/** Longest-common-subsequence diff of two line arrays, already trimmed of any
* shared prefix/suffix by the caller. */
function lcsDiff(a: string[], b: string[]): Op[] {
function lcsDiff(a: SourceLine[], b: SourceLine[]): Op[] {
const n = a.length;
const m = b.length;
if (n === 0) return b.map((text) => ({ kind: "add", text }));
if (m === 0) return a.map((text) => ({ kind: "del", text }));
if (n === 0) return b.map((line) => ({ kind: "add", line }));
if (m === 0) return a.map((line) => ({ kind: "del", line }));
if (n * m > LCS_CELL_LIMIT) {
return [...a.map((text) => ({ kind: "del" as const, text })), ...b.map((text) => ({ kind: "add" as const, text }))];
return [...a.map((line) => ({ kind: "del" as const, line })), ...b.map((line) => ({ kind: "add" as const, line }))];
}
// dp[i][j] = length of the LCS of a[i:] and b[j:].
const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
const columns = m + 1;
const dp = new Uint32Array((n + 1) * columns);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] =
a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
dp[i * columns + j] = sameLine(a[i], b[j])
? dp[(i + 1) * columns + j + 1] + 1
: Math.max(dp[(i + 1) * columns + j], dp[i * columns + j + 1]);
}
}
const ops: Op[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
ops.push({ kind: "ctx", text: a[i] });
if (sameLine(a[i], b[j])) {
ops.push({ kind: "ctx", line: a[i] });
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
ops.push({ kind: "del", text: a[i] });
} else if (dp[(i + 1) * columns + j] >= dp[i * columns + j + 1]) {
ops.push({ kind: "del", line: a[i] });
i++;
} else {
ops.push({ kind: "add", text: b[j] });
ops.push({ kind: "add", line: b[j] });
j++;
}
}
while (i < n) ops.push({ kind: "del", text: a[i++] });
while (j < m) ops.push({ kind: "add", text: b[j++] });
while (i < n) ops.push({ kind: "del", line: a[i++] });
while (j < m) ops.push({ kind: "add", line: b[j++] });
return ops;
}
function diffLines(a: string[], b: string[]): Op[] {
function diffLines(a: SourceLine[], b: SourceLine[]): Op[] {
let pre = 0;
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
while (pre < a.length && pre < b.length && sameLine(a[pre], b[pre])) pre++;
let suf = 0;
while (
suf < a.length - pre &&
suf < b.length - pre &&
a[a.length - 1 - suf] === b[b.length - 1 - suf]
sameLine(a[a.length - 1 - suf], b[b.length - 1 - suf])
) {
suf++;
}
const prefixCtx: Op[] = a.slice(0, pre).map((text) => ({ kind: "ctx", text }));
const suffixCtx: Op[] = a.slice(a.length - suf).map((text) => ({ kind: "ctx", text }));
const prefixCtx: Op[] = a.slice(0, pre).map((line) => ({ kind: "ctx", line }));
const suffixCtx: Op[] = a.slice(a.length - suf).map((line) => ({ kind: "ctx", line }));
const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
return [...prefixCtx, ...middle, ...suffixCtx];
}
@@ -91,10 +107,16 @@ function diffLines(a: string[], b: string[]): Op[] {
* (unless it is the *only* run, i.e. nothing changed at all). */
function hunk(ops: Op[]): DiffLine[] {
const out: DiffLine[] = [];
const push = (op: Op) => {
out.push({ kind: op.kind, text: op.line.text });
if (!op.line.terminated) {
out.push({ kind: "eof", text: "\\ No newline at end of file" });
}
};
let i = 0;
while (i < ops.length) {
if (ops[i].kind !== "ctx") {
out.push(ops[i]);
push(ops[i]);
i++;
continue;
}
@@ -105,14 +127,18 @@ function hunk(ops: Op[]): DiffLine[] {
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
if (run.length <= keepHead + keepTail) {
out.push(...run);
run.forEach(push);
} else {
if (keepHead) out.push(...run.slice(0, keepHead));
if (keepHead) run.slice(0, keepHead).forEach(push);
const collapsed = run.slice(keepHead, run.length - keepTail);
out.push({
kind: "hunk",
text: `⋯ ${run.length - keepHead - keepTail} unchanged lines ⋯`,
text: `⋯ ${collapsed.length} unchanged lines ⋯`,
});
if (keepTail) out.push(...run.slice(run.length - keepTail));
if (collapsed.some((op) => !op.line.terminated)) {
out.push({ kind: "eof", text: "\\ No newline at end of file" });
}
if (keepTail) run.slice(run.length - keepTail).forEach(push);
}
i = j;
}
@@ -123,7 +149,7 @@ function hunk(ops: Op[]): DiffLine[] {
* (a new file) renders every line of `newText` as added. */
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
const newLines = splitLines(newText);
if (oldText == null) return newLines.map((text) => ({ kind: "add", text }));
if (oldText == null) return hunk(newLines.map((line) => ({ kind: "add", line })));
const ops = diffLines(splitLines(oldText), newLines);
return hunk(ops);
}
+11
View File
@@ -157,6 +157,17 @@ describe("buildTranscriptRow: tool diffs", () => {
// stylesheet keys `overflow-x: auto` off, not a computed style.
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
});
it("renders a visible marker for a removed final newline", () => {
const el = buildTranscriptRow(
row("tool_diff", {
title: "Edit(x.rs)",
content: [{ type: "diff", path: "/repo/x.rs", oldText: "same\n", newText: "same" }],
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-ln-eof")!.textContent).toBe("\\ No newline at end of file");
});
});
describe("buildTranscriptRow: approval", () => {
+5
View File
@@ -2509,6 +2509,11 @@ input.leaf-rename:focus {
background: var(--surface-2);
}
.st-ln-eof {
color: var(--faint);
font-style: italic;
}
/* the one lifted, card-like element on the surface: the only thing the
operator owes a decision on */
.st-approval {
+1
View File
@@ -276,6 +276,7 @@ export interface CreateSessionRequest {
env?: Record<string, string>;
metadata?: Record<string, unknown>;
worktree?: WorktreeSpec;
view?: SessionView;
}
export type CatalogEvent =