Adds a transcript surface for a Structured-view session alongside the existing xterm attach view: a WS /api/structured/:id connection (structured.ts) mounted by ConnectionPool whenever a session's `view` is `"Structured"`, and pure per-row/rail renderers (structuredRender.ts, structuredDiff.ts) covering all eight transcript row kinds. Hierarchy follows the design reference (artifact 88ecd690): the approval is the only lifted, card-like element; a user turn is a tinted block with an accent rail; agent prose is plain text; tool calls are compact bordered rows expanding to output or a coloured, horizontally-scrolling diff computed from ACP's full old/new text. The client renders `reduced_state` and rows exactly as received, with no raw-event folding or turn tracking of its own. Extracted attach.ts's pane-header title building into paneTitle.ts and its connection interface into pane.ts (PaneConnection) so both connection kinds share one contract and one header.
142 lines
4.9 KiB
TypeScript
142 lines
4.9 KiB
TypeScript
// Line-level diff for a `ToolDiff` row's `Diff` content block (#140). ACP
|
|
// 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 interface DiffLine {
|
|
kind: DiffLineKind;
|
|
/** The line's text (a `hunk` line is a human summary, not source text). */
|
|
text: string;
|
|
}
|
|
|
|
/** Interior unchanged runs longer than this collapse to a summary line
|
|
* flanked by this many lines of real context on each side. */
|
|
const CONTEXT = 3;
|
|
|
|
/** Bounds the O(n·m) LCS table: above this cell count, the changed middle is
|
|
* shown as one coarse del-then-add block instead of a fine-grained diff —
|
|
* 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");
|
|
}
|
|
|
|
type Op = { kind: "add" | "del" | "ctx"; text: string };
|
|
|
|
/** 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[] {
|
|
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 * m > LCS_CELL_LIMIT) {
|
|
return [...a.map((text) => ({ kind: "del" as const, text })), ...b.map((text) => ({ kind: "add" as const, text }))];
|
|
}
|
|
// 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));
|
|
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]);
|
|
}
|
|
}
|
|
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] });
|
|
i++;
|
|
j++;
|
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
ops.push({ kind: "del", text: a[i] });
|
|
i++;
|
|
} else {
|
|
ops.push({ kind: "add", text: b[j] });
|
|
j++;
|
|
}
|
|
}
|
|
while (i < n) ops.push({ kind: "del", text: a[i++] });
|
|
while (j < m) ops.push({ kind: "add", text: b[j++] });
|
|
return ops;
|
|
}
|
|
|
|
function diffLines(a: string[], b: string[]): Op[] {
|
|
let pre = 0;
|
|
while (pre < a.length && pre < b.length && 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]
|
|
) {
|
|
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 middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
|
|
return [...prefixCtx, ...middle, ...suffixCtx];
|
|
}
|
|
|
|
/** Collapse a long run of unchanged lines to a summary, keeping up to
|
|
* {@link CONTEXT} real lines adjacent to each neighbouring change — same
|
|
* shape a unified diff gives a reader, without claiming line numbers this
|
|
* module never tracked. A run touching the start/end of the whole diff has
|
|
* no change to be adjacent to on that side, so it keeps no context there
|
|
* (unless it is the *only* run, i.e. nothing changed at all). */
|
|
function hunk(ops: Op[]): DiffLine[] {
|
|
const out: DiffLine[] = [];
|
|
let i = 0;
|
|
while (i < ops.length) {
|
|
if (ops[i].kind !== "ctx") {
|
|
out.push(ops[i]);
|
|
i++;
|
|
continue;
|
|
}
|
|
let j = i;
|
|
while (j < ops.length && ops[j].kind === "ctx") j++;
|
|
const run = ops.slice(i, j);
|
|
const wholeDiff = i === 0 && j === ops.length;
|
|
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
|
|
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
|
|
if (run.length <= keepHead + keepTail) {
|
|
out.push(...run);
|
|
} else {
|
|
if (keepHead) out.push(...run.slice(0, keepHead));
|
|
out.push({
|
|
kind: "hunk",
|
|
text: `⋯ ${run.length - keepHead - keepTail} unchanged lines ⋯`,
|
|
});
|
|
if (keepTail) out.push(...run.slice(run.length - keepTail));
|
|
}
|
|
i = j;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Coloured, hunked diff lines for a `Diff` content block. `oldText` absent
|
|
* (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 }));
|
|
const ops = diffLines(splitLines(oldText), newLines);
|
|
return hunk(ops);
|
|
}
|
|
|
|
/** `+added -removed` counts straight off the diff — real derived data, not a
|
|
* fabricated duration or line count. */
|
|
export function diffStats(lines: DiffLine[]): { added: number; removed: number } {
|
|
let added = 0;
|
|
let removed = 0;
|
|
for (const line of lines) {
|
|
if (line.kind === "add") added++;
|
|
else if (line.kind === "del") removed++;
|
|
}
|
|
return { added, removed };
|
|
}
|