mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-27 02:57:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ddcdab347 |
@@ -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 = () =>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user