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 +15 -0
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/package.json +41 -0
- package/src/index.ts +24 -0
- package/src/opentui-math.ts +529 -0
- package/src/tui.ts +54 -0
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
|
+
})
|