opencode-latex 0.1.0 → 0.2.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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project are documented here.
4
4
 
5
+ ## 0.2.0 - 2026-09-19
6
+
7
+ - Adds a **LaTeX: Set default render mode** command to the command palette and `/latex-render-mode` slash command.
8
+ - Persists the selected automatic, Kitty, or Unicode-cell mode across TUI restarts.
9
+ - Applies the selected default whenever LaTeX formulas are rendered.
10
+ - Allows the saved preference to be cleared so plugin configuration becomes authoritative again.
11
+
12
+ ## 0.1.1 - 2026-09-19
13
+
14
+ - Removes visible right and bottom seams around SIXEL-rendered formulas in embedded xterm.js terminals such as `obsidian-opencode`.
15
+ - Pads SIXEL images to complete terminal-cell boundaries while preserving the formula's pixel scale.
16
+ - Adds regression coverage for sub-cell image dimensions and background compositing.
17
+
5
18
  ## 0.1.0 - 2026-09-19
6
19
 
7
20
  Initial public release.
package/README.md CHANGED
@@ -9,6 +9,16 @@ Render readable LaTeX in OpenCode v2 terminal conversations.
9
9
 
10
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
11
 
12
+ ## Demo
13
+
14
+ ![Navier–Stokes equations rendered inline in an OpenCode conversation](docs/images/navier-stokes-demo.png)
15
+
16
+ *Rendered equations stay aligned with the surrounding conversation.*
17
+
18
+ ![A Hermitian matrix rendered in an OpenCode derivation](docs/images/matrix-demo.png)
19
+
20
+ *Larger matrices and derivations remain crisp and readable.*
21
+
12
22
  > [!NOTE]
13
23
  > 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
24
 
@@ -23,7 +33,7 @@ Graphical output uses Kitty graphics in terminals such as Ghostty and Kitty, or
23
33
  Install the package globally through OpenCode:
24
34
 
25
35
  ```sh
26
- opencode plugin add opencode-latex@0.1.0
36
+ opencode plugin add opencode-latex@0.2.0
27
37
  ```
28
38
 
29
39
  Alternatively, add it to `opencode.jsonc`:
@@ -31,7 +41,7 @@ Alternatively, add it to `opencode.jsonc`:
31
41
  ```jsonc
32
42
  {
33
43
  "$schema": "https://opencode.ai/config.json",
34
- "plugins": ["opencode-latex@0.1.0"]
44
+ "plugins": ["opencode-latex@0.2.0"]
35
45
  }
36
46
  ```
37
47
 
@@ -72,6 +82,16 @@ I^2 &= \int_0^{2\pi}\int_0^\infty e^{-r^2}r\,dr\,d\theta \\
72
82
 
73
83
  Use the `latex` fence exactly; this plugin registers that language without replacing other Markdown renderers.
74
84
 
85
+ ### Choose a default render mode
86
+
87
+ Open the command palette with `Ctrl+P` and select **LaTeX: Set default render mode**, or run:
88
+
89
+ ```text
90
+ /latex-render-mode
91
+ ```
92
+
93
+ Choose automatic graphics detection, forced Kitty graphics, or portable Unicode cells. The selection is saved across TUI restarts and applies whenever formulas are rendered. Choose **Use configured default** to return to the `graphicsMode` value from plugin configuration.
94
+
75
95
  ## Options
76
96
 
77
97
  Options are optional:
@@ -80,7 +100,7 @@ Options are optional:
80
100
  {
81
101
  "plugins": [
82
102
  {
83
- "package": "opencode-latex@0.1.0",
103
+ "package": "opencode-latex@0.2.0",
84
104
  "options": {
85
105
  "color": "#d4d4d4",
86
106
  "graphicsMode": "auto",
@@ -93,7 +113,7 @@ Options are optional:
93
113
  ```
94
114
 
95
115
  - `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"`.
116
+ - `graphicsMode` selects `"auto"`, forced `"kitty"`, or portable `"cells"` rendering. The default is `"auto"`; a saved command-palette selection overrides it until **Use configured default** is selected.
97
117
  - `fontSize` sets the graphical MathJax font size. The default is `20`.
98
118
  - `pixelRatio` increases raster sharpness without changing the intended cell footprint. The default is `2`.
99
119
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-latex",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "Render LaTeX from OpenCode agent messages as terminal images.",
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -49,6 +49,12 @@ interface RuntimeNativeImage {
49
49
  readonly height: number
50
50
  }
51
51
 
52
+ interface PreparedRuntimeImage {
53
+ readonly image: RuntimeNativeImage
54
+ readonly pixelWidth: number
55
+ readonly pixelHeight: number
56
+ }
57
+
52
58
  /**
53
59
  * A narrow fork of opentui-math's graphical renderable. MathJax still creates
54
60
  * the PNG, while OpenTUI's image buffer owns Kitty/SIXEL transport and cleanup.
@@ -231,23 +237,33 @@ export class GraphicalLatexRenderable extends LatexRenderable {
231
237
  const x = originX + Math.floor((this.width - columns) / 2)
232
238
  const y = originY + Math.floor((this.height - rows) / 2)
233
239
  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
240
+ const prepared = this.effectiveGraphicsProtocol === "sixel"
241
+ ? this.ensureSixelRuntimeImage(
242
+ buffer,
243
+ x,
244
+ y,
245
+ columns,
246
+ rows,
247
+ pixelWidth,
248
+ pixelHeight,
249
+ Math.max(1, Math.round(columns * cellWidth)),
250
+ Math.max(1, Math.round(rows * cellHeight)),
251
+ )
252
+ : prepareRuntimeImage(this.ensureRuntimeImage(buffer), pixelWidth, pixelHeight)
253
+ if (!prepared) return
238
254
 
239
255
  buffer.drawImage(
240
- nativeImage as never,
256
+ prepared.image as never,
241
257
  x,
242
258
  y,
243
259
  columns,
244
260
  rows,
245
- pixelWidth,
246
- pixelHeight,
261
+ prepared.pixelWidth,
262
+ prepared.pixelHeight,
247
263
  0,
248
264
  0,
249
- nativeImage.width,
250
- nativeImage.height,
265
+ prepared.image.width,
266
+ prepared.image.height,
251
267
  protocol,
252
268
  )
253
269
  }
@@ -394,38 +410,55 @@ export class GraphicalLatexRenderable extends LatexRenderable {
394
410
  y: number,
395
411
  columns: number,
396
412
  rows: number,
397
- ): RuntimeNativeImage | undefined {
413
+ contentPixelWidth: number,
414
+ contentPixelHeight: number,
415
+ cellPixelWidth: number,
416
+ cellPixelHeight: number,
417
+ ): PreparedRuntimeImage | undefined {
398
418
  const source = this.ensureRuntimeImage(buffer)
399
419
  if (!source) return undefined
400
420
 
401
421
  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)}`
422
+ const canvasWidth = Math.max(
423
+ source.width,
424
+ Math.ceil(source.width * cellPixelWidth / contentPixelWidth),
425
+ )
426
+ const canvasHeight = Math.max(
427
+ source.height,
428
+ Math.ceil(source.height * cellPixelHeight / contentPixelHeight),
429
+ )
430
+ const backdropKey = [
431
+ x,
432
+ y,
433
+ columns,
434
+ rows,
435
+ canvasWidth,
436
+ canvasHeight,
437
+ hashBytes(backgrounds),
438
+ ].join(":")
408
439
  if (this.sixelRuntimeImage?.lib === source.lib && this.sixelBackdropKey === backdropKey) {
409
- return this.sixelRuntimeImage
440
+ return prepareRuntimeImage(this.sixelRuntimeImage, cellPixelWidth, cellPixelHeight)
410
441
  }
411
442
 
412
443
  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
444
+ const pixels = new Uint8Array(canvasWidth * canvasHeight * 4)
445
+ const copyStatus = source.lib.imageCopyPixels(source.ptr, pixels, canvasWidth * 4, false)
446
+ if (copyStatus !== 0) return prepareRuntimeImage(source, contentPixelWidth, contentPixelHeight)
447
+
448
+ compositeCellBackgrounds(pixels, canvasWidth, canvasHeight, backgrounds, columns, rows)
449
+ const created = source.lib.imageCreateFromRgba(pixels, canvasWidth, canvasHeight, canvasWidth * 4)
450
+ if (created.status !== 0 || !created.handle) {
451
+ return prepareRuntimeImage(source, contentPixelWidth, contentPixelHeight)
452
+ }
420
453
 
421
454
  this.sixelRuntimeImage = {
422
455
  lib: source.lib,
423
456
  ptr: created.handle,
424
- width: source.width,
425
- height: source.height,
457
+ width: canvasWidth,
458
+ height: canvasHeight,
426
459
  }
427
460
  this.sixelBackdropKey = backdropKey
428
- return this.sixelRuntimeImage
461
+ return prepareRuntimeImage(this.sixelRuntimeImage, cellPixelWidth, cellPixelHeight)
429
462
  }
430
463
 
431
464
  private disposeRuntimeImage(): void {
@@ -444,6 +477,14 @@ export class GraphicalLatexRenderable extends LatexRenderable {
444
477
  }
445
478
  }
446
479
 
480
+ function prepareRuntimeImage(
481
+ image: RuntimeNativeImage | undefined,
482
+ pixelWidth: number,
483
+ pixelHeight: number,
484
+ ): PreparedRuntimeImage | undefined {
485
+ return image ? { image, pixelWidth, pixelHeight } : undefined
486
+ }
487
+
447
488
  function readCellBackgrounds(
448
489
  buffer: OptimizedBuffer,
449
490
  x: number,
package/src/tui.ts CHANGED
@@ -4,6 +4,38 @@ import { GraphicalLatexRenderable } from "./opentui-math.js"
4
4
  const LANGUAGE = "latex"
5
5
  const GRAPHICS_MODES = ["auto", "kitty", "cells"] as const
6
6
  type GraphicsMode = typeof GRAPHICS_MODES[number]
7
+ type RenderModeChoice = GraphicsMode | "configured"
8
+
9
+ interface Settings {
10
+ graphicsMode: GraphicsMode | null
11
+ }
12
+
13
+ const RENDER_MODE_OPTIONS: ReadonlyArray<{
14
+ title: string
15
+ value: RenderModeChoice
16
+ description: string
17
+ }> = [
18
+ {
19
+ title: "Use configured default",
20
+ value: "configured",
21
+ description: "Follow graphicsMode from plugin configuration",
22
+ },
23
+ {
24
+ title: "Automatic",
25
+ value: "auto",
26
+ description: "Use Kitty or SIXEL when available, otherwise Unicode cells",
27
+ },
28
+ {
29
+ title: "Kitty graphics",
30
+ value: "kitty",
31
+ description: "Force the Kitty graphics protocol",
32
+ },
33
+ {
34
+ title: "Unicode cells",
35
+ value: "cells",
36
+ description: "Use the portable terminal-cell renderer",
37
+ },
38
+ ]
7
39
 
8
40
  function positiveNumber(value: unknown, fallback: number): number {
9
41
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback
@@ -15,6 +47,16 @@ function graphicsMode(value: unknown): GraphicsMode {
15
47
  : "auto"
16
48
  }
17
49
 
50
+ function savedGraphicsMode(value: unknown): GraphicsMode | null {
51
+ return typeof value === "string" && GRAPHICS_MODES.includes(value as GraphicsMode)
52
+ ? value as GraphicsMode
53
+ : null
54
+ }
55
+
56
+ function renderModeTitle(mode: GraphicsMode): string {
57
+ return RENDER_MODE_OPTIONS.find((option) => option.value === mode)?.title ?? mode
58
+ }
59
+
18
60
  export function normalizeLatexSource(source: string): string {
19
61
  return source.replace(/\\+/g, (run, offset: number) => {
20
62
  if (run.length >= 4 && run.length % 2 === 0) return "\\".repeat(run.length / 2)
@@ -32,20 +74,58 @@ export default Plugin.define({
32
74
  : context.themeMode === "light" ? "#24292f" : "#d4d4d4"
33
75
  const fontSize = positiveNumber(context.options.fontSize, 20)
34
76
  const pixelRatio = positiveNumber(context.options.pixelRatio, 2)
35
- const mode = graphicsMode(context.options.graphicsMode)
77
+ const configuredMode = graphicsMode(context.options.graphicsMode)
78
+ const [settings, updateSettings] = context.storage.store<Settings>("settings", {
79
+ initial: { graphicsMode: null },
80
+ })
81
+ const effectiveMode = (): GraphicsMode =>
82
+ savedGraphicsMode(settings.graphicsMode) ?? configuredMode
83
+
84
+ context.keymap.layer(() => ({
85
+ mode: "global",
86
+ commands: [
87
+ {
88
+ id: "opencode.latex.render-mode",
89
+ title: "Set default render mode",
90
+ description: "Choose how LaTeX formulas are rendered",
91
+ group: "LaTeX",
92
+ palette: true,
93
+ slash: { name: "latex-render-mode" },
94
+ run: async () => {
95
+ const selected = await context.ui.dialog.select<RenderModeChoice>({
96
+ title: "LaTeX render mode",
97
+ current: savedGraphicsMode(settings.graphicsMode) ?? "configured",
98
+ options: RENDER_MODE_OPTIONS,
99
+ })
100
+ if (selected === undefined) return
101
+
102
+ const preference = selected === "configured" ? null : selected
103
+ await updateSettings((draft) => {
104
+ draft.graphicsMode = preference
105
+ })
106
+ const mode = preference ?? configuredMode
107
+ context.ui.toast.show({
108
+ message: `LaTeX render mode: ${renderModeTitle(mode)}`,
109
+ variant: "success",
110
+ })
111
+ },
112
+ },
113
+ ],
114
+ }))
36
115
 
37
116
  return context.markdown.registerCodeBlockRenderer(LANGUAGE, (token, render) => {
38
117
  try {
39
- return new GraphicalLatexRenderable(context.renderer, {
118
+ const renderable = new GraphicalLatexRenderable(context.renderer, {
40
119
  content: normalizeLatexSource(token.text),
41
120
  displayMode: true,
42
121
  fallback: "source",
43
122
  foregroundColor: color,
44
123
  graphicsForegroundColor: color,
45
- graphicsMode: mode,
124
+ graphicsMode: effectiveMode(),
46
125
  fontSize,
47
126
  pixelRatio,
48
127
  })
128
+ return renderable
49
129
  } catch {
50
130
  return render.defaultRender()
51
131
  }