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

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 (122) 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.test.tsx +1 -0
  13. package/src/app/App.tsx +67 -1
  14. package/src/app/App.visual.test.tsx +1 -0
  15. package/src/app/App.walk.test.tsx +1 -0
  16. package/src/app/App.workbench.test.tsx +1 -0
  17. package/src/app/app-screens.tsx +1 -0
  18. package/src/app/app-view-model.ts +7 -0
  19. package/src/app/components/WorkspacePanels.tsx +1 -0
  20. package/src/app/intent-dispatch.test.ts +1 -0
  21. package/src/app/intent-dispatch.ts +26 -0
  22. package/src/app/ready-signal.ts +2 -0
  23. package/src/app/run.ts +2 -0
  24. package/src/app/serve.test.ts +4 -0
  25. package/src/diff/components/AnnotatableFileView.tsx +1 -0
  26. package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
  27. package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
  28. package/src/diff/components/DiffContentView.tsx +25 -0
  29. package/src/diff/components/diff-content-view-layout.ts +3 -0
  30. package/src/diff/diff-fold.ts +4 -0
  31. package/src/diff/diff-hunk-curate.ts +3 -0
  32. package/src/diff/diff-intraline.ts +6 -0
  33. package/src/diff/diff-syntax.ts +8 -0
  34. package/src/diff/split-diff.ts +6 -0
  35. package/src/diff/view-diff.ts +18 -0
  36. package/src/diff/walk.ts +3 -0
  37. package/src/integrations/attribution.ts +2 -0
  38. package/src/integrations/client-extension-registry.ts +4 -0
  39. package/src/integrations/clipboard.ts +1 -0
  40. package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
  41. package/src/integrations/components/ShareDialog.tsx +28 -0
  42. package/src/integrations/editor.ts +3 -0
  43. package/src/integrations/github-identity.test.ts +1 -0
  44. package/src/integrations/github-identity.ts +1 -0
  45. package/src/integrations/herdr.ts +2 -0
  46. package/src/integrations/pasted-image.ts +2 -0
  47. package/src/integrations/share.ts +4 -0
  48. package/src/keyboard/components/HandleChipsInput.tsx +4 -0
  49. package/src/keyboard/components/SlashComposer.tsx +3 -0
  50. package/src/keyboard/key-bindings.ts +3 -0
  51. package/src/keyboard/keymap.ts +51 -0
  52. package/src/keyboard/skills.ts +2 -0
  53. package/src/keyboard/slash-palette.ts +8 -0
  54. package/src/keyboard/thread-chords.ts +5 -0
  55. package/src/keyboard/use-shared-keyboard.ts +1 -0
  56. package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
  57. package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
  58. package/src/markdown/components/ThreadView.tsx +13 -0
  59. package/src/markdown/mark-runs.ts +4 -0
  60. package/src/markdown/markdown-editor-syntax-style.ts +1 -0
  61. package/src/markdown/markdown-grid.ts +6 -0
  62. package/src/markdown/markdown-highlight.ts +1 -0
  63. package/src/markdown/thread-code-syntax.ts +3 -0
  64. package/src/markdown/view-plan.ts +52 -0
  65. package/src/performance/perf-report.ts +1 -0
  66. package/src/performance/perf-timings.ts +1 -0
  67. package/src/prototype/components/PrototypeContentView.tsx +17 -0
  68. package/src/prototype/prototype-browser.ts +11 -0
  69. package/src/settings/components/SettingsDialog.tsx +3 -0
  70. package/src/settings/config.test.ts +41 -0
  71. package/src/settings/config.ts +44 -0
  72. package/src/settings/use-settings-dialog.test.tsx +98 -0
  73. package/src/settings/use-settings-dialog.tsx +38 -1
  74. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  75. package/src/settings/use-thread-harness-setting.ts +86 -0
  76. package/src/stories/stories.test.tsx +4 -0
  77. package/src/stories/story.ts +6 -0
  78. package/src/terminal/ansi-screen.ts +5 -0
  79. package/src/terminal/components/terminal-pane.ts +12 -0
  80. package/src/terminal/ghostty-terminal.ts +10 -0
  81. package/src/terminal/kitty-image.ts +3 -0
  82. package/src/terminal/pty.ts +9 -0
  83. package/src/testing/test-support.test.ts +2 -0
  84. package/src/testing/test-support.ts +16 -0
  85. package/src/thread/agent-continuation.test.ts +2 -0
  86. package/src/thread/agent-transcript.ts +7 -0
  87. package/src/thread/components/AgentConfigControls.tsx +1 -0
  88. package/src/thread/components/AgentThreadPane.test.tsx +31 -0
  89. package/src/thread/components/AgentThreadPane.tsx +12 -0
  90. package/src/thread/components/ThreadTree.tsx +1 -0
  91. package/src/thread/components/thread-tree.test.ts +1 -0
  92. package/src/thread/thread-controller.curate.test.ts +2 -0
  93. package/src/thread/thread-controller.ts +123 -0
  94. package/src/thread/tree-view.ts +4 -0
  95. package/src/thread/use-thread-agent.test.tsx +1 -0
  96. package/src/thread/use-thread-agent.ts +5 -0
  97. package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
  98. package/src/ui/components/OverlayScrollbar.tsx +5 -0
  99. package/src/ui/components/PromptDialog.tsx +2 -0
  100. package/src/ui/components/RootOverlay.tsx +1 -0
  101. package/src/ui/components/ScrollArea.tsx +2 -0
  102. package/src/ui/components/primitives/Button.tsx +1 -0
  103. package/src/ui/components/primitives/Tabs.tsx +1 -0
  104. package/src/ui/components/primitives/Tree.tsx +4 -0
  105. package/src/ui/components/primitives/tree-model.ts +7 -0
  106. package/src/ui/components/syntax-highlight.ts +5 -0
  107. package/src/ui/components/truncate-title.ts +1 -0
  108. package/src/ui/use-frame-measure.ts +3 -0
  109. package/src/ui/use-terminal-virtualizer.ts +6 -0
  110. package/src/workbench/components/ChangesColumn.tsx +7 -0
  111. package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
  112. package/src/workbench/components/EditorGrid.tsx +8 -0
  113. package/src/workbench/components/FileContentsView.tsx +2 -0
  114. package/src/workbench/components/GridTabContent.tsx +2 -0
  115. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  116. package/src/workbench/components/ProjectTreeView.tsx +8 -0
  117. package/src/workbench/components/TreePane.tsx +2 -0
  118. package/src/workbench/components/editor-grid.ts +3 -0
  119. package/src/workbench/components/file-tree.ts +2 -0
  120. package/src/workbench/use-changes-workbench.ts +5 -0
  121. package/src/workbench/use-remember-layout.ts +2 -0
  122. package/src/workbench/use-repo-changes.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.102",
3
+ "version": "0.1.0-alpha.103",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.102",
31
- "@cueloop/extension-api": "0.1.0-alpha.102",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.102",
33
- "@cueloop/schema": "0.1.0-alpha.102",
30
+ "@cueloop/daemon": "0.1.0-alpha.103",
31
+ "@cueloop/extension-api": "0.1.0-alpha.103",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.103",
33
+ "@cueloop/schema": "0.1.0-alpha.103",
34
34
  "@opentui/core": "^0.5.14",
35
35
  "@opentui/keymap": "0.5.14",
36
36
  "@opentui/react": "^0.5.14",
@@ -47,6 +47,7 @@ export function lighten(hex: string, amount = 0.25): string {
47
47
  const channels = hex.match(/^#(..)(..)(..)$/);
48
48
 
49
49
  if (!channels) return hex;
50
+
50
51
  const lifted = channels
51
52
  .slice(1)
52
53
  .map((channel) => {
@@ -79,6 +79,7 @@ export function Composer({
79
79
  const editor = editorRef.current;
80
80
 
81
81
  if (!editor || !editor.focused || !looksLikeBinaryPaste(event.bytes)) return;
82
+
82
83
  event.preventDefault();
83
84
  pastedImageCount.current += 1;
84
85
  editor.editBuffer.insertText(imagePlaceholder(pastedImageCount.current));
@@ -100,6 +101,7 @@ export function Composer({
100
101
  const editor = editorRef.current;
101
102
 
102
103
  if (!editor) return;
104
+
103
105
  editor.cursorOffset = seed.length;
104
106
  editor.editBuffer.setSyntaxStyle(referenceStyleFor(tokens).style);
105
107
  paintReferences(editor);
@@ -133,6 +135,7 @@ export function Composer({
133
135
  const editor = editorRef.current;
134
136
 
135
137
  if (!editor) return;
138
+
136
139
  paintReferences(editor);
137
140
  setRows(composeRowCount(editor.plainText, editor.width));
138
141
  onInput(editor.plainText, editor.cursorOffset);
@@ -343,6 +346,7 @@ export function CommentRow({
343
346
  style={{ flexDirection: "column" }}
344
347
  onMouseDown={(event) => {
345
348
  if (!onFocus) return;
349
+
346
350
  event.stopPropagation();
347
351
  onFocus();
348
352
  }}
@@ -56,6 +56,7 @@ function ScrollMarkers({
56
56
  const isActive = (index: number): boolean => index === hoveredIndex || index === focusedIndex;
57
57
  const colorFor = (index: number): string => {
58
58
  if (isActive(index)) return tokens.text;
59
+
59
60
  if (hoveredIndex >= 0 && discussions.length >= 5 && Math.abs(index - hoveredIndex) === 1) {
60
61
  return tokens.textMuted;
61
62
  }
@@ -126,9 +127,11 @@ export function DiscussionMarkerRail({
126
127
 
127
128
  const preview = (): React.ReactNode => {
128
129
  if (hovered === null) return null;
130
+
129
131
  const discussion = discussions.find((candidate) => candidate.key === hovered.key);
130
132
 
131
133
  if (!discussion) return null;
134
+
132
135
  const quote = spanQuote(discussion.span);
133
136
  const lastComment = discussion.annotations.at(-1)!;
134
137
 
@@ -34,6 +34,7 @@ export function discussionsFrom(session: Thread, marks: Map<number, Mark[]>): Di
34
34
  for (const [displayIndex, blockMarks] of marks) {
35
35
  for (const mark of blockMarks) {
36
36
  if (!mark.annotationId || spanOf.has(mark.annotationId)) continue;
37
+
37
38
  spanOf.set(
38
39
  mark.annotationId,
39
40
  mark.span ?? {
@@ -69,6 +69,7 @@ export function wordIndexAt(text: string, char: number): number {
69
69
 
70
70
  for (let index = 0; index < words.length; index++) {
71
71
  if (char < words[index]!.start) return Math.max(0, index - 1);
72
+
72
73
  if (char < words[index]!.end) return index;
73
74
  }
74
75
 
@@ -91,6 +92,7 @@ export function wordRangeAt(text: string, char: number): CharRange | null {
91
92
  */
92
93
  export function positionAt(lines: LineGeometry[], x: number, y: number): TextPosition | null {
93
94
  if (lines.length === 0) return null;
95
+
94
96
  const ordered = lines.toSorted((left, right) => left.y - right.y || left.x - right.x);
95
97
  const onRow = ordered.find((line) => line.y === y);
96
98
 
@@ -99,9 +101,11 @@ export function positionAt(lines: LineGeometry[], x: number, y: number): TextPos
99
101
 
100
102
  return { blockIndex: onRow.blockIndex, char: onRow.start + column };
101
103
  }
104
+
102
105
  const above = ordered.findLast((line) => line.y < y);
103
106
 
104
107
  if (above) return { blockIndex: above.blockIndex, char: above.end };
108
+
105
109
  const first = ordered[0]!;
106
110
 
107
111
  return { blockIndex: first.blockIndex, char: first.start };
@@ -156,6 +160,7 @@ export function spanRangeInBlock(
156
160
  textLength: number,
157
161
  ): CharRange | null {
158
162
  if (blockIndex < span.start.blockIndex || blockIndex > span.end.blockIndex) return null;
163
+
159
164
  const start = blockIndex === span.start.blockIndex ? span.start.char : 0;
160
165
  const end = blockIndex === span.end.blockIndex ? Math.min(span.end.char, textLength) : textLength;
161
166
 
@@ -189,6 +194,7 @@ export function snapSpanToWords(span: TextSpan, textOf: (blockIndex: number) =>
189
194
  end: { blockIndex: span.end.blockIndex, char: snapped.end },
190
195
  };
191
196
  }
197
+
192
198
  const startText = textOf(span.start.blockIndex);
193
199
  const endText = textOf(span.end.blockIndex);
194
200
 
@@ -176,6 +176,7 @@ function firstAnnotatable(source: LineSource): number {
176
176
  /** The visual-row direction requested by an arrow or readline-style caret key. */
177
177
  function verticalCaretDelta(key: KeyEvent): -1 | 0 | 1 {
178
178
  if (key.name === "up" || (key.ctrl && key.name === "p")) return -1;
179
+
179
180
  if (key.name === "down" || (key.ctrl && key.name === "n")) return 1;
180
181
 
181
182
  return 0;
@@ -228,6 +229,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
228
229
 
229
230
  useEffect(() => {
230
231
  if (requestedBlock === undefined) return;
232
+
231
233
  const next = { blockIndex: requestedBlock.blockIndex, char: 0 };
232
234
 
233
235
  setCursor(requestedBlock.blockIndex);
@@ -259,7 +261,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
259
261
  const { blockIndex } = caret.head;
260
262
 
261
263
  if (source.count === 0) return;
264
+
262
265
  if (blockIndex < source.count && source.annotatable(blockIndex)) return;
266
+
263
267
  const clamped = Math.min(blockIndex, source.count - 1);
264
268
  const next = source.annotatable(clamped)
265
269
  ? clamped
@@ -290,6 +294,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
290
294
  const focusedDiscussion = getFocusedDiscussion();
291
295
  const setFocusedDiscussion = (key: string | null): void => {
292
296
  if (onFocusAnnotation === undefined) return setLocalFocus(key === null ? null : { key });
297
+
293
298
  onFocusAnnotation(discussions.find((discussion) => discussion.key === key)?.rootId);
294
299
  };
295
300
  const focusSavedComment = (annotationId: string, key: string): void => {
@@ -339,7 +344,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
339
344
  /** Agent comments require a held selection; legacy surfaces may use the caret word. */
340
345
  const caretSpan = (): TextSpan | null => {
341
346
  if (heldSpan) return heldSpan;
347
+
342
348
  if (onInvoke) return isPromptBlock?.(head.blockIndex) ? { start: head, end: head } : null;
349
+
343
350
  const word = wordRangeAt(blockText(head.blockIndex), head.char);
344
351
 
345
352
  return word
@@ -389,15 +396,18 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
389
396
  return (delta, top, bottom) => {
390
397
  if (delta === 0 || oldHeadY === undefined || compose || focusedDiscussion || caretIsSelection)
391
398
  return;
399
+
392
400
  const shiftedHeadY = oldHeadY - delta;
393
401
 
394
402
  if (shiftedHeadY >= top && shiftedHeadY <= bottom) return;
403
+
395
404
  const visible = lines
396
405
  .filter((line) => line.y - delta >= top && line.y - delta <= bottom)
397
406
  .toSorted((left, right) => left.y - right.y || left.x - right.x);
398
407
  const target = delta < 0 ? visible.at(-1) : visible[0];
399
408
 
400
409
  if (!target) return;
410
+
401
411
  const next = { blockIndex: target.blockIndex, char: target.start };
402
412
 
403
413
  flushSync(() => {
@@ -424,6 +434,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
424
434
  const target = currentIndex === -1 ? undefined : lines[currentIndex + direction];
425
435
 
426
436
  if (!target) return null;
437
+
427
438
  const current = lines[currentIndex]!;
428
439
  const column = Math.max(0, position.char - current.start);
429
440
 
@@ -439,8 +450,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
439
450
  const openCompose = (state: ComposeState): void => {
440
451
  // a view-only surface (a non-diff thread's live diff) never opens a draft
441
452
  if (!commentsEnabled) return;
453
+
442
454
  if (observer) return onObserverBlocked?.("observer");
455
+
443
456
  if (resolved) return onObserverBlocked?.("resolved");
457
+
444
458
  composerReady.current = false;
445
459
  readComposerText.current = null;
446
460
  composeRef.current = state;
@@ -476,16 +490,22 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
476
490
  const prompt = target?.kind === "prompt";
477
491
 
478
492
  if (prompt && !invoke) return;
493
+
479
494
  closeCompose();
480
495
  collapseCaret();
496
+
481
497
  if (!target || body.trim().length === 0) return;
498
+
482
499
  if (target.editAnnotationId !== null) {
483
500
  onUpdateAnnotation(target.editAnnotationId, body);
501
+
484
502
  if (target.discussionKey) focusSavedComment(target.editAnnotationId, target.discussionKey);
503
+
485
504
  if (invoke) onInvoke?.(target.editAnnotationId);
486
505
 
487
506
  return;
488
507
  }
508
+
489
509
  if (target.discussionKey !== null) {
490
510
  const discussion = discussions.find((candidate) => candidate.key === target.discussionKey);
491
511
 
@@ -493,11 +513,13 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
493
513
  const commentId = onReply(discussion.rootId, body);
494
514
 
495
515
  if (commentId) focusSavedComment(commentId, discussion.key);
516
+
496
517
  if (invoke && commentId) onInvoke?.(commentId);
497
518
 
498
519
  return;
499
520
  }
500
521
  }
522
+
501
523
  const span = target.span ?? caretSpan();
502
524
 
503
525
  if (span) {
@@ -515,6 +537,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
515
537
  if (!prompt && commentId) {
516
538
  focusSavedComment(commentId, spanKey(span));
517
539
  }
540
+
518
541
  if (invoke && (prompt || commentId)) onInvoke?.(commentId || undefined);
519
542
  };
520
543
  /** A new discussion on the typing anchor; the card renders under the span's last block. */
@@ -526,6 +549,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
526
549
  agentInputTarget(Boolean(isPromptBlock?.(head.blockIndex)), Boolean(heldSpan)) === "none"
527
550
  )
528
551
  return;
552
+
529
553
  if (isPromptBlock?.(head.blockIndex) && span) {
530
554
  openCompose({
531
555
  kind: "prompt",
@@ -545,6 +569,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
545
569
  editAnnotationId: null,
546
570
  });
547
571
  }
572
+
548
573
  if (isPromptBlock?.(head.blockIndex)) promptDraft.current = "";
549
574
  };
550
575
 
@@ -554,14 +579,17 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
554
579
  const target = composeRef.current;
555
580
 
556
581
  if (!target) return;
582
+
557
583
  if (target.kind === "prompt") {
558
584
  promptDraft.current = composeTextRef.current;
559
585
 
560
586
  return closeCompose();
561
587
  }
588
+
562
589
  if (isStandaloneSlashQuery(composeText) || composeText.trim().length === 0) {
563
590
  return closeCompose();
564
591
  }
592
+
565
593
  saveComment(composeText, false);
566
594
  };
567
595
 
@@ -570,6 +598,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
570
598
  const drag = dragging.current;
571
599
 
572
600
  if (!drag) return;
601
+
573
602
  let next = { head: pointer, anchor: drag.anchor };
574
603
 
575
604
  if (drag.wordMode) {
@@ -584,7 +613,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
584
613
  : { anchor: snapped.end, head: snapped.start };
585
614
  }
586
615
  }
616
+
587
617
  if (comparePositions(next.head, drag.head) === 0) return;
618
+
588
619
  drag.head = next.head;
589
620
  setCursor(next.head.blockIndex);
590
621
  setCaret({ head: next.head, anchor: next.anchor });
@@ -597,19 +628,23 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
597
628
  // on screen - the mark follows the pointer across blocks.
598
629
  const handleRootDrag = (event: TerminalMouseEvent): void => {
599
630
  if (!dragging.current) return;
631
+
600
632
  dragPointer.current = { x: event.x, y: event.y };
601
633
  const position = positionAt(allGeometry(), event.x, event.y);
602
634
 
603
635
  if (position) extendSelectionTo(position);
636
+
604
637
  if (edgeScrollTimer.current === null) {
605
638
  edgeScrollTimer.current = setInterval(() => {
606
639
  const pointer = dragPointer.current;
607
640
  const viewport = dragViewportRef.current?.();
608
641
 
609
642
  if (!dragging.current || !pointer || !viewport) return;
643
+
610
644
  const direction = pointer.y <= viewport.top ? -1 : pointer.y >= viewport.bottom ? 1 : 0;
611
645
 
612
646
  if (direction === 0 || !viewport.scrollBy(direction)) return;
647
+
613
648
  const next = positionAt(allGeometry(), pointer.x, pointer.y);
614
649
 
615
650
  if (next) extendSelectionTo(next);
@@ -619,7 +654,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
619
654
  const endDrag = (): void => {
620
655
  dragging.current = null;
621
656
  dragPointer.current = null;
657
+
622
658
  if (edgeScrollTimer.current !== null) clearInterval(edgeScrollTimer.current);
659
+
623
660
  edgeScrollTimer.current = null;
624
661
  };
625
662
 
@@ -629,6 +666,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
629
666
  const target = discussions.find((discussion) => discussion.key === key);
630
667
 
631
668
  if (!target) return;
669
+
632
670
  blurSaveCompose();
633
671
  setCursor(target.blockIndex);
634
672
  setFocusedDiscussion(key);
@@ -638,6 +676,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
638
676
 
639
677
  const handleSlashKey = (key: KeyEvent, activeCompose: ComposeState): boolean => {
640
678
  if (!slashActive || slashItems.length === 0) return false;
679
+
641
680
  const selected = Math.min(slashIndex, slashItems.length - 1);
642
681
 
643
682
  if (key.name === "up") {
@@ -645,11 +684,13 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
645
684
 
646
685
  return true;
647
686
  }
687
+
648
688
  if (key.name === "down") {
649
689
  setSlashIndex(Math.min(slashItems.length - 1, selected + 1));
650
690
 
651
691
  return true;
652
692
  }
693
+
653
694
  if (key.name === "return" || key.name === "tab") {
654
695
  const inserted = insertSlashItem(composeText, caretOffset, slashItems[selected]!.name);
655
696
 
@@ -666,8 +707,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
666
707
  const handlePremountKey = (key: KeyEvent, activeCompose: ComposeState): void => {
667
708
  if (key.name === "return") {
668
709
  if (key.super && onInvoke) return;
710
+
669
711
  if (key.meta || key.ctrl || key.super)
670
712
  return saveComment(activeCompose.seed, isAgentInvokeKey(key, onInvoke));
713
+
671
714
  const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
672
715
 
673
716
  composeRef.current = grown;
@@ -676,6 +719,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
676
719
 
677
720
  return setCompose(grown);
678
721
  }
722
+
679
723
  const sequence = printableSequence(key);
680
724
 
681
725
  if (sequence) {
@@ -694,11 +738,13 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
694
738
 
695
739
  return;
696
740
  }
741
+
697
742
  if (isAgentInvokeKey(key, onInvoke)) {
698
743
  key.preventDefault();
699
744
 
700
745
  return saveComment(readComposerText.current?.() ?? activeCompose.seed, true);
701
746
  }
747
+
702
748
  // the textarea owns every key while open; the view takes dismiss (which
703
749
  // also releases the discussion focus), the slash palette, and pre-mount input
704
750
  if (key.name === "escape") {
@@ -706,6 +752,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
706
752
 
707
753
  return closeCompose();
708
754
  }
755
+
709
756
  // backspace on an already empty draft undoes it: the card dissolves
710
757
  // and the caret sits back on the still-held selection, ready to re-type
711
758
  // (an edit of an existing comment is never deleted this way)
@@ -716,7 +763,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
716
763
  ) {
717
764
  return closeCompose();
718
765
  }
766
+
719
767
  if (handleSlashKey(key, activeCompose)) return;
768
+
720
769
  if (!composerReady.current) handlePremountKey(key, activeCompose);
721
770
  };
722
771
 
@@ -727,6 +776,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
727
776
 
728
777
  for (const discussion of discussions) {
729
778
  if (discussion.blockIndex !== cursor) continue;
779
+
730
780
  if (next.has(discussion.key)) next.delete(discussion.key);
731
781
  else next.add(discussion.key);
732
782
  }
@@ -743,8 +793,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
743
793
 
744
794
  return true;
745
795
  }
796
+
746
797
  if ((key.super || key.meta || key.ctrl) && (key.name === "[" || key.name === "]")) {
747
798
  if (discussions.length === 0) return true;
799
+
748
800
  const currentIndex = discussions.findIndex(
749
801
  (discussion) => discussion.key === focusedDiscussion,
750
802
  );
@@ -759,6 +811,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
759
811
 
760
812
  return true;
761
813
  }
814
+
762
815
  // enter replies into the focused discussion, else the cursor block's discussion
763
816
  if (key.name === "return") {
764
817
  const replyTarget =
@@ -795,15 +848,19 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
795
848
  };
796
849
 
797
850
  if (target !== null) return moveTo(head.blockIndex, target);
851
+
798
852
  if (extend && head.char !== (delta === 1 ? text.length : 0)) {
799
853
  // the block edge first, then the next step crosses into the neighbour
800
854
  return moveTo(head.blockIndex, delta === 1 ? text.length : 0);
801
855
  }
856
+
802
857
  // the neighbour is the next block the caret may rest on: headers are skipped
803
858
  const neighbour = nearestAnnotatable(source, head.blockIndex, delta);
804
859
 
805
860
  if (neighbour === head.blockIndex) return;
861
+
806
862
  if (delta === 1) return moveTo(neighbour, 0);
863
+
807
864
  const previousText = blockText(neighbour);
808
865
 
809
866
  moveTo(
@@ -817,6 +874,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
817
874
  const visualTarget = verticalTextPosition(head, vertical);
818
875
 
819
876
  if (!visualTarget && nextBlock === head.blockIndex) return;
877
+
820
878
  const nextHead = visualTarget?.position ?? {
821
879
  blockIndex: nextBlock,
822
880
  char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
@@ -840,11 +898,13 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
840
898
 
841
899
  return true;
842
900
  }
901
+
843
902
  if (key.name === "right" || (key.ctrl && key.name === "l")) {
844
903
  moveCaretHorizontal(1, Boolean(key.shift || key.meta || key.ctrl));
845
904
 
846
905
  return true;
847
906
  }
907
+
848
908
  if (key.name === "left" || (key.ctrl && key.name === "h")) {
849
909
  moveCaretHorizontal(-1, Boolean(key.shift || key.meta || key.ctrl));
850
910
 
@@ -859,6 +919,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
859
919
  const sequence = printableSequence(key);
860
920
 
861
921
  if (!sequence) return;
922
+
862
923
  const discussion = discussions.find((candidate) => candidate.key === focusedDiscussion);
863
924
 
864
925
  if (discussion) {
@@ -874,6 +935,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
874
935
  editAnnotationId: editingOwn ? last.id : null,
875
936
  });
876
937
  }
938
+
877
939
  openNewCompose(sequence);
878
940
  };
879
941
 
@@ -889,31 +951,41 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
889
951
 
890
952
  useSharedKeyboard((key) => {
891
953
  if (suspended) return;
954
+
892
955
  if (key.ctrl && key.name === "q") return onExit();
956
+
893
957
  const activeCompose = composeRef.current;
894
958
 
895
959
  if (activeCompose) return handleComposeKey(key, activeCompose);
960
+
896
961
  if (isAgentInvokeKey(key, onInvoke)) {
897
962
  key.preventDefault();
898
963
 
899
964
  return invokeFocusedComment();
900
965
  }
966
+
901
967
  if (key.name === "escape") {
902
968
  // from type mode esc only enters nav, so a held mark survives for `c`
903
969
  if (!navModeRef.current) return setNavMode(true);
970
+
904
971
  if (focusedDiscussion !== null) setFocusedDiscussion(null);
905
972
 
906
973
  return collapseCaret();
907
974
  }
975
+
908
976
  if (navModeRef.current) {
909
977
  if (handleCaretKey(key)) return;
978
+
910
979
  if (key.name === "z") return toggleFoldAtCursor();
980
+
911
981
  if (onNavCommand?.(key, heldSpan)) return;
982
+
912
983
  if (key.name === "c") {
913
984
  setNavMode(false);
914
985
 
915
986
  return openNewCompose("");
916
987
  }
988
+
917
989
  if (printableSequence(key)) {
918
990
  setNavMode(false);
919
991
  startTyping(key);
@@ -921,8 +993,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
921
993
 
922
994
  return;
923
995
  }
996
+
924
997
  if (handleDiscussionVerb(key)) return;
998
+
925
999
  if (handleCaretKey(key)) return;
1000
+
926
1001
  startTyping(key);
927
1002
  });
928
1003
 
@@ -1006,6 +1081,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1006
1081
  focused={focusedDiscussion === discussion.key}
1007
1082
  onFocus={() => {
1008
1083
  if (composeRef.current?.discussionKey === discussion.key) return;
1084
+
1009
1085
  blurSaveCompose();
1010
1086
  setFocusedDiscussion(discussion.key);
1011
1087
  setCursor(discussion.blockIndex);
@@ -1024,8 +1100,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1024
1100
  };
1025
1101
 
1026
1102
  for (const discussion of discussions) pushSpan(discussion.span);
1103
+
1027
1104
  if (compose) pushSpan(compose.span);
1105
+
1028
1106
  if (caretIsSelection && !compose) pushSpan(heldSpan);
1107
+
1029
1108
  // the idle caret cell sits at the head, also at the end of a held mark;
1030
1109
  // an open card takes the cursor with it, so no cell is painted then
1031
1110
  if (blockIndex === head.blockIndex && !compose && textLength > 0) {
@@ -1063,6 +1142,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1063
1142
 
1064
1143
  return;
1065
1144
  }
1145
+
1066
1146
  nodes.push(
1067
1147
  <DiscussionCard
1068
1148
  key="compose-new"
@@ -1079,6 +1159,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1079
1159
 
1080
1160
  for (const discussion of discussions) {
1081
1161
  if (discussion.blockIndex !== blockIndex || !endsInLine(discussion.span.end.char)) continue;
1162
+
1082
1163
  if (
1083
1164
  composePending &&
1084
1165
  composeStart &&
@@ -1087,15 +1168,19 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1087
1168
  pushComposeCard();
1088
1169
  composePending = false;
1089
1170
  }
1171
+
1090
1172
  const composingHere = Boolean(composeHere && compose.discussionKey === discussion.key);
1091
1173
 
1092
1174
  if (folded.has(discussion.key) && !composingHere) {
1093
1175
  nodes.push(foldedSummaryFor(discussion));
1094
1176
  continue;
1095
1177
  }
1178
+
1096
1179
  nodes.push(discussionCardFor(discussion, composingHere));
1180
+
1097
1181
  if (composingHere) nodes.push(paletteNode);
1098
1182
  }
1183
+
1099
1184
  if (composePending) pushComposeCard();
1100
1185
 
1101
1186
  return nodes;
@@ -1143,6 +1228,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1143
1228
  active.blockIndex === blockIndex
1144
1229
  )
1145
1230
  return;
1231
+
1146
1232
  const position = { blockIndex, char: 0 };
1147
1233
 
1148
1234
  openCompose({
@@ -1161,12 +1247,15 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1161
1247
  const restoredPrompt = useRef<number | null>(null);
1162
1248
  const restorePrompt = useEffectEvent(() => {
1163
1249
  if (!promptRestoreRequest || suspended || !onInvoke) return;
1250
+
1164
1251
  if (restoredPrompt.current === promptRestoreRequest.id) return;
1252
+
1165
1253
  restoredPrompt.current = promptRestoreRequest.id;
1166
1254
  const active = composeRef.current;
1167
1255
  const newerDraft = active?.kind === "prompt" ? composeTextRef.current : promptDraft.current;
1168
1256
 
1169
1257
  if (active?.kind !== "prompt") blurSaveCompose();
1258
+
1170
1259
  promptDraft.current = [promptRestoreRequest.text, newerDraft].filter(Boolean).join("\n");
1171
1260
  focusPrompt(source.count - 1);
1172
1261
  });
@@ -1177,10 +1266,14 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1177
1266
  const focusedReply = useRef<string | null>(null);
1178
1267
  const continueConversation = useEffectEvent(() => {
1179
1268
  if (!promptFocusRequest || suspended || !onInvoke) return;
1269
+
1180
1270
  if (focusedReply.current === promptFocusRequest.replyId) return;
1271
+
1181
1272
  focusedReply.current = promptFocusRequest.replyId;
1273
+
1182
1274
  // An arriving answer cannot replace a draft or a marked passage under review.
1183
1275
  if (composeRef.current || heldSpan || dragging.current || promptDraft.current) return;
1276
+
1184
1277
  focusPrompt(promptFocusRequest.blockIndex);
1185
1278
  });
1186
1279
 
@@ -1189,6 +1282,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1189
1282
  }, [promptFocusRequest?.replyId, promptFocusRequest?.blockIndex, suspended]);
1190
1283
  const onPromptMouseDown = (event: TerminalMouseEvent): void => {
1191
1284
  if (!onInvoke || suspended) return;
1285
+
1192
1286
  const blockIndex = source.count - 1;
1193
1287
  const geometry = allGeometry();
1194
1288
  const promptLine = geometry.find((entry) => isPromptBlock?.(entry.blockIndex));
@@ -1206,9 +1300,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1206
1300
  (viewport && event.y >= viewport.bottom)
1207
1301
  )
1208
1302
  return;
1303
+
1209
1304
  if (composeRef.current?.kind === "prompt") {
1210
1305
  promptDraft.current = composeTextRef.current;
1211
1306
  } else blurSaveCompose();
1307
+
1212
1308
  endDrag();
1213
1309
  focusPrompt(blockIndex);
1214
1310
  };
@@ -1218,6 +1314,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1218
1314
  const pressed = positionAt(allGeometry(), event.x, event.y);
1219
1315
 
1220
1316
  if (!pressed || !source.annotatable(pressed.blockIndex)) return;
1317
+
1221
1318
  setNavMode(false);
1222
1319
  const stamp = { time: Date.now(), x: event.x, y: event.y };
1223
1320
  const wordMode = isDoubleClick(lastClick.current, stamp);