diff --git a/bun.lock b/bun.lock index faeb8ba36..5afa63dfa 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@intx/types": "workspace:*", "@intx/workflow-host": "0.3.0", "@modelcontextprotocol/sdk": "^1.29.0", - "@opentui/core": "0.5.12", + "@opentui/core": "0.6.4", "arktype": "catalog:", "isomorphic-git": "catalog:", }, @@ -45,14 +45,14 @@ "ws": "^8.21.0", }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "0.5.12", - "@opentui/core-darwin-x64": "0.5.12", - "@opentui/core-linux-arm64": "0.5.12", - "@opentui/core-linux-arm64-musl": "0.5.12", - "@opentui/core-linux-x64": "0.5.12", - "@opentui/core-linux-x64-musl": "0.5.12", - "@opentui/core-win32-arm64": "0.5.12", - "@opentui/core-win32-x64": "0.5.12", + "@opentui/core-darwin-arm64": "0.6.4", + "@opentui/core-darwin-x64": "0.6.4", + "@opentui/core-linux-arm64": "0.6.4", + "@opentui/core-linux-arm64-musl": "0.6.4", + "@opentui/core-linux-x64": "0.6.4", + "@opentui/core-linux-x64-musl": "0.6.4", + "@opentui/core-win32-arm64": "0.6.4", + "@opentui/core-win32-x64": "0.6.4", }, }, "agents/artist": { @@ -409,23 +409,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@opentui/core": ["@opentui/core@0.5.12", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.12", "@opentui/core-darwin-x64": "0.5.12", "@opentui/core-linux-arm64": "0.5.12", "@opentui/core-linux-arm64-musl": "0.5.12", "@opentui/core-linux-x64": "0.5.12", "@opentui/core-linux-x64-musl": "0.5.12", "@opentui/core-win32-arm64": "0.5.12", "@opentui/core-win32-x64": "0.5.12" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ZXBE5gmvdovmV8zJQrOQf6E44v1tJRDEgrM2MYhEglzgXZ+smIUp95O8zeRYGsuIzQIiMPMgQqKtTJuzvAb7BQ=="], + "@opentui/core": ["@opentui/core@0.6.4", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.6.4", "@opentui/core-darwin-x64": "0.6.4", "@opentui/core-linux-arm64": "0.6.4", "@opentui/core-linux-arm64-musl": "0.6.4", "@opentui/core-linux-x64": "0.6.4", "@opentui/core-linux-x64-musl": "0.6.4", "@opentui/core-win32-arm64": "0.6.4", "@opentui/core-win32-x64": "0.6.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ZCywvSi9A8H5IoDyrw3sKpPkzimsJ+CoUuQWg0bSKLVLh/tYJDmOsFFOnlRzUZPnhGRV8rtLlR613NoEIytig=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YdVnP0tAyerBNl0mIcmQEOotPeZzW1VnSXKBl5cyZ5e6nDd2Y+ui/8eRPpn1oqcamf1NCnzS4ohMgejOvna8Zg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.6.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mZRj5dwR9+wvota0WDgGwYe3xiNzr4ZTpXcSDB2/vPCjuT5npvO9Y1KQWnNBtI85aOtszBHKirXtVqMk0/kjPQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-uRrQJdHmLUSj3PV23QPi3WSimYTTxcXnVouxF6U4xMXlOv4N3SxnHfVwMRQkPqbGOfvVWHeLE6FdK4C+ubU0sQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.6.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-9Oa2QPLwg0AFy5EAXCQnIxOphPyCo/g476PBfyl32hUKbSxscKJeq1fvepZBdD3kAzSZFGzIroUZ2FhLg2b2kw=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-XeKhuIaEtgipvuPHbl4qPOBj+Ut+2zObmsxMVM1jDcjz/FatG9PGeGQPx1G1SnvH2AgpT4K+eCu7DUF0+yIqoQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.6.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pstmXbojEysbDiP6SSfDxk7nD6Sy/xFy+AhEfZstV8SKUHGZAbrLep4GnggL5Q5/soy5gQtQTcaaFhK4LtQNxw=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-VZ2sNMw1d/r1SLPjUbOP9LKscKz1CQjID8adTL6gG8Lrrq+mYcIUxutyB+P/eG0J/7oRZLPR6OMt7dUOap6RTg=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.6.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-eKRQ13r146JPCuL2NeHcj9i86arZOk9XGJkFc63x7y1iH7YFxgA5sx/KevsqmLNl3F8LE3j+RhX3HPAlmR/KrQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-eZiCjEzwbb6qClPPfk32Nha9xmr9obt69Xj0+9SKsXxWLBKkjQEGOMRoh/R9ObaQF4aq8If1xV3VEY0sD9W9vg=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-QVEzBYhjq0T2kzrH2qpvn4trUA+XPJyp5ASEsDbcnihyMFtStdj6wggJ8Ufp+EIeEuf1bi7STv7Ui9MtvgQ2+w=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-WWW0hVBoSYZ3D6AgZ4u2Y5/u/IyIq2pDb+4yI3WgJ70Wyt6ofHy+6kRGRgbXFn1p+rPInAHjCXD2v6C7iEKSrA=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.6.4", "", { "os": "linux", "cpu": "x64" }, "sha512-1DW/t/7hJc9HpDzmkrZ6pKbXH2uEjOY3Uwo1FlHGZSRy6XMjireF2iU+ucJKO+Krsu07c8hVAOxFQb+sqkLscQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-aLbm6870Ybls6CYL4zMOCImTBPLZHZMUXJFGqMI44lIWxitkAtT6zg5lYA4oRqFRzzryDclxr29+hDgT3p3Blw=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.6.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmFGqYoQ+ZbDbZrx7GNXxu/mt06zyBOwIDXKdsiUFgC6sZODz69QGN4xpLhF+popDVCjDyLtFxKW0vMfIOXSXA=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-KTwtwpfd2zF9opVh3SyRJYDd1o3Xv4XL8OZb8Zi+CqWUel6Y2IDCiVivCv8fGJt3J7wOIXXtuZI9ZUkLyKJCiQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.6.4", "", { "os": "win32", "cpu": "x64" }, "sha512-NzgXKe+AIrQGXMESc+ScyL6T55h4m7IEA09r8BpxgjwfzMSRzGvI9//0Ps8bQNnGXIDURH8zuS11SWneKhbL3A=="], "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.73.0", "", { "os": "android", "cpu": "arm" }, "sha512-dhunAdMbi/4o0Y66/+Wl6rpsNqpvE4i26Qw6bB77A97L2lJoktWg6m+X9QOon8iOEEqzD9rEZqFi5uZ4oIzhCA=="], diff --git a/package.json b/package.json index cc1905945..2adbd5669 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "@intx/types": "workspace:*", "@intx/workflow-host": "0.3.0", "@modelcontextprotocol/sdk": "^1.29.0", - "@opentui/core": "0.5.12", + "@opentui/core": "0.6.4", "arktype": "catalog:", "isomorphic-git": "catalog:", "@corbits/code-agent-designer": "workspace:*", @@ -125,13 +125,13 @@ "ws": "^8.21.0" }, "optionalDependencies": { - "@opentui/core-darwin-arm64": "0.5.12", - "@opentui/core-darwin-x64": "0.5.12", - "@opentui/core-linux-arm64": "0.5.12", - "@opentui/core-linux-arm64-musl": "0.5.12", - "@opentui/core-linux-x64": "0.5.12", - "@opentui/core-linux-x64-musl": "0.5.12", - "@opentui/core-win32-arm64": "0.5.12", - "@opentui/core-win32-x64": "0.5.12" + "@opentui/core-darwin-arm64": "0.6.4", + "@opentui/core-darwin-x64": "0.6.4", + "@opentui/core-linux-arm64": "0.6.4", + "@opentui/core-linux-arm64-musl": "0.6.4", + "@opentui/core-linux-x64": "0.6.4", + "@opentui/core-linux-x64-musl": "0.6.4", + "@opentui/core-win32-arm64": "0.6.4", + "@opentui/core-win32-x64": "0.6.4" } } diff --git a/src/tui/runner/settings-theme.test.ts b/src/tui/runner/settings-theme.test.ts index 550b1965b..ff309c635 100644 --- a/src/tui/runner/settings-theme.test.ts +++ b/src/tui/runner/settings-theme.test.ts @@ -11,13 +11,16 @@ */ import { afterEach, describe, expect, test } from "bun:test"; -import { RGBA, type CapturedSpan } from "@opentui/core"; +import { RGBA, SyntaxStyle, type CapturedSpan } from "@opentui/core"; import { corbitsDark, corbitsLight, setTheme, UI } from "../theme.js"; import { transcriptSyntaxStyle } from "../stream.js"; import { withTestRenderer, type Harness } from "../harness.js"; +import { settleSpans } from "../test-helpers.js"; import { appendStreamRow } from "../shell/chrome.js"; import { createAppShell } from "../shell/index.js"; +import { setPromptRecognitionSource } from "../shell/internals.js"; +import { syncPromptHighlights } from "../shell/prompt.js"; import { applyThemePinLive, repaintShellForTheme } from "./settings.js"; afterEach(() => { @@ -25,25 +28,26 @@ afterEach(() => { }); /** Live heading colour in the transcript registry. */ -function registryHeadingFg(): RGBA | undefined { - return transcriptSyntaxStyle().getAllStyles().get("markup.heading.1")?.fg; +function registryHeadingFg(h: Harness): RGBA | undefined { + return transcriptSyntaxStyle(h.renderer) + .getAllStyles() + .get("markup.heading.1")?.fg; } -function painted(spans: CapturedSpan[], text: string, fg: RGBA): boolean { - return spans.some((s) => s.text.includes(text) && s.fg.equals(fg)); +/** `destroy()` frees the native style and there is no finalizer; a later + * read throws instead of returning a palette. */ +function syntaxStyleAlive(style: SyntaxStyle): boolean { + try { + style.getStyleCount(); + return true; + } catch (err) { + if (err instanceof Error && err.message.includes("destroyed")) return false; + throw err; + } } -async function settleSpans( - h: Harness, - ready: (spans: CapturedSpan[]) => boolean, -): Promise { - let spans = h.captureSpans().lines.flatMap((line) => line.spans); - for (let i = 0; i < 400 && !ready(spans); i++) { - await h.renderOnce(); - await new Promise((resolve) => setTimeout(resolve, 25)); - spans = h.captureSpans().lines.flatMap((line) => line.spans); - } - return spans; +function painted(spans: CapturedSpan[], text: string, fg: RGBA): boolean { + return spans.some((s) => s.text.includes(text) && s.fg.equals(fg)); } describe("applyThemePinLive", () => { @@ -64,7 +68,7 @@ describe("applyThemePinLive", () => { painted(spans, "pin cycle", darkHeading), ); expect(painted(before, "pin cycle", darkHeading)).toBe(true); - expect(registryHeadingFg()?.equals(darkHeading)).toBe(true); + expect(registryHeadingFg(h)?.equals(darkHeading)).toBe(true); // No cost movement anywhere in this cycle: the meter never moves, // so only an unconditional repaint repaints anything. @@ -74,7 +78,7 @@ describe("applyThemePinLive", () => { expect(applied).toBe("corbits-light"); expect(UI.name).toBe("corbits-light"); expect(UI.ground).not.toBe(darkGround); - expect(registryHeadingFg()?.equals(lightHeading)).toBe(true); + expect(registryHeadingFg(h)?.equals(lightHeading)).toBe(true); const after = await settleSpans(h, (spans) => painted(spans, "pin cycle", lightHeading), @@ -106,14 +110,14 @@ describe("applyThemePinLive", () => { painted(spans, "pin cycle", lightHeading), ); expect(painted(before, "pin cycle", lightHeading)).toBe(true); - expect(registryHeadingFg()?.equals(lightHeading)).toBe(true); + expect(registryHeadingFg(h)?.equals(lightHeading)).toBe(true); const applied = applyThemePinLive("dark", () => repaintShellForTheme(shell), ); expect(applied).toBe("corbits-dark"); expect(UI.name).toBe("corbits-dark"); - expect(registryHeadingFg()?.equals(darkHeading)).toBe(true); + expect(registryHeadingFg(h)?.equals(darkHeading)).toBe(true); const after = await settleSpans(h, (spans) => painted(spans, "pin cycle", darkHeading), @@ -129,11 +133,118 @@ describe("applyThemePinLive", () => { ); }, 30000); - test("auto re-resolves through the startup path and rebuilds the registry", () => { + test("auto re-resolves through the startup path and rebuilds the registry", async () => { setTheme("corbits-dark"); - const applied = applyThemePinLive("auto", () => undefined); - expect(["corbits-dark", "corbits-light"]).toContain(applied); - expect(UI.name).toBe(applied); - expect(registryHeadingFg()?.equals(RGBA.fromHex(UI.heading))).toBe(true); + await withTestRenderer(async (h) => { + const applied = applyThemePinLive("auto", () => undefined); + expect(["corbits-dark", "corbits-light"]).toContain(applied); + expect(UI.name).toBe(applied); + expect(registryHeadingFg(h)?.equals(RGBA.fromHex(UI.heading))).toBe(true); + }); + }); + + test("a second pin destroys the transcript style the rows dropped", async () => { + await withTestRenderer( + async (h) => { + setTheme("corbits-dark"); + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 40 }, + wireKeys: false, + }); + try { + appendStreamRow(shell, { role: "assistant", text: "# pin cycle" }); + const first = transcriptSyntaxStyle(h.renderer); + applyThemePinLive("light", () => repaintShellForTheme(shell)); + const second = transcriptSyntaxStyle(h.renderer); + expect(second).not.toBe(first); + expect(syntaxStyleAlive(first)).toBe(false); + expect(syntaxStyleAlive(second)).toBe(true); + + applyThemePinLive("dark", () => repaintShellForTheme(shell)); + const third = transcriptSyntaxStyle(h.renderer); + expect(third).not.toBe(second); + expect(syntaxStyleAlive(second)).toBe(false); + expect(syntaxStyleAlive(third)).toBe(true); + } finally { + shell.dispose(); + setTheme("corbits-dark"); + } + }, + { width: 80, height: 40 }, + ); + }); + + test("refreshing the transcript cache leaves a style rows still paint", async () => { + await withTestRenderer( + async (h) => { + setTheme("corbits-dark"); + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 40 }, + wireKeys: false, + }); + try { + appendStreamRow(shell, { role: "assistant", text: "# pin cycle" }); + const painted = transcriptSyntaxStyle(h.renderer); + setTheme("corbits-light"); + const rebuilt = transcriptSyntaxStyle(h.renderer); + expect(rebuilt).not.toBe(painted); + expect(syntaxStyleAlive(painted)).toBe(true); + repaintShellForTheme(shell); + expect(syntaxStyleAlive(painted)).toBe(false); + expect(syntaxStyleAlive(transcriptSyntaxStyle(h.renderer))).toBe( + true, + ); + } finally { + shell.dispose(); + setTheme("corbits-dark"); + } + }, + { width: 80, height: 40 }, + ); + }); + + test("a pin destroys the prompt style only after the prompt drops it", async () => { + await withTestRenderer( + async (h) => { + setTheme("corbits-dark"); + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 40 }, + wireKeys: false, + }); + setPromptRecognitionSource(shell, () => ({ + commandNames: ["implement"], + })); + const originalDestroy = SyntaxStyle.prototype.destroy; + let destroyedWhileHeld = false; + try { + shell.prompt.value = "/implement"; + syncPromptHighlights(shell); + const painted = shell.prompt.syntaxStyle; + expect(painted).not.toBeNull(); + if (painted === null) return; + SyntaxStyle.prototype.destroy = function destroy(this: SyntaxStyle) { + if (this === painted && shell.prompt.syntaxStyle === painted) { + destroyedWhileHeld = true; + } + return originalDestroy.call(this); + }; + applyThemePinLive("light", () => repaintShellForTheme(shell)); + expect(shell.prompt.syntaxStyle).toBe(painted); + expect(syntaxStyleAlive(painted)).toBe(true); + syncPromptHighlights(shell); + expect(destroyedWhileHeld).toBe(false); + expect(shell.prompt.syntaxStyle).not.toBe(painted); + expect(syntaxStyleAlive(painted)).toBe(false); + const current = shell.prompt.syntaxStyle; + expect(current).not.toBeNull(); + if (current !== null) expect(syntaxStyleAlive(current)).toBe(true); + } finally { + SyntaxStyle.prototype.destroy = originalDestroy; + shell.dispose(); + setTheme("corbits-dark"); + } + }, + { width: 80, height: 40 }, + ); }); }); diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index 743a9f1c5..d0a2e3438 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -143,6 +143,7 @@ import { transcriptRowChildren, transcriptRowLayout, transcriptRowOffset, + releaseRetiredTranscriptSyntaxStyles, } from "./transcript.js"; function syncPending(shell: AppShell): void { @@ -1411,6 +1412,7 @@ export function repaintTranscriptWindow(shell: AppShell): void { ), ); }); + releaseRetiredTranscriptSyntaxStyles(shell); } /** Tear the landing down on the first transcript row. The prompt box jumps diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index ea352a6ee..64352d44d 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -284,6 +284,9 @@ export type ShellRenderer = Pick< | "off" | "isDestroyed" | "clearSelection" + // 0.6.x native style construction needs the owning Context: the renderer's + // native scene. + | "nativeScene" >; export interface AppShellOptions { diff --git a/src/tui/shell/prompt.ts b/src/tui/shell/prompt.ts index 0e6d4b9e0..b6d8f382a 100644 --- a/src/tui/shell/prompt.ts +++ b/src/tui/shell/prompt.ts @@ -36,6 +36,10 @@ import { interrupt, } from "../delivery-queue.js"; import { UI, onThemeChange } from "../theme.js"; +import { + destroyUnpaintedSyntaxStyles, + syntaxStyleForGeneration, +} from "../syntax-style-cache.js"; import { type AppShell, @@ -45,6 +49,7 @@ import { shellInternals, shellPromptImageSource, shellRecognitionSource, + type ShellRenderer, } from "./internals.js"; import { appendStreamRow, @@ -177,17 +182,22 @@ export function setPromptCostContext( paintPromptBorder(shell); } -let cachedPromptSyntaxStyle: SyntaxStyle | null = null; +/** 0.6.x native styles must name their owning Context — the live renderer's + * native scene — so the style is keyed per renderer instead of a module + * global; `promptStyleGeneration` invalidates it like the old cache did. + * The previous style stays queued until the prompt assignment drops it. */ +const promptStylesByRenderer = new WeakMap< + ShellRenderer, + { generation: number; style: SyntaxStyle } +>(); -let cachedPromptRecognizedStyleId: number | null = null; +const retiredPromptStyles = new WeakMap(); let promptStyleGeneration = 0; const promptHighlightGenerations = new WeakMap(); onThemeChange(() => { - cachedPromptSyntaxStyle = null; - cachedPromptRecognizedStyleId = null; promptStyleGeneration += 1; }); @@ -195,17 +205,18 @@ onThemeChange(() => { * Lazy like `transcriptSyntaxStyle`: construction reaches into the native * render lib. */ -function promptRecognizedStyleId(): number { - if (cachedPromptSyntaxStyle === null) { - cachedPromptSyntaxStyle = SyntaxStyle.fromStyles({ - recognized: { fg: UI.action }, - }); - } - if (cachedPromptRecognizedStyleId === null) { - cachedPromptRecognizedStyleId = - cachedPromptSyntaxStyle.resolveStyleId("recognized") ?? 0; - } - return cachedPromptRecognizedStyleId; +function promptStyleFor(renderer: ShellRenderer): SyntaxStyle { + return syntaxStyleForGeneration( + promptStylesByRenderer, + retiredPromptStyles, + renderer, + promptStyleGeneration, + () => + SyntaxStyle.fromStyles( + { recognized: { fg: UI.action } }, + renderer.nativeScene, + ), + ); } const promptHighlightedValue = new WeakMap(); @@ -226,8 +237,14 @@ export function syncPromptHighlights(shell: AppShell): void { promptHighlightedValue.set(shell, value); promptHighlightGenerations.set(shell, promptStyleGeneration); - const styleId = promptRecognizedStyleId(); - shell.prompt.syntaxStyle = cachedPromptSyntaxStyle; + const style = promptStyleFor(shell.renderer); + const styleId = style.resolveStyleId("recognized") ?? 0; + shell.prompt.syntaxStyle = style; + destroyUnpaintedSyntaxStyles( + retiredPromptStyles, + shell.renderer, + (pending) => shell.prompt.syntaxStyle === pending, + ); shell.prompt.clearAllHighlights(); const matcher = resolvePromptRecognitionMatcher(source); for (const span of resolvePromptHighlightSpans(value, matcher)) { diff --git a/src/tui/shell/transcript.ts b/src/tui/shell/transcript.ts index 9db2ae7b9..9ef3eedbf 100644 --- a/src/tui/shell/transcript.ts +++ b/src/tui/shell/transcript.ts @@ -9,6 +9,7 @@ import { StyledText, type BaseRenderable, type CliRenderer, + type SyntaxStyle, } from "@opentui/core"; import { stringWidth } from "../view/height.js"; import { viewToTableContent, type McpStructuredView } from "../mcp-view.js"; @@ -37,6 +38,7 @@ import { toolRowLines, toolSentenceLines, transcriptSyntaxStyle, + dropRetiredTranscriptSyntaxStyles, type PaintedStreamLine, type RowLayout, type StreamRow, @@ -355,9 +357,13 @@ export function buildRowNode( } /** Shared construction options for a transcript markdown body's renderer. */ -function markdownBodyOptions(gutter: PaintedStreamLine, width: number) { +function markdownBodyOptions( + ctx: CliRenderer, + gutter: PaintedStreamLine, + width: number, +) { return { - syntaxStyle: transcriptSyntaxStyle(), + syntaxStyle: transcriptSyntaxStyle(ctx), fg: gutter.fg, width, flexShrink: 0, @@ -430,7 +436,7 @@ function createMarkdownBody( if (split === null) { // Native incremental block stability: only the trailing block is unstable. return new MarkdownRenderable(ctx, { - ...markdownBodyOptions(gutter, width), + ...markdownBodyOptions(ctx, gutter, width), content, streaming: row.streaming === true, }); @@ -438,14 +444,14 @@ function createMarkdownBody( const column = new BoxRenderable(ctx, { flexDirection: "column", width }); column.add( new MarkdownRenderable(ctx, { - ...markdownBodyOptions(gutter, width), + ...markdownBodyOptions(ctx, gutter, width), content: split.frozen, streaming: false, }), ); column.add( new MarkdownRenderable(ctx, { - ...markdownBodyOptions(gutter, width), + ...markdownBodyOptions(ctx, gutter, width), content: split.live, streaming: row.streaming === true, marginTop: split.gapRows, @@ -460,6 +466,27 @@ function createMarkdownBody( return column; } +/** Markdown bodies still holding a syntax style. A theme pin must not free + * a style that one of these nodes is still painting. */ +function paintedMarkdownSyntaxStyles(node: BaseRenderable): Set { + const painted = new Set(); + const walk = (current: BaseRenderable): void => { + if (current instanceof MarkdownRenderable) { + painted.add(current.syntaxStyle); + } + for (const child of current.getChildren()) walk(child); + }; + walk(node); + return painted; +} + +/** After the transcript rows have been rebuilt, free styles they dropped. */ +export function releaseRetiredTranscriptSyntaxStyles(shell: AppShell): void { + dropRetiredTranscriptSyntaxStyles(shell.renderer as CliRenderer, () => + paintedMarkdownSyntaxStyles(shell.renderer.root as BaseRenderable), + ); +} + /** Gutter + one pre-coloured text line per body row (a diff, expanded tool * arguments); wraps land under the body. */ function createStyledLinesRowRenderable( diff --git a/src/tui/slash-popup-gate.test.ts b/src/tui/slash-popup-gate.test.ts index 38ac34eac..4b0838aa7 100644 --- a/src/tui/slash-popup-gate.test.ts +++ b/src/tui/slash-popup-gate.test.ts @@ -1072,4 +1072,25 @@ describe("overlay host occupancy and opt-in deferral", () => { expect(isOverlayHostIdle(shell)).toBe(true); }); }); + + test("a `/` right after the Ctrl+C arm still opens the popup", async () => { + // Smoke suspicion (2026-10-14 TUI QA): a `/` typed immediately after the + // first Ctrl+C press seemed suppressed, letting the slash text submit to + // the agent. Both path stages share the key wiring, so the arm must not + // eat the key or leave the popup host unusable — this pins the sequence + // end to end through the real keybinding dispatch (idle shell, empty + // prompt, no live workers — the arming press, never the quit). + await withShell(async ({ shell, press, render }) => { + press("Ctrl+C"); + await render(); + expect(isSlashPopupOpen(shell)).toBe(false); + + press("/"); + await render(); + expect(shell.prompt.value).toBe("/"); + expect(isSlashPopupOpen(shell)).toBe(true); + // The unfiltered catalog stays listed (popup opens; nothing filtered). + expect(shell.paletteCommands[0]?.id).toBe("help"); + }); + }); }); diff --git a/src/tui/stream.test.ts b/src/tui/stream.test.ts index d5d7f59c5..202666f3f 100644 --- a/src/tui/stream.test.ts +++ b/src/tui/stream.test.ts @@ -19,6 +19,7 @@ import { toolCallRow } from "./diff"; import { toolResultRow } from "./mcp-view"; import { mergeToolRows } from "./tool-rows"; import { UI } from "./theme"; +import { withTestRenderer } from "./harness"; const SOLO: RowLayout = { width: 56, multiAgent: false }; const CREW: RowLayout = { width: 56, multiAgent: true }; @@ -577,9 +578,11 @@ describe("sub-agent dispatch row marks", () => { // underline-absence on the link scopes so a future hover style cannot // sneak one in. describe("transcriptSyntaxStyle markdown links", () => { - test("link cells carry no underline", () => { - const styles = transcriptSyntaxStyle().getAllStyles(); - expect(styles.get("markup.link")?.underline).not.toBe(true); - expect(styles.get("markup.link.url")?.underline).not.toBe(true); + test("link cells carry no underline", async () => { + await withTestRenderer(async (h) => { + const styles = transcriptSyntaxStyle(h.renderer).getAllStyles(); + expect(styles.get("markup.link")?.underline).not.toBe(true); + expect(styles.get("markup.link.url")?.underline).not.toBe(true); + }); }); }); diff --git a/src/tui/stream.ts b/src/tui/stream.ts index c8d30a592..df14607a8 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -1,7 +1,7 @@ /** Transcript stream row model — product-skin styling for plain and * markdown renderables. */ -import { SyntaxStyle } from "@opentui/core"; +import { SyntaxStyle, type CliRenderer } from "@opentui/core"; import { stringWidth, wrapLines } from "./view/height.js"; import type { DiffView } from "./diff.js"; @@ -12,6 +12,10 @@ import { type Thought, } from "./thinking.js"; import { UI, onThemeChange } from "./theme.js"; +import { + syntaxStyleForGeneration, + destroyUnpaintedSyntaxStyles, +} from "./syntax-style-cache.js"; import { pastTenseToolLabel } from "./tool-formatter.js"; /** One pre-coloured body line (an expanded call's structured args). */ @@ -773,17 +777,47 @@ function markdownStyles() { }; } -let cachedSyntaxStyle: SyntaxStyle | null = null; +/** 0.6.x native styles must name the Context that owns them; the owner is the + * live renderer, so the shared style is keyed per renderer instead of held in + * a module global. A theme pin bumps the generation; the next reader rebuilds + * from the new `UI` colors, matching the old invalidation. The previous + * style stays alive until rows drop it (`dropRetiredTranscriptSyntaxStyles`). */ +const transcriptStylesByRenderer = new WeakMap< + CliRenderer, + { generation: number; style: SyntaxStyle } +>(); + +const retiredTranscriptStyles = new WeakMap(); + +let transcriptStyleGeneration = 0; + +/** Shared transcript SyntaxStyle for `ctx`, built lazily — the native render + * lib is unreachable until a renderer exists. */ +export function transcriptSyntaxStyle(ctx: CliRenderer): SyntaxStyle { + return syntaxStyleForGeneration( + transcriptStylesByRenderer, + retiredTranscriptStyles, + ctx, + transcriptStyleGeneration, + () => SyntaxStyle.fromStyles({ ...markdownStyles() }, ctx.nativeScene), + ); +} -/** Shared transcript SyntaxStyle, built lazily — the native render lib is - * unreachable until a renderer exists. */ -export function transcriptSyntaxStyle(): SyntaxStyle { - if (cachedSyntaxStyle === null) { - cachedSyntaxStyle = SyntaxStyle.fromStyles({ ...markdownStyles() }); - } - return cachedSyntaxStyle; +/** Destroy transcript styles this renderer no longer paints. `painted` runs + * only when a previous style is queued, and only after rows have been + * rebuilt — a style the thunk still reports stays queued. */ +export function dropRetiredTranscriptSyntaxStyles( + ctx: CliRenderer, + painted: () => ReadonlySet, +): void { + const pending = retiredTranscriptStyles.get(ctx); + if (pending === undefined || pending.length === 0) return; + const held = painted(); + destroyUnpaintedSyntaxStyles(retiredTranscriptStyles, ctx, (style) => + held.has(style), + ); } onThemeChange(() => { - cachedSyntaxStyle = null; + transcriptStyleGeneration += 1; }); diff --git a/src/tui/syntax-highlight.test.ts b/src/tui/syntax-highlight.test.ts index 72b8becf0..5a398f56c 100644 --- a/src/tui/syntax-highlight.test.ts +++ b/src/tui/syntax-highlight.test.ts @@ -1,6 +1,7 @@ /** * Fenced-code highlighting: colour lives in the native renderer - * (transcriptSyntaxStyle() over the bundled tree-sitter grammars), while the + * (the per-renderer SyntaxStyle built by transcriptSyntaxStyle(h.renderer) + * over the bundled tree-sitter grammars), while the * synchronous StyledSegment model keeps geometry only. These tests pin both * halves: the plain sync fallback and the language parity the native renderer * applies. @@ -9,6 +10,7 @@ import { describe, expect, test } from "bun:test"; import { MarkdownRenderable, RGBA, type CapturedSpan } from "@opentui/core"; import { withTestRenderer, type Harness } from "./harness"; +import { settleSpans } from "./test-helpers"; import { appendStreamRow } from "./shell/chrome.js"; import { createAppShell } from "./shell/index.js"; import { transcriptSyntaxStyle } from "./stream"; @@ -41,7 +43,7 @@ const SYNTAX_FGS = [KEYWORD_FG, STRING_FG, COMMENT_FG, FUNCTION_FG]; function renderFence(h: Harness, fence: string): void { h.root.add( new MarkdownRenderable(h.renderer, { - syntaxStyle: transcriptSyntaxStyle(), + syntaxStyle: transcriptSyntaxStyle(h.renderer), fg: UI.text, width: 80, flexShrink: 0, @@ -51,19 +53,6 @@ function renderFence(h: Harness, fence: string): void { ); } -async function settleSpans( - h: Harness, - ready: (spans: CapturedSpan[]) => boolean, -): Promise { - let spans = h.captureSpans().lines.flatMap((line) => line.spans); - for (let i = 0; i < 400 && !ready(spans); i++) { - await h.renderOnce(); - await new Promise((resolve) => setTimeout(resolve, 25)); - spans = h.captureSpans().lines.flatMap((line) => line.spans); - } - return spans; -} - function coloured(spans: CapturedSpan[], text: string, fg: RGBA): boolean { return spans.some((s) => s.text.includes(text) && s.fg.equals(fg)); } diff --git a/src/tui/syntax-style-cache.ts b/src/tui/syntax-style-cache.ts new file mode 100644 index 000000000..6907c380a --- /dev/null +++ b/src/tui/syntax-style-cache.ts @@ -0,0 +1,51 @@ +import type { SyntaxStyle } from "@opentui/core"; + +interface GenerationStyle { + readonly generation: number; + readonly style: SyntaxStyle; +} + +/** + * Theme pins bump `generation` while rows and the prompt still hold the + * previous native style. `SyntaxStyle.destroy()` frees that style and nothing + * finalizes it, so the previous one is queued here until the caller has + * dropped it — overwriting the cache must not free a style that is still + * painted. + */ +export function syntaxStyleForGeneration( + cache: WeakMap, + retired: WeakMap, + key: K, + generation: number, + build: () => SyntaxStyle, +): SyntaxStyle { + const cached = cache.get(key); + if (cached !== undefined && cached.generation === generation) { + return cached.style; + } + const style = build(); + if (cached !== undefined) { + const pending = retired.get(key); + if (pending === undefined) retired.set(key, [cached.style]); + else pending.push(cached.style); + } + cache.set(key, { generation, style }); + return style; +} + +/** Free queued styles `stillPainted` rejects. Held styles stay queued. */ +export function destroyUnpaintedSyntaxStyles( + retired: WeakMap, + key: K, + stillPainted: (style: SyntaxStyle) => boolean, +): void { + const pending = retired.get(key); + if (pending === undefined || pending.length === 0) return; + const keep: SyntaxStyle[] = []; + for (const style of pending) { + if (stillPainted(style)) keep.push(style); + else style.destroy(); + } + if (keep.length === 0) retired.delete(key); + else retired.set(key, keep); +} diff --git a/src/tui/test-helpers.ts b/src/tui/test-helpers.ts index 011141a7a..2cf7f4e0d 100644 --- a/src/tui/test-helpers.ts +++ b/src/tui/test-helpers.ts @@ -3,6 +3,8 @@ * that is always disposed. Defaults match the 80×24 / wireKeys-off scaffold * the suite used to copy into every file. */ +import type { CapturedSpan } from "@opentui/core"; + import { withTestRenderer, type Harness } from "./harness.js"; import { createAppShell } from "./shell/index.js"; import type { AppShell, AppShellOptions } from "./shell/internals.js"; @@ -39,3 +41,29 @@ export async function withAppShell( { width, height }, ); } + +/** 0.6.x leases a session buffer around each presented frame; a capture + * landing inside that window throws OutputBusy. The poll retries on the + * next round anyway, so a losing capture keeps polling instead of + * throwing — the ready predicate, never the trace, decides the outcome. */ +function readSpans(h: Harness): CapturedSpan[] { + try { + return h.captureSpans().lines.flatMap((line) => line.spans); + } catch (err) { + if (err instanceof Error && err.message.includes("OutputBusy")) return []; + throw err; + } +} + +export async function settleSpans( + h: Harness, + ready: (spans: CapturedSpan[]) => boolean, +): Promise { + let spans = readSpans(h); + for (let i = 0; i < 400 && !ready(spans); i++) { + await h.renderOnce(); + await new Promise((resolve) => setTimeout(resolve, 25)); + spans = readSpans(h); + } + return spans; +} diff --git a/src/tui/url-links.ts b/src/tui/url-links.ts index 2ff13a209..b4291a57b 100644 --- a/src/tui/url-links.ts +++ b/src/tui/url-links.ts @@ -398,7 +398,22 @@ export function armMarkdownLinks( if (start === null || !isUrlOpenClick(event)) return; if (markdownLinkAt(renderer, event.x, event.y) === start) openUrl(start); }; - target.onMouseOut = () => { + target.onMouseOut = (event) => { + // 0.6.x refreshes over/out lazily, so an out earned by an earlier + // pointer position can land between a press and its release although + // the pointer never moved. An out whose coordinates still resolve to a + // descendant of target means the pointer stayed inside the transcript — + // only an actual departure clears the press. + try { + let current: Renderable | null | undefined = + Renderable.renderablesByNumber.get(renderer.hitTest(event.x, event.y)); + while (current) { + if (current === target) return; + current = current.parent; + } + } catch { + // Unresolvable hit: treat as departed, like pre-0.6 did implicitly. + } press = null; }; } diff --git a/src/tui/width-contract.ts b/src/tui/width-contract.ts index e07331246..937919832 100644 --- a/src/tui/width-contract.ts +++ b/src/tui/width-contract.ts @@ -10,7 +10,11 @@ * is the failure mode this check removes. */ -import { resolveRenderLib, type WidthMethod } from "@opentui/core"; +import { + OptimizedBuffer, + ResourceContext, + type WidthMethod, +} from "@opentui/core"; import { stringWidth, WIDTH_PROBE } from "./view/height.js"; @@ -22,18 +26,39 @@ export interface WidthContractReport { readonly widthMethod: WidthMethod; } -/** Width OpenTUI's own table assigns `text`, or null when it cannot encode it. */ +/** Width OpenTUI's own table assigns `text`, or null when it cannot encode it. + * + * 0.6.x moves `encodeUnicode` off the render lib onto a Context-owned buffer, + * so the measure builds its own throwaway Context. `objectCapacity: 1, + * renderCellsMax: 1` is not OpenTUI's internal shape (ImageOwner keeps a live + * 4096/1 context); this call is one-shot and self-destroying, and 1/1 is the + * tightest capacity the probe verifies as sufficient to encode and free. An + * encode failure still reads as null — this check reports, never throws. */ export function measureRendererWidth( text: string, widthMethod: WidthMethod, ): number | null { - const lib = resolveRenderLib(); - const encoded = lib.encodeUnicode(text, widthMethod); - if (encoded === null) return null; + const owner = new ResourceContext({ objectCapacity: 1, renderCellsMax: 1 }); + const buffer = OptimizedBuffer.create(1, 1, widthMethod, { owner }); try { - return encoded.data.reduce((n, cell) => n + cell.width, 0); + let encoded; + try { + encoded = buffer.encodeUnicode(text); + } catch { + return null; + } + if (encoded === null) return null; + try { + return encoded.data.reduce((n, cell) => n + cell.width, 0); + } finally { + buffer.freeUnicode(encoded); + } } finally { - lib.freeUnicode(encoded); + try { + buffer.destroy(); + } finally { + owner.destroy(); + } } }