Compare commits

...
Author SHA1 Message Date
Simon Klee 2ddcdab347 wip oc-ot-native 2026-09-26 22:58:09 +02:00
47 changed files with 477 additions and 254 deletions
@@ -117,8 +117,9 @@ async function open(from?: string): Promise<Session> {
</Show>
),
renderer,
).catch((error) => {
).catch(async (error) => {
if (!renderer.isDestroyed) renderer.destroy()
await renderer.closed
throw error
})
let shownAt = performance.now()
@@ -153,10 +154,11 @@ async function open(from?: string): Promise<Session> {
(closing ??= (async () => {
if (transferred) return
setAnimating(false)
if (renderer.isDestroyed) return
if (renderer.isDestroyed) return renderer.closed
renderer.pause()
await Promise.race([renderer.idle(), setTimeout(500)])
renderer.destroy()
await renderer.closed
})())
let loading: Promise<void> | undefined
const load = () =>
+2
View File
@@ -94,6 +94,7 @@ function bounded(task: Promise<unknown>) {
}
async function shutdown(renderer: CliRenderer): Promise<void> {
if (renderer.isDestroyed) return renderer.closed
await bounded(renderer.idle())
try {
renderer.externalOutputMode = "passthrough"
@@ -102,6 +103,7 @@ async function shutdown(renderer: CliRenderer): Promise<void> {
renderer.screenMode = "main-screen"
} finally {
if (!renderer.isDestroyed) renderer.destroy()
await renderer.closed
}
}
}
+14 -6
View File
@@ -13,11 +13,19 @@ import { createTestRenderer } from "@opentui/core/testing"
import { renderLatex } from "./render"
import { createLatexCodeBlockRenderer } from "./markdown"
const renderers: Awaited<ReturnType<typeof createTestRenderer>>["renderer"][] = []
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })
const renderers: {
renderer: Awaited<ReturnType<typeof createTestRenderer>>["renderer"]
syntaxStyle: SyntaxStyle
}[] = []
afterEach(() => {
renderers.splice(0).forEach((renderer) => renderer.destroy())
afterEach(async () => {
await Promise.all(
renderers.splice(0).map(async (output) => {
output.renderer.destroy()
await output.renderer.closed
output.syntaxStyle.destroy()
}),
)
})
async function setup(content: string, width = 80) {
@@ -25,9 +33,9 @@ async function setup(content: string, width = 80) {
width,
height: 24,
remote: true,
useThread: false,
})
renderers.push(output.renderer)
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }, output.renderer.nativeScene)
renderers.push({ renderer: output.renderer, syntaxStyle })
const palette = { text: "#abcdef", subdued: "#667788" }
const render = createLatexCodeBlockRenderer(output.renderer, () => palette)
const markdown = new MarkdownRenderable(output.renderer, {
+28 -38
View File
@@ -6,11 +6,9 @@ import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TreeSitterClient
import { createTestRenderer } from "@opentui/core/testing"
import { createMermaidMarkdownRenderer } from "../markdown.js"
const syntaxStyle = SyntaxStyle.fromStyles({
default: { fg: RGBA.fromValues(1, 1, 1, 1) },
})
let syntaxStyle: SyntaxStyle
let treeSitterClient: TreeSitterClient
let renderer: Awaited<ReturnType<typeof createTestRenderer>>["renderer"] | undefined
let renderer: Awaited<ReturnType<typeof createTestRenderer>>["renderer"]
beforeAll(async () => {
const dataPath = join(tmpdir(), "mermaid-markdown-test-data")
@@ -23,11 +21,19 @@ afterAll(async () => {
await treeSitterClient.destroy()
})
afterEach(() => {
afterEach(async () => {
renderer?.destroy()
renderer = undefined
await renderer?.closed
syntaxStyle?.destroy()
})
async function setup(options: Parameters<typeof createTestRenderer>[0]) {
const output = await createTestRenderer(options)
renderer = output.renderer
syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: RGBA.fromValues(1, 1, 1, 1) } }, renderer.nativeScene)
return output
}
async function renderMarkdown(
markdown: MarkdownRenderable,
renderOnce: () => Promise<void>,
@@ -54,8 +60,7 @@ async function renderMarkdown(
}
test("renders a Mermaid flowchart fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 80, height: 14 })
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-mermaid",
@@ -79,8 +84,7 @@ flowchart LR
})
test("uses compact terminal spacing for Mermaid diagrams by default", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 40 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 80, height: 40 })
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-compact-mermaid",
content: `\`\`\`mermaid
@@ -102,8 +106,7 @@ flowchart TD
})
test("recognizes normalized Mermaid fence info strings", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 80, height: 14 })
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-normalized-mermaid",
@@ -125,8 +128,7 @@ flowchart LR
})
test("keeps surrounding Markdown content visible around a Mermaid diagram", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 80, height: 14 })
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-with-mermaid",
@@ -154,8 +156,7 @@ After`,
})
test("renders an incomplete Mermaid fence as ordinary code", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 10 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 80, height: 10 })
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-incomplete-mermaid",
@@ -175,8 +176,7 @@ flowchart LR
})
test("keeps the last valid Mermaid diagram while a fence is streaming", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 12 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 80, height: 12 })
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-streaming-mermaid",
content: `\`\`\`mermaid
@@ -212,8 +212,7 @@ flowchart LR
})
test("renders the valid prefix of an interrupted Mermaid fence", async () => {
const testRenderer = await createTestRenderer({ width: 100, height: 18 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 100, height: 18 })
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-interrupted-mermaid",
content: `\`\`\`mermaid
@@ -238,8 +237,7 @@ flowchart TD
})
test("renders a Mermaid sequence fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 80, height: 14 })
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-sequence",
@@ -262,8 +260,7 @@ sequenceDiagram
})
test("wraps wide Mermaid diagrams in a horizontal viewport", async () => {
const testRenderer = await createTestRenderer({ width: 40, height: 14 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 40, height: 14 })
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-wide-sequence",
content: `\`\`\`mermaid
@@ -300,8 +297,7 @@ sequenceDiagram
})
test("keeps surrounding Markdown anchored around a wide flowchart", async () => {
const testRenderer = await createTestRenderer({ width: 100, height: 40 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 100, height: 40 })
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-wide-flowchart",
content: `## Architecture — the profile is three mechanisms
@@ -339,8 +335,7 @@ flowchart TB
})
test("folds a horizontal flowchart to fit the Markdown viewport", async () => {
const testRenderer = await createTestRenderer({ width: 160, height: 30 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 160, height: 30 })
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-horizontal-flowchart",
content: `\`\`\`mermaid
@@ -361,8 +356,7 @@ flowchart LR
})
test("folds a horizontal state diagram to the Markdown context width", async () => {
const testRenderer = await createTestRenderer({ width: 60, height: 48 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 60, height: 48 })
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-horizontal-state",
content: `\`\`\`mermaid
@@ -388,8 +382,7 @@ stateDiagram-v2
})
test("renders a Mermaid state fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 80, height: 14 })
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-state",
@@ -411,8 +404,7 @@ stateDiagram-v2
})
test("sizes a standalone state choice after trimming leading rows", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 6 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 80, height: 6 })
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-state-choice",
content: `\`\`\`mermaid
@@ -432,8 +424,7 @@ stateDiagram-v2
})
test("renders a Mermaid timeline fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 18 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 80, height: 18 })
const { renderOnce, captureCharFrame } = testRenderer
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-timeline",
@@ -460,8 +451,7 @@ timeline
})
test("renders a Mermaid GitGraph fence inside MarkdownRenderable", async () => {
const testRenderer = await createTestRenderer({ width: 80, height: 18 })
renderer = testRenderer.renderer
const testRenderer = await setup({ width: 80, height: 18 })
const markdown = new MarkdownRenderable(renderer, {
id: "markdown-gitgraph",
content: `\`\`\`mermaid
+13 -3
View File
@@ -1,5 +1,6 @@
import type { CliRenderer, CliRendererConfig } from "@opentui/core"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { Writable } from "node:stream"
import { Effect } from "effect"
import { Timeline, type Pointer } from "../recording"
@@ -44,15 +45,21 @@ export const create = Effect.fn("SimulationRenderer.create")(function* (
kittyKeyboard: Boolean(options.useKittyKeyboard),
...(recording
? {
stdout: recording as unknown as NodeJS.WriteStream,
// Finishing a recording must not close the live renderer's output sink.
stdout: new Writable({
write(chunk, encoding, callback) {
recording.write(chunk, encoding, callback)
},
}) as unknown as NodeJS.WriteStream,
bufferedOutput: "stdout" as const,
}
: {}),
}),
),
(setup) =>
Effect.sync(() => {
Effect.promise(async () => {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await setup.renderer.closed
}),
)
setups.set(setup.renderer, setup)
@@ -75,7 +82,10 @@ export function setupFor(renderer: CliRenderer): TestRendererSetup | undefined {
export function finish(renderer: CliRenderer) {
const recording = recordings.get(renderer)
if (!recording) return Effect.fail(new Error("UI recording is not available"))
return Effect.tryPromise(() => recording.finish())
return Effect.tryPromise(async () => {
if (renderer.isDestroyed) await renderer.closed
return recording.finish()
})
}
export * as SimulationRenderer from "./renderer"
@@ -14,8 +14,9 @@ export const create = Effect.fn("Drive.create")(function* (options: CliRendererC
: yield* Effect.acquireRelease(
Effect.tryPromise(() => createCliRenderer(options)),
(renderer) =>
Effect.sync(() => {
Effect.promise(async () => {
if (!renderer.isDestroyed) renderer.destroy()
await renderer.closed
}),
)
if (!headless && manifest.viewport) renderer.resize(manifest.viewport.cols, manifest.viewport.rows)
+20 -10
View File
@@ -104,7 +104,7 @@ test.each(["pointer", "output"])("joins both recording streams after an early %s
}
})
test("captures native renderer output and finishes on destroy", async () => {
test.each(["destroy", "scope"])("records terminal restoration before finishing on %s", async (close) => {
const directory = await mkdtemp(join(tmpdir(), "simulation-renderer-recording-"))
const path = join(directory, "timeline.jsonl")
@@ -112,19 +112,26 @@ test("captures native renderer output and finishes on destroy", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({}, path)
const renderer = yield* SimulationRenderer.create({ screenMode: "alternate-screen" }, path)
yield* Effect.promise(() => renderer.setupTerminal())
yield* Effect.promise(() => SimulationRenderer.setupFor(renderer)?.renderOnce() ?? Promise.resolve())
renderer.destroy()
expect(yield* SimulationRenderer.finish(renderer)).toBe(path)
const events = (yield* Effect.promise(() => Bun.file(path).text()))
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Event)
expect(events.some((event) => event.type === "output")).toBe(true)
if (close === "destroy") {
renderer.destroy()
expect(yield* SimulationRenderer.finish(renderer)).toBe(path)
}
}),
),
)
const events = (await Bun.file(path).text())
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Event)
const output = events.flatMap((event) =>
event.type === "output" ? [Buffer.from(event.data, "base64").toString()] : [],
)
expect(output.join("")).toContain("\u001b[?1049l")
expect(output.at(-2)).toContain("\u001b[?25h")
expect(output.at(-1)).toBe("")
} finally {
await rm(directory, { recursive: true, force: true })
}
@@ -143,6 +150,9 @@ test("matches live screen text while recording", async () => {
yield* Effect.promise(() => SimulationRenderer.setupFor(renderer)?.renderOnce() ?? Promise.resolve())
expect(matches(createHarness(renderer), "recorded screen text")).toBe(true)
expect(yield* SimulationRenderer.finish(renderer)).toBe(path)
yield* Effect.promise(() => SimulationRenderer.setupFor(renderer)?.renderOnce() ?? Promise.resolve())
expect(renderer.isDestroyed).toBe(false)
}),
),
)
+5 -4
View File
@@ -1,13 +1,13 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import { SyntaxStyle, type NativeResourceOwner, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import type { Mode, ResolvedThemeTokens } from "./index.js"
export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode, owner: NativeResourceOwner) {
const step = mode === "light" ? 800 : 200
const syntax = theme.syntax
const markdown = theme.markdown
const feedback = theme.text.feedback
return SyntaxStyle.fromTheme([
const rules = [
rule(["default"], theme.text.default),
rule(["prompt"], theme.hue.accent[step]),
rule(["extmark.file"], feedback.warning.default, { bold: true }),
@@ -82,7 +82,8 @@ export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
rule(["error"], feedback.error.default, { bold: true }),
rule(["warning"], feedback.warning.default, { bold: true }),
rule(["info"], feedback.info.default),
])
]
return SyntaxStyle.fromTheme(rules, owner)
}
function rule(
+9 -2
View File
@@ -247,7 +247,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
if (handoff) {
handoff.renderer.useMouse = options.useMouse
return yield* Effect.acquireRelease(Effect.succeed(handoff.renderer), (renderer) =>
Effect.sync(() => destroyRenderer(renderer)),
Effect.promise(async () => {
destroyRenderer(renderer)
await renderer.closed
}),
)
}
if (process.env.OPENCODE_DRIVE) {
@@ -259,7 +262,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
try: () => createCliRenderer(options),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
(renderer) =>
Effect.promise(async () => {
destroyRenderer(renderer)
await renderer.closed
}),
)
})
const clipboard = yield* Effect.acquireRelease(
+3 -2
View File
@@ -1,6 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { useRenderer, type JSX } from "@opentui/solid"
import { createMemo, For, Show, splitProps } from "solid-js"
import { splitPatchHunks } from "../util/diff"
import { stringWidth } from "../util/string-width"
@@ -17,6 +17,7 @@ type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref"
}
export function PatchDiff(props: Props) {
const renderer = useRenderer()
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref"])
const hunks = createMemo(() => splitPatchHunks(local.diff))
const nodes = new Map<number, DiffRenderable>()
@@ -28,7 +29,7 @@ export function PatchDiff(props: Props) {
.filter((node) => !node.isDestroyed),
})
const syncGutters = (attempt = 0) => {
requestAnimationFrame(() => {
renderer.requestAnimationFrame(() => {
const sides = [...nodes.values()]
.filter((item) => !item.isDestroyed)
.flatMap((item) => item.getChildren().filter((side) => side instanceof LineNumberRenderable))
+2 -2
View File
@@ -342,7 +342,7 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
input.invalidateIntrinsicSize()
input.gotoBufferEnd()
renderer.requestRender()
}, 0)
@@ -1484,7 +1484,7 @@ export function Prompt(props: PromptProps) {
setTimeout(() => {
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
input.invalidateIntrinsicSize()
renderer.requestRender()
}, 0)
}
+81 -73
View File
@@ -109,9 +109,9 @@ export class TitleShimmerRenderable extends TextRenderable {
this.requestRender()
}
override render(buffer: OptimizedBuffer, deltaTime: number) {
protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0) {
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
if (!this.animating) return super.render(buffer, deltaTime)
if (!this.animating) return super.renderSelf(buffer)
// A newly live title must not inherit time spent idle before its fade started.
const delta = this.fresh ? 0 : deltaTime
this.fresh = false
@@ -131,10 +131,13 @@ export class TitleShimmerRenderable extends TextRenderable {
if (!this.animating) {
this.previous?.destroy()
this.previous = undefined
return super.render(buffer, deltaTime)
return super.renderSelf(buffer)
}
if (!this.scratch)
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, {
owner: this._ctx.nativeScene,
respectAlpha: true,
})
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
this.scratch.resize(this.width, this.height)
@@ -175,80 +178,85 @@ export class TitleShimmerRenderable extends TextRenderable {
Math.max(0, clip.right - clip.left),
Math.max(0, clip.bottom - clip.top),
)
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
const characters = this.scratch.buffers.char
let end = 0
for (let row = 0; row < this.height; row++) {
let column = this.width
while (
column > 0 &&
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
)
column--
end = Math.max(end, column)
}
const wipeFront =
this.arrival !== undefined && this.previous
? -WIPE_FEATHER +
coast(this.arrival / ARRIVAL_DURATION) * (Math.max(end, this.previous.width) + WIPE_FEATHER * 2)
: undefined
const cut = Math.max(0, Math.min(this.width, Math.round(wipeFront ?? 0)))
if (wipeFront !== undefined && this.previous) {
this.scratch.clear(TRANSPARENT)
this.scratch.pushScissorRect(0, 0, cut, this.height)
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
this.scratch.popScissorRect()
// Snapshot slices must also end on whole glyphs; framebuffer clipping alone can split them.
for (let row = 0; row < Math.min(this.height, this.previous.height); row++) {
let left = Math.max(cut, clip.left)
let right = Math.min(this.previous.width, clip.right)
const offset = row * this.previous.width
while (left < right && (this.previous.buffers.char[offset + left] & CONTINUATION) === CONTINUATION) left++
this.drawToBuffer(this.scratch, 0, 0)
const scratch = this.scratch
scratch.withBuffers((cells) => {
const characters = cells.char
let end = 0
for (let row = 0; row < this.height; row++) {
let column = this.width
while (
right > left &&
right < this.previous.width &&
(this.previous.buffers.char[offset + right] & CONTINUATION) === CONTINUATION
column > 0 &&
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
)
right--
if (right > left) this.scratch.drawFrameBuffer(left, row, this.previous, left, row, right - left, 1)
column--
end = Math.max(end, column)
}
}
this.scratch.clearScissorRects()
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
if (wipeFront === undefined) {
if (!this.previous)
this.previous = OptimizedBuffer.create(Math.max(1, end), this.height, this._ctx.widthMethod, {
respectAlpha: true,
const wipeFront =
this.arrival !== undefined && this.previous
? -WIPE_FEATHER +
coast(this.arrival / ARRIVAL_DURATION) * (Math.max(end, this.previous.width) + WIPE_FEATHER * 2)
: undefined
const cut = Math.max(0, Math.min(this.width, Math.round(wipeFront ?? 0)))
if (wipeFront !== undefined && this.previous) {
scratch.clear(TRANSPARENT)
scratch.pushScissorRect(0, 0, cut, this.height)
this.drawToBuffer(scratch, 0, 0)
scratch.popScissorRect()
const previous = this.previous
previous.withBuffers((cells) => {
// Snapshot slices must also end on whole glyphs; framebuffer clipping alone can split them.
for (let row = 0; row < Math.min(this.height, previous.height); row++) {
let left = Math.max(cut, clip.left)
let right = Math.min(previous.width, clip.right)
const offset = row * previous.width
while (left < right && (cells.char[offset + left] & CONTINUATION) === CONTINUATION) left++
while (
right > left &&
right < previous.width &&
(cells.char[offset + right] & CONTINUATION) === CONTINUATION
)
right--
if (right > left) scratch.drawFrameBuffer(left, row, previous, left, row, right - left, 1)
}
})
if (this.previous.width !== Math.max(1, end) || this.previous.height !== this.height)
this.previous.resize(Math.max(1, end), this.height)
this.previous.clear(TRANSPARENT)
this.previous.drawFrameBuffer(0, 0, this.scratch)
}
const front = -4 + coast(this.elapsed / SHIMMER_DURATION) * ((this.previous?.width ?? end) + 4 + 18)
const level = smootherstep(this.blend)
let strength = 0
for (let cell = 0; cell < characters.length; cell++) {
const column = cell % this.width
if ((characters[cell] & CONTINUATION) !== CONTINUATION) {
const old = wipeFront === undefined || column >= cut
let visibility = old ? 1 - 0.6 * level * (1 - intensityAt(column, front, 4, 18)) : 1
if (wipeFront !== undefined) {
let width = 1
while (column + width < this.width && (characters[cell + width] & CONTINUATION) === CONTINUATION) width++
const distance = old ? column - wipeFront : wipeFront - (column + width)
visibility *= smootherstep(Math.max(0, Math.min(1, distance / WIPE_FEATHER)))
}
strength = 1 - visibility
}
this.mask[cell * 3] = column
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
this.mask[cell * 3 + 2] = strength
}
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
this.markClean()
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
scratch.clearScissorRects()
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
if (wipeFront === undefined) {
if (!this.previous)
this.previous = OptimizedBuffer.create(Math.max(1, end), this.height, this._ctx.widthMethod, {
owner: this._ctx.nativeScene,
respectAlpha: true,
})
if (this.previous.width !== Math.max(1, end) || this.previous.height !== this.height)
this.previous.resize(Math.max(1, end), this.height)
this.previous.clear(TRANSPARENT)
this.previous.drawFrameBuffer(0, 0, scratch)
}
const front = -4 + coast(this.elapsed / SHIMMER_DURATION) * ((this.previous?.width ?? end) + 4 + 18)
const level = smootherstep(this.blend)
let strength = 0
for (let cell = 0; cell < characters.length; cell++) {
const column = cell % this.width
if ((characters[cell] & CONTINUATION) !== CONTINUATION) {
const old = wipeFront === undefined || column >= cut
let visibility = old ? 1 - 0.6 * level * (1 - intensityAt(column, front, 4, 18)) : 1
if (wipeFront !== undefined) {
let width = 1
while (column + width < this.width && (characters[cell + width] & CONTINUATION) === CONTINUATION) width++
const distance = old ? column - wipeFront : wipeFront - (column + width)
visibility *= smootherstep(Math.max(0, Math.min(1, distance / WIPE_FEATHER)))
}
strength = 1 - visibility
}
this.mask[cell * 3] = column
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
this.mask[cell * 3 + 2] = strength
}
scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
buffer.drawFrameBuffer(this.screenX, this.screenY, scratch)
})
}
override destroy() {
+1 -1
View File
@@ -322,7 +322,7 @@ const themeContext = createSimpleContext({
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
const currentSyntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode(), renderer.nativeScene))
const service: Themes = {
current,
currentTokens: valuesV2,
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { MouseButton, TextAttributes, type MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import { truncateFilePath } from "../../ui/file-path"
import { stringWidth } from "../../util/string-width"
import { useTheme } from "../../context/theme"
@@ -27,6 +28,7 @@ export type DiffViewerFileTreeProps = {
}
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const renderer = useRenderer()
const theme = useTheme("elevated")
const [sourceHovered, setSourceHovered] = createSignal(false)
const list = () => props.layout === "list"
@@ -51,7 +53,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
const height = list() ? 2 : 1
const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, top, height)
scrollSelectedIntoView()
requestAnimationFrame(scrollSelectedIntoView)
renderer.requestAnimationFrame(scrollSelectedIntoView)
})
return (
@@ -452,11 +452,11 @@ export function DiffViewerContent(props: {
const scrollToPatchFileIndexAfterRender = (fileIndex: number, offset?: number) => {
setPendingPatchScrollFileIndex(fileIndex)
requestAnimationFrame(() => {
renderer.requestAnimationFrame(() => {
if (pendingPatchScrollFileIndex() !== fileIndex) return
const patchNode = patchNodeByFileIndex.get(fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode, offset)
requestAnimationFrame(() => {
renderer.requestAnimationFrame(() => {
if (pendingPatchScrollFileIndex() !== fileIndex) return
const patchNode = patchNodeByFileIndex.get(fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode, offset)
@@ -466,9 +466,9 @@ export function DiffViewerContent(props: {
}
const scrollSinglePatchToTop = () => {
requestAnimationFrame(() => {
renderer.requestAnimationFrame(() => {
scroll?.scrollTo(0)
requestAnimationFrame(() => scroll?.scrollTo(0))
renderer.requestAnimationFrame(() => scroll?.scrollTo(0))
})
}
+2 -2
View File
@@ -249,7 +249,7 @@ export function RunPromptBody(props: {
}
// Paste can leave the textarea layout stale until the next edit.
area.getLayoutNode().markDirty()
area.invalidateIntrinsicSize()
renderer.requestRender()
void renderer
.idle()
@@ -936,7 +936,7 @@ export function createPromptState(input: PromptInput): PromptState {
}
hide()
syncDraft()
target.getLayoutNode().markDirty()
target.invalidateIntrinsicSize()
renderer.requestRender()
scheduleRows()
})
+5 -1
View File
@@ -1144,7 +1144,11 @@ export class RunFooter implements FooterApi {
this.prompts.clear()
this.closes.clear()
this.scrollback.destroy()
for (const theme of [...this.themes]) this.destroyTheme(theme)
void this.renderer.closed
.catch(() => {})
.then(() => {
for (const theme of [...this.themes]) this.destroyTheme(theme)
})
}
// Drains the commit queue to scrollback. The surface manager owns grouping,
+1 -1
View File
@@ -524,7 +524,7 @@ export function RunFooterView(props: RunFooterViewProps) {
.join(props.mono ? " - " : " \u00b7 ")
if (stringWidth(text) <= statusWidth() && !text.includes("\n")) return 1
// Measure outside the clipped footer so wrapped required controls can grow it.
const buffer = TextBuffer.create(renderer.widthMethod)
const buffer = TextBuffer.create(renderer.widthMethod, renderer.nativeScene)
const view = TextBufferView.create(buffer)
buffer.setText(text)
view.setWrapMode("word")
+16 -15
View File
@@ -185,21 +185,22 @@ function monoText(value: string): string {
}
export function monoSnapshot(event: CliRendererExternalOutputEvent): void {
const buffers = event.snapshot.buffers
const chars = buffers.char
for (let index = 0; index < chars.length; index += 1) {
const point = chars[index]
if (point <= 0x7f) continue
const offset = index * 4
event.snapshot.setCell(
index % event.snapshot.width,
Math.floor(index / event.snapshot.width),
monoCell(point),
RGBA.fromArray(buffers.fg.subarray(offset, offset + 4)),
RGBA.fromArray(buffers.bg.subarray(offset, offset + 4)),
buffers.attributes[index],
)
}
event.snapshot.withBuffers((buffers) => {
const chars = buffers.char
for (let index = 0; index < chars.length; index += 1) {
const point = chars[index]
if (point <= 0x7f) continue
const offset = index * 4
event.snapshot.setCell(
index % event.snapshot.width,
Math.floor(index / event.snapshot.width),
monoCell(point),
RGBA.fromArray(buffers.fg.subarray(offset, offset + 4)),
RGBA.fromArray(buffers.bg.subarray(offset, offset + 4)),
buffers.attributes[index],
)
}
})
}
function monoCell(point: number): string {
+5 -4
View File
@@ -94,9 +94,9 @@ export type Lifecycle = {
// Gracefully tears down the renderer. Order matters: switch external output
// back to passthrough before leaving split-footer mode, so pending stdout
// doesn't get captured into the now-dead scrollback pipeline.
function shutdown(renderer: CliRenderer): void {
async function shutdown(renderer: CliRenderer): Promise<void> {
if (renderer.isDestroyed) {
return
return renderer.closed
}
if (renderer.externalOutputMode === "capture-stdout") {
@@ -110,6 +110,7 @@ function shutdown(renderer: CliRenderer): void {
if (!renderer.isDestroyed) {
renderer.destroy()
}
await renderer.closed
}
function splashTitle(title: string | undefined, history: RunPrompt[]) {
@@ -334,8 +335,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
footer.close()
await footer.idle().catch(() => {})
footer.destroy()
if (input.host.platform === "linux") renderer.setTerminalTitle("")
shutdown(renderer)
if (input.host.platform === "linux" && !renderer.isDestroyed) renderer.setTerminalTitle("")
await shutdown(renderer)
if (!wroteExit) {
input.host.stdout.write("\n")
}
+3 -7
View File
@@ -1,13 +1,9 @@
import { SyntaxStyle, TextAttributes, type ColorInput } from "@opentui/core"
import { SyntaxStyle, TextAttributes, type ColorInput, type NativeResourceOwner } from "@opentui/core"
import { type RunEntryTheme, type RunTheme } from "./theme"
import type { StreamCommit } from "./types"
function syntax(style?: SyntaxStyle): SyntaxStyle {
return style ?? SyntaxStyle.fromTheme([])
}
export function entrySyntax(theme: RunTheme): SyntaxStyle {
return syntax(theme.block.syntax)
export function entrySyntax(theme: RunTheme, owner: NativeResourceOwner): SyntaxStyle {
return SyntaxStyle.fromStyles(theme.block.syntax?.getAllStyles() ?? {}, owner)
}
function entryFailed(commit: StreamCommit): boolean {
+16 -4
View File
@@ -12,6 +12,7 @@ import {
type TreeSitterClient,
type CliRenderer,
type ScrollbackSurface,
type SyntaxStyle,
} from "@opentui/core"
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"
@@ -28,6 +29,7 @@ type ActiveEntry = {
commit: StreamCommit
surface: ScrollbackSurface
renderable: TextRenderable | CodeRenderable | MarkdownRenderable
syntax?: SyntaxStyle
content: string
committedRows: number
committedBlocks: number
@@ -95,6 +97,7 @@ export class RunScrollbackStream {
private imagePreview: boolean
private destroyed = false
private pendingThemes: RunTheme[] = []
private pendingStyles: SyntaxStyle[] = []
constructor(
private renderer: CliRenderer,
@@ -128,12 +131,14 @@ export class RunScrollbackStream {
// Rebuild the Markdown tree, keeping its source and printed block boundary.
// Mono hooks are one-way, and ending the entry would lose open fence/list context.
const next = this.createEntry(active.commit, active.body)
this.active = { ...active, surface: next.surface, renderable: next.renderable }
this.active = { ...active, surface: next.surface, renderable: next.renderable, syntax: next.syntax }
active.surface.destroy()
active.syntax?.destroy()
this.releasePendingThemes()
}
private releasePendingThemes(): void {
for (const style of this.pendingStyles.splice(0)) style.destroy()
if (this.pendingThemes.length === 0) {
return
}
@@ -164,7 +169,10 @@ export class RunScrollbackStream {
}
active.renderable.fg = entryColor(active.commit, theme)
active.renderable.syntaxStyle = entrySyntax(theme)
const syntax = entrySyntax(theme, active.surface.renderContext.nativeScene)
if (active.syntax) this.pendingStyles.push(active.syntax)
active.syntax = syntax
active.renderable.syntaxStyle = syntax
}
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
@@ -172,6 +180,7 @@ export class RunScrollbackStream {
startOnNewLine: entryFlags(commit).startOnNewLine,
})
const style = entryLook(commit, this.theme.entry)
const syntax = body.type === "text" ? undefined : entrySyntax(this.theme, surface.renderContext.nativeScene)
const treeSitterClient = body.type === "text" ? undefined : (this.treeSitterClient ??= getTreeSitterClient())
const renderable =
body.type === "text"
@@ -186,7 +195,7 @@ export class RunScrollbackStream {
? new CodeRenderable(surface.renderContext, {
content: "",
filetype: body.filetype,
syntaxStyle: entrySyntax(this.theme),
syntaxStyle: syntax!,
width: "100%",
wrapMode: "word",
drawUnstyledText: false,
@@ -196,7 +205,7 @@ export class RunScrollbackStream {
})
: new MarkdownRenderable(surface.renderContext, {
content: "",
syntaxStyle: entrySyntax(this.theme),
syntaxStyle: syntax!,
width: "100%",
streaming: true,
internalBlockMode: "top-level",
@@ -215,6 +224,7 @@ export class RunScrollbackStream {
commit,
surface,
renderable,
syntax,
content: "",
committedRows: 0,
committedBlocks: 0,
@@ -347,6 +357,7 @@ export class RunScrollbackStream {
if (!active.surface.isDestroyed) {
active.surface.destroy()
}
active.syntax?.destroy()
this.releasePendingThemes()
}
@@ -504,6 +515,7 @@ export class RunScrollbackStream {
if (!this.active.surface.isDestroyed) {
this.active.surface.destroy()
}
this.active.syntax?.destroy()
this.active = undefined
this.releasePendingThemes()
+27 -10
View File
@@ -1,12 +1,14 @@
import { createScrollbackWriter } from "@opentui/solid"
import { createScrollbackWriter, useRenderer } from "@opentui/solid"
import {
MarkdownRenderable,
TextRenderable,
type ColorInput,
type Renderable,
type ScrollbackRenderContext,
type ScrollbackWriter,
type SyntaxStyle,
} from "@opentui/core"
import { For, Match, Switch, createMemo } from "solid-js"
import { For, Match, Switch, createMemo, onCleanup } from "solid-js"
import { entryBody, entryFlags } from "./entry.body"
import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
@@ -82,10 +84,19 @@ export function RunEntryContent(props: {
theme?: RunTheme
opts?: ScrollbackOptions
}) {
const renderer = useRenderer()
const borrowers: Renderable[] = []
let fallback: SyntaxStyle | undefined
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
const body = createMemo(() => props.body ?? entryBody(props.commit, props.opts))
const style = createMemo(() => entryLook(props.commit, theme().entry))
const syntax = createMemo(() => entrySyntax(theme()))
const syntax = () => theme().block.syntax ?? (fallback ??= entrySyntax(RUN_THEME_FALLBACK, renderer.nativeScene))
onCleanup(() => {
if (!fallback) return
// Solid defers removed-node destruction; release borrowers before their fallback style.
borrowers.forEach((node) => node.destroyRecursively())
fallback.destroy()
})
const color = createMemo(() => entryColor(props.commit, theme()))
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
@@ -141,6 +152,7 @@ export function RunEntryContent(props: {
</Match>
<Match when={code()}>
<code
ref={(node: Renderable) => borrowers.push(node)}
width="100%"
wrapMode="word"
filetype={code()!.filetype}
@@ -159,6 +171,7 @@ export function RunEntryContent(props: {
<box width="100%" paddingLeft={1}>
<line_number width="100%" fg={theme().block.muted} minWidth={3} paddingRight={1}>
<code
ref={(node: Renderable) => borrowers.push(node)}
width="100%"
wrapMode="char"
filetype={toolFiletype(code_snapshot()!.file)}
@@ -179,7 +192,7 @@ export function RunEntryContent(props: {
{item.title}
</text>
{item.diff.trim() ? (
<box width="100%" paddingLeft={1}>
<box ref={(node: Renderable) => borrowers.push(node)} width="100%" paddingLeft={1}>
<PatchDiff
diff={item.diff}
hunkFg={theme().block.diffLineNumber}
@@ -259,6 +272,7 @@ export function RunEntryContent(props: {
{(mono) => (
<markdown
ref={(renderable: MarkdownRenderable) => {
borrowers.push(renderable)
if (mono) monoMarkdownRenderable(renderable)
}}
width="100%"
@@ -281,17 +295,20 @@ export function entryWriter(input: {
theme?: RunTheme
opts?: ScrollbackOptions
}): ScrollbackWriter {
return createScrollbackWriter(
() => (
return createScrollbackWriter((ctx) => {
const theme = input.theme ?? RUN_THEME_FALLBACK
const syntax = entrySyntax(theme, ctx.renderContext.nativeScene)
// The writer tears down its Solid root after destroying the snapshot tree.
onCleanup(() => syntax.destroy())
return (
<RunEntryContent
commit={input.commit}
body={input.body}
theme={input.theme}
theme={{ ...theme, block: { ...theme.block, syntax } }}
opts={{ ...input.opts, suppressBackgrounds: true }}
/>
),
entryFlags(input.commit),
)
)
}, entryFlags(input.commit))
}
export function spacerWriter(): ScrollbackWriter {
+7 -1
View File
@@ -301,7 +301,13 @@ export async function resolveRunTheme(
? ansiPalette.map((color, index) => (colors.palette[index] ? RGBA.fromIndex(index, colors.palette[index]!) : color))
: ansiPalette
return {
...map(theme, indexed, mode, generateSyntax(theme, mode), name === "system" && resolved !== undefined),
...map(
theme,
indexed,
mode,
renderer.isDestroyed ? undefined : generateSyntax(theme, mode, renderer.nativeScene),
name === "system" && resolved !== undefined,
),
background: RGBA.defaultBackground(colors?.defaultBackground ?? theme.background.default),
}
}
@@ -1,6 +1,7 @@
import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid"
import type { SessionInfo } from "@opencode/client"
import { useRoute, useRouteData } from "../../../context/route"
import { useData } from "../../../context/data"
@@ -22,6 +23,7 @@ interface SubagentEntry {
}
export function SubagentsTab(props: { sessionID: string }) {
const renderer = useRenderer()
const route = useRouteData("session")
const data = useData()
const client = useClient()
@@ -84,7 +86,7 @@ export function SubagentsTab(props: { sessionID: string }) {
const scrollCurrentIntoView = () => scrollToIndex(next, true)
scrollCurrentIntoView()
// The remounted scrollbox finishes layout on the next frame and resets its scroll position.
requestAnimationFrame(() => requestAnimationFrame(scrollCurrentIntoView))
renderer.requestAnimationFrame(() => renderer.requestAnimationFrame(scrollCurrentIntoView))
}
wasActive = true
if (store.selected >= list.length) moveTo(Math.max(0, list.length - 1))
+6 -4
View File
@@ -215,6 +215,7 @@ export function Session(props: {
return messages().findLast((x) => x.type === "assistant")
})
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
const showScrollbar = createMemo(() => config.session?.scrollbar ?? false)
@@ -366,8 +367,8 @@ export function Session(props: {
/** Runs after layout has settled (two frames), unless the transcript was torn down. */
const afterLayout = (continuation: () => void) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
renderer.requestAnimationFrame(() => {
renderer.requestAnimationFrame(() => {
if (!scroll || scroll.isDestroyed) return
continuation()
})
@@ -537,7 +538,6 @@ export function Session(props: {
current.submit()
})
const dialog = useDialog()
const renderer = useRenderer()
const runPendingAction = createSingleFlight<string>()
const mutatePending = async (action: PendingAction, inboxID: string) => {
const result = await runPendingAction(inboxID, async () => {
@@ -1744,8 +1744,10 @@ function SessionReasoningGroupView(props: {
const ctx = use()
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const renderer = useRenderer()
const thinkingSyntax = createSyntaxStyleMemo(() =>
generateThinkingSyntax(syntax(), theme.text.subdued, renderer.nativeScene),
)
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
const parts = createMemo(() =>
@@ -1,6 +1,6 @@
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
import { RGBA, TextAttributes } from "@opentui/core"
import type { JSX } from "@opentui/solid"
import { useRenderer, type JSX } from "@opentui/solid"
import type {
SessionMessageAssistant,
SessionMessageAssistantReasoning,
@@ -24,7 +24,10 @@ export function ReasoningPart(props: {
}) {
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
const renderer = useRenderer()
const thinkingSyntax = createSyntaxStyleMemo(() =>
generateThinkingSyntax(syntax(), theme.text.subdued, renderer.nativeScene),
)
const ctx = use()
// Collapsed by default in hide mode: a single line throughout, so the
// layout never shifts. Click to open the full markdown block, click to close.
@@ -1,7 +1,8 @@
import { SyntaxStyle, type RGBA } from "@opentui/core"
import { SyntaxStyle, type NativeResourceOwner, type RGBA } from "@opentui/core"
export function generateThinkingSyntax(syntax: SyntaxStyle, foreground: RGBA) {
export function generateThinkingSyntax(syntax: SyntaxStyle, foreground: RGBA, owner: NativeResourceOwner) {
return SyntaxStyle.fromStyles(
Object.fromEntries(syntax.getRegisteredNames().map((name) => [name, { ...syntax.getStyle(name), fg: foreground }])),
owner,
)
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { RGBA, SyntaxStyle } from "@opentui/core"
import { RGBA, SyntaxStyle, type NativeResourceOwner } from "@opentui/core"
import type { Theme, ThemeV1Json } from "@opencode/theme/tui/v1"
import aura from "./assets/aura.json" with { type: "json" }
import ayu from "./assets/ayu.json" with { type: "json" }
@@ -85,8 +85,8 @@ export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
return theme.background
}
export function generateSyntax(theme: Theme) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
export function generateSyntax(theme: Theme, owner: NativeResourceOwner) {
return SyntaxStyle.fromTheme(getSyntaxRules(theme), owner)
}
function getSyntaxRules(theme: Theme) {
+1 -1
View File
@@ -1,7 +1,7 @@
import type { CliRenderer } from "@opentui/core"
export function destroyRenderer(renderer: Pick<CliRenderer, "isDestroyed" | "setTerminalTitle" | "destroy">) {
renderer.setTerminalTitle("")
if (renderer.isDestroyed) return
renderer.setTerminalTitle("")
renderer.destroy()
}
+24 -8
View File
@@ -5,6 +5,7 @@ import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Global } from "@opencode/util/global"
import path from "node:path"
import { Writable } from "node:stream"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import { createAppFixture } from "./fixture/app"
@@ -216,15 +217,25 @@ test.each(["dismissed", "refreshing"])(
)
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const titles: string[] = []
const output: string[] = []
const setup = await createTestRenderer({
width: 80,
height: 24,
bufferedOutput: "stdout",
stdout: new Writable({
write(chunk, _encoding, callback) {
output.push(chunk.toString())
callback()
},
}) as NodeJS.WriteStream,
})
await setup.renderer.setupTerminal()
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => {
titles.push(title)
if (title === "OpenCode") started()
setTitle(title)
}
@@ -250,16 +261,18 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
await task
expect(setup.renderer.isDestroyed).toBe(true)
expect(titles.at(-1)).toBe("")
expect(output.join("")).toContain("\x1b]0;\x07")
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await setup.renderer.closed
await server.stop()
}
})
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const setup = await createTestRenderer({ width: 80, height: 24 })
await setup.renderer.setupTerminal()
let initialTitle!: () => void
const initialTitleSet = new Promise<void>((resolve) => {
initialTitle = resolve
@@ -345,7 +358,8 @@ test("session lifecycle updates the terminal title and prints the epilogue after
})
test("session title generated while an untitled session is loading remains visible", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const setup = await createTestRenderer({ width: 80, height: 24 })
await setup.renderer.setupTerminal()
const titles: string[] = []
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
const generatedTitle = Promise.withResolvers<void>()
@@ -766,7 +780,8 @@ test("keeps assistant footer metrics current after prepend, same-length refresh,
})
test("session startup prompt is submitted exactly once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const setup = await createTestRenderer({ width: 80, height: 24 })
await setup.renderer.setupTerminal()
const events = createEventStream()
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
@@ -1270,7 +1285,8 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
test.each(["manual", "select"] as const)(
"selection copy and pane management respect %s mode in the prompt and terminal pane",
async (copy) => {
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
const setup = await createTestRenderer({ width: 100, height: 30, kittyKeyboard: true })
await setup.renderer.setupTerminal()
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const session = {
@@ -2120,14 +2120,18 @@ test.each([100, 160])("the sidebar source picker switches VCS sources at %i colu
viewer.app.mockInput.pressArrow("down")
await viewer.app.flush()
viewer.app.mockInput.pressEnter()
await viewer.app.waitForFrame((frame) => frame.includes("Uncommitted") && frame.includes("const first"))
await viewer.app.waitForFrame(
(frame) => !frame.includes("Diff source") && frame.includes("Uncommitted") && frame.includes("const first"),
)
expect(viewer.vcsDiffInput()).toEqual({ location: { directory: "/repo/session" }, mode: "working", context: "12" })
await viewer.app.mockMouse.click(source().x, source().y)
await viewer.app.waitForFrame((frame) => frame.includes("Diff source"))
viewer.app.mockInput.pressKey("HOME")
await viewer.app.flush()
viewer.app.mockInput.pressEnter()
await viewer.app.waitForFrame((frame) => frame.includes("All") && frame.includes("const first"))
await viewer.app.waitForFrame(
(frame) => !frame.includes("Diff source") && frame.includes("All") && frame.includes("const first"),
)
expect(viewer.vcsDiffInput()).toEqual({
location: { directory: "/repo/session" },
mode: "branch",
+4 -2
View File
@@ -10,7 +10,8 @@ test.each([70, 120])(
"/compact renders before model setup, suppresses repeat gestures, and toasts rollback at %i columns",
async (width) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true })
const setup = await createTestRenderer({ width, height: 30, kittyKeyboard: true })
await setup.renderer.setupTerminal()
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const model = Promise.withResolvers<Response>()
@@ -96,7 +97,8 @@ test.each(["first", "second"])(
"a following prompt commits its selected model after prompt and compaction setup (cached: %s)",
async (initial) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
const setup = await createTestRenderer({ width: 100, height: 30, kittyKeyboard: true })
await setup.renderer.setupTerminal()
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const first = Promise.withResolvers<void>()
@@ -5,7 +5,7 @@ import { TitleShimmerRenderable } from "../../src/component/title-shimmer"
test("shimmer fades in from idle and fades out on unchanged completion", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 24, height: 1, useThread: false, clock })
const app = await createTestRenderer({ width: 24, height: 1, clock })
const title = new TitleShimmerRenderable(app.renderer, {
width: 24,
height: 1,
@@ -45,12 +45,13 @@ test("shimmer fades in from idle and fades out on unchanged completion", async (
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
await app.renderer.closed
}
})
test("a feathered wipe keeps the old shimmer moving without dimming the revealed new title", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 16, height: 1, useThread: false, clock })
const app = await createTestRenderer({ width: 16, height: 1, clock })
const title = new TitleShimmerRenderable(app.renderer, {
width: 16,
height: 1,
@@ -102,12 +103,13 @@ test("a feathered wipe keeps the old shimmer moving without dimming the revealed
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
await app.renderer.closed
}
})
test("native Unicode clipping and shorter replacement leave no split glyphs or old tail", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 24, height: 3, useThread: false, clock })
const app = await createTestRenderer({ width: 24, height: 3, clock })
const content = "A\u65e5B \u{1f680} cafe\u0301"
const title = new TitleShimmerRenderable(app.renderer, {
width: 20,
@@ -141,5 +143,6 @@ test("native Unicode clipping and shorter replacement leave no split glyphs or o
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
await app.renderer.closed
}
})
+1 -1
View File
@@ -20,9 +20,9 @@ export async function createAppFixture(
const setup = await createTestRenderer({
width: input.width ?? 100,
height: input.height ?? 30,
useThread: false,
kittyKeyboard: true,
})
await setup.renderer.setupTerminal()
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const events = createEventStream()
+22 -10
View File
@@ -1,25 +1,37 @@
import { afterEach, expect, test } from "bun:test"
import { CodeRenderable, MarkdownRenderable, SyntaxStyle } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { testRender, useRenderer } from "@opentui/solid"
import { batch, createSignal } from "solid-js"
const renderers: Awaited<ReturnType<typeof testRender>>["renderer"][] = []
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })
const renderers: {
renderer: Awaited<ReturnType<typeof testRender>>["renderer"]
syntaxStyle: SyntaxStyle
}[] = []
afterEach(() => {
renderers.splice(0).forEach((renderer) => renderer.destroy())
afterEach(async () => {
await Promise.all(
renderers.splice(0).map(async (output) => {
output.renderer.destroy()
await output.renderer.closed
output.syntaxStyle.destroy()
}),
)
})
test.each(["completion-first", "content-first"])("applies final fence text in a Solid batch: %s", async (order) => {
const [content, setContent] = createSignal("```text\ninitial")
const [streaming, setStreaming] = createSignal(true)
const output = await testRender(
() => (
<markdown syntaxStyle={syntaxStyle} content={content()} streaming={streaming()} internalBlockMode="top-level" />
),
{ width: 80, height: 12, remote: true, useThread: false },
() => {
const renderer = useRenderer()
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }, renderer.nativeScene)
renderers.push({ renderer, syntaxStyle })
return (
<markdown syntaxStyle={syntaxStyle} content={content()} streaming={streaming()} internalBlockMode="top-level" />
)
},
{ width: 80, height: 12, remote: true },
)
renderers.push(output.renderer)
await output.renderOnce()
batch(() => {
+2 -1
View File
@@ -108,6 +108,7 @@ async function setup(
const app = await createTestRenderer({
width: 112,
height: 24,
exitOnCtrlC: false,
screenMode: "split-footer",
footerHeight: 4,
externalOutputMode: "capture-stdout",
@@ -321,7 +322,7 @@ test.each([false, true])("monochrome toggles live without replacing the footer (
])
expect(output.filter((text) => text.includes("new output"))).toHaveLength(2)
app.mockInput.pressKey("c", { ctrl: true })
await app.renderOnce()
await app.flush()
expect(app.renderer.currentFocusedEditor?.plainText).toBe("draft")
expect(app.captureCharFrame()).toContain(mono ? "| draft" : "\u2503 draft")
} finally {
+8 -2
View File
@@ -1,5 +1,5 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { afterAll, expect, test } from "bun:test"
import { BoxRenderable, ImageRenderable, RGBA, type CliRenderer, type RootRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
@@ -49,6 +49,11 @@ import { tmpdir } from "../fixture/fixture"
import { diffImageFixture } from "../fixture/diff-image"
const tuiConfig = createTuiResolvedConfig()
const themeRenderer = await createTestRenderer({})
afterAll(async () => {
themeRenderer.renderer.destroy()
await themeRenderer.renderer.closed
})
async function nativeLightTheme() {
await using tmp = await tmpdir()
@@ -58,6 +63,7 @@ async function nativeLightTheme() {
try {
return await resolveRunTheme(
{
nativeScene: themeRenderer.renderer.nativeScene,
themeMode: "light",
getPalette: async (): ReturnType<CliRenderer["getPalette"]> => {
throw new Error("Palette unavailable")
@@ -225,7 +231,7 @@ async function renderFooter(
<Harness />
</box>
),
{ width: input.width ?? 100, height: input.height ?? 8, kittyKeyboard: true },
{ width: input.width ?? 100, height: input.height ?? 8, kittyKeyboard: true, exitOnCtrlC: false },
)
return {
@@ -1,11 +1,19 @@
import { afterEach, expect, test } from "bun:test"
import type { SessionMessageAssistantTool } from "@opencode/client/promise"
import { CliRenderEvents, MarkdownRenderable, RGBA, SyntaxStyle, TextAttributes, TextRenderable } from "@opentui/core"
import {
CliRenderEvents,
MarkdownRenderable,
ResourceContext,
RGBA,
SyntaxStyle,
TextAttributes,
TextRenderable,
} from "@opentui/core"
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
import { monoSnapshot } from "../../src/mini/mono"
import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
import { entryLook } from "../../src/mini/scrollback.shared"
import { entryGroupKey } from "../../src/mini/scrollback.writer"
import { entryGroupKey, entryWriter } from "../../src/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, RUN_THEME_MONO, type RunTheme } from "../../src/mini/theme"
import type { StreamCommit } from "../../src/mini/types"
import { canonicalToolPart } from "./fixture/tool-part"
@@ -22,9 +30,10 @@ type ClaimedCommit = {
const decoder = new TextDecoder()
const active: TestRenderer[] = []
afterEach(() => {
afterEach(async () => {
for (const renderer of active.splice(0)) {
renderer.destroy()
await renderer.closed
}
})
@@ -144,8 +153,9 @@ test("turn summary starts at the left edge", async () => {
})
test("theme swaps restyle active reasoning without resetting the stream", async () => {
const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } })
const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } })
const owner = new ResourceContext({ objectCapacity: 8, renderCellsMax: 1 })
const previousSyntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } }, owner)
const nextSyntax = SyntaxStyle.fromStyles({ default: { fg: "#abcdef" } }, owner)
const released: RunTheme[] = []
const previous = {
...RUN_THEME_FALLBACK,
@@ -165,20 +175,30 @@ test("theme swaps restyle active reasoning without resetting the stream", async
try {
await out.scrollback.append(reasoning("before"))
expect(activeSyntax(out.scrollback)).toBe(previousSyntax)
const previousBound = activeSyntax(out.scrollback)!
expect(previousBound).not.toBe(previousSyntax)
expect(previousBound.getAllStyles()).toEqual(previousSyntax.getAllStyles())
out.scrollback.setTheme(next)
expect(activeSyntax(out.scrollback)).toBe(nextSyntax)
const nextBound = activeSyntax(out.scrollback)!
expect(nextBound).not.toBe(nextSyntax)
expect(nextBound.getAllStyles()).toEqual(nextSyntax.getAllStyles())
expect(previousBound.getAllStyles()).toEqual(previousSyntax.getAllStyles())
expect(released).toEqual([])
await out.scrollback.append(reasoning("after"))
expect(activeSyntax(out.scrollback)).toBe(nextSyntax)
expect(activeSyntax(out.scrollback)).toBe(nextBound)
expect(released).toEqual([previous])
expect(() => previousBound.getAllStyles()).toThrow()
await out.scrollback.complete()
expect(() => nextBound.getAllStyles()).toThrow()
expect(nextSyntax.getStyle("default")?.fg?.toInts()).toEqual(RGBA.fromHex("#abcdef").toInts())
} finally {
out.scrollback.destroy()
destroy(claim(out.renderer))
previousSyntax.destroy()
nextSyntax.destroy()
owner.destroy()
}
})
@@ -187,6 +207,35 @@ function activeSyntax(scrollback: RunScrollbackStream) {
return entry?.renderable?.syntaxStyle
}
test("static scrollback owns its bound style without releasing the source theme", async () => {
const out = await setup()
const syntax = SyntaxStyle.fromStyles({ default: { fg: "#123456" } }, out.renderer.nativeScene)
const surface = out.renderer.createScrollbackSurface()
try {
const snapshot = entryWriter({
commit: assistant("Hello", "final"),
body: { type: "markdown", content: "Hello" },
theme: { ...RUN_THEME_FALLBACK, block: { ...RUN_THEME_FALLBACK.block, syntax } },
})({
width: surface.width,
widthMethod: out.renderer.widthMethod,
tailColumn: 0,
renderContext: surface.renderContext,
})
const bound = (snapshot.root.getChildren()[0] as MarkdownRenderable).syntaxStyle
expect(bound).not.toBe(syntax)
expect(bound.getAllStyles()).toEqual(syntax.getAllStyles())
snapshot.root.destroyRecursively()
snapshot.teardown?.()
expect(() => bound.getAllStyles()).toThrow()
expect(syntax.getStyleCount()).toBe(1)
} finally {
surface.destroy()
syntax.destroy()
out.scrollback.destroy()
}
})
test("theme swaps preserve streamed markdown parser state", async () => {
const out = await setup()
const next = {
+18
View File
@@ -1,6 +1,7 @@
import { afterAll, beforeAll, expect, test } from "bun:test"
import path from "node:path"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { DEFAULT_THEME, resolveThemeDocument, type ResolvedTheme } from "@opencode/theme/tui"
import {
RUN_THEME_MONO,
@@ -14,6 +15,11 @@ import { generateSystem } from "../../src/theme/system"
import { tmpdir } from "../fixture/fixture"
const tmp = await tmpdir()
const resources = await createTestRenderer({})
afterAll(async () => {
resources.renderer.destroy()
await resources.renderer.closed
})
const previousConfig = process.env.OPENCODE_CONFIG_DIR
beforeAll(() => {
process.env.OPENCODE_CONFIG_DIR = tmp.path
@@ -51,6 +57,17 @@ const emptyColors = terminalColors({
highlightForeground: null,
})
test("theme lookup does not allocate resources after renderer shutdown", async () => {
const setup = await createTestRenderer({})
const palette = Promise.withResolvers<TerminalColors>()
setup.renderer.getPalette = () => palette.promise
const pending = resolveRunTheme(setup.renderer)
setup.renderer.destroy()
await setup.renderer.closed
palette.resolve(terminalColors())
expect((await pending).block.syntax).toBeUndefined()
})
function renderer(
input: {
themeMode?: "dark" | "light"
@@ -60,6 +77,7 @@ function renderer(
} = {},
) {
return {
nativeScene: resources.renderer.nativeScene,
get themeMode() {
return input.themeMode
},
+2 -1
View File
@@ -26,7 +26,8 @@ test.each([
"prepend-failure",
])("Home loads a stable, bounded beginning (%s)", async (mode) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
const setup = await createTestRenderer({ width: 100, height: 30, kittyKeyboard: true })
await setup.renderer.setupTerminal()
setup.renderer.start()
const session = {
id: "ses_test",
@@ -8,7 +8,8 @@ import { tmpdir } from "./fixture/fixture"
test.each([80, 120, 180])("session wheel scrolling preserves pane focus at width %s", async (width) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 36, useThread: false, kittyKeyboard: true })
const setup = await createTestRenderer({ width, height: 36, kittyKeyboard: true })
await setup.renderer.setupTerminal()
setup.renderer.start()
const session = {
id: "ses_wheel",
+2 -1
View File
@@ -7,7 +7,8 @@ import { tmpdir } from "./fixture/fixture"
test("stats shows only this year and returns after errors or success", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 34, useThread: false, kittyKeyboard: true })
const setup = await createTestRenderer({ width: 100, height: 34, kittyKeyboard: true })
await setup.renderer.setupTerminal()
setup.renderer.start()
const requests: URL[] = []
const calls = createFetch((url) => {
+9 -4
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { ResourceContext, RGBA } from "@opentui/core"
import {
DEFAULT_THEME,
generateSyntax,
@@ -45,10 +45,15 @@ test("validates and resolves categorical hues in configured order", () => {
test("generates syntax with one categorical hue", () => {
const theme = resolveSource({ version: 2, light: { categorical: ["red"] } }, "light")
const syntax = generateSyntax(theme, "light")
const owner = new ResourceContext({ objectCapacity: 1, renderCellsMax: 1 })
const syntax = generateSyntax(theme, "light", owner)
expect(syntax.getStyleId("extmark.skill")).not.toBeNull()
syntax.destroy()
try {
expect(syntax.getStyleId("extmark.skill")).not.toBeNull()
} finally {
syntax.destroy()
owner.destroy()
}
})
test("uses the default categorical order for direct definitions", () => {
@@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { destroyRenderer } from "../../src/util/renderer"
test("destroyRenderer is synchronous and safe during and after native closure", async () => {
const setup = await createTestRenderer({ width: 10, height: 2 })
try {
await setup.renderer.setupTerminal()
destroyRenderer(setup.renderer)
expect(setup.renderer.isDestroyed).toBe(true)
expect(() => destroyRenderer(setup.renderer)).not.toThrow()
await setup.renderer.closed
expect(() => destroyRenderer(setup.renderer)).not.toThrow()
} finally {
setup.renderer.destroy()
await setup.renderer.closed
}
})
+2 -2
View File
@@ -15,7 +15,7 @@ test("clears the terminal title before destroying the renderer", () => {
expect(calls).toEqual(["title:", "destroy"])
})
test("still clears the title after renderer destruction", () => {
test("does not use native controls after renderer destruction", () => {
const calls: string[] = []
destroyRenderer({
isDestroyed: true,
@@ -26,5 +26,5 @@ test("still clears the title after renderer destruction", () => {
calls.push("destroy")
},
})
expect(calls).toEqual(["title:"])
expect(calls).toEqual([])
})
@@ -9,7 +9,6 @@ async function setup(copyOnSelect = false) {
const app = await createTestRenderer({
width: 24,
height: 3,
useThread: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
clock,