@cfbender/cesium 0.5.0 → 0.5.2

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
@@ -1,5 +1,90 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.5.2 — 2026-05-12
4
+
5
+ A 16th block type — `diff` — that renders a beautiful side-by-side
6
+ before/after code diff with curved SVG bezier connectors between the two
7
+ columns. JetBrains diff-viewer aesthetic. Plus tooling improvements for
8
+ hot-reload visual iteration during plugin development.
9
+
10
+ - **feat:** New `diff` block type. Two input arms (XOR):
11
+ - `patch`: literal unified diff string (e.g. from `git diff` output)
12
+ - `before`/`after`: paired text strings; server runs Myers O(ND) line
13
+ diff to compute the change set
14
+ - **feat:** Per-line shiki syntax highlighting is preserved through the
15
+ diff. The renderer recomposes before-side and after-side text, runs each
16
+ through `highlightCode`, then zips the styled line spans back into the
17
+ diff line list — so multi-line constructs (template strings, block
18
+ comments) tokenize correctly.
19
+ - **feat:** Visual rendering:
20
+ - Three-column grid (1fr | 60px | 1fr) with line numbers per side
21
+ - Subtle red/green line-tint backgrounds on remove/add lines
22
+ - 60px-wide SVG connector column draws semi-transparent cubic-bezier
23
+ ribbons connecting each remove region on the left to the corresponding
24
+ add region on the right; pure adds collapse to a teardrop pointing
25
+ into the left, pure removes mirror
26
+ - Optional file-header strip with filename + `+N -M` stats
27
+ - Optional caption strip below
28
+ - 720px breakpoint collapses to single-column with connector hidden
29
+ - **feat:** Theme tokens `--diff-add`, `--diff-remove`, `--diff-change`
30
+ added to all seven palette presets so colors fit each preset's character
31
+ (claret rose, warm clay, cool blue, etc.).
32
+ - **fix:** Diff connector SVG now matches the `padding: 8px 0` of the
33
+ side columns so the bezier paths line up exactly with their target
34
+ regions instead of sitting 8px high.
35
+ - **chore:** Project-local opencode config tracked: `.opencode/opencode.json`,
36
+ `.opencode/plugins/cesium.ts` (dev-loop shim that loads the working
37
+ tree's source instead of the published npm package), and
38
+ `.opencode/skills/cesium-preview/SKILL.md` (hot-reload visual iteration
39
+ workflow that bypasses the stale plugin host by importing render code
40
+ directly via bun and writing to /tmp).
41
+ - **chore:** `scripts/dogfood-diff.ts` reference preview script for the
42
+ diff block. Useful template for previewing other block work.
43
+ - **chore:** Apply oxfmt to 30 files that drifted out of format compliance
44
+ in v0.5.1 (no CI lint gate caught it). Pure cosmetic — line-wrapping,
45
+ italic style normalization, key ordering. No behavior changes.
46
+
47
+ ## v0.5.1 — 2026-05-12
48
+
49
+ Server-side syntax highlighting for `code` blocks via shiki, custom claret
50
+ themes derived from the canonical `claret.nvim` sources, and a critical fix
51
+ to the lazy-start lifecycle so `cesium stop` (or test friendly-fire) can no
52
+ longer kill the plugin host process.
53
+
54
+ - **fix (critical):** Lazy-started cesium server now runs as a detached
55
+ subprocess. Previously `ensureRunning` (called from publish/ask plugin
56
+ paths) ran `Bun.serve()` in-process and wrote `pid: process.pid` to the PID
57
+ file — meaning that PID was the _plugin host_ (e.g. opencode). Any
58
+ invocation of `cesium stop` (CLI, tool, or test) would signal the host
59
+ process and kill it. Now lazy-start spawns `bun run cli serve` as a
60
+ detached child; the PID file points at that child. Foreground `cesium
61
+ serve` still runs in-process (correct for its semantics).
62
+ - **api:** Split `ensureRunning` into `runServerForeground` (in-process, for
63
+ the foreground CLI) and `ensureServerRunning` (detached subprocess, for
64
+ plugins). `ensureRunning` is kept as a backward-compat alias for
65
+ `runServerForeground`.
66
+ - **fix:** `test/cli-entry.test.ts` now sets `CESIUM_STATE_DIR` to a temp
67
+ dir per spawned subprocess, so the `cesium stop` test no longer reads the
68
+ user's real PID file.
69
+ - **feat:** Server-side syntax highlighting via shiki. `code` blocks are
70
+ tokenized at publish time and emit styled token spans. Async cascade
71
+ through `renderBlock`/`renderBlocks`/`renderSection`/`renderCode`.
72
+ - **feat:** Custom `claret-dark` shiki theme — converted directly from
73
+ `claret.nvim`'s `ports/bat/ClaretDark.tmTheme` so colors are faithful to
74
+ the canonical claret palette (keywords claret rose, strings olive,
75
+ comments muted italic, functions gold, etc.).
76
+ - **feat:** Custom `claret-light` shiki theme — derived from claret.nvim's
77
+ light palette using the same scope grammar as the dark theme.
78
+ - **feat:** `resolveHighlightTheme(cesiumThemeName)` — maps cesium presets
79
+ to the right shiki theme. `claret`/`claret-dark` → claret-dark,
80
+ `claret-light` → claret-light, all others → `vitesse-dark`.
81
+ - **feat:** `RenderCtx.highlightTheme` threaded from publish through the
82
+ render context. Default is `claret-dark` (matches the framework default).
83
+ - **feat:** `cesium serve` gains a `--state-dir` flag and respects
84
+ `CESIUM_STATE_DIR` / `CESIUM_PORT` env vars (used by the detached spawn).
85
+ - **dep:** Added `shiki@^4.0.0` as a runtime dependency (~3.8MB on disk;
86
+ languages loaded lazily).
87
+
3
88
  ## v0.5.0 — 2026-05-12
4
89
 
5
90
  Block-mode refactor — `cesium_publish` now accepts a structured `blocks` array
@@ -41,7 +126,7 @@ tokens, more on heavily structured artifacts.
41
126
  surfaced as a small badge on index cards.
42
127
  - **feat:** Framework CSS extended with rules for every block-renderer
43
128
  pattern: `dl.kv` (2-column grid), `.pill-row`, `.check-list`, `<hr
44
- data-label>`, `figure.code`, timeline-item internals, `.lede`, plus
129
+ data-label>`, `figure.code`, timeline-item internals, `.lede`, plus
45
130
  `.diagram svg text { fill: currentColor }` so SVGs inherit theme color.
46
131
  - **feat:** `escapeHtml` and `escapeAttr` throw a clear error on non-string
47
132
  input instead of crashing inside `.replace()`.
package/README.md CHANGED
@@ -382,15 +382,15 @@ state directory, hostname, and theme settings flow through.
382
382
 
383
383
  Optional `~/.config/opencode/cesium.json`:
384
384
 
385
- | Key | Type | Default | Description |
386
- | --------------- | ------ | ----------------------- | ---------------------------------------------------------------------------------------- |
387
- | `stateDir` | string | `~/.local/state/cesium` | Where artifacts and indexes live |
388
- | `port` | number | `3030` | First port to try for the local HTTP server |
389
- | `portMax` | number | `3050` | Upper bound when scanning for free ports |
390
- | `hostname` | string | `127.0.0.1` | Bind address. Use `0.0.0.0` to expose on the LAN |
385
+ | Key | Type | Default | Description |
386
+ | --------------- | ------ | ----------------------- | ------------------------------------------------------------------------------------------- |
387
+ | `stateDir` | string | `~/.local/state/cesium` | Where artifacts and indexes live |
388
+ | `port` | number | `3030` | First port to try for the local HTTP server |
389
+ | `portMax` | number | `3050` | Upper bound when scanning for free ports |
390
+ | `hostname` | string | `127.0.0.1` | Bind address. Use `0.0.0.0` to expose on the LAN |
391
391
  | `idleTimeoutMs` | number | `1800000` | Plugin server idle-shutdown threshold (30 min). Does not apply to foreground `cesium serve` |
392
- | `themePreset` | string | `"claret-dark"` | Named color palette (`claret-dark`/`claret-light`/`claret`/`warm`/`cool`/`mono`/`paper`) |
393
- | `theme` | object | (claret-dark palette) | Per-token color overrides (stacked on preset) |
392
+ | `themePreset` | string | `"claret-dark"` | Named color palette (`claret-dark`/`claret-light`/`claret`/`warm`/`cool`/`mono`/`paper`) |
393
+ | `theme` | object | (claret-dark palette) | Per-token color overrides (stacked on preset) |
394
394
 
395
395
  Environment overrides: `CESIUM_PORT`, `CESIUM_STATE_DIR`, `CESIUM_HOSTNAME`, `CESIUM_THEME_PRESET`.
396
396
 
package/package.json CHANGED
@@ -1,29 +1,27 @@
1
1
  {
2
2
  "name": "@cfbender/cesium",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Beautiful self-contained HTML artifacts from your opencode agent.",
5
- "license": "MIT",
6
- "author": "Cody Bender",
5
+ "keywords": [
6
+ "agent",
7
+ "artifact",
8
+ "claret",
9
+ "cli",
10
+ "html",
11
+ "opencode",
12
+ "plugin"
13
+ ],
7
14
  "homepage": "https://github.com/cfbender/cesium#readme",
8
15
  "bugs": "https://github.com/cfbender/cesium/issues",
16
+ "license": "MIT",
17
+ "author": "Cody Bender",
9
18
  "repository": {
10
19
  "type": "git",
11
20
  "url": "git+https://github.com/cfbender/cesium.git"
12
21
  },
13
- "keywords": [
14
- "opencode",
15
- "agent",
16
- "html",
17
- "artifact",
18
- "plugin",
19
- "cli",
20
- "claret"
21
- ],
22
22
  "bin": {
23
23
  "cesium": "./src/cli/index.ts"
24
24
  },
25
- "type": "module",
26
- "main": "src/index.ts",
27
25
  "files": [
28
26
  "src",
29
27
  "assets/styleguide.html",
@@ -31,12 +29,11 @@
31
29
  "ARCHITECTURE.md",
32
30
  "CHANGELOG.md"
33
31
  ],
32
+ "type": "module",
33
+ "main": "src/index.ts",
34
34
  "publishConfig": {
35
35
  "access": "public"
36
36
  },
37
- "engines": {
38
- "bun": ">=1.0.0"
39
- },
40
37
  "scripts": {
41
38
  "test": "bun test",
42
39
  "typecheck": "tsc --noEmit",
@@ -50,12 +47,16 @@
50
47
  "dependencies": {
51
48
  "@opencode-ai/plugin": "latest",
52
49
  "nanoid": "^5.0.0",
53
- "parse5": "^7.1.0"
50
+ "parse5": "^7.1.0",
51
+ "shiki": "^4.0.2"
54
52
  },
55
53
  "devDependencies": {
56
54
  "@types/bun": "latest",
57
55
  "oxfmt": "^0.48.0",
58
56
  "oxlint": "^1.63.0",
59
57
  "typescript": "^5.4.0"
58
+ },
59
+ "engines": {
60
+ "bun": ">=1.0.0"
60
61
  }
61
62
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { parseArgs } from "node:util";
4
4
  import { loadConfig, type CesiumConfig } from "../../config.ts";
5
- import { ensureRunning, stopRunning } from "../../server/lifecycle.ts";
5
+ import { runServerForeground, stopRunning } from "../../server/lifecycle.ts";
6
6
  import { resolveDisplayHost } from "../../tools/publish.ts";
7
7
  import { themeFromPreset, mergeTheme } from "../../render/theme.ts";
8
8
 
@@ -22,6 +22,7 @@ function defaultCtx(): ServeContext {
22
22
  export interface ServeOptions {
23
23
  port?: number;
24
24
  hostname?: string;
25
+ stateDir?: string;
25
26
  /**
26
27
  * Idle timeout in milliseconds. 0 (the default for `cesium serve`) means the
27
28
  * server runs forever until SIGINT/SIGTERM. Override with --idle-timeout to
@@ -40,8 +41,7 @@ function parseDuration(input: string): number | null {
40
41
  const n = parseFloat(match[1] ?? "");
41
42
  if (!isFinite(n) || n < 0) return null;
42
43
  const unit = match[2] ?? "ms";
43
- const mul =
44
- unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : /* h */ 3_600_000;
44
+ const mul = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : /* h */ 3_600_000;
45
45
  return Math.floor(n * mul);
46
46
  }
47
47
 
@@ -54,6 +54,7 @@ export function parseServeArgs(
54
54
  port: string | undefined;
55
55
  hostname: string | undefined;
56
56
  "idle-timeout": string | undefined;
57
+ "state-dir": string | undefined;
57
58
  help: boolean;
58
59
  };
59
60
 
@@ -64,6 +65,7 @@ export function parseServeArgs(
64
65
  port: { type: "string", short: "p" },
65
66
  hostname: { type: "string", short: "H" },
66
67
  "idle-timeout": { type: "string" },
68
+ "state-dir": { type: "string" },
67
69
  help: { type: "boolean", short: "h", default: false },
68
70
  },
69
71
  allowPositionals: false,
@@ -85,6 +87,7 @@ export function parseServeArgs(
85
87
  "Options:",
86
88
  " --port, -p N Override configured port (default: 3030)",
87
89
  " --hostname, -H H Override configured bind address (default: 127.0.0.1)",
90
+ " --state-dir DIR Override the cesium state directory",
88
91
  " --idle-timeout DUR Auto-shutdown after DUR of inactivity. Accepts plain",
89
92
  " milliseconds or a suffixed value (90s, 30m, 2h).",
90
93
  " Use 0 / never / off to disable. Default: 0 (never).",
@@ -121,6 +124,14 @@ export function parseServeArgs(
121
124
  opts.hostname = values.hostname;
122
125
  }
123
126
 
127
+ if (values["state-dir"] !== undefined) {
128
+ if (values["state-dir"].length === 0) {
129
+ ctx.stderr.write(`cesium serve: --state-dir must not be empty\n`);
130
+ return null;
131
+ }
132
+ opts.stateDir = values["state-dir"];
133
+ }
134
+
124
135
  if (values["idle-timeout"] !== undefined) {
125
136
  const ms = parseDuration(values["idle-timeout"]);
126
137
  if (ms === null) {
@@ -154,6 +165,7 @@ export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>):
154
165
  // Apply overrides from CLI flags
155
166
  const effectiveCfg = {
156
167
  ...cfg,
168
+ ...(opts.stateDir !== undefined ? { stateDir: opts.stateDir } : {}),
157
169
  ...(opts.port !== undefined ? { port: opts.port, portMax: opts.port } : {}),
158
170
  ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
159
171
  idleTimeoutMs: effectiveIdleTimeoutMs,
@@ -162,7 +174,7 @@ export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>):
162
174
  let serverInfo: { port: number; url: string };
163
175
  try {
164
176
  const theme = mergeTheme(themeFromPreset(effectiveCfg.themePreset), effectiveCfg.theme);
165
- serverInfo = await ensureRunning({
177
+ serverInfo = await runServerForeground({
166
178
  stateDir: effectiveCfg.stateDir,
167
179
  port: effectiveCfg.port,
168
180
  portMax: effectiveCfg.portMax,
package/src/index.ts CHANGED
@@ -17,7 +17,10 @@ const rawFragment = await readFile(
17
17
  "utf8",
18
18
  );
19
19
 
20
- const PROMPT_FRAGMENT = rawFragment.replace("{{BLOCK_FIELD_REFERENCE}}", generateBlockFieldReference());
20
+ const PROMPT_FRAGMENT = rawFragment.replace(
21
+ "{{BLOCK_FIELD_REFERENCE}}",
22
+ generateBlockFieldReference(),
23
+ );
21
24
 
22
25
  export const CesiumPlugin: Plugin = async (ctx): Promise<Hooks> => {
23
26
  return {
@@ -86,8 +86,8 @@ export function generateBlockFieldReference(): string {
86
86
  lines.push("");
87
87
  lines.push(
88
88
  "All `markdown` fields support `**bold**`, `*italic*`, `` `code` ``, lists, blockquotes, " +
89
- "and the safelisted inline tags `<kbd>`, `<span class=\"pill\">`, `<span class=\"tag\">`. " +
90
- "External URLs in links render as plain text.",
89
+ 'and the safelisted inline tags `<kbd>`, `<span class="pill">`, `<span class="tag">`. ' +
90
+ "External URLs in links render as plain text.",
91
91
  );
92
92
 
93
93
  return lines.join("\n");
@@ -20,22 +20,52 @@ You have access to six tools:
20
20
  ### Example
21
21
 
22
22
  ```json
23
- { "title": "Migration Guide", "kind": "plan", "blocks": [
24
- { "type": "hero", "eyebrow": "v2", "title": "Migration Guide",
25
- "meta": [{ "k": "Status", "v": "Draft" }, { "k": "Owner", "v": "platform" }] },
26
- { "type": "tldr", "markdown": "**Summary:** Update one import path and bump the SDK." },
27
- { "type": "section", "title": "What Changed", "children": [
28
- { "type": "prose", "markdown": "The `auth` module is now a standalone package." },
29
- { "type": "callout", "variant": "warn", "markdown": "Change `sdk/auth` imports before upgrading." }
30
- ]},
31
- { "type": "risk_table", "rows": [
32
- { "risk": "Missed imports", "likelihood": "medium", "impact": "high", "mitigation": "Run codemods." }
33
- ]},
34
- { "type": "timeline", "items": [
35
- { "label": "Phase 1", "text": "Audit existing imports", "date": "2026-06-01" },
36
- { "label": "Phase 2", "text": "Run migration script" }
37
- ]}
38
- ]}
23
+ {
24
+ "title": "Migration Guide",
25
+ "kind": "plan",
26
+ "blocks": [
27
+ {
28
+ "type": "hero",
29
+ "eyebrow": "v2",
30
+ "title": "Migration Guide",
31
+ "meta": [
32
+ { "k": "Status", "v": "Draft" },
33
+ { "k": "Owner", "v": "platform" }
34
+ ]
35
+ },
36
+ { "type": "tldr", "markdown": "**Summary:** Update one import path and bump the SDK." },
37
+ {
38
+ "type": "section",
39
+ "title": "What Changed",
40
+ "children": [
41
+ { "type": "prose", "markdown": "The `auth` module is now a standalone package." },
42
+ {
43
+ "type": "callout",
44
+ "variant": "warn",
45
+ "markdown": "Change `sdk/auth` imports before upgrading."
46
+ }
47
+ ]
48
+ },
49
+ {
50
+ "type": "risk_table",
51
+ "rows": [
52
+ {
53
+ "risk": "Missed imports",
54
+ "likelihood": "medium",
55
+ "impact": "high",
56
+ "mitigation": "Run codemods."
57
+ }
58
+ ]
59
+ },
60
+ {
61
+ "type": "timeline",
62
+ "items": [
63
+ { "label": "Phase 1", "text": "Audit existing imports", "date": "2026-06-01" },
64
+ { "label": "Phase 2", "text": "Run migration script" }
65
+ ]
66
+ }
67
+ ]
68
+ }
39
69
  ```
40
70
 
41
71
  ## Quick block reference
@@ -17,6 +17,7 @@ import { meta as pillRowMeta } from "./renderers/pill-row.ts";
17
17
  import { meta as dividerMeta } from "./renderers/divider.ts";
18
18
  import { meta as diagramMeta } from "./renderers/diagram.ts";
19
19
  import { meta as rawHtmlMeta } from "./renderers/raw-html.ts";
20
+ import { meta as diffMeta } from "./renderers/diff.ts";
20
21
 
21
22
  export const blockCatalog: Record<Block["type"], BlockMeta> = {
22
23
  hero: heroMeta,
@@ -34,6 +35,7 @@ export const blockCatalog: Record<Block["type"], BlockMeta> = {
34
35
  divider: dividerMeta,
35
36
  diagram: diagramMeta,
36
37
  raw_html: rawHtmlMeta,
38
+ diff: diffMeta,
37
39
  };
38
40
 
39
41
  export const blockTypes = Object.keys(blockCatalog) as Array<Block["type"]>;
@@ -0,0 +1,221 @@
1
+ // Myers O(ND) line-level diff algorithm.
2
+ // src/render/blocks/diff/myers.ts
3
+ //
4
+ // Re-implemented from scratch — no external dependencies.
5
+ // Reference: "An O(ND) Difference Algorithm and Its Variations" — Eugene W. Myers (1986).
6
+ //
7
+ // Implementation follows the standard "trace + backtrack" pattern from:
8
+ // https://blog.jcoglan.com/2017/02/12/the-myers-diff-algorithm-part-1/
9
+
10
+ import type { DiffLine } from "./parse-unified.ts";
11
+
12
+ export type { DiffLine };
13
+
14
+ /**
15
+ * Split a string into lines, trimming a single trailing empty element when the
16
+ * input ends with "\n" (so clean files don't produce a phantom blank final line).
17
+ */
18
+ function splitLines(text: string): string[] {
19
+ if (text === "") return [];
20
+ const lines = text.split("\n");
21
+ // Trim trailing phantom empty line produced by a trailing newline
22
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
23
+ lines.pop();
24
+ }
25
+ return lines;
26
+ }
27
+
28
+ // ─── Myers forward pass ───────────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Run the Myers forward pass and return the trace (one v[] snapshot per d).
32
+ * v[k + offset] = furthest row reached on diagonal k at this step.
33
+ */
34
+ function myersTrace(a: string[], b: string[]): number[][] {
35
+ const N = a.length;
36
+ const M = b.length;
37
+ const MAX = N + M;
38
+ const offset = MAX;
39
+
40
+ const v: number[] = Array.from({ length: 2 * MAX + 2 }, () => 0);
41
+ v[offset + 1] = 0;
42
+
43
+ const trace: number[][] = [];
44
+
45
+ for (let d = 0; d <= MAX; d++) {
46
+ for (let k = -d; k <= d; k += 2) {
47
+ const ki = k + offset;
48
+ const vKm1 = v[ki - 1] ?? 0;
49
+ const vKp1 = v[ki + 1] ?? 0;
50
+
51
+ let x: number;
52
+ if (k === -d || (k !== d && vKm1 < vKp1)) {
53
+ x = vKp1; // down: insert
54
+ } else {
55
+ x = vKm1 + 1; // right: delete
56
+ }
57
+
58
+ let y = x - k;
59
+
60
+ // Follow the diagonal (matches)
61
+ while (x < N && y < M && a[x] === b[y]) {
62
+ x++;
63
+ y++;
64
+ }
65
+
66
+ v[ki] = x;
67
+
68
+ if (x >= N && y >= M) {
69
+ trace.push(v.slice());
70
+ return trace;
71
+ }
72
+ }
73
+
74
+ trace.push(v.slice());
75
+ }
76
+
77
+ return trace;
78
+ }
79
+
80
+ // ─── Myers backtrack ──────────────────────────────────────────────────────────
81
+
82
+ type Edit =
83
+ | { op: "keep"; aIdx: number; bIdx: number }
84
+ | { op: "delete"; aIdx: number }
85
+ | { op: "insert"; bIdx: number };
86
+
87
+ /**
88
+ * Backtrack through the trace to produce the edit script.
89
+ * Reconstructs the path from (N, M) back to (0, 0).
90
+ */
91
+ function backtrack(a: string[], b: string[], trace: number[][]): Edit[] {
92
+ const MAX = a.length + b.length;
93
+ const offset = MAX;
94
+ const edits: Edit[] = [];
95
+
96
+ let x = a.length;
97
+ let y = b.length;
98
+
99
+ // Walk backwards through d steps
100
+ for (let d = trace.length - 1; d > 0; d--) {
101
+ const vPrev = trace[d - 1] ?? [];
102
+ const k = x - y;
103
+ const ki = k + offset;
104
+
105
+ const vPrevKm1 = vPrev[ki - 1] ?? 0;
106
+ const vPrevKp1 = vPrev[ki + 1] ?? 0;
107
+
108
+ // Determine which diagonal we came from
109
+ let prevK: number;
110
+ if (k === -d || (k !== d && vPrevKm1 < vPrevKp1)) {
111
+ prevK = k + 1; // came via down (insert)
112
+ } else {
113
+ prevK = k - 1; // came via right (delete)
114
+ }
115
+
116
+ const prevX = vPrev[prevK + offset] ?? 0;
117
+ const prevY = prevX - prevK;
118
+
119
+ // Retrace the snake (diagonal matches) from (prevX, prevY) to (x, y)
120
+ // but skip the single edit step itself.
121
+ // After the edit step, we were at:
122
+ // insert: (prevX, prevY + 1)
123
+ // delete: (prevX + 1, prevY)
124
+ if (prevK === k + 1) {
125
+ // insert: moved down, x stays same, y += 1
126
+ const snakeX = prevX;
127
+ // snake goes from (snakeX, prevY+1) to (x, y)
128
+ for (let sx = x - 1; sx >= snakeX; sx--) {
129
+ const sy = sx - k;
130
+ edits.push({ op: "keep", aIdx: sx, bIdx: sy });
131
+ }
132
+ edits.push({ op: "insert", bIdx: prevY });
133
+ } else {
134
+ // delete: moved right, x += 1, y stays same
135
+ const snakeX = prevX + 1;
136
+ // snake goes from (snakeX, prevY) to (x, y)
137
+ for (let sx = x - 1; sx >= snakeX; sx--) {
138
+ const sy = sx - k;
139
+ edits.push({ op: "keep", aIdx: sx, bIdx: sy });
140
+ }
141
+ edits.push({ op: "delete", aIdx: prevX });
142
+ }
143
+
144
+ x = prevX;
145
+ y = prevY;
146
+ }
147
+
148
+ // Remaining snake at d=0: (0,0) to (x,y)
149
+ for (let sx = x - 1; sx >= 0; sx--) {
150
+ const sy = sx; // k=0 at the start
151
+ edits.push({ op: "keep", aIdx: sx, bIdx: sy });
152
+ }
153
+
154
+ edits.reverse();
155
+ return edits;
156
+ }
157
+
158
+ function myersDiff(a: string[], b: string[]): Edit[] {
159
+ if (a.length === 0 && b.length === 0) return [];
160
+ if (a.length === 0) return b.map((_, i) => ({ op: "insert" as const, bIdx: i }));
161
+ if (b.length === 0) return a.map((_, i) => ({ op: "delete" as const, aIdx: i }));
162
+
163
+ const trace = myersTrace(a, b);
164
+ return backtrack(a, b, trace);
165
+ }
166
+
167
+ // ─── Public API ───────────────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Compute a line-level diff between two text strings.
171
+ * Returns a flat DiffLine[] with 1-indexed line numbers per side.
172
+ * No hunk-sep entries — single contiguous diff.
173
+ */
174
+ export function diffLines(before: string, after: string): DiffLine[] {
175
+ const aLines = splitLines(before);
176
+ const bLines = splitLines(after);
177
+
178
+ const edits = myersDiff(aLines, bLines);
179
+
180
+ let beforeLine = 1;
181
+ let afterLine = 1;
182
+
183
+ return edits.map((edit): DiffLine => {
184
+ switch (edit.op) {
185
+ case "keep": {
186
+ const text = aLines[edit.aIdx] ?? "";
187
+ const entry: DiffLine = {
188
+ kind: "context",
189
+ text,
190
+ beforeLineNum: beforeLine,
191
+ afterLineNum: afterLine,
192
+ };
193
+ beforeLine++;
194
+ afterLine++;
195
+ return entry;
196
+ }
197
+ case "delete": {
198
+ const text = aLines[edit.aIdx] ?? "";
199
+ const entry: DiffLine = {
200
+ kind: "remove",
201
+ text,
202
+ beforeLineNum: beforeLine,
203
+ afterLineNum: null,
204
+ };
205
+ beforeLine++;
206
+ return entry;
207
+ }
208
+ case "insert": {
209
+ const text = bLines[edit.bIdx] ?? "";
210
+ const entry: DiffLine = {
211
+ kind: "add",
212
+ text,
213
+ beforeLineNum: null,
214
+ afterLineNum: afterLine,
215
+ };
216
+ afterLine++;
217
+ return entry;
218
+ }
219
+ }
220
+ });
221
+ }