@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.6

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/package.json CHANGED
@@ -1,21 +1,22 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.6",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@cueloop/daemon": "0.1.0-alpha.4",
11
- "@cueloop/extension-api": "0.1.0-alpha.4",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.4",
13
- "@cueloop/schema": "0.1.0-alpha.4",
10
+ "@cueloop/daemon": "0.1.0-alpha.6",
11
+ "@cueloop/extension-api": "0.1.0-alpha.6",
12
+ "@cueloop/integration-obsidian": "0.1.0-alpha.6",
13
+ "@cueloop/schema": "0.1.0-alpha.6",
14
14
  "@opentui/core": "^0.4.3",
15
15
  "@opentui/react": "^0.4.3",
16
+ "@opentui/ssh": "0.4.5",
16
17
  "@pierre/diffs": "^1.3.5",
17
18
  "react": "^19.2.0",
18
- "@opentui/ssh": "0.4.5"
19
+ "shiki": "^4.4.2"
19
20
  },
20
21
  "devDependencies": {
21
22
  "@types/react": "^19.2.0",
@@ -36,5 +37,10 @@
36
37
  "type": "git",
37
38
  "url": "git+https://github.com/mmurakaru/cueloop.git",
38
39
  "directory": "packages/client"
39
- }
40
- }
40
+ },
41
+ "homepage": "https://github.com/mmurakaru/cueloop#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/mmurakaru/cueloop/issues"
44
+ },
45
+ "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail"
46
+ }
@@ -0,0 +1,118 @@
1
+ /** Post-submit completion overlay: prompt, opt-in persistence, countdown, dismissal. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+
13
+ const PLAN = "# Plan\n\nShip the thing.\n";
14
+
15
+ let home: string;
16
+ let server: DaemonServer;
17
+ let session: ReviewSession;
18
+ let configPath: string;
19
+
20
+ beforeEach(() => {
21
+ home = mkdtempSync(join(tmpdir(), "cueloop-close-"));
22
+ configPath = join(home, "config.toml");
23
+ process.env.CUELOOP_CONFIG = configPath;
24
+ server = new DaemonServer({ home, idleExitMs: 0 });
25
+ server.start();
26
+ session = server.core.sessionCreate({
27
+ workspace: { repoRoot: "/repo", branch: "main" },
28
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
29
+ });
30
+ });
31
+ afterEach(() => {
32
+ delete process.env.CUELOOP_CONFIG;
33
+ server.stop();
34
+ rmSync(home, { recursive: true, force: true });
35
+ });
36
+
37
+ async function renderApp(onExit?: (code: number) => void) {
38
+ const setup = await testRender(<App home={home} sessionId={session.id} onExit={onExit} />, {
39
+ width: 120,
40
+ height: 30,
41
+ });
42
+ for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
43
+ await Bun.sleep(25);
44
+ await setup.renderOnce();
45
+ }
46
+ return setup;
47
+ }
48
+
49
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
50
+ async function press(setup: Setup, k: string): Promise<void> {
51
+ if (k === "enter") setup.mockInput.pressKey("RETURN");
52
+ else if (k === "escape") setup.mockInput.pressKey("ESCAPE");
53
+ else await setup.mockInput.typeText(k);
54
+ await Bun.sleep(15);
55
+ await setup.renderOnce();
56
+ }
57
+
58
+ async function submitApprove(setup: Setup): Promise<void> {
59
+ await press(setup, "enter"); // open submit (approve default: no pending items)
60
+ await press(setup, "enter"); // confirm
61
+ const deadline = Date.now() + 10_000;
62
+ while (Date.now() < deadline && !setup.captureCharFrame().includes("review approved")) {
63
+ await Bun.sleep(25);
64
+ await setup.renderOnce();
65
+ }
66
+ }
67
+
68
+ describe("completion overlay", () => {
69
+ test("submit with auto-close off shows the prompt; ⏎ exits", async () => {
70
+ let exited = -1;
71
+ const setup = await renderApp((code) => (exited = code));
72
+ await submitApprove(setup);
73
+ const frame = setup.captureCharFrame();
74
+ expect(frame).toContain("✓ review approved");
75
+ expect(frame).toContain("a always close after submit");
76
+ await press(setup, "enter");
77
+ expect(exited).toBe(0);
78
+ });
79
+
80
+ test("a opts in: persists the config and starts the countdown", async () => {
81
+ const setup = await renderApp();
82
+ await submitApprove(setup);
83
+ await press(setup, "a");
84
+ expect(setup.captureCharFrame()).toContain("closing in 3s");
85
+ expect(existsSync(configPath)).toBe(true);
86
+ expect(readFileSync(configPath, "utf8")).toContain("auto_close = 3");
87
+ });
88
+
89
+ test("esc dismisses to the resolved read-only view", async () => {
90
+ const setup = await renderApp();
91
+ await submitApprove(setup);
92
+ await press(setup, "escape");
93
+ // a bare ESC sits in the input parser's escape-sequence disambiguation
94
+ // window before it is delivered - poll for the dismissal
95
+ const deadline = Date.now() + 5_000;
96
+ while (Date.now() < deadline && !setup.captureCharFrame().includes("resolved: approve")) {
97
+ await Bun.sleep(50);
98
+ await setup.renderOnce();
99
+ }
100
+ const frame = setup.captureCharFrame();
101
+ expect(frame).toContain("resolved: approve");
102
+ expect(frame).not.toContain("always close after submit");
103
+ });
104
+
105
+ test("configured auto_close counts down and exits without interaction", async () => {
106
+ writeFileSync(configPath, "[ui]\nauto_close = 1\n");
107
+ let exited = -1;
108
+ const setup = await renderApp((code) => (exited = code));
109
+ await submitApprove(setup);
110
+ expect(setup.captureCharFrame()).toContain("closing in 1s");
111
+ const deadline = Date.now() + 10_000;
112
+ while (Date.now() < deadline && exited === -1) {
113
+ await Bun.sleep(100);
114
+ await setup.renderOnce();
115
+ }
116
+ expect(exited).toBe(0);
117
+ }, 20_000);
118
+ });
@@ -0,0 +1,108 @@
1
+ /** Plan rendering (#67): code blocks verbatim + contained, spacing never collapses. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+
13
+ const PLAN = `# Render Plan
14
+
15
+ ## Sensor
16
+
17
+ - wire the duck over MQTT
18
+
19
+ \`\`\`tsx
20
+ export function Gate({ full }: { full: number }) {
21
+ const blocked = full < 0.6;
22
+ return <text>{blocked ? "blocked" : "clear"}</text>;
23
+ }
24
+ \`\`\`
25
+
26
+ ## Gate
27
+
28
+ Deploys block below the threshold.
29
+ `;
30
+
31
+ let home: string;
32
+ let server: DaemonServer;
33
+ let session: ReviewSession;
34
+
35
+ beforeEach(() => {
36
+ home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
37
+ server = new DaemonServer({ home, idleExitMs: 0 });
38
+ server.start();
39
+ session = server.core.sessionCreate({
40
+ workspace: { repoRoot: "/repo", branch: "main" },
41
+ artifact: { type: "plan", content: PLAN, meta: { title: "Render Plan" } },
42
+ });
43
+ });
44
+ afterEach(() => {
45
+ server.stop();
46
+ rmSync(home, { recursive: true, force: true });
47
+ });
48
+
49
+ async function frame(): Promise<string> {
50
+ const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 36 });
51
+ const deadline = Date.now() + 10_000;
52
+ while (Date.now() < deadline && !setup.captureCharFrame().includes("Render Plan")) {
53
+ await Bun.sleep(25);
54
+ await setup.renderOnce();
55
+ }
56
+ // give the async highlighter a beat, then take the final frame
57
+ await Bun.sleep(150);
58
+ await setup.renderOnce();
59
+ return setup.captureCharFrame();
60
+ }
61
+
62
+ describe("code blocks", () => {
63
+ test("lines render verbatim: indentation preserved, no word-wrap", async () => {
64
+ const f = await frame();
65
+ expect(f).toContain(" const blocked = full < 0.6;");
66
+ expect(f).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
67
+ });
68
+
69
+ test("the block carries its language tag", async () => {
70
+ const f = await frame();
71
+ expect(f).toContain("tsx");
72
+ });
73
+ });
74
+
75
+ describe("block spacing", () => {
76
+ test("a code block never glues to the list item above it", async () => {
77
+ const lines = (await frame()).split("\n").map((l) => l.trimEnd());
78
+ const li = lines.findIndex((l) => l.includes("wire the duck over MQTT"));
79
+ const codeHeader = lines.findIndex((l) => l.trim().startsWith("tsx"));
80
+ expect(li).toBeGreaterThan(-1);
81
+ expect(codeHeader).toBeGreaterThan(li);
82
+ expect(lines.slice(li + 1, codeHeader).some((l) => l === "")).toBe(true);
83
+ });
84
+
85
+ test("a heading never sits directly on the previous block", async () => {
86
+ const lines = (await frame()).split("\n").map((l) => l.trimEnd());
87
+ const gate = lines.findIndex((l) => l.trim() === "Gate");
88
+ expect(gate).toBeGreaterThan(0);
89
+ expect(lines[gate - 1]).toBe("");
90
+ });
91
+
92
+ test("consecutive list items stay tight", async () => {
93
+ const listPlan = "# T\n\n- one\n- two\n";
94
+ const s2 = server.core.sessionCreate({
95
+ workspace: { repoRoot: "/repo", branch: "main" },
96
+ artifact: { type: "plan", content: listPlan, meta: {} },
97
+ });
98
+ const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
99
+ const deadline = Date.now() + 10_000;
100
+ while (Date.now() < deadline && !setup.captureCharFrame().includes("one")) {
101
+ await Bun.sleep(25);
102
+ await setup.renderOnce();
103
+ }
104
+ const lines = setup.captureCharFrame().split("\n").map((l) => l.trimEnd());
105
+ const one = lines.findIndex((l) => l.includes("- one"));
106
+ expect(lines[one + 1]).toContain("- two");
107
+ });
108
+ });
package/src/App.test.tsx CHANGED
@@ -186,7 +186,8 @@ describe("submit", () => {
186
186
  expect(stored.status).toBe("resolved");
187
187
  expect(stored.verdict!.kind).toBe("request_changes");
188
188
  expect(stored.verdict!.feedback).toContain("Needs a phase list.");
189
- expect(setup.captureCharFrame()).toContain("resolved: request changes");
189
+ // submit hands the reviewer back to the agent via the completion overlay
190
+ expect(setup.captureCharFrame()).toContain("✎ feedback sent");
190
191
  });
191
192
 
192
193
  test("approve via ←/→ verdict cycling", async () => {
package/src/App.tsx CHANGED
Binary file
package/src/config.ts CHANGED
@@ -8,6 +8,8 @@ import { existsSync, readFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
11
+ import { writeFileSync, mkdirSync } from "node:fs";
12
+ import { dirname } from "node:path";
11
13
  import { DARK, type Theme } from "./theme";
12
14
 
13
15
  export interface KeymapConfig {
@@ -18,9 +20,13 @@ export interface IntegrationsConfig {
18
20
  obsidian: ObsidianConfig;
19
21
  }
20
22
 
23
+ /** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
24
+ export type AutoClose = "off" | number;
25
+
21
26
  export interface CueloopConfig {
22
27
  keys: Record<string, string[]>;
23
28
  theme: Theme;
29
+ ui: { autoClose: AutoClose };
24
30
  integrations: IntegrationsConfig;
25
31
  }
26
32
 
@@ -51,6 +57,7 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
51
57
  const out: CueloopConfig = {
52
58
  keys: { ...base.keys },
53
59
  theme: { ...base.theme },
60
+ ui: { ...base.ui },
54
61
  integrations: { obsidian: { ...base.integrations.obsidian } },
55
62
  };
56
63
  const keys = raw["keys"] as KeymapConfig | undefined;
@@ -59,6 +66,11 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
59
66
  out.keys[action] = Array.isArray(combo) ? combo : [combo];
60
67
  }
61
68
  }
69
+ const ui = raw["ui"] as { auto_close?: unknown } | undefined;
70
+ if (ui && ui.auto_close !== undefined) {
71
+ if (ui.auto_close === "off") out.ui.autoClose = "off";
72
+ else if (typeof ui.auto_close === "number" && ui.auto_close >= 0) out.ui.autoClose = ui.auto_close;
73
+ }
62
74
  const theme = raw["theme"] as Partial<Record<keyof Theme, string>> | undefined;
63
75
  if (theme) {
64
76
  for (const [token, value] of Object.entries(theme)) {
@@ -86,6 +98,7 @@ export function loadConfig(opts: { repoRoot?: string; userConfigPath?: string }
86
98
  let config: CueloopConfig = {
87
99
  keys: { ...DEFAULT_KEYS },
88
100
  theme: { ...DARK },
101
+ ui: { autoClose: "off" },
89
102
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
90
103
  };
91
104
  const userPath =
@@ -111,3 +124,27 @@ export function actionFor(keys: Record<string, string[]>, name: string, shift: b
111
124
  }
112
125
  return undefined;
113
126
  }
127
+
128
+ /**
129
+ * Persist the auto-close choice into the user config file. Line-level TOML
130
+ * surgery on the one key we own: replace an existing `auto_close`, or append
131
+ * an `[ui]` section - a full TOML writer is not worth its weight here.
132
+ */
133
+ export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
134
+ const path =
135
+ userConfigPath ??
136
+ process.env.CUELOOP_CONFIG ??
137
+ join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
138
+ const rendered = value === "off" ? '"off"' : String(value);
139
+ let text = "";
140
+ if (existsSync(path)) text = readFileSync(path, "utf8");
141
+ if (/^\s*auto_close\s*=/m.test(text)) {
142
+ text = text.replace(/^(\s*)auto_close\s*=.*$/m, `$1auto_close = ${rendered}`);
143
+ } else if (/^\[ui\]/m.test(text)) {
144
+ text = text.replace(/^\[ui\]\s*$/m, `[ui]\nauto_close = ${rendered}`);
145
+ } else {
146
+ text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\nauto_close = ${rendered}\n`;
147
+ }
148
+ mkdirSync(dirname(path), { recursive: true });
149
+ writeFileSync(path, text);
150
+ }
@@ -0,0 +1,68 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { focusHerdrPane, returnPaneFor } from "./herdr";
6
+
7
+ let dir: string;
8
+ const savedEnv: Record<string, string | undefined> = {};
9
+
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-"));
12
+ for (const k of ["HERDR_ENV", "HERDR_PANE_ID", "HERDR_BIN_PATH", "CUELOOP_RETURN_PANE"]) {
13
+ savedEnv[k] = process.env[k];
14
+ delete process.env[k];
15
+ }
16
+ });
17
+ afterEach(() => {
18
+ for (const [k, v] of Object.entries(savedEnv)) {
19
+ if (v === undefined) delete process.env[k];
20
+ else process.env[k] = v;
21
+ }
22
+ rmSync(dir, { recursive: true, force: true });
23
+ });
24
+
25
+ describe("returnPaneFor", () => {
26
+ test("outside herdr there is never a return target", () => {
27
+ expect(returnPaneFor("w1:p1")).toBeUndefined();
28
+ });
29
+
30
+ test("env override wins, session meta is the fallback", () => {
31
+ process.env.HERDR_ENV = "1";
32
+ process.env.HERDR_PANE_ID = "w1:p9";
33
+ expect(returnPaneFor("w1:p1")).toBe("w1:p1");
34
+ process.env.CUELOOP_RETURN_PANE = "w1:p2";
35
+ expect(returnPaneFor("w1:p1")).toBe("w1:p2");
36
+ });
37
+
38
+ test("returning to our own pane is a no-op", () => {
39
+ process.env.HERDR_ENV = "1";
40
+ process.env.HERDR_PANE_ID = "w1:p1";
41
+ expect(returnPaneFor("w1:p1")).toBeUndefined();
42
+ });
43
+ });
44
+
45
+ describe("focusHerdrPane", () => {
46
+ test("resolves the pane's tab and focuses it", () => {
47
+ const log = join(dir, "calls.log");
48
+ const stub = join(dir, "herdr");
49
+ writeFileSync(
50
+ stub,
51
+ `#!/bin/sh
52
+ echo "$@" >> "${log}"
53
+ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
54
+ `,
55
+ );
56
+ Bun.spawnSync(["chmod", "+x", stub]);
57
+ process.env.HERDR_BIN_PATH = stub;
58
+ expect(focusHerdrPane("w1:p1")).toBe(true);
59
+ const calls = readFileSync(log, "utf8").trim().split("\n");
60
+ expect(calls[0]).toBe("pane get w1:p1");
61
+ expect(calls[1]).toBe("tab focus w1:t3");
62
+ });
63
+
64
+ test("a missing binary never throws", () => {
65
+ process.env.HERDR_BIN_PATH = join(dir, "nope");
66
+ expect(focusHerdrPane("w1:p1")).toBe(false);
67
+ });
68
+ });
package/src/herdr.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * First-class herdr return (#23): a review opened beside an agent should hand
3
+ * focus back to the agent's pane when it closes, not leave the reviewer on a
4
+ * dead tab. Detection is the ambient env contract; the return target comes
5
+ * from CUELOOP_RETURN_PANE (set by whoever opened this pane) or the session's
6
+ * recorded herdrPane (set by the adapter hook). Everything here is
7
+ * best-effort: focus failures never block closing the review.
8
+ */
9
+
10
+ export function insideHerdr(): boolean {
11
+ return process.env.HERDR_ENV === "1";
12
+ }
13
+
14
+ export function returnPaneFor(sessionHerdrPane?: string): string | undefined {
15
+ if (!insideHerdr()) return undefined;
16
+ const pane = process.env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
17
+ // returning to our own pane would be a no-op at best
18
+ return pane && pane !== process.env.HERDR_PANE_ID ? pane : undefined;
19
+ }
20
+
21
+ /** Focus the tab holding the target pane. Synchronous: runs just before exit. */
22
+ export function focusHerdrPane(paneId: string): boolean {
23
+ const bin = process.env.HERDR_BIN_PATH ?? "herdr";
24
+ try {
25
+ const got = Bun.spawnSync([bin, "pane", "get", paneId], { stdout: "pipe", stderr: "ignore" });
26
+ if (got.exitCode !== 0) return false;
27
+ const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: { tab_id?: string } } };
28
+ const tab = parsed.result?.pane?.tab_id;
29
+ if (!tab) return false;
30
+ return Bun.spawnSync([bin, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { filetypeFor, highlightCode } from "./syntax";
3
+
4
+ const TS = `export function gate(full: number) {
5
+ // threshold
6
+ return full < 0.6 ? "blocked" : "clear";
7
+ }`;
8
+
9
+ describe("highlightCode", () => {
10
+ test("token text concatenates back to the exact source (verbatim rendering)", async () => {
11
+ const lines = await highlightCode(TS, "ts");
12
+ expect(lines).not.toBeNull();
13
+ const rebuilt = lines!.map((l) => l.map((t) => t.content).join("")).join("\n");
14
+ expect(rebuilt).toBe(TS);
15
+ });
16
+
17
+ test("keywords, strings, and comments get distinct colors", async () => {
18
+ const lines = await highlightCode(TS, "ts");
19
+ const colorOf = (needle: string) => {
20
+ for (const line of lines!) for (const t of line) if (t.content.includes(needle)) return t.color;
21
+ return undefined;
22
+ };
23
+ const kw = colorOf("export");
24
+ const str = colorOf("blocked");
25
+ const comment = colorOf("threshold");
26
+ expect(kw).toBeDefined();
27
+ expect(str).toBeDefined();
28
+ expect(comment).toBeDefined();
29
+ expect(new Set([kw, str, comment]).size).toBe(3);
30
+ });
31
+
32
+ test("unknown languages return null and render unstyled", async () => {
33
+ expect(await highlightCode("whatever", "brainfog")).toBeNull();
34
+ expect(await highlightCode("plain", undefined)).toBeNull();
35
+ });
36
+
37
+ test("fence aliases map to real grammars", async () => {
38
+ expect(await highlightCode("x = 1", "py")).not.toBeNull();
39
+ expect(await highlightCode('echo "hi"', "sh")).not.toBeNull();
40
+ });
41
+ });
package/src/syntax.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Code-block syntax highlighting via Shiki (#67): TextMate-grammar tokens
3
+ * colored by a theme built from cueloop's tokens. The highlighter loads
4
+ * lazily on the first code block; unknown languages render unstyled.
5
+ */
6
+
7
+ import { DARK as T } from "./theme";
8
+
9
+ export interface CodeToken {
10
+ content: string;
11
+ color?: string;
12
+ }
13
+
14
+ type Highlighter = {
15
+ codeToTokensBase(code: string, opts: { lang: string; theme: string }): { content: string; color?: string }[][];
16
+ getLoadedLanguages(): string[];
17
+ };
18
+
19
+ const LANGS = ["typescript", "tsx", "javascript", "jsx", "json", "bash", "python", "rust", "go", "yaml", "diff", "markdown", "toml", "sql", "html", "css"];
20
+
21
+ /** Markdown fence info → Shiki language id. */
22
+ export function filetypeFor(lang?: string): string {
23
+ const l = (lang ?? "").toLowerCase();
24
+ const map: Record<string, string> = {
25
+ ts: "typescript",
26
+ js: "javascript",
27
+ py: "python",
28
+ rb: "ruby",
29
+ rs: "rust",
30
+ sh: "bash",
31
+ shell: "bash",
32
+ zsh: "bash",
33
+ yml: "yaml",
34
+ };
35
+ return map[l] ?? l;
36
+ }
37
+
38
+ function cueloopTheme() {
39
+ const scope = (scopes: string, foreground: string, fontStyle?: string) => ({
40
+ scope: scopes.split(", "),
41
+ settings: fontStyle ? { foreground, fontStyle } : { foreground },
42
+ });
43
+ return {
44
+ name: "cueloop",
45
+ type: "dark" as const,
46
+ colors: { "editor.background": T.elevated, "editor.foreground": T.textMuted },
47
+ settings: [
48
+ { settings: { foreground: T.textMuted } },
49
+ scope("comment, punctuation.definition.comment", T.textDim, "italic"),
50
+ scope("string, string.quoted, punctuation.definition.string", T.green),
51
+ scope("constant.numeric, constant.language, constant.character", T.blue),
52
+ scope("keyword, storage, storage.type, storage.modifier, keyword.control", T.accent),
53
+ scope("entity.name.function, support.function, meta.function-call", T.text),
54
+ scope("entity.name.type, entity.name.class, support.type, support.class", T.blue),
55
+ scope("variable, variable.other, variable.parameter", T.textMuted),
56
+ scope("entity.name.tag", T.accent),
57
+ scope("entity.other.attribute-name", T.blue),
58
+ scope("punctuation, meta.brace", T.textDim),
59
+ scope("keyword.operator", T.textDim),
60
+ ],
61
+ };
62
+ }
63
+
64
+ let highlighterPromise: Promise<Highlighter | null> | null = null;
65
+
66
+ function getHighlighter(): Promise<Highlighter | null> {
67
+ highlighterPromise ??= import("shiki")
68
+ .then((shiki) => shiki.createHighlighter({ themes: [cueloopTheme()], langs: LANGS }) as Promise<Highlighter>)
69
+ .catch(() => null); // highlighting is an enhancement, never a blocker
70
+ return highlighterPromise;
71
+ }
72
+
73
+ /**
74
+ * Tokenize code into styled lines. Token text concatenates back to the exact
75
+ * source line, so verbatim rendering (indentation, no wrap) is preserved.
76
+ * Returns null when the language is unknown or the highlighter is unavailable.
77
+ */
78
+ export async function highlightCode(content: string, lang?: string): Promise<CodeToken[][] | null> {
79
+ const language = filetypeFor(lang);
80
+ if (!language) return null;
81
+ const highlighter = await getHighlighter();
82
+ if (!highlighter || !highlighter.getLoadedLanguages().includes(language)) return null;
83
+ try {
84
+ return highlighter.codeToTokensBase(content, { lang: language, theme: "cueloop" });
85
+ } catch {
86
+ return null;
87
+ }
88
+ }