@cueloop/client 0.1.0-alpha.102 → 0.1.0-alpha.104

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 (126) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/annotation-palette.ts +1 -0
  3. package/src/annotations/components/AnnotationCards.tsx +4 -0
  4. package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
  5. package/src/annotations/discussions.ts +1 -0
  6. package/src/annotations/thread-selection.ts +6 -0
  7. package/src/annotations/use-annotation-surface.tsx +97 -0
  8. package/src/app/App.deleted-scroll.test.tsx +3 -0
  9. package/src/app/App.harness-settings.test.tsx +77 -0
  10. package/src/app/App.plan-render.test.tsx +2 -0
  11. package/src/app/App.plan-review.test.tsx +1 -0
  12. package/src/app/App.readonly.test.tsx +63 -0
  13. package/src/app/App.test.tsx +1 -0
  14. package/src/app/App.tsx +94 -12
  15. package/src/app/App.visual.test.tsx +1 -0
  16. package/src/app/App.walk.test.tsx +1 -0
  17. package/src/app/App.workbench.test.tsx +1 -0
  18. package/src/app/app-screens.tsx +3 -2
  19. package/src/app/app-view-model.ts +11 -1
  20. package/src/app/components/WorkspacePanels.tsx +1 -0
  21. package/src/app/intent-dispatch.test.ts +1 -0
  22. package/src/app/intent-dispatch.ts +26 -0
  23. package/src/app/ready-signal.ts +2 -0
  24. package/src/app/run.ts +2 -0
  25. package/src/app/serve.test.ts +4 -0
  26. package/src/diff/components/AnnotatableFileView.tsx +1 -0
  27. package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
  28. package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
  29. package/src/diff/components/DiffContentView.tsx +25 -0
  30. package/src/diff/components/diff-content-view-layout.ts +3 -0
  31. package/src/diff/diff-fold.ts +4 -0
  32. package/src/diff/diff-hunk-curate.ts +3 -0
  33. package/src/diff/diff-intraline.ts +6 -0
  34. package/src/diff/diff-syntax.ts +8 -0
  35. package/src/diff/split-diff.ts +6 -0
  36. package/src/diff/view-diff.ts +18 -0
  37. package/src/diff/walk.ts +3 -0
  38. package/src/index.ts +1 -0
  39. package/src/integrations/attribution.ts +2 -0
  40. package/src/integrations/client-extension-registry.ts +4 -0
  41. package/src/integrations/clipboard.ts +1 -0
  42. package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
  43. package/src/integrations/components/ShareDialog.tsx +28 -0
  44. package/src/integrations/editor.ts +3 -0
  45. package/src/integrations/github-identity.test.ts +1 -0
  46. package/src/integrations/github-identity.ts +1 -0
  47. package/src/integrations/herdr.ts +2 -0
  48. package/src/integrations/pasted-image.ts +2 -0
  49. package/src/integrations/share.ts +46 -1
  50. package/src/keyboard/components/HandleChipsInput.tsx +4 -0
  51. package/src/keyboard/components/SlashComposer.tsx +3 -0
  52. package/src/keyboard/key-bindings.ts +3 -0
  53. package/src/keyboard/keymap.ts +51 -0
  54. package/src/keyboard/skills.ts +2 -0
  55. package/src/keyboard/slash-palette.ts +8 -0
  56. package/src/keyboard/thread-chords.ts +5 -0
  57. package/src/keyboard/use-shared-keyboard.ts +1 -0
  58. package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
  59. package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
  60. package/src/markdown/components/ThreadView.tsx +13 -0
  61. package/src/markdown/mark-runs.ts +4 -0
  62. package/src/markdown/markdown-editor-syntax-style.ts +1 -0
  63. package/src/markdown/markdown-grid.ts +6 -0
  64. package/src/markdown/markdown-highlight.ts +1 -0
  65. package/src/markdown/thread-code-syntax.ts +3 -0
  66. package/src/markdown/view-plan.ts +52 -0
  67. package/src/performance/perf-report.ts +1 -0
  68. package/src/performance/perf-timings.ts +1 -0
  69. package/src/prototype/components/PrototypeContentView.tsx +17 -0
  70. package/src/prototype/prototype-browser.ts +11 -0
  71. package/src/settings/components/SettingsDialog.tsx +3 -0
  72. package/src/settings/config.test.ts +79 -1
  73. package/src/settings/config.ts +91 -1
  74. package/src/settings/use-settings-dialog.test.tsx +172 -0
  75. package/src/settings/use-settings-dialog.tsx +115 -1
  76. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  77. package/src/settings/use-thread-harness-setting.ts +86 -0
  78. package/src/stories/stories.test.tsx +4 -0
  79. package/src/stories/story.ts +6 -0
  80. package/src/terminal/ansi-screen.ts +5 -0
  81. package/src/terminal/components/terminal-pane.ts +12 -0
  82. package/src/terminal/ghostty-terminal.ts +10 -0
  83. package/src/terminal/kitty-image.ts +3 -0
  84. package/src/terminal/pty.ts +9 -0
  85. package/src/testing/test-support.test.ts +2 -0
  86. package/src/testing/test-support.ts +16 -0
  87. package/src/thread/agent-continuation.test.ts +2 -0
  88. package/src/thread/agent-transcript.ts +7 -0
  89. package/src/thread/components/AgentConfigControls.test.tsx +41 -0
  90. package/src/thread/components/AgentConfigControls.tsx +8 -1
  91. package/src/thread/components/AgentThreadPane.test.tsx +91 -3
  92. package/src/thread/components/AgentThreadPane.tsx +61 -0
  93. package/src/thread/components/ThreadTree.tsx +1 -0
  94. package/src/thread/components/thread-tree.test.ts +1 -0
  95. package/src/thread/thread-controller.curate.test.ts +2 -0
  96. package/src/thread/thread-controller.share.test.ts +47 -2
  97. package/src/thread/thread-controller.ts +129 -4
  98. package/src/thread/tree-view.ts +4 -0
  99. package/src/thread/use-thread-agent.test.tsx +1 -0
  100. package/src/thread/use-thread-agent.ts +5 -0
  101. package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
  102. package/src/ui/components/OverlayScrollbar.tsx +5 -0
  103. package/src/ui/components/PromptDialog.tsx +2 -0
  104. package/src/ui/components/RootOverlay.tsx +1 -0
  105. package/src/ui/components/ScrollArea.tsx +2 -0
  106. package/src/ui/components/primitives/Button.tsx +1 -0
  107. package/src/ui/components/primitives/Tabs.tsx +1 -0
  108. package/src/ui/components/primitives/Tree.tsx +4 -0
  109. package/src/ui/components/primitives/tree-model.ts +7 -0
  110. package/src/ui/components/syntax-highlight.ts +5 -0
  111. package/src/ui/components/truncate-title.ts +1 -0
  112. package/src/ui/use-frame-measure.ts +3 -0
  113. package/src/ui/use-terminal-virtualizer.ts +6 -0
  114. package/src/workbench/components/ChangesColumn.tsx +7 -0
  115. package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
  116. package/src/workbench/components/EditorGrid.tsx +8 -0
  117. package/src/workbench/components/FileContentsView.tsx +2 -0
  118. package/src/workbench/components/GridTabContent.tsx +2 -0
  119. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  120. package/src/workbench/components/ProjectTreeView.tsx +8 -0
  121. package/src/workbench/components/TreePane.tsx +2 -0
  122. package/src/workbench/components/editor-grid.ts +3 -0
  123. package/src/workbench/components/file-tree.ts +2 -0
  124. package/src/workbench/use-changes-workbench.ts +5 -0
  125. package/src/workbench/use-remember-layout.ts +2 -0
  126. package/src/workbench/use-repo-changes.ts +2 -0
@@ -121,9 +121,11 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
121
121
  const overlayGrammar = overlayGrammars[state.overlay];
122
122
 
123
123
  if (overlayGrammar) return overlayGrammar(state, key);
124
+
124
125
  const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
125
126
 
126
127
  if (action === "quit") return [{ type: "exit" }];
128
+
127
129
  // the ONE read-only rule: any mutating attempt answers instead of acting
128
130
  // (span-mode c, x, and a are hardwired keys, so they gate by name as well)
129
131
  const mutating =
@@ -140,8 +142,10 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
140
142
  }
141
143
 
142
144
  if (state.view === "inbox") return inboxGrammar(state, name);
145
+
143
146
  // span mode owns its single-letter keys (b slides the span back)
144
147
  if (state.spanMode) return spanGrammar(state, name);
148
+
145
149
  if (state.view === "diff") return diffGrammar(state, action);
146
150
 
147
151
  return planGrammar(state, action, name);
@@ -164,6 +168,7 @@ function composeOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
164
168
  const name = key.name;
165
169
 
166
170
  if (name === "escape") return [{ type: "closeOverlay" }];
171
+
167
172
  if (name === "return" || name === "enter") {
168
173
  // In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
169
174
  // focused textarea owns that; only a bare ⏎ saves. The submit overlay
@@ -180,6 +185,7 @@ function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
180
185
  const name = key.name;
181
186
 
182
187
  if (name === "escape") return [{ type: "closeOverlay" }];
188
+
183
189
  if (name === "left" || name === "right") {
184
190
  return [{ type: "cycleMessage", direction: name === "left" ? -1 : 1 }];
185
191
  }
@@ -191,6 +197,7 @@ function confirmOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
191
197
  const name = key.name;
192
198
 
193
199
  if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
200
+
194
201
  if (name === "escape") return [{ type: "closeOverlay" }];
195
202
 
196
203
  return [];
@@ -200,6 +207,7 @@ function promptOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
200
207
  const name = key.name;
201
208
 
202
209
  if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
210
+
203
211
  if (name === "escape") return [{ type: "closeOverlay" }];
204
212
 
205
213
  return [];
@@ -209,8 +217,11 @@ function spanActionsOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
209
217
  const name = key.name;
210
218
 
211
219
  if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
220
+
212
221
  if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
222
+
213
223
  if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
224
+
214
225
  if (name === "escape") return [{ type: "closeSpanActions" }];
215
226
 
216
227
  return [];
@@ -220,7 +231,9 @@ function completionOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
220
231
  const name = key.name;
221
232
 
222
233
  if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
234
+
223
235
  if (name === "a") return [{ type: "optInAutoClose" }];
236
+
224
237
  if (name === "escape") return [{ type: "dismissCompletion" }];
225
238
 
226
239
  return [];
@@ -230,11 +243,15 @@ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
230
243
  const name = key.name;
231
244
 
232
245
  if (name === "]") return [{ type: "walkForward" }];
246
+
233
247
  if (name === "[") return [{ type: "walkBack" }];
248
+
234
249
  if (name === "escape") return [{ type: "walkLeave" }];
250
+
235
251
  if ((name === "return" || name === "enter") && state.walkAtEnd) {
236
252
  return [{ type: "walkLeave" }, { type: "openSubmit" }];
237
253
  }
254
+
238
255
  if (name === "q") return [{ type: "exit" }];
239
256
 
240
257
  return [];
@@ -242,9 +259,13 @@ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
242
259
 
243
260
  function inboxGrammar(state: KeyState, name: string): Intent[] {
244
261
  if (!state.hasInboxItems) return [];
262
+
245
263
  if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
264
+
246
265
  if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
266
+
247
267
  if (name === "return" || name === "enter") return [{ type: "openSession" }];
268
+
248
269
  if (name === "d") return [{ type: "requestDeleteSession" }];
249
270
 
250
271
  return [];
@@ -254,39 +275,49 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
254
275
  const navigation = navigationIntent(action);
255
276
 
256
277
  if (navigation) return navigation;
278
+
257
279
  if (action === "walk") {
258
280
  // marking viewed writes the session record, so a resolved review answers
259
281
  if (state.resolved) return status("review submitted - read-only");
260
282
 
261
283
  return [{ type: "walkStart" }];
262
284
  }
285
+
263
286
  if (action === "comment") {
264
287
  if (state.resolved) return status("review submitted - read-only");
288
+
265
289
  if (!state.cursorAnnotatable) return status("move to a code line to comment");
266
290
 
267
291
  return [{ type: "openCompose", kind: "comment", from: "cursor" }];
268
292
  }
293
+
269
294
  // cut rejects the change under the cursor, reject_hunk the whole hunk; both
270
295
  // write the working copy, so they gate on owner like a plan edit.
271
296
  if (action === "cut" || action === "reject_hunk") {
272
297
  if (state.resolved) return status("review submitted - read-only");
298
+
273
299
  if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
274
300
 
275
301
  return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
276
302
  }
303
+
277
304
  const viewToggle = viewToggleIntent(action);
278
305
 
279
306
  if (viewToggle) return viewToggle;
307
+
280
308
  // restore un-does a curated-out rejection from the rail; same owner gate as reject
281
309
  if (action === "restore_curation") {
282
310
  if (state.resolved) return status("review submitted - read-only");
311
+
283
312
  if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
284
313
 
285
314
  return [{ type: "restoreCuration" }];
286
315
  }
316
+
287
317
  const shared = annotationCluster(state, action);
288
318
 
289
319
  if (shared) return shared;
320
+
290
321
  if (action === "span" || action === "edit") {
291
322
  return status("plan-only primitive - diff review uses c on a line");
292
323
  }
@@ -296,13 +327,18 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
296
327
 
297
328
  function spanGrammar(state: KeyState, name: string): Intent[] {
298
329
  if (name === "escape") return [{ type: "closeOverlay" }];
330
+
299
331
  if (SPAN_KEYS.has(name)) return [{ type: "spanKey", name }];
332
+
300
333
  // c comments, x cuts, a opens quick-actions - all mutate, so a resolved
301
334
  // review is read-only, like the plan grammar
302
335
  if (name === "c" || name === "x" || name === "a") {
303
336
  if (state.resolved) return status("review submitted - read-only");
337
+
304
338
  if (name === "c") return [{ type: "openCompose", kind: "comment", from: "span" }];
339
+
305
340
  if (name === "a") return [{ type: "openSpanActions" }];
341
+
306
342
  if (state.canEditPlan === false) return [];
307
343
 
308
344
  return [{ type: "spanCut" }];
@@ -315,28 +351,38 @@ function planGrammar(state: KeyState, action: string | undefined, name: string):
315
351
  const navigation = navigationIntent(action);
316
352
 
317
353
  if (navigation) return navigation;
354
+
318
355
  if (action === "walk") return status("the guided walk is a diff-review mode");
356
+
319
357
  if (name === "escape") return [{ type: "deselect" }];
358
+
320
359
  if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
360
+
321
361
  if (action === "comment") {
322
362
  if (state.resolved) return status("review submitted - read-only");
363
+
323
364
  if (!state.cursorAnnotatable) return status("text is cut - restore it first");
324
365
 
325
366
  return [{ type: "openCompose", kind: "comment", from: "cursor" }];
326
367
  }
368
+
327
369
  // restore un-does a rail removal (a cut block); a plan edit, so owner-only
328
370
  if (action === "restore_curation") {
329
371
  if (state.resolved) return status("review submitted - read-only");
372
+
330
373
  if (state.canEditPlan === false) return [];
331
374
 
332
375
  return [{ type: "restoreCuration" }];
333
376
  }
377
+
334
378
  if (action === "cut" || action === "edit") {
335
379
  if (state.resolved) return status("review submitted - read-only");
380
+
336
381
  // the document selects, the rail edits: with a card selected, Cut deletes
337
382
  // the annotation and edit rewrites the card body in place
338
383
  if (state.hasFocusedAnnotation)
339
384
  return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
385
+
340
386
  // editing the plan itself is the owner's primitive; a share viewer only annotates,
341
387
  // and has no edit affordance, so the key is silent rather than a nag
342
388
  if (state.canEditPlan === false) return [];
@@ -360,7 +406,9 @@ function navigationIntent(action: string | undefined): Intent[] | null {
360
406
  * split/stacked (split lays out only when the Changes pane is wide/zoomed). */
361
407
  function viewToggleIntent(action: string | undefined): Intent[] | null {
362
408
  if (action === "collapse_file") return [{ type: "foldFile" }];
409
+
363
410
  if (action === "expand_file") return [{ type: "unfoldFile" }];
411
+
364
412
  if (action === "split_diff") return [{ type: "toggleDiffView" }];
365
413
 
366
414
  return null;
@@ -373,16 +421,19 @@ function annotationCluster(state: KeyState, action: string | undefined): Intent[
373
421
 
374
422
  return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
375
423
  }
424
+
376
425
  if (action === "delete_annotation") {
377
426
  if (state.resolved || !state.hasFocusedAnnotation) return [];
378
427
 
379
428
  return [{ type: "removeAnnotation" }];
380
429
  }
430
+
381
431
  if (action === "rename") {
382
432
  if (!state.hasFocusedAnnotation) return status("select a collaborator's note to rename them");
383
433
 
384
434
  return [{ type: "openRename" }];
385
435
  }
436
+
386
437
  if (action === "submit") {
387
438
  // a collaborator's notes union back as they go; there is no message to submit
388
439
  if (state.canSubmitMessage === false)
@@ -37,6 +37,7 @@ export function loadSkills(dir: string): SlashItem[] {
37
37
 
38
38
  for (const entry of entries) {
39
39
  if (!entry.isDirectory()) continue;
40
+
40
41
  let text;
41
42
 
42
43
  try {
@@ -47,6 +48,7 @@ export function loadSkills(dir: string): SlashItem[] {
47
48
  const frontmatter = /^---\n([\s\S]*?)\n---/.exec(text.replace(/\r\n/g, "\n"));
48
49
 
49
50
  if (!frontmatter) continue;
51
+
50
52
  const name = v.safeParse(
51
53
  SkillNameSchema,
52
54
  frontmatterValue(frontmatter[1]!, "name") ?? entry.name,
@@ -28,11 +28,14 @@ const WORD_BOUNDARY = /[\s\-_./:]/;
28
28
  /** Order-preserving subsequence score, or null when a query character is missing; higher is better. */
29
29
  export function scoreMatch(name: string, query: string): number | null {
30
30
  if (query.length === 0) return 0;
31
+
31
32
  const lowerName = name.toLowerCase();
32
33
  const lowerQuery = query.toLowerCase();
33
34
 
34
35
  if (lowerName === lowerQuery) return 1000;
36
+
35
37
  if (lowerName.startsWith(lowerQuery)) return 500 - name.length;
38
+
36
39
  let score = 0;
37
40
  let cursor = 0;
38
41
  let run = 0;
@@ -41,9 +44,12 @@ export function scoreMatch(name: string, query: string): number | null {
41
44
  const found = lowerName.indexOf(character, cursor);
42
45
 
43
46
  if (found === -1) return null;
47
+
44
48
  run = found === cursor ? run + 1 : 0;
45
49
  score += found === cursor ? 5 + run : 1;
50
+
46
51
  if (found === 0 || WORD_BOUNDARY.test(lowerName[found - 1]!)) score += 10;
52
+
47
53
  score -= found - cursor;
48
54
  cursor = found + 1;
49
55
  }
@@ -53,6 +59,7 @@ export function scoreMatch(name: string, query: string): number | null {
53
59
 
54
60
  export function slashFilter(items: SlashItem[], query: string): SlashItem[] {
55
61
  if (query.length === 0) return items;
62
+
56
63
  const scored: Array<{ item: SlashItem; score: number }> = [];
57
64
 
58
65
  for (const item of items) {
@@ -113,6 +120,7 @@ export function skillReferenceRanges(
113
120
  const token = match[1]!;
114
121
 
115
122
  if (!names.has(token.slice(1))) continue;
123
+
116
124
  const start = match.index + match[0].length - token.length;
117
125
 
118
126
  ranges.push({ start, end: start + token.length });
@@ -61,6 +61,7 @@ const RESOLVED: Intent = { type: "status", message: "review submitted - read-onl
61
61
 
62
62
  function mutating(intent: Intent, context: ThreadNavContext): Intent {
63
63
  if (!context.isOwner) return READ_ONLY;
64
+
64
65
  if (context.resolved) return RESOLVED;
65
66
 
66
67
  return intent;
@@ -134,13 +135,17 @@ export function resolveSessionChord(
134
135
  ): Intent | null {
135
136
  if (key.name === "return" || key.name === "enter") {
136
137
  if (!(key.ctrl || key.meta || key.super)) return null;
138
+
137
139
  if (!context.isOwner) return READ_ONLY;
138
140
 
139
141
  return context.resolved ? null : { type: "openSubmit" };
140
142
  }
143
+
141
144
  if (!key.ctrl) return null;
145
+
142
146
  if (key.name === "e")
143
147
  return context.resolved ? RESOLVED : context.isOwner ? { type: "edit" } : READ_ONLY;
148
+
144
149
  if (key.name === "s") return context.isOwner ? { type: "share" } : READ_ONLY;
145
150
 
146
151
  return null;
@@ -34,6 +34,7 @@ function subscribeToKeypress(input: KeyInput, handler: KeyHandler): () => void {
34
34
 
35
35
  return () => {
36
36
  handlers.delete(handler);
37
+
37
38
  if (handlers.size > 0) return;
38
39
 
39
40
  input.off("keypress", notify);
@@ -53,6 +53,7 @@ function newlinePrefixCounts(text: string): number[] {
53
53
 
54
54
  for (let index = 0; index < text.length; index++) {
55
55
  counts[index] = seen;
56
+
56
57
  if (text[index] === "\n") seen += 1;
57
58
  }
58
59
  counts[text.length] = seen;
@@ -96,6 +97,7 @@ export const MarkdownThreadEditor = forwardRef<MarkdownEditorHandle, MarkdownThr
96
97
  const editor = editorRef.current;
97
98
 
98
99
  if (!editor) return;
100
+
99
101
  editor.editBuffer.setSyntaxStyle(markdownEditorStyle(theme).style);
100
102
  paintMarkdown(editor);
101
103
  setPosition((prior) => ({ ...prior, lineCount: editor.editBuffer.getLineCount() }));
@@ -123,6 +125,7 @@ export const MarkdownThreadEditor = forwardRef<MarkdownEditorHandle, MarkdownThr
123
125
  const editor = editorRef.current;
124
126
 
125
127
  if (!editor) return;
128
+
126
129
  paintMarkdown(editor);
127
130
  setPosition((prior) => ({ ...prior, lineCount: editor.editBuffer.getLineCount() }));
128
131
  }}
@@ -98,6 +98,7 @@ function highlightedByRow(): Map<number, string> {
98
98
 
99
99
  if (backdrop || (caretCell && underlined)) marked += span.text;
100
100
  }
101
+
101
102
  if (marked.length > 0) rows.set(rowIndex, marked);
102
103
  });
103
104
 
@@ -121,6 +122,7 @@ function caretCells(): Array<{ row: number; column: number; text: string }> {
121
122
  if (red === CARET_RGB[0] && green === CARET_RGB[1] && blue === CARET_RGB[2]) {
122
123
  cells.push({ row, column, text: span.text });
123
124
  }
125
+
124
126
  column += span.width;
125
127
  }
126
128
  });
@@ -158,6 +160,7 @@ async function framesUntilHighlight(expected: string, limit = 4): Promise<number
158
160
  // one scheduler turn (the input parser and React commit), then one frame
159
161
  await new Promise((resolve) => setTimeout(resolve, 0));
160
162
  await setup.renderOnce();
163
+
161
164
  if (highlightedText() === expected) return frame;
162
165
  }
163
166
 
@@ -103,7 +103,9 @@ function blockStyle(block: DisplayBlock, tokens: Theme): BlockStyle {
103
103
  /** The read-only color of an inline markdown role: links accent, code and removals dim, insertions green. */
104
104
  function roleForeground(role: RunRole, baseFg: string, tokens: Theme): string {
105
105
  if (role === "link") return tokens.blue;
106
+
106
107
  if (role === "ins") return tokens.green;
108
+
107
109
  if (role === "code" || role === "del") return tokens.textDim;
108
110
 
109
111
  return baseFg;
@@ -112,7 +114,9 @@ function roleForeground(role: RunRole, baseFg: string, tokens: Theme): string {
112
114
  /** The text attributes an inline role adds over a block's base attributes. */
113
115
  function roleAttributes(role: RunRole, baseAttributes: number): number {
114
116
  if (role === "strong") return baseAttributes | BOLD;
117
+
115
118
  if (role === "em") return baseAttributes | ITALIC;
119
+
116
120
  if (role === "strike" || role === "del") return baseAttributes | CUT;
117
121
 
118
122
  return baseAttributes;
@@ -288,6 +292,7 @@ export function ThreadView({
288
292
  const block = display[index];
289
293
 
290
294
  if (!block) return 1;
295
+
291
296
  const usable = viewWidth > 0 ? Math.max(1, viewWidth - 6) : 40;
292
297
  const lines = Math.max(1, Math.ceil(displayText(block).length / usable));
293
298
 
@@ -378,6 +383,7 @@ export function ThreadView({
378
383
  // measurement never adds a row and shifts the blocks below it out from under a pending click
379
384
  const headingRule = (block: DisplayBlock): React.ReactNode => {
380
385
  if (block.kind !== "h1" && block.kind !== "h2") return null;
386
+
381
387
  const width = Math.max(1, (viewWidth > 6 ? viewWidth : 46) - 6);
382
388
 
383
389
  return (
@@ -398,6 +404,7 @@ export function ThreadView({
398
404
  {custom}
399
405
  </box>
400
406
  );
407
+
401
408
  const block = display[blockIndex]!;
402
409
  const { baseFg, baseAttributes, marker } = blockStyle(block, tokens);
403
410
  // list items of one list stay tight; every other block sits a blank row below its neighbour
@@ -472,8 +479,11 @@ export function ThreadView({
472
479
  cards.length > 0,
473
480
  ),
474
481
  );
482
+
475
483
  if (isLastLine) lineRows.push(headingRule(block));
484
+
476
485
  lineRows.push(...cards);
486
+
477
487
  if (cards.length > 0 && !isLastLine) {
478
488
  lineRows.push(<box key={`gap-${lineIndex}`} style={{ height: 1 }} />);
479
489
  }
@@ -508,14 +518,17 @@ export function ThreadView({
508
518
  for (let index = 0; index < display.length; index++) {
509
519
  nodes.push(blockNodeFor(index));
510
520
  mountedRows += estimateBlockRows(index);
521
+
511
522
  if (mountedRows > terminalHeight + OVERSCAN_BLOCKS) break;
512
523
  }
513
524
 
514
525
  return nodes;
515
526
  }
527
+
516
528
  if (firstItem.start > 0) {
517
529
  nodes.push(<box key="spacer-above" style={{ height: firstItem.start }} />);
518
530
  }
531
+
519
532
  for (const item of virtual.items) nodes.push(blockNodeFor(item.index));
520
533
  const below = virtual.totalSize - lastItem.end;
521
534
 
@@ -36,6 +36,7 @@ export function runsFor(text: string, ranges: MarkRange[]): Run[] {
36
36
  const end = edges[index + 1]!;
37
37
 
38
38
  if (end <= start) continue;
39
+
39
40
  const covering = ranges.filter((range) => range.start <= start && end <= range.end);
40
41
 
41
42
  // the caret cell shows through a mark, so the head of a selection is visible
@@ -84,6 +85,7 @@ export function wrapLines(text: string, width: number): VisualLine[] {
84
85
  lines.push({ start: lineStart, end: lineEnd });
85
86
  lineStart = wordStart;
86
87
  }
88
+
87
89
  // a token wider than the line (a long identifier, a URL) breaks at the cell edge, so
88
90
  // every visual line stays one terminal row and nothing is clipped away
89
91
  while (wordEnd - lineStart > width) {
@@ -94,6 +96,7 @@ export function wrapLines(text: string, width: number): VisualLine[] {
94
96
  }
95
97
  lines.push({ start: lineStart, end: Math.max(lineEnd, lineStart) });
96
98
  }
99
+
97
100
  segmentStart = segmentEnd + 1;
98
101
  }
99
102
 
@@ -114,6 +117,7 @@ export function lineMarkRanges(ranges: MarkRange[], line: VisualLine): MarkRange
114
117
  /** Printable input that should reach a composer: one key, or a whole pasted run in one event. */
115
118
  export function printableSequence(key: KeyEvent): string | null {
116
119
  if (!key.sequence || key.ctrl || key.meta) return null;
120
+
117
121
  // reject any run carrying a control byte, so escape sequences and \r\t\b never seed a draft
118
122
  for (const character of key.sequence) if (character < " ") return null;
119
123
 
@@ -32,6 +32,7 @@ export function markdownEditorStyle(theme: Theme): MarkdownEditorStyle {
32
32
  const cached = markdownEditorStyleCache.get(theme);
33
33
 
34
34
  if (cached) return cached;
35
+
35
36
  const groupStyles = markdownGroupStyles(theme);
36
37
  const style = SyntaxStyle.fromStyles(groupStyles);
37
38
  const styleIds = new Map<MarkdownHighlightGroup, number>();
@@ -52,7 +52,9 @@ function splitTableRow(line: string): string[] {
52
52
  }
53
53
  }
54
54
  parts.push(cell);
55
+
55
56
  if (parts.length > 1 && parts[0] === "") parts.shift();
57
+
56
58
  if (parts.length > 1 && parts[parts.length - 1] === "") parts.pop();
57
59
 
58
60
  return parts.map((part) => part.trim());
@@ -78,6 +80,7 @@ function pad(text: string, width: number, align: ColumnAlign): string {
78
80
  const slack = Math.max(0, width - text.length);
79
81
 
80
82
  if (align === "right") return " ".repeat(slack) + text;
83
+
81
84
  if (align === "center") {
82
85
  const left = Math.floor(slack / 2);
83
86
 
@@ -132,6 +135,7 @@ export function parseFrontmatterRows(text: string): FrontmatterRow[] {
132
135
 
133
136
  for (const line of text.split("\n")) {
134
137
  if (line.trim() === "") continue;
138
+
135
139
  const match = /^([^:\s][^:]*):\s?(.*)$/.exec(line);
136
140
 
137
141
  if (match) {
@@ -163,6 +167,7 @@ function wrapCell(value: string, width: number): string[] {
163
167
  */
164
168
  export function layoutFrontmatterGrid(rows: FrontmatterRow[], maxWidth: number): GridLine[] {
165
169
  if (rows.length === 0) return [];
170
+
166
171
  const keyWidth = Math.max(3, ...rows.map((row) => row.key.length));
167
172
  // size the value column to its content, capped at the width left after the key column and borders,
168
173
  // so a short frontmatter table stays compact and a long value wraps instead of overflowing
@@ -183,6 +188,7 @@ export function layoutFrontmatterGrid(rows: FrontmatterRow[], maxWidth: number):
183
188
 
184
189
  rows.forEach((row, rowIndex) => {
185
190
  if (rowIndex > 0) lines.push(rule("├", "┼", "┤"));
191
+
186
192
  const valueLines = wrapCell(row.value, valueWidth);
187
193
 
188
194
  valueLines.forEach((valueLine, valueIndex) => {
@@ -50,6 +50,7 @@ function scanInlineTokens(text: string, base: number, into: MarkdownHighlightRan
50
50
  into.push({ start, end, group: "code" });
51
51
  continue;
52
52
  }
53
+
53
54
  // a link colors its brackets and href; the [] label stays plain unless it is an @-scope, matching how a
54
55
  // markdown source view paints links
55
56
  const closeBracket = token.indexOf("]");
@@ -14,6 +14,7 @@ export async function highlightThreadCodeBlocks(
14
14
  ): Promise<Map<number, SyntaxSpan[]>> {
15
15
  const jobs = display.flatMap((block, blockIndex) => {
16
16
  if (block.kind !== "code") return [];
17
+
17
18
  const filetype = filetypeFor(block.work?.lang ?? block.base?.lang);
18
19
 
19
20
  return filetype ? [{ blockIndex, filetype, source: renderedText(block) }] : [];
@@ -21,6 +22,7 @@ export async function highlightThreadCodeBlocks(
21
22
  const byBlock = new Map<number, SyntaxSpan[]>();
22
23
 
23
24
  if (jobs.length === 0) return byBlock;
25
+
24
26
  const client = getTreeSitterClient();
25
27
 
26
28
  await client.initialize();
@@ -28,6 +30,7 @@ export async function highlightThreadCodeBlocks(
28
30
  const result = await client.highlightOnce(job.source, job.filetype);
29
31
 
30
32
  if (!result.highlights) continue;
33
+
31
34
  const spans: SyntaxSpan[] = [];
32
35
  let offset = 0;
33
36
  const lines = job.source.split("\n");