ompchamber 0.7.0 → 1.0.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.
Files changed (165) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.e880517fcd.css +2 -0
  3. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  4. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  5. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  6. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  7. package/dist/client/static/js/async/c.797d933882.js +1 -0
  8. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  9. package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
  10. package/dist/client/static/js/async/g.a06ca3b3e9.js.LICENSE.txt +11 -0
  11. package/dist/client/static/js/async/{m.4b29d34f93.js → m.c038c71b9f.js} +2 -2
  12. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  13. package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  15. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
  17. package/dist/client/static/js/async/ot.84c71b308f.js.LICENSE.txt +11 -0
  18. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  19. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  20. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  21. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  22. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  23. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  24. package/dist/client/static/js/async/xnl.feb0021e50.js.LICENSE.txt +12 -0
  25. package/dist/client/static/js/index.0de15c5e17.js +33 -0
  26. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  27. package/package.json +2 -1
  28. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  29. package/src/client/components/common/code-editor/history.test.ts +118 -0
  30. package/src/client/components/common/code-editor/history.ts +101 -0
  31. package/src/client/components/common/code-editor/index.tsx +56 -66
  32. package/src/client/components/common/code-surface/index.tsx +84 -11
  33. package/src/client/components/common/code-surface/measure.ts +66 -17
  34. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  35. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  36. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +2 -1
  37. package/src/client/components/layout/session-sidebar/SessionItem.tsx +11 -4
  38. package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
  39. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
  40. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
  41. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
  42. package/src/client/components/settings/LazyModal.tsx +1 -1
  43. package/src/client/components/settings/Modal.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +11 -4
  45. package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
  46. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  47. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  48. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  49. package/src/client/components/workspace/chat-timeline/index.tsx +124 -108
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  60. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  61. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
  62. package/src/client/components/workspace/git-panel/index.tsx +5 -1
  63. package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
  64. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
  65. package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
  66. package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
  67. package/src/client/data/theme/terminal.ts +27 -29
  68. package/src/client/hooks/chat/omp/session-list.tsx +23 -2
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
  70. package/src/client/hooks/chat/timeline/actions.ts +23 -34
  71. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  72. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  73. package/src/client/hooks/chat/timeline/index.ts +18 -28
  74. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  75. package/src/client/hooks/chat/timeline/queue.ts +110 -46
  76. package/src/client/hooks/chat/timeline/send.ts +13 -27
  77. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  78. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  79. package/src/client/hooks/editor/use-code-window.ts +118 -0
  80. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  81. package/src/client/hooks/ui/notification-sound.ts +65 -0
  82. package/src/client/hooks/ui/theme.ts +22 -6
  83. package/src/client/hooks/workspace/terminal/index.ts +217 -0
  84. package/src/client/hooks/workspace/terminal/socket.ts +227 -0
  85. package/src/client/tailwind.css +38 -12
  86. package/src/server/db.server.ts +19 -0
  87. package/src/server/lib/fs/git-sync.ts +191 -0
  88. package/src/server/lib/omp/rpc/manager.ts +72 -6
  89. package/src/server/lib/omp/rpc/session-commands.ts +6 -0
  90. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  91. package/src/server/lib/omp/session/sidebar-data.server.ts +10 -4
  92. package/src/server/lib/queue/delivery.server.ts +114 -0
  93. package/src/server/lib/queue/schema-migration.server.ts +50 -0
  94. package/src/server/lib/queue/store.server.ts +185 -0
  95. package/src/server/lib/terminal/foreground.ts +73 -0
  96. package/src/server/lib/terminal/history.test.ts +118 -0
  97. package/src/server/lib/terminal/history.ts +205 -0
  98. package/src/server/lib/terminal/runtime.server.ts +338 -0
  99. package/src/server/lib/terminal/session.server.ts +215 -0
  100. package/src/server/lib/terminal/shell.test.ts +97 -0
  101. package/src/server/lib/terminal/shell.ts +103 -0
  102. package/src/server/routes/fs/git.ts +22 -12
  103. package/src/server/routes/index.ts +2 -1
  104. package/src/server/routes/sessions/index.ts +11 -3
  105. package/src/server/routes/sessions/queue.ts +121 -0
  106. package/src/server/routes/sessions/session.ts +1 -132
  107. package/src/server/routes/settings/commands.ts +1 -1
  108. package/src/server/routes/terminal/index.ts +2 -2
  109. package/src/server/routes/terminal/run.ts +8 -100
  110. package/src/server/routes/terminal/sessions.ts +24 -0
  111. package/src/server/routes/terminal/ws.ts +149 -0
  112. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  113. package/src/shared/lib/chat/ask-questions.ts +264 -0
  114. package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
  115. package/src/shared/lib/chat/omp/agent-events.ts +30 -0
  116. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
  117. package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
  118. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  119. package/src/shared/lib/code/lazy-window.ts +156 -0
  120. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  121. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  122. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  123. package/src/shared/lib/markdown/mermaid.ts +8 -6
  124. package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
  125. package/src/shared/lib/workspace/session-state/store.ts +1 -4
  126. package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
  127. package/src/shared/types/omp/agent.ts +14 -2
  128. package/src/shared/types/terminal.ts +17 -15
  129. package/src/shared/types/workspace.ts +6 -2
  130. package/dist/client/static/css/index.51f68210a5.css +0 -2
  131. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  132. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  133. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  134. package/dist/client/static/js/async/g.86e2256a70.js +0 -2
  135. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  136. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  137. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  138. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  139. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  140. package/dist/client/static/js/async/uf.24ca3d49c3.js.LICENSE.txt +0 -6
  141. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  142. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  143. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  144. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  145. package/dist/client/static/js/index.eccad0b70a.js +0 -33
  146. package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
  147. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  148. package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
  149. package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
  150. package/src/client/hooks/workspace/terminal-output.ts +0 -66
  151. package/src/client/hooks/workspace/terminal.ts +0 -283
  152. package/src/server/lib/fs/terminal-cwd.ts +0 -49
  153. package/src/server/routes/terminal/stream.ts +0 -100
  154. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  155. /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  156. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  157. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
  158. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  159. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → ms.98960af4eb.js.LICENSE.txt} +0 -0
  160. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  161. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  162. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  163. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  164. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.0de15c5e17.js.LICENSE.txt} +0 -0
  165. /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
@@ -165,6 +165,11 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
165
165
  callbacks?.onAgentStart?.();
166
166
  break;
167
167
 
168
+ case 'turn_start':
169
+ setActivity(PHASE_VERBS.thinking, deps);
170
+ callbacks?.onTurnStart?.();
171
+ break;
172
+
168
173
  case 'message_start':
169
174
  case 'message_update': {
170
175
  const msg = data.message as Record<string, unknown> | undefined;
@@ -177,6 +182,10 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
177
182
  // Assistant phases (thinking / prose / tool-call assembly) name the
178
183
  // action directly — a tool that never starts still shows its phrase.
179
184
  setActivity(describeAssistantPhase(data.assistantMessageEvent), deps);
185
+ // The first message frame of a turn is another proof the run is live —
186
+ // re-signal so the sidebar can restore a lost `stream` status row.
187
+ // message_update frames arrive per delta and must stay silent here.
188
+ if (data.type === 'message_start') callbacks?.onTurnStart?.();
180
189
  // Steering (abort_and_prompt) and follow-up deliveries create no
181
190
  // optimistic bubble — the user turn only exists on the stream. Convert
182
191
  // it here so the bubble renders live instead of after a JSONL reload.
@@ -298,6 +307,27 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
298
307
  break;
299
308
  }
300
309
 
310
+ // omp's prompt ack ran a built-in slash command instead of an agent turn
311
+ // (`agentInvoked:false`, mirrored server-side as this frame): there will be
312
+ // no agent_start/agent_end pair. Clear the optimistic generating state —
313
+ // the callbacks drop the AI placeholder and keep the user's message row.
314
+ case 'prompt_result': {
315
+ if (data.agentInvoked === false) {
316
+ deps.setState((prev) => ({ ...prev, isGenerating: false }));
317
+ deps.activityRef.current = '';
318
+ callbacks?.onPromptSettled?.();
319
+ }
320
+ break;
321
+ }
322
+
323
+ // Built-in slash command output (/usage, /compact result, …). Rendered as
324
+ // a notice row — see the callback type note: this frame is the ONLY copy.
325
+ case 'command_output': {
326
+ const text = typeof data.text === 'string' ? data.text.trim() : '';
327
+ if (text) callbacks?.onCommandOutput?.(text);
328
+ break;
329
+ }
330
+
301
331
  case 'notice': {
302
332
  callbacks?.onNotice?.(
303
333
  typeof data.level === 'string' ? data.level : 'info',
@@ -63,7 +63,6 @@ describe('createOmpAgentCallbacks first-assistant sidebar signal', () => {
63
63
 
64
64
  function makeCallbacks(): OmpAgentCallbacks {
65
65
  const deps: OmpAgentCallbacksDeps = {
66
- removeDeliveredFromQueue: () => {},
67
66
  setGenerating: () => {},
68
67
  setGeneratingVerb: () => {},
69
68
  scrollToBottom: () => {},
@@ -133,6 +132,15 @@ describe('createOmpAgentCallbacks first-assistant sidebar signal', () => {
133
132
  expect(signals).toHaveLength(2);
134
133
  });
135
134
 
135
+ test('onTurnStart signals the sidebar on every turn of the run', () => {
136
+ const callbacks = makeCallbacks();
137
+
138
+ callbacks.onTurnStart?.();
139
+ callbacks.onTurnStart?.();
140
+ expect(signals).toHaveLength(2);
141
+ expect(signals.every(s => s.name === 'omp:session-updated' && s.sessionId === 'sess-1')).toBe(true);
142
+ });
143
+
136
144
  test('never signals from user turns or notice rows alone', () => {
137
145
  const callbacks = makeCallbacks();
138
146
 
@@ -47,7 +47,6 @@ export function cancelStreamingCoalescer(): void {
47
47
  }
48
48
 
49
49
  export interface OmpAgentCallbacksDeps {
50
- removeDeliveredFromQueue: (text: string) => void;
51
50
  setGenerating: (v: boolean) => void;
52
51
  setGeneratingVerb: (v: string) => void;
53
52
  scrollToBottom: (behavior?: ScrollBehavior) => void;
@@ -73,7 +72,6 @@ export interface OmpAgentCallbacksDeps {
73
72
 
74
73
  export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCallbacks {
75
74
  const {
76
- removeDeliveredFromQueue,
77
75
  setGenerating,
78
76
  setGeneratingVerb,
79
77
  scrollToBottom,
@@ -97,12 +95,6 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
97
95
  scrollAfterFlush = () => scrollToBottom('smooth');
98
96
 
99
97
  return {
100
- // A queued steer/follow-up text was picked up by the agent (user turn
101
- // arrived) — drop it from whichever queue mirrors it so the panel stays
102
- // truthful without a chat bubble for every delivery.
103
- onQueuedMessageDelivered: (text) => {
104
- removeDeliveredFromQueue(text);
105
- },
106
98
  onAgentStart: () => {
107
99
  setGenerating(true);
108
100
  setGeneratingVerb(PHASE_VERBS.thinking);
@@ -122,6 +114,15 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
122
114
  }
123
115
  }
124
116
  },
117
+ onTurnStart: () => {
118
+ // Every turn inside the run re-signals the sidebar: the throttle makes
119
+ // this cheap, and it recovers the `stream` status row if a previous
120
+ // dispatch raced with the status write (or the row was healed away).
121
+ const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
122
+ if (sid && typeof window !== 'undefined') {
123
+ window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
124
+ }
125
+ },
125
126
  // Reload recovery: the omp process kept running server-side, so the event
126
127
  // stream is reattached and the generating UI must resume (the timeline
127
128
  // fetch already loaded the committed messages; live updates continue).
@@ -308,6 +309,42 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
308
309
  abortControllerRef.current = null;
309
310
  console.error('OMP prompt error:', errorMessage);
310
311
  },
312
+ // The prompt ran a built-in slash command (omp answered on the command
313
+ // path, no agent run): drop the optimistic AI placeholder and settle the
314
+ // optimistic user row — it stays as the record of what the user invoked.
315
+ onPromptSettled: () => {
316
+ disposeStreamingCoalescer();
317
+ setGenerating(false);
318
+ abortControllerRef.current = null;
319
+ optimisticUserIdRef.current = null;
320
+ const placeholderId = aiPlaceholderIdRef.current;
321
+ aiPlaceholderIdRef.current = null;
322
+ if (placeholderId) {
323
+ setLocalMessages(prev => {
324
+ if (!prev.some(m => m.id === placeholderId)) return prev;
325
+ return prev.filter(m => m.id !== placeholderId);
326
+ });
327
+ }
328
+ },
329
+ // Built-in slash command output only rides the live stream — append it as
330
+ // a notice row before any active placeholder, exactly like omp notices.
331
+ onCommandOutput: (text) => {
332
+ const row: ChatMessageData = {
333
+ id: `cmdout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
334
+ role: 'ai',
335
+ content: '',
336
+ notice: text,
337
+ };
338
+ setLocalMessages(prev => {
339
+ const placeholderId = aiPlaceholderIdRef.current;
340
+ if (placeholderId) {
341
+ const pIdx = prev.findIndex(m => m.id === placeholderId);
342
+ if (pIdx !== -1) return [...prev.slice(0, pIdx), row, ...prev.slice(pIdx)];
343
+ }
344
+ if (prev.some(m => m.notice === text)) return prev;
345
+ return [...prev, row];
346
+ });
347
+ },
311
348
  onNotice: (_level, message) => {
312
349
  console.info('OMP notice:', message);
313
350
  },
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ codeWindow,
10
+ clampWindow,
11
+ firstWindow,
12
+ measuredGeometry,
13
+ resolveWindow,
14
+ shouldWindow,
15
+ uniformGeometry,
16
+ windowSpace,
17
+ WHOLE_DOCUMENT_MAX_CHARS,
18
+ WHOLE_DOCUMENT_MAX_LINES,
19
+ WINDOW_OVERSCAN,
20
+ type LineGeometry,
21
+ } from '@/shared/lib/code/lazy-window';
22
+
23
+ const ROW = 18;
24
+
25
+ /** `measuredGeometry` returns null for heights that do not fit the document. */
26
+ function measured(heights: number[]): LineGeometry {
27
+ const geometry = measuredGeometry(heights, ROW, heights.length);
28
+ if (!geometry) throw new Error('heights should describe every line');
29
+ return geometry;
30
+ }
31
+
32
+ describe('shouldWindow', () => {
33
+ test('leaves a short document on the plain surface', () => {
34
+ expect(shouldWindow(120, 4_000)).toBe(false);
35
+ });
36
+
37
+ test('windows a document that is long in lines or in characters', () => {
38
+ expect(shouldWindow(WHOLE_DOCUMENT_MAX_LINES + 1, 10)).toBe(true);
39
+ expect(shouldWindow(10, WHOLE_DOCUMENT_MAX_CHARS + 1)).toBe(true);
40
+ });
41
+ });
42
+
43
+ describe('uniformGeometry', () => {
44
+ const geometry = uniformGeometry(ROW, 500);
45
+
46
+ test('spaces lines one row apart and totals the document', () => {
47
+ expect(geometry.offsetOf(0)).toBe(0);
48
+ expect(geometry.offsetOf(10)).toBe(10 * ROW);
49
+ expect(geometry.totalHeight).toBe(500 * ROW);
50
+ });
51
+
52
+ test('maps an offset back to the line covering it', () => {
53
+ expect(geometry.lineAt(0)).toBe(0);
54
+ expect(geometry.lineAt(ROW - 1)).toBe(0);
55
+ expect(geometry.lineAt(ROW)).toBe(1);
56
+ expect(geometry.lineAt(499 * ROW + 3)).toBe(499);
57
+ });
58
+
59
+ test('clamps an offset outside the document', () => {
60
+ expect(geometry.lineAt(-50)).toBe(0);
61
+ expect(geometry.lineAt(999_999)).toBe(499);
62
+ expect(geometry.offsetOf(500)).toBe(geometry.totalHeight);
63
+ expect(geometry.offsetOf(999)).toBe(geometry.totalHeight);
64
+ });
65
+ });
66
+
67
+ describe('measuredGeometry', () => {
68
+ // Every third line wraps over three rows; the rest are single rows.
69
+ const heights = Array.from({ length: 9 }, (_, index) => (index % 3 === 2 ? ROW * 3 : ROW));
70
+ const geometry = measured(heights);
71
+
72
+ test('builds offsets from the wrapped heights', () => {
73
+ expect(geometry.offsetOf(0)).toBe(0);
74
+ expect(geometry.offsetOf(1)).toBe(ROW);
75
+ expect(geometry.offsetOf(2)).toBe(2 * ROW);
76
+ // Line 2 spans three rows, so line 3 starts after all three.
77
+ expect(geometry.offsetOf(3)).toBe(5 * ROW);
78
+ expect(geometry.totalHeight).toBe(heights.reduce((sum, height) => sum + height, 0));
79
+ });
80
+
81
+ test('maps an offset inside a wrapped line to that line', () => {
82
+ expect(geometry.lineAt(2 * ROW)).toBe(2);
83
+ expect(geometry.lineAt(4 * ROW + 5)).toBe(2);
84
+ expect(geometry.lineAt(5 * ROW)).toBe(3);
85
+ });
86
+
87
+ test('refuses heights that do not describe every line', () => {
88
+ expect(measuredGeometry([ROW, ROW], ROW, 5)).toBeNull();
89
+ expect(measuredGeometry([], ROW, 0)).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe('resolveWindow', () => {
94
+ const geometry = uniformGeometry(ROW, 1_000);
95
+
96
+ test('covers the viewport plus overscan on both sides', () => {
97
+ // Viewport rows 100..128 (the last row is half covered, so it is included).
98
+ const window = resolveWindow(geometry, 100 * ROW, 28 * ROW);
99
+ expect(window).toEqual({ start: 100 - WINDOW_OVERSCAN, end: 129 + WINDOW_OVERSCAN });
100
+ });
101
+
102
+ test('never runs past either end of the document', () => {
103
+ expect(resolveWindow(geometry, 0, ROW)).toEqual({ start: 0, end: 2 + WINDOW_OVERSCAN });
104
+ expect(resolveWindow(geometry, 999 * ROW, 40 * ROW).end).toBe(1_000);
105
+ });
106
+
107
+ test('keeps a zero-height band to at least the line under it', () => {
108
+ const window = resolveWindow(geometry, 400 * ROW, 0);
109
+ expect(window.start).toBeLessThanOrEqual(400);
110
+ expect(window.end).toBeGreaterThan(400);
111
+ });
112
+ });
113
+
114
+ describe('firstWindow', () => {
115
+ test('bounds the opening render of a long document', () => {
116
+ expect(firstWindow(20_000).start).toBe(0);
117
+ expect(firstWindow(20_000).end).toBeLessThan(20_000);
118
+ });
119
+
120
+ test('keeps a short document whole', () => {
121
+ expect(firstWindow(3)).toEqual({ start: 0, end: 3 });
122
+ });
123
+ });
124
+
125
+ describe('clampWindow', () => {
126
+ test('pulls a window left over from another document back inside it', () => {
127
+ expect(clampWindow({ start: 9_000, end: 9_500 }, 40)).toEqual({ start: 39, end: 40 });
128
+ expect(clampWindow({ start: -5, end: 10 }, 40)).toEqual({ start: 0, end: 10 });
129
+ });
130
+
131
+ test('never returns an empty range', () => {
132
+ expect(clampWindow({ start: 5, end: 5 }, 40).end).toBeGreaterThan(5);
133
+ });
134
+ });
135
+
136
+ describe('windowSpace', () => {
137
+ test('reserves exactly the height of the lines outside the window', () => {
138
+ const geometry = uniformGeometry(ROW, 100);
139
+ const space = windowSpace(geometry, { start: 10, end: 30 });
140
+ expect(space).toEqual({ top: 10 * ROW, bottom: 70 * ROW, breakAtEnd: false });
141
+ expect(space.top + space.bottom + (30 - 10) * ROW).toBe(geometry.totalHeight);
142
+ });
143
+
144
+ test('flags a window that reaches the last line', () => {
145
+ const geometry = uniformGeometry(ROW, 100);
146
+ expect(windowSpace(geometry, { start: 90, end: 100 }).breakAtEnd).toBe(true);
147
+ });
148
+ });
149
+
150
+ describe('codeWindow', () => {
151
+ test('clamps the range and spaces it in one step', () => {
152
+ const geometry = uniformGeometry(ROW, 50);
153
+ expect(codeWindow(geometry, { start: 0, end: 5_000 })).toEqual({
154
+ start: 0,
155
+ end: 50,
156
+ top: 0,
157
+ bottom: 0,
158
+ breakAtEnd: true,
159
+ });
160
+ });
161
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Line geometry and windowing policy for the lazy editor surface.
8
+ *
9
+ * Shiki tokenization is linear in document size (≈0.14 ms per line), so a
10
+ * whole-file highlight costs ~0.8 s at 7 000 lines — and the editor pays it on
11
+ * every keystroke, on top of one gutter node and one wrapped-row measurement
12
+ * per line. A long document therefore renders only the lines the reader can
13
+ * actually see, and this module is the DOM-free half of that: px offsets per
14
+ * line, the viewport → line-range mapping, and the blank space the unrendered
15
+ * lines take up so the caret never leaves its row.
16
+ *
17
+ * The browser measurements this needs (row height, per-line wrapped heights)
18
+ * stay in the surface, which feeds them in as a `LineGeometry`.
19
+ */
20
+
21
+ /** Documents at or below this size render whole — windowing them saves less than it costs. */
22
+ export const WHOLE_DOCUMENT_MAX_LINES = 300;
23
+ export const WHOLE_DOCUMENT_MAX_CHARS = 20_000;
24
+
25
+ /** Lines kept beyond the viewport so a fast scroll cannot outrun the window. */
26
+ export const WINDOW_OVERSCAN = 24;
27
+
28
+ /** Lines rendered before the first measurement lands; the surface corrects this in a layout effect. */
29
+ export const INITIAL_WINDOW_LINES = 200;
30
+
31
+ /** Half-open line range `[start, end)`. */
32
+ export interface LineWindow {
33
+ start: number;
34
+ end: number;
35
+ }
36
+
37
+ /** Blank space (px) the unrendered lines occupy above and below a window. */
38
+ export interface WindowSpace {
39
+ top: number;
40
+ bottom: number;
41
+ /** The window reaches the document's last line, so it keeps the editor's trailing line break. */
42
+ breakAtEnd: boolean;
43
+ }
44
+
45
+ /** A windowed render request: the lines to draw plus the space the rest of the document takes. */
46
+ export interface CodeWindow extends LineWindow, WindowSpace {}
47
+
48
+ /** Per-line vertical layout of a document, in px. */
49
+ export interface LineGeometry {
50
+ readonly lineCount: number;
51
+ /** Height (px) of a single visual row. */
52
+ readonly rowHeight: number;
53
+ /** Height (px) of every line together. */
54
+ readonly totalHeight: number;
55
+ /** Y offset (px) of the top edge of line `index`; `lineCount` is the document's end. */
56
+ offsetOf(index: number): number;
57
+ /** Index of the line covering `y`, clamped to the document. */
58
+ lineAt(y: number): number;
59
+ }
60
+
61
+ function clamp(value: number, min: number, max: number): number {
62
+ return value < min ? min : value > max ? max : value;
63
+ }
64
+
65
+ /** Whether a document is long enough that rendering all of it would be felt. */
66
+ export function shouldWindow(lineCount: number, charCount: number): boolean {
67
+ return lineCount > WHOLE_DOCUMENT_MAX_LINES || charCount > WHOLE_DOCUMENT_MAX_CHARS;
68
+ }
69
+
70
+ /** Word wrap off: every line is exactly one row, so no measurement is involved. */
71
+ export function uniformGeometry(rowHeight: number, lineCount: number): LineGeometry {
72
+ return {
73
+ lineCount,
74
+ rowHeight,
75
+ totalHeight: rowHeight * lineCount,
76
+ offsetOf: (index) => rowHeight * clamp(index, 0, lineCount),
77
+ lineAt: (y) => clamp(Math.floor(y / rowHeight), 0, Math.max(lineCount - 1, 0)),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Word wrap on: heights are the measured wrapped heights, one entry per line.
83
+ * Returns `null` when they do not describe every line, which leaves the caller
84
+ * on the whole-document path rather than spacing lines with a wrong geometry.
85
+ */
86
+ export function measuredGeometry(heights: number[], rowHeight: number, lineCount: number): LineGeometry | null {
87
+ if (lineCount === 0 || heights.length !== lineCount) return null;
88
+ const offsets = new Float64Array(lineCount + 1);
89
+ for (let i = 0; i < lineCount; i++) offsets[i + 1] = offsets[i] + heights[i];
90
+ const total = offsets[lineCount];
91
+ return {
92
+ lineCount,
93
+ rowHeight,
94
+ totalHeight: total,
95
+ offsetOf: (index) => offsets[clamp(index, 0, lineCount)],
96
+ lineAt: (y) => {
97
+ if (y < 0) return 0;
98
+ if (total <= 0 || y >= total) return lineCount - 1;
99
+ // Last line whose top edge sits at or above `y`.
100
+ let low = 0;
101
+ let high = lineCount - 1;
102
+ while (low < high) {
103
+ const mid = (low + high + 1) >> 1;
104
+ if (offsets[mid] <= y) low = mid;
105
+ else high = mid - 1;
106
+ }
107
+ return low;
108
+ },
109
+ };
110
+ }
111
+
112
+ /** The lines a viewport band (`top`, `height`) covers, plus overscan on both sides. */
113
+ export function resolveWindow(
114
+ geometry: LineGeometry,
115
+ top: number,
116
+ height: number,
117
+ overscan: number = WINDOW_OVERSCAN,
118
+ ): LineWindow {
119
+ const first = geometry.lineAt(top);
120
+ const last = geometry.lineAt(top + Math.max(height, 0));
121
+ return {
122
+ start: Math.max(0, first - overscan),
123
+ end: Math.min(geometry.lineCount, Math.max(last + 1, first + 1) + overscan),
124
+ };
125
+ }
126
+
127
+ /** What a freshly opened document renders before its viewport has been read. */
128
+ export function firstWindow(lineCount: number): LineWindow {
129
+ return { start: 0, end: clamp(INITIAL_WINDOW_LINES, 1, Math.max(lineCount, 1)) };
130
+ }
131
+
132
+ /** Keep a window inside the document it belongs to (the state may predate a file switch). */
133
+ export function clampWindow(window: LineWindow, lineCount: number): LineWindow {
134
+ const start = clamp(window.start, 0, Math.max(lineCount - 1, 0));
135
+ return { start, end: clamp(window.end, start + 1, Math.max(lineCount, 1)) };
136
+ }
137
+
138
+ export function sameWindow(a: LineWindow | null, b: LineWindow | null): boolean {
139
+ return a === b || (a !== null && b !== null && a.start === b.start && a.end === b.end);
140
+ }
141
+
142
+ /** Space the unrendered lines take around `window`, and whether it ends the document. */
143
+ export function windowSpace(geometry: LineGeometry, window: LineWindow): WindowSpace {
144
+ const top = geometry.offsetOf(window.start);
145
+ return {
146
+ top,
147
+ bottom: Math.max(0, geometry.totalHeight - geometry.offsetOf(window.end)),
148
+ breakAtEnd: window.end >= geometry.lineCount,
149
+ };
150
+ }
151
+
152
+ /** Clamp a window to its document and resolve the space it leaves around itself. */
153
+ export function codeWindow(geometry: LineGeometry, window: LineWindow): CodeWindow {
154
+ const clamped = clampWindow(window, geometry.lineCount);
155
+ return { ...clamped, ...windowSpace(geometry, clamped) };
156
+ }
@@ -6,7 +6,8 @@
6
6
  import { beforeAll, describe, expect, test } from 'bun:test';
7
7
 
8
8
  import { bootSyntax, onLanguageReady, requestLanguage } from '@/shared/lib/code/highlighter';
9
- import { getLanguageFromPath, highlightCode } from '@/shared/lib/code/syntax-highlight';
9
+ import { getLanguageFromPath, highlightCode, highlightLines } from '@/shared/lib/code/syntax-highlight';
10
+ import { highlightCodeWindow } from '@/shared/lib/code/windowed-highlight';
10
11
 
11
12
  /** Shiki emits `<span class="shiki">…` with dual-theme CSS variables per token. */
12
13
  const SHIKI_RE = /<span class="shiki">/;
@@ -112,6 +113,54 @@ describe('highlightCode', () => {
112
113
  });
113
114
  });
114
115
 
116
+ describe('highlightCodeWindow', () => {
117
+ const source = Array.from({ length: 120 }, (_, index) =>
118
+ index === 0 ? 'export const first = 1;' : `export const value${index} = ${index}; // line ${index}`,
119
+ ).join('\n');
120
+
121
+ /** The wrapper the app's `.shiki span` colour rule depends on. */
122
+ const unwrap = (html: string) => html.replace(/^<span class="shiki">/, '').replace(/<\/span>$/, '');
123
+
124
+ test('wraps the window in the class the token colours are scoped to', () => {
125
+ const window = highlightCodeWindow(source, 'typescript', { start: 0, end: 3 });
126
+
127
+ // Without this class no `.shiki span` rule matches, so the tokens would
128
+ // render in the plain ink colour.
129
+ expect(window).toMatch(SHIKI_RE);
130
+ expect(window.endsWith('</span>')).toBe(true);
131
+ });
132
+
133
+ test('returns only the requested lines, joined as the editor renders them', () => {
134
+ const window = highlightCodeWindow(source, 'typescript', { start: 10, end: 13 });
135
+ const rows = highlightLines(source, 'typescript');
136
+
137
+ expect(unwrap(window).split('\n')).toEqual(rows.slice(10, 13));
138
+ });
139
+
140
+ test('keeps multi-line grammar state from the lines above the window', () => {
141
+ const commented = ['/*', ' * a block comment', ' * spanning several lines', ' */', 'const after = 1;'].join('\n');
142
+ const rows = highlightLines(commented, 'typescript');
143
+ // Line 2 sits inside the comment, whose opening line is outside the window.
144
+ const window = highlightCodeWindow(commented, 'typescript', { start: 2, end: 4 });
145
+
146
+ expect(unwrap(window).split('\n')).toEqual(rows.slice(2, 4));
147
+ });
148
+
149
+ test('renders an over-long line as plain text instead of tokenizing it', () => {
150
+ const minified = `const bundled = "${'x'.repeat(200)}";\nconst next = 1;`;
151
+ const window = highlightCodeWindow(minified, 'javascript', { start: 0, end: 2 }, { maxLineLength: 50 });
152
+
153
+ expect(window).toContain('const bundled');
154
+ expect(unwrap(window).split('\n')[0]).not.toContain('--shiki-light:');
155
+ expect(unwrap(window).split('\n')[1]).toContain('--shiki-light:');
156
+ });
157
+
158
+ test('returns nothing for a window outside the document', () => {
159
+ expect(highlightCodeWindow('const a = 1;', 'typescript', { start: 5, end: 9 })).toBe('');
160
+ expect(highlightCodeWindow('const a = 1;', 'typescript', { start: 0, end: 0 })).toBe('');
161
+ });
162
+ });
163
+
115
164
  describe('getLanguageFromPath', () => {
116
165
  test.each([
117
166
  ['a.ts', 'typescript'],
@@ -268,12 +268,24 @@ export function highlightJson(code: string): string {
268
268
  return highlightCode(code, 'json');
269
269
  }
270
270
 
271
+ export interface HighlightLinesOptions {
272
+ /**
273
+ * Lines at or above this length are emitted as plain text instead of being
274
+ * tokenized — the guard a minified bundle (one line of hundreds of KB) needs.
275
+ */
276
+ maxLineLength?: number;
277
+ }
278
+
271
279
  /**
272
280
  * Highlight code and return one HTML fragment per line, aligned 1:1 with
273
281
  * `code.split('\n')`. Tokenizes the whole input at once so multi-line grammar
274
282
  * state (block comments, template literals) survives, unlike per-line calls.
275
283
  */
276
- export function highlightLines(code: string, language = 'javascript'): string[] {
284
+ export function highlightLines(
285
+ code: string,
286
+ language = 'javascript',
287
+ options: HighlightLinesOptions = {},
288
+ ): string[] {
277
289
  const rawLines = code.split('\n');
278
290
  if (!code) return rawLines;
279
291
  const hl = getHighlighterSync();
@@ -284,6 +296,7 @@ export function highlightLines(code: string, language = 'javascript'): string[]
284
296
  lang,
285
297
  themes: SHIKI_THEMES,
286
298
  defaultColor: false,
299
+ tokenizeMaxLineLength: options.maxLineLength ?? 0,
287
300
  });
288
301
  if (tokens.length !== rawLines.length) return rawLines.map(escapeCode);
289
302
  return tokens.map((lineTokens) =>
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import type { LineWindow } from '@/shared/lib/code/lazy-window';
7
+ import { highlightLines } from '@/shared/lib/code/syntax-highlight';
8
+
9
+ /**
10
+ * Lines tokenized *above* the window and dropped from the result. They only
11
+ * seed the grammar state, so a window that starts inside a block comment or a
12
+ * template literal still tokenizes as a comment / template literal. Tokenizing
13
+ * them costs a fraction of the window itself; carrying the whole document as
14
+ * context would defeat the point of windowing at all.
15
+ */
16
+ const CONTEXT_LINES = 40;
17
+
18
+ /**
19
+ * Lines at or above this length render as plain text. A minified bundle is one
20
+ * line of hundreds of KB, which the tokenizer would chew through on every
21
+ * scroll; the cap keeps that bounded, exactly like Shiki's own
22
+ * `tokenizeMaxLineLength` bail-out.
23
+ */
24
+ const MAX_LINE_CHARS = 12_000;
25
+
26
+ export interface HighlightCodeWindowOptions {
27
+ /** Lines of grammar context above the window. */
28
+ contextLines?: number;
29
+ /** Length at which a line falls back to plain text. */
30
+ maxLineLength?: number;
31
+ }
32
+
33
+ /**
34
+ * Shiki markup for lines `[window.start, window.end)` of `code`, joined with
35
+ * newlines so it can be dropped straight into the editor's `<pre>`. The caller
36
+ * reserves the space of the lines it is not given (`lazy-window`'s
37
+ * `windowSpace`), which is what keeps the caret over its own row.
38
+ *
39
+ * The rows are wrapped in `.shiki` — the app colours tokens through the
40
+ * `.shiki span { color: var(--shiki-light) }` rule (`tailwind.css`), so markup
41
+ * that skipped the wrapper would render in the plain ink colour. Every other
42
+ * consumer of `highlightLines` carries the same class.
43
+ *
44
+ * Editing is unaffected: the `<textarea>` above the highlight layer always
45
+ * holds the full document — only the markup is windowed.
46
+ */
47
+ export function highlightCodeWindow(
48
+ code: string,
49
+ language: string,
50
+ window: LineWindow,
51
+ options: HighlightCodeWindowOptions = {},
52
+ ): string {
53
+ const { contextLines = CONTEXT_LINES, maxLineLength = MAX_LINE_CHARS } = options;
54
+ const lines = code.split('\n');
55
+ const start = Math.min(Math.max(window.start, 0), lines.length);
56
+ const end = Math.min(Math.max(window.end, start), lines.length);
57
+ if (start === end) return '';
58
+
59
+ const contextStart = Math.max(0, start - contextLines);
60
+ const rows = highlightLines(lines.slice(contextStart, end).join('\n'), language, { maxLineLength });
61
+ const offset = start - contextStart;
62
+ return `<span class="shiki">${rows.slice(offset, offset + (end - start)).join('\n')}</span>`;
63
+ }
@@ -94,7 +94,7 @@ function applySvg(block: HTMLElement, svg: string, themeKey: ThemeKey): void {
94
94
  const source = block.dataset.mermaid ?? '';
95
95
  const copyAttr = source.replace(/"/g, '&quot;');
96
96
  block.innerHTML =
97
- `<button type="button" class="code-copy-float" data-copy="${copyAttr}" aria-label="Copy diagram source">` +
97
+ `<button type="button" class="code-copy-float" data-copy="${copyAttr}" aria-label="Copy diagram source" title="Copy diagram source">` +
98
98
  '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>' +
99
99
  '</button>' +
100
100
  svg;
@@ -142,11 +142,13 @@ async function renderBlock(block: HTMLElement, cachedOnly: boolean): Promise<boo
142
142
  }
143
143
 
144
144
  /**
145
- * Render every pending `.mermaid-block` under `root`. Theme changes re-run
146
- * this via `force: true`. Idempotent — blocks already rendered with the
147
- * current theme are skipped unless forced. With `cachedOnly`, only blocks
148
- * whose SVG is already cached are restored (synchronous, no rendering) — the
149
- * fast path that keeps remounted blocks from flashing the placeholder.
145
+ * Render every pending `.mermaid-block` under `root`. A block whose SVG was
146
+ * rendered for a different theme key is re-rendered here, which is how a theme
147
+ * switch repaints diagrams; `force` re-renders even matching blocks.
148
+ * Idempotent — blocks already rendered with the current theme are skipped
149
+ * unless forced, and blocks mid-render are left alone. With `cachedOnly`, only
150
+ * blocks whose SVG is already cached are restored (synchronous, no rendering) —
151
+ * the fast path that keeps remounted blocks from flashing the placeholder.
150
152
  */
151
153
  export async function hydrateMermaidBlocks(
152
154
  root: ParentNode,