Skip to content

Commit faf65e8

Browse files
committed
fix(tui): arm a fence that replaces a prose block
A same-count block swap can introduce a new code node. Follow child identity so that fence still gets link callbacks, and skip the walk only when those nodes are unchanged.
1 parent 15151de commit faf65e8

2 files changed

Lines changed: 64 additions & 10 deletions

File tree

‎src/tui/code-links.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -406,28 +406,37 @@ export function armMarkdownLinks(
406406
};
407407
}
408408

409-
const armedChildCounts = new WeakMap<Renderable, number>();
409+
const armedChildIdentity = new WeakMap<Renderable, readonly Renderable[]>();
410410
let codeLinkWalkCount = 0;
411411

412-
/** Subtree walks `armCodeLinks` actually performed. Equal child counts do not count. */
412+
/** Subtree walks `armCodeLinks` actually performed. Same child nodes do not count. */
413413
export function codeLinkArmWalks(): number {
414414
return codeLinkWalkCount;
415415
}
416416

417+
function sameChildIdentity(
418+
previous: readonly Renderable[],
419+
next: readonly Renderable[],
420+
): boolean {
421+
if (previous.length !== next.length) return false;
422+
for (let i = 0; i < previous.length; i += 1) {
423+
if (previous[i] !== next[i]) return false;
424+
}
425+
return true;
426+
}
427+
417428
/**
418429
* Fenced code is a Code renderable without markdown's link callbacks.
419430
* `onHighlight` keeps the tree-sitter groups; `onChunks` is `detectLinks`,
420431
* so a bare URL becomes an OSC-8 span and survives native wrap. Markdown
421432
* prose already sets both — do not replace those, or conceal regresses.
422-
* A retext whose child count did not grow has no new code block to arm.
433+
* OpenTUI can swap the only block for a new CodeRenderable without growing
434+
* the child count, so the walk follows child identity, not the count.
423435
*/
424436
export function armCodeLinks(node: Renderable): void {
425-
const count = node.getChildrenCount();
426-
const previous = armedChildCounts.get(node);
427-
if (previous !== undefined && count <= previous) {
428-
if (count < previous) armedChildCounts.set(node, count);
429-
return;
430-
}
437+
const children = node.getChildren();
438+
const previous = armedChildIdentity.get(node);
439+
if (previous !== undefined && sameChildIdentity(previous, children)) return;
431440
codeLinkWalkCount += 1;
432441
const walk = (current: Renderable): void => {
433442
if (current instanceof CodeRenderable) {
@@ -441,5 +450,5 @@ export function armCodeLinks(node: Renderable): void {
441450
for (const child of current.getChildren()) walk(child);
442451
};
443452
walk(node);
444-
armedChildCounts.set(node, count);
453+
armedChildIdentity.set(node, children);
445454
}

‎src/tui/stream-retext-work.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* copy the transcript child list to find the open row.
44
*/
55
import { describe, expect, test } from "bun:test";
6+
import { CodeRenderable, type BaseRenderable } from "@opentui/core";
67
import { withTestRenderer } from "./harness";
78
import { codeLinkArmWalks } from "./code-links";
89
import { appendStreamRow, replaceStreamRowAt } from "./shell/chrome";
@@ -17,6 +18,16 @@ const SHELL_OPTS = {
1718

1819
const SETTLED = "### Title\n\n```bash\necho hi\n```\n\ntail";
1920

21+
function codeNodes(node: BaseRenderable): CodeRenderable[] {
22+
const found: CodeRenderable[] = [];
23+
const walk = (current: BaseRenderable): void => {
24+
if (current instanceof CodeRenderable) found.push(current);
25+
for (const child of current.getChildren()) walk(child);
26+
};
27+
walk(node);
28+
return found;
29+
}
30+
2031
describe("streaming retext work", () => {
2132
test("an in-place retext does not call getChildren to find the row", async () => {
2233
await withTestRenderer(
@@ -102,4 +113,38 @@ describe("streaming retext work", () => {
102113
{ width: 80, height: 24 },
103114
);
104115
});
116+
117+
test("prose rewritten as one fenced URL block arms that code node", async () => {
118+
await withTestRenderer(
119+
async (h) => {
120+
const shell = createAppShell(h.renderer, SHELL_OPTS);
121+
try {
122+
appendStreamRow(shell, {
123+
role: "assistant",
124+
streaming: true,
125+
text: "hello",
126+
});
127+
await h.renderOnce();
128+
const before = codeLinkArmWalks();
129+
replaceStreamRowAt(shell, 0, {
130+
role: "assistant",
131+
streaming: true,
132+
text: "```js\nhttps://example.com\n```",
133+
});
134+
const row = transcriptRowChildren(shell)[0];
135+
if (row === undefined) throw new Error("row missing");
136+
const codes = codeNodes(row);
137+
expect(codes).toHaveLength(1);
138+
const code = codes[0];
139+
if (code === undefined) throw new Error("code node missing");
140+
expect(code.onChunks).toBeDefined();
141+
expect(code.onHighlight).toBeDefined();
142+
expect(codeLinkArmWalks()).toBeGreaterThan(before);
143+
} finally {
144+
shell.dispose();
145+
}
146+
},
147+
{ width: 80, height: 24 },
148+
);
149+
});
105150
});

0 commit comments

Comments
 (0)