Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eafeac5c05 | ||
|
|
f96f94db8f | ||
|
|
efb54750ed | ||
|
|
9e59121eec | ||
|
|
4a5c3b9982 | ||
|
|
443800bd79 | ||
|
|
d55d92d58a | ||
|
|
41770a54a7 | ||
|
|
6cf0b200f8 |
@@ -477,7 +477,7 @@ impl App {
|
|||||||
.state
|
.state
|
||||||
.lock()
|
.lock()
|
||||||
.expect("structured state mutex")
|
.expect("structured state mutex")
|
||||||
.approval_failed(error.to_string());
|
.approval_failed(&nonce, error.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use crate::session_task::SessionState;
|
|||||||
use crate::structured_task::{offered_decisions, StructuredHandle, StructuredState};
|
use crate::structured_task::{offered_decisions, StructuredHandle, StructuredState};
|
||||||
|
|
||||||
const DIFF_PREVIEW_LINES: usize = 3;
|
const DIFF_PREVIEW_LINES: usize = 3;
|
||||||
|
const DIFF_CONTEXT_LINES: usize = 3;
|
||||||
|
const LCS_CELL_LIMIT: usize = 250_000;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct StructuredPane {
|
pub struct StructuredPane {
|
||||||
@@ -322,8 +324,8 @@ fn tool_lines(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let content = tool_content_lines(row);
|
|
||||||
if row.kind == TranscriptRowKind::ToolDiff && !expanded {
|
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);
|
let shown = content.len().min(DIFF_PREVIEW_LINES);
|
||||||
for text in content.iter().take(shown) {
|
for text in content.iter().take(shown) {
|
||||||
push_wrapped(out, row, " ", text.clone(), width, diff_tone(text), false);
|
push_wrapped(out, row, " ", text.clone(), width, diff_tone(text), false);
|
||||||
@@ -340,6 +342,7 @@ fn tool_lines(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if expanded {
|
} else if expanded {
|
||||||
|
let content = tool_content_lines(row, None);
|
||||||
for text in content {
|
for text in content {
|
||||||
push_wrapped(out, row, " ", text.clone(), width, diff_tone(&text), false);
|
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())
|
(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();
|
let mut out = Vec::new();
|
||||||
for block in row
|
for block in row
|
||||||
.data
|
.data
|
||||||
@@ -457,6 +460,9 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
{
|
{
|
||||||
|
if limit.is_some_and(|limit| out.len() >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
match block.get("type").and_then(serde_json::Value::as_str) {
|
match block.get("type").and_then(serde_json::Value::as_str) {
|
||||||
Some("content") => {
|
Some("content") => {
|
||||||
let nested = block.get("content").cloned().unwrap_or_default();
|
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}),
|
data: serde_json::json!({"content": nested}),
|
||||||
};
|
};
|
||||||
let text = content_text(&fake);
|
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") => {
|
Some("diff") => {
|
||||||
let path = block
|
let path = block
|
||||||
@@ -474,11 +481,10 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("file");
|
.unwrap_or("file");
|
||||||
out.push(format!("{path}:"));
|
out.push(format!("{path}:"));
|
||||||
if let Some(old) = block.get("oldText").and_then(serde_json::Value::as_str) {
|
let old = block.get("oldText").and_then(serde_json::Value::as_str);
|
||||||
out.extend(old.lines().map(|line| format!("- {line}")));
|
|
||||||
}
|
|
||||||
if let Some(new) = block.get("newText").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()),
|
Some("terminal") => out.push("[embedded terminal]".into()),
|
||||||
@@ -488,6 +494,236 @@ fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
|
|||||||
out
|
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 {
|
fn diff_tone(text: &str) -> Tone {
|
||||||
if text.starts_with("+ ") {
|
if text.starts_with("+ ") {
|
||||||
Tone::Active
|
Tone::Active
|
||||||
@@ -722,6 +958,230 @@ mod tests {
|
|||||||
assert!(lines.last().unwrap().text.contains("Enter to expand"));
|
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]
|
#[test]
|
||||||
fn plan_is_not_a_transcript_row_and_status_is_two_lines() {
|
fn plan_is_not_a_transcript_row_and_status_is_two_lines() {
|
||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
|
|||||||
@@ -289,7 +289,10 @@ impl StructuredState {
|
|||||||
Some((nonce, decision))
|
Some((nonce, decision))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn approval_failed(&mut self, error: String) {
|
pub fn approval_failed(&mut self, nonce: &str, error: String) {
|
||||||
|
if self.pending_nonce() != Some(nonce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.approval_submitting = false;
|
self.approval_submitting = false;
|
||||||
self.error = Some(error);
|
self.error = Some(error);
|
||||||
}
|
}
|
||||||
@@ -554,6 +557,81 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_approval_ignores_its_late_failure() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.rows.push(row(
|
||||||
|
"approval:1",
|
||||||
|
TranscriptRowKind::Approval,
|
||||||
|
json!({"options":[{"kind":"allow_once"}]}),
|
||||||
|
));
|
||||||
|
state.selected = Some("approval:1".into());
|
||||||
|
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
||||||
|
assert!(state.approval_decision('a').is_some());
|
||||||
|
|
||||||
|
state.apply(StructuredEvent::ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: ReducedState::default(),
|
||||||
|
});
|
||||||
|
state.approval_failed("old", "response lost".into());
|
||||||
|
|
||||||
|
assert_eq!(state.error, None);
|
||||||
|
assert!(!state.approval_submitting);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn superseded_approval_ignores_its_late_failure() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.rows.push(row(
|
||||||
|
"approval:1",
|
||||||
|
TranscriptRowKind::Approval,
|
||||||
|
json!({"options":[{"kind":"allow_once"}]}),
|
||||||
|
));
|
||||||
|
state.selected = Some("approval:1".into());
|
||||||
|
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
||||||
|
assert!(state.approval_decision('a').is_some());
|
||||||
|
|
||||||
|
state.rows.push(row(
|
||||||
|
"approval:2",
|
||||||
|
TranscriptRowKind::Approval,
|
||||||
|
json!({"options":[{"kind":"allow_once"}]}),
|
||||||
|
));
|
||||||
|
state.apply(StructuredEvent::ProjectionUpdate {
|
||||||
|
transcript: None,
|
||||||
|
state: ReducedState {
|
||||||
|
pending_approval: Some(json!({"nonce":"new"})),
|
||||||
|
..ReducedState::default()
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
state.approval_decision('a'),
|
||||||
|
Some(("new".into(), StructuredApprovalDecision::AllowOnce))
|
||||||
|
);
|
||||||
|
|
||||||
|
state.approval_failed("old", "response lost".into());
|
||||||
|
|
||||||
|
assert_eq!(state.error, None);
|
||||||
|
assert!(state.approval_submitting);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_approval_surfaces_its_failure() {
|
||||||
|
let mut state = StructuredState::default();
|
||||||
|
state.rows.push(row(
|
||||||
|
"approval:1",
|
||||||
|
TranscriptRowKind::Approval,
|
||||||
|
json!({"options":[{"kind":"allow_once"}]}),
|
||||||
|
));
|
||||||
|
state.selected = Some("approval:1".into());
|
||||||
|
state.reduced.pending_approval = Some(json!({"nonce":"current"}));
|
||||||
|
assert!(state.approval_decision('a').is_some());
|
||||||
|
|
||||||
|
state.approval_failed("current", "request failed".into());
|
||||||
|
|
||||||
|
assert_eq!(state.error.as_deref(), Some("request failed"));
|
||||||
|
assert!(!state.approval_submitting);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn composer_is_gated_by_the_projected_turn_lifecycle() {
|
fn composer_is_gated_by_the_projected_turn_lifecycle() {
|
||||||
let mut state = StructuredState::default();
|
let mut state = StructuredState::default();
|
||||||
|
|||||||
@@ -115,6 +115,113 @@ describe("structured connection: data flow", () => {
|
|||||||
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
|
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
|
||||||
conn.dispose();
|
conn.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [
|
||||||
|
row("tool:1", "tool_call", {
|
||||||
|
title: "Bash(pnpm test)",
|
||||||
|
status: "in_progress",
|
||||||
|
content: [{ type: "content", content: { type: "text", text: "starting" } }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
|
||||||
|
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||||
|
originalHead.click();
|
||||||
|
expect(originalHead.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
const clickListener = vi.fn();
|
||||||
|
window.addEventListener("click", clickListener, { capture: true, once: true });
|
||||||
|
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "patch",
|
||||||
|
row: row("tool:1", "tool_call", {
|
||||||
|
title: "Bash(pnpm test)",
|
||||||
|
status: "in_progress",
|
||||||
|
content: [{ type: "content", content: { type: "text", text: "still running" } }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clickListener).not.toHaveBeenCalled();
|
||||||
|
window.removeEventListener("click", clickListener, true);
|
||||||
|
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||||
|
expect(patchedHead).not.toBe(originalHead);
|
||||||
|
expect(patchedHead.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(conn.el.querySelector<HTMLElement>(".st-tool-out")!.hidden).toBe(false);
|
||||||
|
expect(conn.el.textContent).toContain("still running");
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores focus to a patched tool header with the same stable row id", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||||
|
originalHead.focus();
|
||||||
|
expect(document.activeElement).toBe(originalHead);
|
||||||
|
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "patch",
|
||||||
|
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||||
|
expect(patchedHead).not.toBe(originalHead);
|
||||||
|
expect(document.activeElement).toBe(patchedHead);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not move unrelated focus when a tool row is patched", () => {
|
||||||
|
const ws = new FakeSocket();
|
||||||
|
const conn = mount({ sockets: [ws] });
|
||||||
|
ws.open();
|
||||||
|
ws.emit({
|
||||||
|
type: "transcript_snapshot",
|
||||||
|
snapshot: {
|
||||||
|
cursor: 1,
|
||||||
|
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ws.emit({ type: "reduced_state", state: idleState });
|
||||||
|
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
ws.emit({
|
||||||
|
type: "projection_update",
|
||||||
|
transcript: {
|
||||||
|
type: "patch",
|
||||||
|
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
|
||||||
|
},
|
||||||
|
state: idleState,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
conn.dispose();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("structured connection: turn/composer gating", () => {
|
describe("structured connection: turn/composer gating", () => {
|
||||||
|
|||||||
@@ -159,10 +159,15 @@ export function createStructuredConnection(
|
|||||||
|
|
||||||
function remountRow(row: TranscriptRow): void {
|
function remountRow(row: TranscriptRow): void {
|
||||||
const old = rowEls.get(row.id);
|
const old = rowEls.get(row.id);
|
||||||
const el = buildTranscriptRow(row, approvalStateFor(row));
|
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
|
||||||
|
const wasExpanded = oldToolHead?.getAttribute("aria-expanded") === "true";
|
||||||
|
const restoreFocus = document.activeElement === oldToolHead;
|
||||||
|
const el = buildTranscriptRow(row, approvalStateFor(row), wasExpanded);
|
||||||
rowEls.set(row.id, el);
|
rowEls.set(row.id, el);
|
||||||
if (old) old.replaceWith(el);
|
if (old) old.replaceWith(el);
|
||||||
else rowsEl.appendChild(el);
|
else rowsEl.appendChild(el);
|
||||||
|
const newToolHead = el.querySelector<HTMLButtonElement>(".st-tool-head");
|
||||||
|
if (restoreFocus) newToolHead?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
||||||
|
|||||||
@@ -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", () => {
|
it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => {
|
||||||
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
||||||
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
|
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);
|
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", () => {
|
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;
|
// 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.
|
// only the interior run (10 lines, budgeted 3+3=6) needs to collapse.
|
||||||
@@ -75,9 +146,10 @@ describe("computeDiffLines", () => {
|
|||||||
["ctx", "lead0"],
|
["ctx", "lead0"],
|
||||||
["ctx", "lead1"],
|
["ctx", "lead1"],
|
||||||
]);
|
]);
|
||||||
expect(texts(lines).slice(-2)).toEqual([
|
expect(texts(lines).slice(-3)).toEqual([
|
||||||
["ctx", "trail0"],
|
["ctx", "trail0"],
|
||||||
["ctx", "trail1"],
|
["ctx", "trail1"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,10 +157,22 @@ describe("computeDiffLines", () => {
|
|||||||
const lines = computeDiffLines("a\nb", "a\nb\nc");
|
const lines = computeDiffLines("a\nb", "a\nb\nc");
|
||||||
expect(texts(lines)).toEqual([
|
expect(texts(lines)).toEqual([
|
||||||
["ctx", "a"],
|
["ctx", "a"],
|
||||||
["ctx", "b"],
|
["del", "b"],
|
||||||
|
["eof", "\\ No newline at end of file"],
|
||||||
|
["add", "b"],
|
||||||
["add", "c"],
|
["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", () => {
|
describe("diffStats", () => {
|
||||||
|
|||||||
+57
-31
@@ -2,7 +2,7 @@
|
|||||||
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
|
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
|
||||||
// coloured hunks the design calls for have to be computed here.
|
// 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 {
|
export interface DiffLine {
|
||||||
kind: DiffLineKind;
|
kind: DiffLineKind;
|
||||||
@@ -19,66 +19,82 @@ const CONTEXT = 3;
|
|||||||
* still correct, just not minimal. */
|
* still correct, just not minimal. */
|
||||||
const LCS_CELL_LIMIT = 250_000;
|
const LCS_CELL_LIMIT = 250_000;
|
||||||
|
|
||||||
function splitLines(text: string): string[] {
|
interface SourceLine {
|
||||||
if (text === "") return [];
|
text: string;
|
||||||
const body = text.endsWith("\n") ? text.slice(0, -1) : text;
|
terminated: boolean;
|
||||||
return body.split("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
/** Longest-common-subsequence diff of two line arrays, already trimmed of any
|
||||||
* shared prefix/suffix by the caller. */
|
* 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 n = a.length;
|
||||||
const m = b.length;
|
const m = b.length;
|
||||||
if (n === 0) return b.map((text) => ({ kind: "add", text }));
|
if (n === 0) return b.map((line) => ({ kind: "add", line }));
|
||||||
if (m === 0) return a.map((text) => ({ kind: "del", text }));
|
if (m === 0) return a.map((line) => ({ kind: "del", line }));
|
||||||
if (n * m > LCS_CELL_LIMIT) {
|
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:].
|
// 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 i = n - 1; i >= 0; i--) {
|
||||||
for (let j = m - 1; j >= 0; j--) {
|
for (let j = m - 1; j >= 0; j--) {
|
||||||
dp[i][j] =
|
dp[i * columns + j] = sameLine(a[i], b[j])
|
||||||
a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
? dp[(i + 1) * columns + j + 1] + 1
|
||||||
|
: Math.max(dp[(i + 1) * columns + j], dp[i * columns + j + 1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ops: Op[] = [];
|
const ops: Op[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
let j = 0;
|
let j = 0;
|
||||||
while (i < n && j < m) {
|
while (i < n && j < m) {
|
||||||
if (a[i] === b[j]) {
|
if (sameLine(a[i], b[j])) {
|
||||||
ops.push({ kind: "ctx", text: a[i] });
|
ops.push({ kind: "ctx", line: a[i] });
|
||||||
i++;
|
i++;
|
||||||
j++;
|
j++;
|
||||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
} else if (dp[(i + 1) * columns + j] >= dp[i * columns + j + 1]) {
|
||||||
ops.push({ kind: "del", text: a[i] });
|
ops.push({ kind: "del", line: a[i] });
|
||||||
i++;
|
i++;
|
||||||
} else {
|
} else {
|
||||||
ops.push({ kind: "add", text: b[j] });
|
ops.push({ kind: "add", line: b[j] });
|
||||||
j++;
|
j++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (i < n) ops.push({ kind: "del", text: a[i++] });
|
while (i < n) ops.push({ kind: "del", line: a[i++] });
|
||||||
while (j < m) ops.push({ kind: "add", text: b[j++] });
|
while (j < m) ops.push({ kind: "add", line: b[j++] });
|
||||||
return ops;
|
return ops;
|
||||||
}
|
}
|
||||||
|
|
||||||
function diffLines(a: string[], b: string[]): Op[] {
|
function diffLines(a: SourceLine[], b: SourceLine[]): Op[] {
|
||||||
let pre = 0;
|
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;
|
let suf = 0;
|
||||||
while (
|
while (
|
||||||
suf < a.length - pre &&
|
suf < a.length - pre &&
|
||||||
suf < b.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++;
|
suf++;
|
||||||
}
|
}
|
||||||
const prefixCtx: Op[] = a.slice(0, pre).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((text) => ({ kind: "ctx", text }));
|
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));
|
const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
|
||||||
return [...prefixCtx, ...middle, ...suffixCtx];
|
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). */
|
* (unless it is the *only* run, i.e. nothing changed at all). */
|
||||||
function hunk(ops: Op[]): DiffLine[] {
|
function hunk(ops: Op[]): DiffLine[] {
|
||||||
const out: 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;
|
let i = 0;
|
||||||
while (i < ops.length) {
|
while (i < ops.length) {
|
||||||
if (ops[i].kind !== "ctx") {
|
if (ops[i].kind !== "ctx") {
|
||||||
out.push(ops[i]);
|
push(ops[i]);
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -105,14 +127,18 @@ function hunk(ops: Op[]): DiffLine[] {
|
|||||||
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
|
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
|
||||||
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
|
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
|
||||||
if (run.length <= keepHead + keepTail) {
|
if (run.length <= keepHead + keepTail) {
|
||||||
out.push(...run);
|
run.forEach(push);
|
||||||
} else {
|
} 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({
|
out.push({
|
||||||
kind: "hunk",
|
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;
|
i = j;
|
||||||
}
|
}
|
||||||
@@ -123,7 +149,7 @@ function hunk(ops: Op[]): DiffLine[] {
|
|||||||
* (a new file) renders every line of `newText` as added. */
|
* (a new file) renders every line of `newText` as added. */
|
||||||
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
|
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
|
||||||
const newLines = splitLines(newText);
|
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);
|
const ops = diffLines(splitLines(oldText), newLines);
|
||||||
return hunk(ops);
|
return hunk(ops);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,17 @@ describe("buildTranscriptRow: tool diffs", () => {
|
|||||||
// stylesheet keys `overflow-x: auto` off, not a computed style.
|
// stylesheet keys `overflow-x: auto` off, not a computed style.
|
||||||
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
|
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", () => {
|
describe("buildTranscriptRow: approval", () => {
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ function statusMeta(status: string | undefined, hasDiff: boolean): { text: strin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolRow(row: TranscriptRow): HTMLElement {
|
function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement {
|
||||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
const tool = (row.data ?? {}) as AcpToolCallLike;
|
||||||
const content = tool.content ?? [];
|
const content = tool.content ?? [];
|
||||||
const hasDiff = content.some((c) => c.type === "diff");
|
const hasDiff = content.some((c) => c.type === "diff");
|
||||||
@@ -251,13 +251,16 @@ function buildToolRow(row: TranscriptRow): HTMLElement {
|
|||||||
};
|
};
|
||||||
card.appendChild(out);
|
card.appendChild(out);
|
||||||
|
|
||||||
|
const setExpanded = (nextExpanded: boolean) => {
|
||||||
|
if (nextExpanded) buildOut();
|
||||||
|
if (out.children.length === 0) nextExpanded = false;
|
||||||
|
head.setAttribute("aria-expanded", String(nextExpanded));
|
||||||
|
out.hidden = !nextExpanded;
|
||||||
|
};
|
||||||
head.addEventListener("click", () => {
|
head.addEventListener("click", () => {
|
||||||
const expanded = head.getAttribute("aria-expanded") === "true";
|
setExpanded(head.getAttribute("aria-expanded") !== "true");
|
||||||
if (!expanded) buildOut();
|
|
||||||
if (out.children.length === 0) return; // nothing to show — stay collapsed
|
|
||||||
head.setAttribute("aria-expanded", String(!expanded));
|
|
||||||
out.hidden = expanded;
|
|
||||||
});
|
});
|
||||||
|
setExpanded(expanded);
|
||||||
|
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
@@ -361,7 +364,11 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
|||||||
|
|
||||||
/** Build the DOM for one transcript row. `approval` is consulted only for an
|
/** Build the DOM for one transcript row. `approval` is consulted only for an
|
||||||
* `approval` row; pass it whenever the row might be that kind. */
|
* `approval` row; pass it whenever the row might be that kind. */
|
||||||
export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowState): HTMLElement {
|
export function buildTranscriptRow(
|
||||||
|
row: TranscriptRow,
|
||||||
|
approval?: ApprovalRowState,
|
||||||
|
toolExpanded = false,
|
||||||
|
): HTMLElement {
|
||||||
let built: HTMLElement;
|
let built: HTMLElement;
|
||||||
switch (row.kind) {
|
switch (row.kind) {
|
||||||
case "user":
|
case "user":
|
||||||
@@ -375,7 +382,7 @@ export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowSta
|
|||||||
break;
|
break;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
case "tool_diff":
|
case "tool_diff":
|
||||||
built = buildToolRow(row);
|
built = buildToolRow(row, toolExpanded);
|
||||||
break;
|
break;
|
||||||
case "approval":
|
case "approval":
|
||||||
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
||||||
|
|||||||
@@ -2509,6 +2509,11 @@ input.leaf-rename:focus {
|
|||||||
background: var(--surface-2);
|
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
|
/* the one lifted, card-like element on the surface: the only thing the
|
||||||
operator owes a decision on */
|
operator owes a decision on */
|
||||||
.st-approval {
|
.st-approval {
|
||||||
|
|||||||
Reference in New Issue
Block a user