@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.12

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.
Files changed (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.autoclose.test.tsx +24 -35
  3. package/src/App.diff.test.tsx +5 -18
  4. package/src/App.export.test.tsx +8 -21
  5. package/src/App.plan-review.test.tsx +21 -52
  6. package/src/App.readonly.test.tsx +3 -18
  7. package/src/App.rendering.test.tsx +5 -13
  8. package/src/App.submit-confirm.test.tsx +7 -25
  9. package/src/App.test.tsx +13 -52
  10. package/src/App.tsx +197 -910
  11. package/src/components/AnnotationCard.stories.tsx +75 -0
  12. package/src/components/AnnotationCard.tsx +136 -0
  13. package/src/components/Breadcrumb.stories.tsx +19 -0
  14. package/src/components/Breadcrumb.tsx +38 -0
  15. package/src/components/CodeBlock.stories.tsx +25 -0
  16. package/src/components/CodeBlock.tsx +68 -0
  17. package/src/components/CompletionOverlay.stories.tsx +23 -0
  18. package/src/components/CompletionOverlay.tsx +50 -0
  19. package/src/components/ComposeBar.stories.tsx +18 -0
  20. package/src/components/ComposeBar.tsx +54 -0
  21. package/src/components/ConfirmCard.stories.tsx +22 -0
  22. package/src/components/ConfirmCard.tsx +130 -0
  23. package/src/components/DiffSheet.stories.tsx +44 -0
  24. package/src/components/DiffSheet.tsx +243 -0
  25. package/src/components/InboxList.stories.tsx +18 -0
  26. package/src/components/InboxList.tsx +42 -0
  27. package/src/components/PlanSheet.stories.tsx +81 -0
  28. package/src/components/PlanSheet.tsx +309 -0
  29. package/src/components/ReviewRail.stories.tsx +103 -0
  30. package/src/components/ReviewRail.tsx +158 -0
  31. package/src/components/SettingsDialog.stories.tsx +63 -0
  32. package/src/components/SettingsDialog.tsx +132 -0
  33. package/src/components/SettingsRows.stories.tsx +18 -0
  34. package/src/components/SettingsRows.tsx +92 -0
  35. package/src/components/__snapshots__/stories.test.tsx.snap +1189 -0
  36. package/src/components/format.ts +6 -0
  37. package/src/components/primitives/Button.stories.tsx +32 -0
  38. package/src/components/primitives/Button.tsx +44 -0
  39. package/src/components/primitives/Card.stories.tsx +25 -0
  40. package/src/components/primitives/Card.tsx +58 -0
  41. package/src/components/primitives/Dialog.stories.tsx +24 -0
  42. package/src/components/primitives/Dialog.tsx +52 -0
  43. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  44. package/src/components/primitives/StatusBar.tsx +22 -0
  45. package/src/components/primitives/Tabs.stories.tsx +29 -0
  46. package/src/components/primitives/Tabs.tsx +97 -0
  47. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  48. package/src/components/primitives/Toolbar.tsx +14 -0
  49. package/src/components/stories-app.tsx +94 -0
  50. package/src/components/stories.test.tsx +51 -0
  51. package/src/components/story-fixtures.ts +90 -0
  52. package/src/components/story.ts +66 -0
  53. package/src/components/syntax-highlight.ts +57 -0
  54. package/src/components/theme-context.stories.tsx +41 -0
  55. package/src/components/theme-context.tsx +30 -0
  56. package/src/key-bindings.test.ts +103 -0
  57. package/src/key-bindings.ts +332 -0
  58. package/src/keymap.ts +8 -2
  59. package/src/run.ts +3 -1
  60. package/src/session-controller.ts +28 -7
  61. package/src/test-support.ts +87 -0
  62. package/src/theme.ts +3 -0
  63. package/src/view.test.ts +35 -13
  64. package/src/view.ts +41 -64
  65. package/src/syntax.test.ts +0 -41
  66. package/src/syntax.ts +0 -88
@@ -7,6 +7,7 @@
7
7
  * App subscribes to snapshots and keeps only view state.
8
8
  */
9
9
 
10
+ import { SystemClock, type Clock, type TimerHandle } from "@opentui/core";
10
11
  import { DaemonClient } from "@cueloop/daemon/client";
11
12
  import {
12
13
  cutBlock,
@@ -57,6 +58,8 @@ export interface ReviewControllerOptions {
57
58
  /** Observer mode: stored for the key reducer's read-only gate. */
58
59
  readOnly?: boolean;
59
60
  onExit?: (code: number) => void;
61
+ /** Timer source for the auto-close countdown; tests inject a ManualClock. */
62
+ clock?: Clock;
60
63
  }
61
64
 
62
65
  export interface ReviewController {
@@ -118,13 +121,15 @@ class Controller implements ReviewController {
118
121
  private autoClose: AutoClose = "off";
119
122
  private obsidian: ObsidianConfig | null = null;
120
123
  private exporters = new Map<string, Exporter>();
121
- private countdown: ReturnType<typeof setTimeout> | undefined;
124
+ private readonly clock: Clock;
125
+ private countdown: TimerHandle | undefined;
122
126
  /** Projections keyed by session identity so renders reuse one computation. */
123
127
  private derivedFor: ReviewSession | null = null;
124
128
  private derived: { display: DisplayBlock[]; rows: DiffRow[] } = { display: [], rows: [] };
125
129
 
126
130
  constructor(private readonly opts: ReviewControllerOptions) {
127
131
  this.readOnly = opts.readOnly ?? false;
132
+ this.clock = opts.clock ?? new SystemClock();
128
133
  }
129
134
 
130
135
  subscribe = (listener: () => void): (() => void) => {
@@ -165,10 +170,15 @@ class Controller implements ReviewController {
165
170
 
166
171
  close(): void {
167
172
  this.closed = true;
168
- clearTimeout(this.countdown);
173
+ this.clearCountdown();
169
174
  this.client?.close();
170
175
  }
171
176
 
177
+ private clearCountdown(): void {
178
+ if (this.countdown !== undefined) this.clock.clearTimeout(this.countdown);
179
+ this.countdown = undefined;
180
+ }
181
+
172
182
  applyConfig(cfg: CueloopConfig): void {
173
183
  this.autoClose = cfg.ui.autoClose;
174
184
  this.obsidian = cfg.integrations.obsidian;
@@ -209,12 +219,23 @@ class Controller implements ReviewController {
209
219
  return s ? (s.workingCopy ?? s.artifact.content) : "";
210
220
  }
211
221
 
222
+ // Refreshes race the connection teardown: an event can arrive while close()
223
+ // is rejecting in-flight requests, and a fire-and-forget refresh must never
224
+ // surface that as an unhandled rejection.
212
225
  private async refreshSession(id: string): Promise<void> {
213
- if (this.client) this.update({ session: await this.client.sessionGet(id) });
226
+ try {
227
+ if (this.client) this.update({ session: await this.client.sessionGet(id) });
228
+ } catch (error) {
229
+ if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
230
+ }
214
231
  }
215
232
 
216
233
  private async refreshInbox(): Promise<void> {
217
- if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
234
+ try {
235
+ if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
236
+ } catch (error) {
237
+ if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
238
+ }
218
239
  }
219
240
 
220
241
  /** Optimistic apply: the daemon response is the next session snapshot. */
@@ -370,18 +391,18 @@ class Controller implements ReviewController {
370
391
  private startCounting(remaining: number): void {
371
392
  if (remaining <= 0) return this.finishReview();
372
393
  this.update({ completion: { phase: "counting", remaining } });
373
- this.countdown = setTimeout(() => this.startCounting(remaining - 1), 1000);
394
+ this.countdown = this.clock.setTimeout(() => this.startCounting(remaining - 1), 1000);
374
395
  }
375
396
 
376
397
  finishReview(): void {
377
- clearTimeout(this.countdown);
398
+ this.clearCountdown();
378
399
  const pane = returnPaneFor(this.snap.session?.artifact.meta.herdrPane);
379
400
  if (pane) focusHerdrPane(pane);
380
401
  this.opts.onExit?.(0);
381
402
  }
382
403
 
383
404
  dismissCompletion(): void {
384
- clearTimeout(this.countdown);
405
+ this.clearCountdown();
385
406
  this.update({ completion: { phase: "dismissed" } });
386
407
  }
387
408
 
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared driving helpers for the virtual-terminal App suites: key presses
3
+ * settle through a macrotask yield (input parser + React scheduler), one
4
+ * render pass, and the renderer's visual-idle wait - no fixed-duration
5
+ * sleeps. Waits on daemon round-trips go through waitForFrame/waitFor with
6
+ * generous pass budgets.
7
+ */
8
+
9
+ import type { TestRendererSetup } from "@opentui/core/testing";
10
+
11
+ /** Pass budget for waits that include daemon or subprocess round-trips. */
12
+ export const WAIT_PASSES = { maxPasses: 400 };
13
+
14
+ /**
15
+ * One macrotask yield lets the input parser and the React scheduler run,
16
+ * then a render pass commits the result before the visual-idle wait.
17
+ */
18
+ export async function settle(setup: TestRendererSetup): Promise<void> {
19
+ await new Promise((resolve) => setTimeout(resolve, 0));
20
+ await setup.renderOnce();
21
+ await setup.waitForVisualIdle();
22
+ }
23
+
24
+ /** Drive one key press: letters type as text; named keys use KeyCodes ids. */
25
+ export async function press(setup: TestRendererSetup, key: string): Promise<void> {
26
+ if (key === "enter") setup.mockInput.pressKey("RETURN");
27
+ else if (key === "escape") setup.mockInput.pressKey("ESCAPE");
28
+ else if (key === "backspace") setup.mockInput.pressKey("BACKSPACE");
29
+ else if (key === "left") setup.mockInput.pressKey("ARROW_LEFT");
30
+ else if (key === "right") setup.mockInput.pressKey("ARROW_RIGHT");
31
+ else return typeText(setup, key);
32
+ await settle(setup);
33
+ }
34
+
35
+ export async function typeText(setup: TestRendererSetup, text: string): Promise<void> {
36
+ await setup.mockInput.typeText(text);
37
+ await settle(setup);
38
+ }
39
+
40
+ const WAIT_DEADLINE_MS = 30_000;
41
+
42
+ /**
43
+ * waitForFrame gives up as soon as the renderer is idle, but external timers
44
+ * (the parser's bare-ESC window, daemon IO, spawned editors) settle on the
45
+ * event loop while nothing is scheduled. Between attempts, one real
46
+ * event-loop turn plus a render pass lets those land; the deadline bounds
47
+ * the whole wait.
48
+ */
49
+ async function waitForFramePredicate(
50
+ setup: TestRendererSetup,
51
+ predicate: (frame: string) => boolean,
52
+ ): Promise<string> {
53
+ const deadline = Date.now() + WAIT_DEADLINE_MS;
54
+ for (;;) {
55
+ try {
56
+ return await setup.waitForFrame(predicate, { maxPasses: 50 });
57
+ } catch (error) {
58
+ if (Date.now() > deadline) throw error;
59
+ await new Promise((resolve) => setTimeout(resolve, 1));
60
+ await setup.renderOnce();
61
+ }
62
+ }
63
+ }
64
+
65
+ /** Wait until a state predicate holds (daemon round-trips included). */
66
+ export async function waitForState(setup: TestRendererSetup, predicate: () => boolean): Promise<void> {
67
+ const deadline = Date.now() + WAIT_DEADLINE_MS;
68
+ for (;;) {
69
+ try {
70
+ return await setup.waitFor(predicate, { maxPasses: 50 });
71
+ } catch (error) {
72
+ if (Date.now() > deadline) throw error;
73
+ await new Promise((resolve) => setTimeout(resolve, 1));
74
+ await setup.renderOnce();
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Wait until the frame contains the needle (daemon round-trips included). */
80
+ export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
81
+ return waitForFramePredicate(setup, (frame) => frame.includes(needle));
82
+ }
83
+
84
+ /** Wait until the frame no longer contains the needle. */
85
+ export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
86
+ return waitForFramePredicate(setup, (frame) => !frame.includes(needle));
87
+ }
package/src/theme.ts CHANGED
@@ -23,6 +23,8 @@ export interface Theme {
23
23
  markSuggestionBg: string;
24
24
  insFg: string;
25
25
  delFg: string;
26
+ /** Dimmed layer painted behind centered dialogs. */
27
+ backdrop: string;
26
28
  }
27
29
 
28
30
  export const DARK: Theme = {
@@ -43,4 +45,5 @@ export const DARK: Theme = {
43
45
  markSuggestionBg: "#20351f",
44
46
  insFg: "#62d96b",
45
47
  delFg: "#ff6b6b",
48
+ backdrop: "#14141b",
46
49
  }
package/src/view.test.ts CHANGED
@@ -6,9 +6,10 @@ import {
6
6
  marksByDisplay,
7
7
  nextWorkBlock,
8
8
  overlayMarks,
9
+ renderedOffsetFor,
9
10
  spanKey,
10
11
  startSpan,
11
- wrapRuns,
12
+ workRangeForRendered,
12
13
  } from "./view";
13
14
  import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
14
15
 
@@ -107,22 +108,43 @@ describe("marksByDisplay", () => {
107
108
  });
108
109
  });
109
110
 
110
- describe("wrapRuns", () => {
111
- test("wraps at word boundaries within width", () => {
112
- const lines = wrapRuns([{ text: "alpha beta gamma delta", role: "plain", start: 0 }], 11);
113
- const rendered = lines.map((l) => l.map((r) => r.text).join(""));
114
- expect(rendered.every((l) => l.length <= 11)).toBe(true);
115
- expect(rendered.join(" ").replace(/\s+/g, " ").trim()).toBe("alpha beta gamma delta");
111
+ describe("rendered/work offset mapping", () => {
112
+ test("plain blocks map 1:1 in both directions", () => {
113
+ const d = buildDisplay("alpha beta gamma\n")[0]!;
114
+ const runs = blockRuns(d, true);
115
+ expect(renderedOffsetFor(runs, 6)).toBe(6);
116
+ expect(workRangeForRendered(runs, 6, 10)).toEqual({ start: 6, end: 10 });
117
+ });
118
+
119
+ test("inline del runs shift rendered offsets in word-diffed blocks", () => {
120
+ // rendered: "old new words linger" - "old " is a del run without offsets
121
+ const d = buildDisplay("old words linger\n", "new words linger\n")[0]!;
122
+ const runs = blockRuns(d, true);
123
+ const workText = displayText(d);
124
+ const wordsAt = workText.indexOf("words");
125
+ const rendered = renderedOffsetFor(runs, wordsAt)!;
126
+ const renderedText = runs.map((r) => r.text).join("");
127
+ expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
128
+ // reading a rendered selection over "words" recovers the work range
129
+ expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
130
+ start: wordsAt,
131
+ end: wordsAt + "words".length,
132
+ });
116
133
  });
117
134
 
118
- test("hard-splits words longer than the width", () => {
119
- const lines = wrapRuns([{ text: "abcdefghij", role: "plain", start: 0 }], 4);
120
- expect(lines.length).toBeGreaterThan(1);
135
+ test("a rendered selection spanning a del run keeps only positioned text", () => {
136
+ const d = buildDisplay("old words\n", "new words\n")[0]!;
137
+ const runs = blockRuns(d, true);
138
+ const renderedText = runs.map((r) => r.text).join("");
139
+ // select everything rendered: the work range is the whole work text
140
+ const range = workRangeForRendered(runs, 0, renderedText.length)!;
141
+ expect(displayText(d).slice(range.start, range.end)).toBe(displayText(d));
121
142
  });
122
143
 
123
- test("newlines force line breaks (code blocks)", () => {
124
- const lines = wrapRuns([{ text: "line1\nline2", role: "plain", start: 0 }], 80);
125
- expect(lines.length).toBe(2);
144
+ test("offsets outside any positioned run map to null", () => {
145
+ const runs = blockRuns(buildDisplay("short\n")[0]!, true);
146
+ expect(renderedOffsetFor(runs, 99)).toBeNull();
147
+ expect(workRangeForRendered(runs, 90, 99)).toBeNull();
126
148
  });
127
149
  });
128
150
 
package/src/view.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * The projection pipeline (#22): parse → display blocks (base vs working
3
- * copy reconciled) → styled runs → wrapped lines. Pure functions - the TUI
4
- * renders the output, tests assert on it directly. One planning layer:
5
- * rendering, navigation, and selection all derive from this module.
3
+ * copy reconciled) → styled runs. Pure functions - the TUI renders the runs
4
+ * (the native text primitive wraps them), tests assert on them directly.
5
+ * One planning layer: rendering, navigation, and selection all derive from
6
+ * this module; the rendered/work offset mappings keep the native selection
7
+ * addressable over word-diffed blocks.
6
8
  */
7
9
 
8
10
  import {
@@ -219,77 +221,52 @@ export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
219
221
  }
220
222
 
221
223
  /**
222
- * Wrap styled runs into lines of at most `width` display cells. Split pieces
223
- * keep exact `start` offsets into the block text, so wrapped lines stay
224
- * addressable for the native selection primitive.
224
+ * Rendered-text offset of a work-text offset within a block's runs. The
225
+ * rendered text is every run's text in order (inline del runs included);
226
+ * only positioned runs carry work offsets. Drives the renderer's native
227
+ * selection from keyboard span offsets - the quote-anchor path depends on
228
+ * this mapping staying exact.
225
229
  */
226
- export function wrapRuns(runs: StyleRun[], width: number): StyleRun[][] {
227
- const lines: StyleRun[][] = [];
228
- let line: StyleRun[] = [];
229
- let used = 0;
230
- const pushLine = () => {
231
- lines.push(line);
232
- line = [];
233
- used = 0;
234
- };
230
+ export function renderedOffsetFor(runs: StyleRun[], workOffset: number): number | null {
231
+ let rendered = 0;
235
232
  for (const run of runs) {
236
- // split run into word/space tokens; newlines force breaks
237
- const tokens = run.text.split(/(\n|\s+)/).filter((t) => t !== "");
238
- let consumed = 0;
239
- const startAt = (offsetInRun: number): number | null => (run.start === null ? null : run.start + offsetInRun);
240
- for (const tok of tokens) {
241
- if (tok === "\n") {
242
- consumed += 1;
243
- pushLine();
244
- continue;
245
- }
246
- if (used + tok.length > width && used > 0 && tok.trim() !== "") pushLine();
247
- if (tok.trim() === "" && used === 0 && lines.length > 0) {
248
- consumed += tok.length;
249
- continue; // no leading spaces after wrap
250
- }
251
- let slice = tok;
252
- while (slice.length > width) {
253
- const piece = slice.slice(0, width - used);
254
- line.push({ ...run, text: piece, start: startAt(consumed) });
255
- consumed += piece.length;
256
- slice = slice.slice(piece.length);
257
- pushLine();
258
- }
259
- line.push({ ...run, text: slice, start: startAt(consumed) });
260
- consumed += slice.length;
261
- used += slice.length;
233
+ if (run.start !== null && workOffset >= run.start && workOffset < run.start + run.text.length) {
234
+ return rendered + (workOffset - run.start);
262
235
  }
236
+ rendered += run.text.length;
263
237
  }
264
- if (line.length || !lines.length) lines.push(line);
265
- return lines;
238
+ return null;
266
239
  }
267
240
 
268
241
  /**
269
- * Locate a block-text offset inside wrapped run lines: the wrapped line index
270
- * and the column within the line's rendered run text. Drives the renderer's
271
- * native selection from keyboard span offsets.
242
+ * Work-text range covered by a rendered-text selection [start, end). Del runs
243
+ * and other unpositioned text inside the selection contribute nothing; the
244
+ * result is the tightest work range whose text the selection touched.
272
245
  */
273
- export function locateOffset(lines: StyleRun[][], offset: number): { lineIndex: number; column: number } | null {
274
- for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
275
- let column = 0;
276
- for (const run of lines[lineIndex]!) {
277
- if (run.start !== null && offset >= run.start && offset < run.start + run.text.length) {
278
- return { lineIndex, column: column + (offset - run.start) };
279
- }
280
- column += run.text.length;
246
+ export function workRangeForRendered(
247
+ runs: StyleRun[],
248
+ renderedStart: number,
249
+ renderedEnd: number,
250
+ ): { start: number; end: number } | null {
251
+ let rendered = 0;
252
+ let range: { start: number; end: number } | null = null;
253
+ for (const run of runs) {
254
+ const runRenderedStart = rendered;
255
+ const runRenderedEnd = rendered + run.text.length;
256
+ rendered = runRenderedEnd;
257
+ if (run.start === null) continue;
258
+ const overlapStart = Math.max(renderedStart, runRenderedStart);
259
+ const overlapEnd = Math.min(renderedEnd, runRenderedEnd);
260
+ if (overlapEnd <= overlapStart) continue;
261
+ const workStart = run.start + (overlapStart - runRenderedStart);
262
+ const workEnd = run.start + (overlapEnd - runRenderedStart);
263
+ if (!range) range = { start: workStart, end: workEnd };
264
+ else {
265
+ range.start = Math.min(range.start, workStart);
266
+ range.end = Math.max(range.end, workEnd);
281
267
  }
282
268
  }
283
- return null;
284
- }
285
-
286
- /**
287
- * Inline compose box height, shared by layout and render so the row math and
288
- * the mounted box never drift: top border (title), the single-row input, the
289
- * Save/Cancel button row, bottom border.
290
- */
291
- export function composeBoxHeight(): number {
292
- return 4;
269
+ return range;
293
270
  }
294
271
 
295
272
  /** Line delta between two revision contents, for the sheet-header summary. */
@@ -1,41 +0,0 @@
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 DELETED
@@ -1,88 +0,0 @@
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
- }