Skip to content

Commit b7a3118

Browse files
committed
fix(tui): arm a fence added under a stable block
1 parent ff611a8 commit b7a3118

2 files changed

Lines changed: 84 additions & 3 deletions

File tree

‎src/tui/code-links.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -431,12 +431,20 @@ function sameChildIdentity(
431431
* so a bare URL becomes an OSC-8 span and survives native wrap. Markdown
432432
* prose already sets both — do not replace those, or conceal regresses.
433433
* 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.
434+
* the child count, so the walk follows child identity, not the count. A
435+
* list box can also stay the same object while a fence is inserted under
436+
* it; that new code node still needs the walk.
435437
*/
436438
export function armCodeLinks(node: Renderable): void {
437439
const children = node.getChildren();
438440
const previous = armedChildIdentity.get(node);
439-
if (previous !== undefined && sameChildIdentity(previous, children)) return;
441+
if (
442+
previous !== undefined &&
443+
sameChildIdentity(previous, children) &&
444+
!hasUnarmedCode(node)
445+
) {
446+
return;
447+
}
440448
codeLinkWalkCount += 1;
441449
const walk = (current: Renderable): void => {
442450
if (current instanceof CodeRenderable) {
@@ -452,3 +460,17 @@ export function armCodeLinks(node: Renderable): void {
452460
walk(node);
453461
armedChildIdentity.set(node, children);
454462
}
463+
464+
/** True when a code node under `node` was created without link callbacks. */
465+
function hasUnarmedCode(node: Renderable): boolean {
466+
if (
467+
node instanceof CodeRenderable &&
468+
(node.onHighlight === undefined || node.onChunks === undefined)
469+
) {
470+
return true;
471+
}
472+
for (const child of node.getChildren()) {
473+
if (hasUnarmedCode(child)) return true;
474+
}
475+
return false;
476+
}

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
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";
6+
import {
7+
CodeRenderable,
8+
MarkdownRenderable,
9+
type BaseRenderable,
10+
} from "@opentui/core";
711
import { withTestRenderer } from "./harness";
812
import { codeLinkArmWalks } from "./code-links";
913
import { appendStreamRow, replaceStreamRowAt } from "./shell/chrome";
@@ -147,4 +151,59 @@ describe("streaming retext work", () => {
147151
{ width: 80, height: 24 },
148152
);
149153
});
154+
155+
test("a fence added under a stable list still arms that code node", async () => {
156+
await withTestRenderer(
157+
async (h) => {
158+
const shell = createAppShell(h.renderer, SHELL_OPTS);
159+
try {
160+
appendStreamRow(shell, {
161+
role: "assistant",
162+
streaming: true,
163+
text: "- intro",
164+
});
165+
await h.renderOnce();
166+
const row = transcriptRowChildren(shell)[0];
167+
if (row === undefined) throw new Error("row missing");
168+
const markdown = row
169+
.getChildren()
170+
.find((child) => child instanceof MarkdownRenderable);
171+
if (!(markdown instanceof MarkdownRenderable)) {
172+
throw new Error("markdown missing");
173+
}
174+
// OpenTUI keeps one list box and inserts the fence under it. The
175+
// first retext arms that box; the fence must still be walked.
176+
markdown.internalBlockMode = "top-level";
177+
replaceStreamRowAt(shell, 0, {
178+
role: "assistant",
179+
streaming: true,
180+
text: "- intro",
181+
});
182+
const list = markdown.getChildren()[0];
183+
if (list === undefined || list instanceof CodeRenderable) {
184+
throw new Error("list box missing");
185+
}
186+
const before = codeLinkArmWalks();
187+
replaceStreamRowAt(shell, 0, {
188+
role: "assistant",
189+
streaming: true,
190+
text: "- intro\n\n ```\n https://example.com\n ```",
191+
});
192+
expect(markdown.getChildren()[0]).toBe(list);
193+
const codes = codeNodes(row);
194+
const fence = codes.find(
195+
(code) =>
196+
code.onChunks === undefined || code.filetype !== "markdown",
197+
);
198+
expect(fence).toBeDefined();
199+
expect(fence?.onChunks).toBeDefined();
200+
expect(fence?.onHighlight).toBeDefined();
201+
expect(codeLinkArmWalks()).toBeGreaterThan(before);
202+
} finally {
203+
shell.dispose();
204+
}
205+
},
206+
{ width: 80, height: 24 },
207+
);
208+
});
150209
});

0 commit comments

Comments
 (0)