opencode-latex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ ## 0.1.0 - 2026-09-19
6
+
7
+ Initial public release.
8
+
9
+ - Adds an OpenCode v2 server instruction for readable inline and display math.
10
+ - Registers graphical rendering for fenced `latex` blocks without replacing other Markdown renderers.
11
+ - Uses Kitty graphics or SIXEL when supported, with selectable Unicode-cell fallback.
12
+ - Preserves a consistent formula scale across single-line and multiline expressions.
13
+ - Tracks terminal zoom without permanently enlarging later formulas.
14
+ - Normalizes commonly double-escaped model output while preserving TeX row breaks.
15
+ - Composites SIXEL images against OpenTUI cell backgrounds for embedded xterm.js terminals.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-latex contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # opencode-latex
2
+
3
+ Render readable LaTeX in OpenCode v2 terminal conversations.
4
+
5
+ `opencode-latex` has two coordinated parts:
6
+
7
+ - The server plugin teaches agents to use compact Unicode for simple inline math and fenced `latex` blocks for longer expressions.
8
+ - The TUI plugin renders those blocks with [`opentui-math`](https://opentui-math.dev/) through OpenTUI's image pipeline.
9
+
10
+ Graphical output uses Kitty graphics in terminals such as Ghostty and Kitty, or SIXEL in compatible xterm.js terminals such as `obsidian-opencode`. Other terminals and multiplexers receive a selectable Unicode-cell fallback.
11
+
12
+ > [!NOTE]
13
+ > This is an initial `0.1.x` release. Kitty output preserves real PNG transparency. SIXEL output is composited against the covered OpenTUI cell backgrounds because SIXEL cannot reproduce Kitty's alpha compositing exactly.
14
+
15
+ ## Requirements
16
+
17
+ - OpenCode v2
18
+ - A terminal with Kitty graphics or SIXEL for graphical output
19
+ - No external TeX installation; MathJax performs the rendering
20
+
21
+ ## Install
22
+
23
+ Install the package globally through OpenCode:
24
+
25
+ ```sh
26
+ opencode plugin add opencode-latex@0.1.0
27
+ ```
28
+
29
+ Alternatively, add it to `opencode.jsonc`:
30
+
31
+ ```jsonc
32
+ {
33
+ "$schema": "https://opencode.ai/config.json",
34
+ "plugins": ["opencode-latex@0.1.0"]
35
+ }
36
+ ```
37
+
38
+ The package's `./tui` companion is loaded automatically by the OpenCode v2 CLI. Restart OpenCode after installing or updating the package.
39
+
40
+ For local development, configure the repository path instead:
41
+
42
+ ```jsonc
43
+ {
44
+ "$schema": "https://opencode.ai/config.json",
45
+ "plugins": ["/absolute/path/to/opencode-latex"]
46
+ }
47
+ ```
48
+
49
+ ## Use
50
+
51
+ Ask OpenCode a math-heavy question normally. The plugin's server instruction tells the agent when to use Unicode inline math and when to emit a rendered block.
52
+
53
+ To test the renderer directly, send a fenced block:
54
+
55
+ ````markdown
56
+ ```latex
57
+ \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
58
+ ```
59
+ ````
60
+
61
+ Long derivations should use compact rows:
62
+
63
+ ````markdown
64
+ ```latex
65
+ \begin{aligned}
66
+ I^2 &= \int_0^{2\pi}\int_0^\infty e^{-r^2}r\,dr\,d\theta \\
67
+ &= 2\pi\left[-\frac12 e^{-r^2}\right]_0^\infty \\
68
+ &= \pi
69
+ \end{aligned}
70
+ ```
71
+ ````
72
+
73
+ Use the `latex` fence exactly; this plugin registers that language without replacing other Markdown renderers.
74
+
75
+ ## Options
76
+
77
+ Options are optional:
78
+
79
+ ```jsonc
80
+ {
81
+ "plugins": [
82
+ {
83
+ "package": "opencode-latex@0.1.0",
84
+ "options": {
85
+ "color": "#d4d4d4",
86
+ "graphicsMode": "auto",
87
+ "fontSize": 20,
88
+ "pixelRatio": 2
89
+ }
90
+ }
91
+ ]
92
+ }
93
+ ```
94
+
95
+ - `color` sets the equation foreground. Its default follows OpenCode's light or dark theme.
96
+ - `graphicsMode` selects `"auto"`, forced `"kitty"`, or portable `"cells"` rendering. The default is `"auto"`.
97
+ - `fontSize` sets the graphical MathJax font size. The default is `20`.
98
+ - `pixelRatio` increases raster sharpness without changing the intended cell footprint. The default is `2`.
99
+
100
+ ## Troubleshooting
101
+
102
+ ### Renderer already registered
103
+
104
+ If startup reports `Markdown code-block renderer already registered: latex`, the plugin is loaded more than once. Remove the older local copy or duplicate config entry, then restart OpenCode. Check both the global `~/.config/opencode/plugins/` directory and project `.opencode/plugins/` directories.
105
+
106
+ ### Raw red LaTeX
107
+
108
+ The fallback source renderer appears when a formula is invalid or graphical rendering fails. Each TeX command should use one backslash; only aligned or matrix row breaks use two.
109
+
110
+ ### Graphical output is unavailable
111
+
112
+ Leave `graphicsMode` on `"auto"`. OpenTUI will use Kitty, then SIXEL when current pixel geometry is available, then Unicode cells. tmux and terminals without a supported graphics protocol normally use the cell fallback.
113
+
114
+ ## Develop
115
+
116
+ ```sh
117
+ bun install
118
+ bun run check
119
+ npm pack --dry-run
120
+ ```
121
+
122
+ The narrow `opentui-math` transport fork is in `src/opentui-math.ts`. It keeps upstream MathJax rasterization and Unicode fallback while routing graphical placement, cleanup, terminal zoom, and SIXEL background compositing through the host OpenTUI runtime.
123
+
124
+ ## License
125
+
126
+ MIT
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "opencode-latex",
4
+ "version": "0.1.0",
5
+ "description": "Render LaTeX from OpenCode agent messages as terminal images.",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "files": ["src", "README.md", "CHANGELOG.md", "LICENSE"],
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./tui": "./src/tui.ts"
12
+ },
13
+ "scripts": {
14
+ "test": "bun test",
15
+ "typecheck": "tsc --noEmit",
16
+ "check": "bun run typecheck && bun test",
17
+ "prepack": "bun run check"
18
+ },
19
+ "dependencies": {
20
+ "@opencode/plugin": "^2.0.9",
21
+ "opentui-math": "0.1.0"
22
+ },
23
+ "peerDependencies": {
24
+ "@opentui/core": ">=0.5.10",
25
+ "solid-js": ">=1.9.0"
26
+ },
27
+ "devDependencies": {
28
+ "@opentui/core": "^0.5.11",
29
+ "@types/bun": "^1.2.23",
30
+ "solid-js": "^1.9.9",
31
+ "typescript": "^5.9.2"
32
+ },
33
+ "engines": {
34
+ "bun": ">=1.3.0"
35
+ },
36
+ "packageManager": "bun@1.4.2",
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "keywords": ["opencode", "opencode-plugin", "latex", "math", "tui"]
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ const LATEX_INSTRUCTION = `Use Unicode only for short, simple inline mathematics that remains easy to read, such as π, θ, x², aₙ, √x, and ∞. Never use $...$ or \\(...\\) for inline mathematics.
4
+
5
+ Put every long, nested, multi-step, or visually awkward formula in a fenced code block labelled latex, even when it occurs in the middle of an explanation. Do not compromise readability by forcing a long formula into Unicode. Put only the TeX expression inside the block. For example:
6
+
7
+ \`\`\`latex
8
+ \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
9
+ \`\`\`
10
+
11
+ Each TeX command must begin with exactly one backslash, for example \\frac, \\int, or \\begin. Use exactly two backslashes only for a TeX row break inside an aligned or matrix environment. Do not JSON-escape or double every backslash.
12
+
13
+ Keep every display row compact so it remains readable in a narrow terminal. Split long derivations across rows in an aligned environment, with one meaningful step per row. Never chain several long equalities on one row.
14
+
15
+ Never put \\[...\\] in normal prose; OpenCode will show it as raw text. Do not wrap fenced expressions in dollar signs or \\[...\\]. If an inline expression cannot be written clearly with a few Unicode symbols, render it as a latex block instead.`
16
+
17
+ export default Plugin.define({
18
+ id: "opencode.latex",
19
+ async setup(context) {
20
+ await context.session.hook("context", (event) => {
21
+ event.system.push({ type: "text", text: LATEX_INSTRUCTION })
22
+ })
23
+ },
24
+ })
@@ -0,0 +1,529 @@
1
+ import {
2
+ ImageRenderable,
3
+ type ColorInput,
4
+ type ImageRenderProtocol,
5
+ type OptimizedBuffer,
6
+ type RGBA,
7
+ type RenderContext,
8
+ } from "@opentui/core"
9
+ import { MeasureMode } from "@opentui/core/yoga"
10
+ import { LatexRenderable, type LatexRenderableOptions } from "opentui-math"
11
+ import { renderLatexToPng, type RenderedMathImage } from "opentui-math/graphics"
12
+
13
+ export type GraphicsMode = "auto" | "kitty" | "cells"
14
+
15
+ export interface GraphicalLatexRenderableOptions extends LatexRenderableOptions {
16
+ graphicsMode?: GraphicsMode
17
+ fontSize?: number
18
+ pixelRatio?: number
19
+ maxRasterWidth?: number
20
+ maxRasterHeight?: number
21
+ maxRasterPixels?: number
22
+ graphicsForegroundColor?: string
23
+ }
24
+
25
+ const DEFAULT_CELL_WIDTH = 8
26
+ const DEFAULT_CELL_HEIGHT = 16
27
+
28
+ interface RuntimeImageLibrary {
29
+ imageDecode(data: Uint8Array): { status: number; handle: unknown | null }
30
+ imageCopyPixels(
31
+ handle: unknown,
32
+ destination: Uint8Array,
33
+ stride: number,
34
+ bgra: boolean,
35
+ ): number
36
+ imageCreateFromRgba(
37
+ pixels: Uint8Array,
38
+ width: number,
39
+ height: number,
40
+ stride: number,
41
+ ): { status: number; handle: unknown | null }
42
+ imageDestroy(handle: unknown): void
43
+ }
44
+
45
+ interface RuntimeNativeImage {
46
+ readonly lib: RuntimeImageLibrary
47
+ readonly ptr: unknown
48
+ readonly width: number
49
+ readonly height: number
50
+ }
51
+
52
+ /**
53
+ * A narrow fork of opentui-math's graphical renderable. MathJax still creates
54
+ * the PNG, while OpenTUI's image buffer owns Kitty/SIXEL transport and cleanup.
55
+ */
56
+ export class GraphicalLatexRenderable extends LatexRenderable {
57
+ private readonly graphicsContext: RenderContext
58
+ private readonly graphicsMode: GraphicsMode
59
+ private readonly fontSize: number
60
+ private readonly pixelRatio: number
61
+ private readonly graphicsParseOptions: Pick<
62
+ GraphicalLatexRenderableOptions,
63
+ "macros" | "maxExpand" | "maxSourceLength" | "maxExpandedLength" | "maxDepth"
64
+ >
65
+ private readonly rasterLimitOptions: Pick<
66
+ GraphicalLatexRenderableOptions,
67
+ "maxRasterWidth" | "maxRasterHeight" | "maxRasterPixels"
68
+ >
69
+ private readonly graphicsImage: ImageRenderable
70
+ private graphicsColorFollowsForeground: boolean
71
+ private graphicsColor: string
72
+ private image: RenderedMathImage | undefined
73
+ private runtimeImage: RuntimeNativeImage | undefined
74
+ private sixelRuntimeImage: RuntimeNativeImage | undefined
75
+ private sixelBackdropKey: string | undefined
76
+ private imageColumns = 0
77
+ private imageRows = 0
78
+ private rasterRevision = 0
79
+ private rasterizing = false
80
+ private renderFailure: Error | undefined
81
+ private pendingRaster: Promise<void> = Promise.resolve()
82
+
83
+ constructor(ctx: RenderContext, options: GraphicalLatexRenderableOptions = {}) {
84
+ super(ctx, options)
85
+ this.graphicsContext = ctx
86
+ this.graphicsMode = options.graphicsMode ?? "auto"
87
+ this.fontSize = positiveNumber(options.fontSize, 20)
88
+ this.pixelRatio = positiveNumber(options.pixelRatio, 2)
89
+ this.graphicsParseOptions = {
90
+ ...(options.macros ? { macros: options.macros } : {}),
91
+ ...(options.maxExpand !== undefined ? { maxExpand: options.maxExpand } : {}),
92
+ ...(options.maxSourceLength !== undefined ? { maxSourceLength: options.maxSourceLength } : {}),
93
+ ...(options.maxExpandedLength !== undefined
94
+ ? { maxExpandedLength: options.maxExpandedLength }
95
+ : {}),
96
+ ...(options.maxDepth !== undefined ? { maxDepth: options.maxDepth } : {}),
97
+ }
98
+ this.rasterLimitOptions = {
99
+ ...(options.maxRasterWidth !== undefined ? { maxRasterWidth: options.maxRasterWidth } : {}),
100
+ ...(options.maxRasterHeight !== undefined ? { maxRasterHeight: options.maxRasterHeight } : {}),
101
+ ...(options.maxRasterPixels !== undefined ? { maxRasterPixels: options.maxRasterPixels } : {}),
102
+ }
103
+ this.graphicsColorFollowsForeground = options.graphicsForegroundColor === undefined
104
+ this.graphicsColor = options.graphicsForegroundColor ?? colorToCss(super.foregroundColor)
105
+ this.graphicsImage = new ImageRenderable(ctx, {
106
+ fit: "fit",
107
+ protocol: this.requestedImageProtocol(),
108
+ })
109
+ this.setupGraphicsMeasureFunction()
110
+ this.graphicsContext.on("capabilities", this.handleCapabilities)
111
+ this.graphicsContext.on("frame", this.handleFrame)
112
+ this.graphicsContext.on("resize", this.handleResize)
113
+ this.scheduleRaster()
114
+ }
115
+
116
+ public override get content(): string {
117
+ return super.content
118
+ }
119
+
120
+ public override set content(value: string) {
121
+ if (value === super.content) return
122
+ super.content = value
123
+ this.scheduleRaster()
124
+ }
125
+
126
+ public override get foregroundColor(): RGBA {
127
+ return super.foregroundColor
128
+ }
129
+
130
+ public override set foregroundColor(value: ColorInput) {
131
+ super.foregroundColor = value
132
+ if (this.graphicsColorFollowsForeground) {
133
+ this.graphicsColor = colorToCss(super.foregroundColor)
134
+ this.scheduleRaster()
135
+ }
136
+ }
137
+
138
+ public get graphicsForegroundColor(): string {
139
+ return this.graphicsColor
140
+ }
141
+
142
+ public set graphicsForegroundColor(value: string | undefined) {
143
+ const followsForeground = value === undefined
144
+ const nextColor = value ?? colorToCss(super.foregroundColor)
145
+ if (
146
+ followsForeground === this.graphicsColorFollowsForeground &&
147
+ nextColor === this.graphicsColor
148
+ ) return
149
+
150
+ this.graphicsColorFollowsForeground = followsForeground
151
+ this.graphicsColor = nextColor
152
+ this.scheduleRaster()
153
+ }
154
+
155
+ public override get displayMode(): boolean {
156
+ return super.displayMode
157
+ }
158
+
159
+ public override set displayMode(value: boolean) {
160
+ if (value === super.displayMode) return
161
+ super.displayMode = value
162
+ this.scheduleRaster()
163
+ }
164
+
165
+ public get isUsingGraphics(): boolean {
166
+ return this.canUseGraphics() && Boolean(this.image) && !this.renderFailure
167
+ }
168
+
169
+ public get graphicsError(): Error | undefined {
170
+ return this.renderFailure
171
+ }
172
+
173
+ public get effectiveGraphicsProtocol(): Exclude<ImageRenderProtocol, "auto"> {
174
+ return this.graphicsImage.effectiveProtocol
175
+ }
176
+
177
+ public async whenGraphicsReady(): Promise<boolean> {
178
+ while (!this.isDestroyed) {
179
+ const pending = this.pendingRaster
180
+ await pending
181
+ if (pending === this.pendingRaster) return this.isUsingGraphics
182
+ }
183
+ return false
184
+ }
185
+
186
+ protected override renderSelf(buffer: OptimizedBuffer): void {
187
+ if (!this.isUsingGraphics) {
188
+ super.renderSelf(buffer)
189
+ return
190
+ }
191
+
192
+ const background = super.backgroundColor
193
+ const originX = this.buffered ? 0 : this.screenX
194
+ const originY = this.buffered ? 0 : this.screenY
195
+ if (background.a > 0 && this.width > 0 && this.height > 0) {
196
+ buffer.fillRect(originX, originY, this.width, this.height, background)
197
+ }
198
+ this.renderGraphics(buffer)
199
+ }
200
+
201
+ private renderGraphics(buffer: OptimizedBuffer): void {
202
+ if (!this.isUsingGraphics || !this.image || this.width <= 0 || this.height <= 0) return
203
+ const terminalWidth = this.graphicsContext.terminalWidth ?? 0
204
+ const terminalHeight = this.graphicsContext.terminalHeight ?? 0
205
+ const resolution = terminalWidth > 0 && terminalHeight > 0
206
+ ? this.graphicsContext.resolution
207
+ : null
208
+ const cellWidth = resolution?.width
209
+ ? resolution.width / terminalWidth
210
+ : DEFAULT_CELL_WIDTH
211
+ const cellHeight = resolution?.height
212
+ ? resolution.height / terminalHeight
213
+ : DEFAULT_CELL_HEIGHT
214
+ const terminalScale = Math.min(
215
+ cellWidth / DEFAULT_CELL_WIDTH,
216
+ cellHeight / DEFAULT_CELL_HEIGHT,
217
+ )
218
+ const naturalPixelWidth = this.image.width / this.pixelRatio * terminalScale
219
+ const naturalPixelHeight = this.image.height / this.pixelRatio * terminalScale
220
+ const scale = Math.min(
221
+ 1,
222
+ this.width * cellWidth / naturalPixelWidth,
223
+ this.height * cellHeight / naturalPixelHeight,
224
+ )
225
+ const pixelWidth = Math.max(1, Math.round(naturalPixelWidth * scale))
226
+ const pixelHeight = Math.max(1, Math.round(naturalPixelHeight * scale))
227
+ const columns = Math.max(1, Math.min(this.width, Math.ceil(pixelWidth / cellWidth)))
228
+ const rows = Math.max(1, Math.min(this.height, Math.ceil(pixelHeight / cellHeight)))
229
+ const originX = this.buffered ? 0 : this.screenX
230
+ const originY = this.buffered ? 0 : this.screenY
231
+ const x = originX + Math.floor((this.width - columns) / 2)
232
+ const y = originY + Math.floor((this.height - rows) / 2)
233
+ const protocol = this.requestedImageProtocol()
234
+ const nativeImage = this.effectiveGraphicsProtocol === "sixel"
235
+ ? this.ensureSixelRuntimeImage(buffer, x, y, columns, rows)
236
+ : this.ensureRuntimeImage(buffer)
237
+ if (!nativeImage) return
238
+
239
+ buffer.drawImage(
240
+ nativeImage as never,
241
+ x,
242
+ y,
243
+ columns,
244
+ rows,
245
+ pixelWidth,
246
+ pixelHeight,
247
+ 0,
248
+ 0,
249
+ nativeImage.width,
250
+ nativeImage.height,
251
+ protocol,
252
+ )
253
+ }
254
+
255
+ protected override destroySelf(): void {
256
+ this.graphicsContext.off("capabilities", this.handleCapabilities)
257
+ this.graphicsContext.off("frame", this.handleFrame)
258
+ this.graphicsContext.off("resize", this.handleResize)
259
+ this.disposeRuntimeImage()
260
+ if (!this.graphicsImage.isDestroyed) this.graphicsImage.destroy()
261
+ super.destroySelf()
262
+ }
263
+
264
+ private readonly handleCapabilities = (): void => {
265
+ this.graphicsImage.protocol = this.requestedImageProtocol()
266
+ this.scheduleRaster()
267
+ this.updateImageCellSize()
268
+ this.yogaNode.markDirty()
269
+ this.requestRender()
270
+ }
271
+
272
+ private readonly handleFrame = (): void => {
273
+ if (
274
+ this.canUseGraphics() &&
275
+ !this.image &&
276
+ !this.renderFailure &&
277
+ !this.rasterizing
278
+ ) this.scheduleRaster()
279
+ }
280
+
281
+ private readonly handleResize = (): void => {
282
+ if (this.canUseGraphics()) {
283
+ if (!this.image && !this.renderFailure && !this.rasterizing) this.scheduleRaster()
284
+ } else if (this.image || this.rasterizing) this.scheduleRaster()
285
+ this.updateImageCellSize()
286
+ this.yogaNode.markDirty()
287
+ this.requestRender()
288
+ }
289
+
290
+ private scheduleRaster(): void {
291
+ const revision = ++this.rasterRevision
292
+ this.renderFailure = undefined
293
+ if (!this.canUseGraphics()) {
294
+ this.clearGraphics()
295
+ this.rasterizing = false
296
+ this.pendingRaster = Promise.resolve()
297
+ return
298
+ }
299
+
300
+ this.rasterizing = true
301
+ this.pendingRaster = renderLatexToPng(this.content, {
302
+ displayMode: this.displayMode,
303
+ foregroundColor: this.graphicsColor,
304
+ fontSize: this.fontSize,
305
+ pixelRatio: this.pixelRatio,
306
+ ...this.graphicsParseOptions,
307
+ ...this.rasterLimitOptions,
308
+ })
309
+ .then((image) => {
310
+ if (revision !== this.rasterRevision || this.isDestroyed) return
311
+ this.disposeRuntimeImage()
312
+ this.image = image
313
+ this.updateImageCellSize()
314
+ this.graphicsImage.protocol = this.requestedImageProtocol()
315
+ this.yogaNode.markDirty()
316
+ this.requestRender()
317
+ })
318
+ .catch((error) => {
319
+ if (revision !== this.rasterRevision || this.isDestroyed) return
320
+ this.renderFailure = error instanceof Error ? error : new Error(String(error))
321
+ this.clearGraphics()
322
+ this.yogaNode.markDirty()
323
+ this.requestRender()
324
+ })
325
+ .finally(() => {
326
+ if (revision === this.rasterRevision) this.rasterizing = false
327
+ })
328
+ }
329
+
330
+ private updateImageCellSize(): void {
331
+ if (!this.image) return
332
+ this.imageColumns = Math.max(
333
+ 1,
334
+ Math.ceil(this.image.width / (DEFAULT_CELL_WIDTH * this.pixelRatio)),
335
+ )
336
+ this.imageRows = Math.max(
337
+ 1,
338
+ Math.ceil(this.image.height / (DEFAULT_CELL_HEIGHT * this.pixelRatio)),
339
+ )
340
+ }
341
+
342
+ private canUseGraphics(): boolean {
343
+ if (this.graphicsMode === "cells") return false
344
+ if (this.graphicsMode === "kitty") return true
345
+ return this.graphicsImage.effectiveProtocol !== "blocks"
346
+ }
347
+
348
+ private requestedImageProtocol(): ImageRenderProtocol {
349
+ return this.graphicsMode === "kitty" ? "kitty" : "auto"
350
+ }
351
+
352
+ private clearGraphics(): void {
353
+ this.disposeRuntimeImage()
354
+ this.image = undefined
355
+ }
356
+
357
+ private setupGraphicsMeasureFunction(): void {
358
+ this.yogaNode.setMeasureFunc((width, widthMode, height, heightMode) => {
359
+ const intrinsicWidth = this.isUsingGraphics ? this.imageColumns : this.intrinsicWidth
360
+ const intrinsicHeight = this.isUsingGraphics ? this.imageRows : this.intrinsicHeight
361
+ return {
362
+ width: constrainedSize(intrinsicWidth, width, widthMode),
363
+ height: constrainedSize(intrinsicHeight, height, heightMode),
364
+ }
365
+ })
366
+ }
367
+
368
+ private ensureRuntimeImage(buffer: OptimizedBuffer): RuntimeNativeImage | undefined {
369
+ if (!this.image) return undefined
370
+ // TUI plugins can resolve a separate @opentui/core module. Decode through
371
+ // the target buffer's native library so its image handle is valid there.
372
+ const lib = (buffer as unknown as { lib: RuntimeImageLibrary }).lib
373
+ if (this.runtimeImage?.lib === lib) return this.runtimeImage
374
+ this.disposeRuntimeImage()
375
+
376
+ const decoded = lib.imageDecode(this.image.png)
377
+ if (decoded.status !== 0 || !decoded.handle) {
378
+ this.renderFailure = new Error(`OpenTUI image decode failed with status ${decoded.status}`)
379
+ this.requestRender()
380
+ return undefined
381
+ }
382
+ this.runtimeImage = {
383
+ lib,
384
+ ptr: decoded.handle,
385
+ width: this.image.width,
386
+ height: this.image.height,
387
+ }
388
+ return this.runtimeImage
389
+ }
390
+
391
+ private ensureSixelRuntimeImage(
392
+ buffer: OptimizedBuffer,
393
+ x: number,
394
+ y: number,
395
+ columns: number,
396
+ rows: number,
397
+ ): RuntimeNativeImage | undefined {
398
+ const source = this.ensureRuntimeImage(buffer)
399
+ if (!source) return undefined
400
+
401
+ const backgrounds = readCellBackgrounds(buffer, x, y, columns, rows)
402
+ if (!backgrounds.some((_, index) => index % 4 === 3 && backgrounds[index]! > 0)) {
403
+ this.disposeSixelRuntimeImage()
404
+ return source
405
+ }
406
+
407
+ const backdropKey = `${x}:${y}:${columns}:${rows}:${hashBytes(backgrounds)}`
408
+ if (this.sixelRuntimeImage?.lib === source.lib && this.sixelBackdropKey === backdropKey) {
409
+ return this.sixelRuntimeImage
410
+ }
411
+
412
+ this.disposeSixelRuntimeImage()
413
+ const pixels = new Uint8Array(source.width * source.height * 4)
414
+ const copyStatus = source.lib.imageCopyPixels(source.ptr, pixels, source.width * 4, false)
415
+ if (copyStatus !== 0) return source
416
+
417
+ compositeCellBackgrounds(pixels, source.width, source.height, backgrounds, columns, rows)
418
+ const created = source.lib.imageCreateFromRgba(pixels, source.width, source.height, source.width * 4)
419
+ if (created.status !== 0 || !created.handle) return source
420
+
421
+ this.sixelRuntimeImage = {
422
+ lib: source.lib,
423
+ ptr: created.handle,
424
+ width: source.width,
425
+ height: source.height,
426
+ }
427
+ this.sixelBackdropKey = backdropKey
428
+ return this.sixelRuntimeImage
429
+ }
430
+
431
+ private disposeRuntimeImage(): void {
432
+ this.disposeSixelRuntimeImage()
433
+ if (!this.runtimeImage) return
434
+ this.runtimeImage.lib.imageDestroy(this.runtimeImage.ptr)
435
+ this.runtimeImage = undefined
436
+ }
437
+
438
+ private disposeSixelRuntimeImage(): void {
439
+ if (this.sixelRuntimeImage) {
440
+ this.sixelRuntimeImage.lib.imageDestroy(this.sixelRuntimeImage.ptr)
441
+ this.sixelRuntimeImage = undefined
442
+ }
443
+ this.sixelBackdropKey = undefined
444
+ }
445
+ }
446
+
447
+ function readCellBackgrounds(
448
+ buffer: OptimizedBuffer,
449
+ x: number,
450
+ y: number,
451
+ columns: number,
452
+ rows: number,
453
+ ): Uint8Array {
454
+ const result = new Uint8Array(columns * rows * 4)
455
+ const source = buffer.buffers.bg
456
+ for (let row = 0; row < rows; row++) {
457
+ for (let column = 0; column < columns; column++) {
458
+ const sourceX = x + column
459
+ const sourceY = y + row
460
+ if (sourceX < 0 || sourceY < 0 || sourceX >= buffer.width || sourceY >= buffer.height) continue
461
+ const sourceOffset = (sourceY * buffer.width + sourceX) * 4
462
+ const targetOffset = (row * columns + column) * 4
463
+ result[targetOffset] = source[sourceOffset]! & 0xff
464
+ result[targetOffset + 1] = source[sourceOffset + 1]! & 0xff
465
+ result[targetOffset + 2] = source[sourceOffset + 2]! & 0xff
466
+ result[targetOffset + 3] = source[sourceOffset + 3]! & 0xff
467
+ }
468
+ }
469
+ return result
470
+ }
471
+
472
+ function compositeCellBackgrounds(
473
+ pixels: Uint8Array,
474
+ width: number,
475
+ height: number,
476
+ backgrounds: Uint8Array,
477
+ columns: number,
478
+ rows: number,
479
+ ): void {
480
+ for (let py = 0; py < height; py++) {
481
+ const cellY = Math.min(rows - 1, Math.floor(py * rows / height))
482
+ for (let px = 0; px < width; px++) {
483
+ const cellX = Math.min(columns - 1, Math.floor(px * columns / width))
484
+ const pixelOffset = (py * width + px) * 4
485
+ const backgroundOffset = (cellY * columns + cellX) * 4
486
+ const sourceAlpha = pixels[pixelOffset + 3]!
487
+ const backgroundAlpha = backgrounds[backgroundOffset + 3]!
488
+ if (backgroundAlpha === 0) continue
489
+
490
+ const inverseSourceAlpha = 255 - sourceAlpha
491
+ const outputAlphaScaled = sourceAlpha * 255 + backgroundAlpha * inverseSourceAlpha
492
+ for (let channel = 0; channel < 3; channel++) {
493
+ const source = pixels[pixelOffset + channel]!
494
+ const background = backgrounds[backgroundOffset + channel]!
495
+ pixels[pixelOffset + channel] = Math.round(
496
+ (source * sourceAlpha * 255 + background * backgroundAlpha * inverseSourceAlpha) /
497
+ outputAlphaScaled,
498
+ )
499
+ }
500
+ pixels[pixelOffset + 3] = Math.round(outputAlphaScaled / 255)
501
+ }
502
+ }
503
+ }
504
+
505
+ function hashBytes(bytes: Uint8Array): number {
506
+ let hash = 0x811c9dc5
507
+ for (const byte of bytes) {
508
+ hash ^= byte
509
+ hash = Math.imul(hash, 0x01000193)
510
+ }
511
+ return hash >>> 0
512
+ }
513
+
514
+ function constrainedSize(intrinsic: number, available: number, mode: MeasureMode): number {
515
+ if (mode === MeasureMode.Exactly) return Math.max(0, Math.floor(available))
516
+ if (mode === MeasureMode.AtMost) {
517
+ return Math.max(0, Math.min(intrinsic, Math.floor(available)))
518
+ }
519
+ return intrinsic
520
+ }
521
+
522
+ function colorToCss(color: RGBA): string {
523
+ const [r, g, b, a] = color.toInts()
524
+ return `rgba(${r}, ${g}, ${b}, ${a / 255})`
525
+ }
526
+
527
+ function positiveNumber(value: number | undefined, fallback: number): number {
528
+ return Number.isFinite(value) && value! > 0 ? value! : fallback
529
+ }
package/src/tui.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { Plugin } from "@opencode/plugin/tui"
2
+ import { GraphicalLatexRenderable } from "./opentui-math.js"
3
+
4
+ const LANGUAGE = "latex"
5
+ const GRAPHICS_MODES = ["auto", "kitty", "cells"] as const
6
+ type GraphicsMode = typeof GRAPHICS_MODES[number]
7
+
8
+ function positiveNumber(value: unknown, fallback: number): number {
9
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback
10
+ }
11
+
12
+ function graphicsMode(value: unknown): GraphicsMode {
13
+ return typeof value === "string" && GRAPHICS_MODES.includes(value as GraphicsMode)
14
+ ? value as GraphicsMode
15
+ : "auto"
16
+ }
17
+
18
+ export function normalizeLatexSource(source: string): string {
19
+ return source.replace(/\\+/g, (run, offset: number) => {
20
+ if (run.length >= 4 && run.length % 2 === 0) return "\\".repeat(run.length / 2)
21
+ const next = source[offset + run.length]
22
+ const looksLikeEscapedCommand = next !== undefined && !/\s|&|\\|\[/.test(next)
23
+ return run.length === 2 && looksLikeEscapedCommand ? "\\" : run
24
+ })
25
+ }
26
+
27
+ export default Plugin.define({
28
+ id: "opencode.latex.tui",
29
+ setup(context) {
30
+ const color = typeof context.options.color === "string"
31
+ ? context.options.color
32
+ : context.themeMode === "light" ? "#24292f" : "#d4d4d4"
33
+ const fontSize = positiveNumber(context.options.fontSize, 20)
34
+ const pixelRatio = positiveNumber(context.options.pixelRatio, 2)
35
+ const mode = graphicsMode(context.options.graphicsMode)
36
+
37
+ return context.markdown.registerCodeBlockRenderer(LANGUAGE, (token, render) => {
38
+ try {
39
+ return new GraphicalLatexRenderable(context.renderer, {
40
+ content: normalizeLatexSource(token.text),
41
+ displayMode: true,
42
+ fallback: "source",
43
+ foregroundColor: color,
44
+ graphicsForegroundColor: color,
45
+ graphicsMode: mode,
46
+ fontSize,
47
+ pixelRatio,
48
+ })
49
+ } catch {
50
+ return render.defaultRender()
51
+ }
52
+ })
53
+ },
54
+ })