@cueloop/client 0.1.0-alpha.101 → 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 +14 -1
  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 +154 -19
  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 +72 -6
  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 +3 -2
  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 +28 -3
  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 +16 -3
  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 +18 -10
  87. package/src/thread/components/AgentConfigControls.tsx +1 -0
  88. package/src/thread/components/AgentThreadPane.test.tsx +257 -1
  89. package/src/thread/components/AgentThreadPane.tsx +48 -16
  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
@@ -47,7 +47,7 @@ import {
47
47
  slashItemsFrom,
48
48
  } from "../keyboard/slash-palette";
49
49
  import { SlashSkillsContext } from "../keyboard/skills";
50
- import { discussionsFrom, type Discussion } from "./discussions";
50
+ import { discussionsFrom, spanKey, type Discussion } from "./discussions";
51
51
  import {
52
52
  CommentRow,
53
53
  Composer,
@@ -107,13 +107,13 @@ export interface AnnotationSurfaceOptions {
107
107
  ) => boolean;
108
108
  focusedAnnotationId?: string;
109
109
  onFocusAnnotation?: (annotationId: string | undefined) => void;
110
- onAnnotate: (span: TextSpan, body: string) => void;
111
- onReply: (rootAnnotationId: string, body: string) => void;
110
+ onAnnotate: (span: TextSpan, body: string) => string | void;
111
+ onReply: (rootAnnotationId: string, body: string) => string | void;
112
112
  onUpdateAnnotation: (id: string, body: string) => void;
113
113
  isAnnotationReadOnly?: (id: string) => boolean;
114
114
  annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
115
115
  requestedBlock?: { blockIndex: number };
116
- onInvoke?: () => void;
116
+ onInvoke?: (commentId?: string) => void;
117
117
  isPromptBlock?: (blockIndex: number) => boolean;
118
118
  promptFocusRequest?: PromptFocusRequest;
119
119
  promptRestoreRequest?: PromptRestoreRequest;
@@ -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
@@ -277,17 +281,26 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
277
281
  // app owns it (onFocusAnnotation), the focused discussion is the one holding
278
282
  // the focused card and focusing here names the root comment; standalone, it
279
283
  // is local state
280
- const [localFocus, setLocalFocus] = useState<string | null>(null);
281
- const focusedDiscussion =
282
- onFocusAnnotation === undefined
283
- ? localFocus
284
- : (discussions.find((discussion) =>
285
- discussion.annotations.some((annotation) => annotation.id === focusedAnnotationId),
286
- )?.key ?? null);
284
+ const [localFocus, setLocalFocus] = useState<{ key: string; annotationId?: string } | null>(null);
285
+ const getFocusedDiscussion = (): string | null => {
286
+ if (onFocusAnnotation === undefined) return localFocus?.key ?? null;
287
+
288
+ return (
289
+ discussions.find((discussion) =>
290
+ discussion.annotations.some((annotation) => annotation.id === focusedAnnotationId),
291
+ )?.key ?? null
292
+ );
293
+ };
294
+ const focusedDiscussion = getFocusedDiscussion();
287
295
  const setFocusedDiscussion = (key: string | null): void => {
288
- if (onFocusAnnotation === undefined) return setLocalFocus(key);
296
+ if (onFocusAnnotation === undefined) return setLocalFocus(key === null ? null : { key });
297
+
289
298
  onFocusAnnotation(discussions.find((discussion) => discussion.key === key)?.rootId);
290
299
  };
300
+ const focusSavedComment = (annotationId: string, key: string): void => {
301
+ if (onFocusAnnotation) onFocusAnnotation(annotationId);
302
+ else setLocalFocus({ key, annotationId });
303
+ };
291
304
  const [folded, setFolded] = useState<Set<string>>(new Set());
292
305
  const [composeText, setComposeText] = useState("");
293
306
  const [composerFocusRequest, setComposerFocusRequest] = useState(0);
@@ -331,7 +344,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
331
344
  /** Agent comments require a held selection; legacy surfaces may use the caret word. */
332
345
  const caretSpan = (): TextSpan | null => {
333
346
  if (heldSpan) return heldSpan;
347
+
334
348
  if (onInvoke) return isPromptBlock?.(head.blockIndex) ? { start: head, end: head } : null;
349
+
335
350
  const word = wordRangeAt(blockText(head.blockIndex), head.char);
336
351
 
337
352
  return word
@@ -381,15 +396,18 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
381
396
  return (delta, top, bottom) => {
382
397
  if (delta === 0 || oldHeadY === undefined || compose || focusedDiscussion || caretIsSelection)
383
398
  return;
399
+
384
400
  const shiftedHeadY = oldHeadY - delta;
385
401
 
386
402
  if (shiftedHeadY >= top && shiftedHeadY <= bottom) return;
403
+
387
404
  const visible = lines
388
405
  .filter((line) => line.y - delta >= top && line.y - delta <= bottom)
389
406
  .toSorted((left, right) => left.y - right.y || left.x - right.x);
390
407
  const target = delta < 0 ? visible.at(-1) : visible[0];
391
408
 
392
409
  if (!target) return;
410
+
393
411
  const next = { blockIndex: target.blockIndex, char: target.start };
394
412
 
395
413
  flushSync(() => {
@@ -416,6 +434,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
416
434
  const target = currentIndex === -1 ? undefined : lines[currentIndex + direction];
417
435
 
418
436
  if (!target) return null;
437
+
419
438
  const current = lines[currentIndex]!;
420
439
  const column = Math.max(0, position.char - current.start);
421
440
 
@@ -431,8 +450,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
431
450
  const openCompose = (state: ComposeState): void => {
432
451
  // a view-only surface (a non-diff thread's live diff) never opens a draft
433
452
  if (!commentsEnabled) return;
453
+
434
454
  if (observer) return onObserverBlocked?.("observer");
455
+
435
456
  if (resolved) return onObserverBlocked?.("resolved");
457
+
436
458
  composerReady.current = false;
437
459
  readComposerText.current = null;
438
460
  composeRef.current = state;
@@ -468,31 +490,55 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
468
490
  const prompt = target?.kind === "prompt";
469
491
 
470
492
  if (prompt && !invoke) return;
493
+
471
494
  closeCompose();
472
495
  collapseCaret();
496
+
473
497
  if (!target || body.trim().length === 0) return;
498
+
474
499
  if (target.editAnnotationId !== null) {
475
500
  onUpdateAnnotation(target.editAnnotationId, body);
476
- if (invoke) onInvoke?.();
501
+
502
+ if (target.discussionKey) focusSavedComment(target.editAnnotationId, target.discussionKey);
503
+
504
+ if (invoke) onInvoke?.(target.editAnnotationId);
477
505
 
478
506
  return;
479
507
  }
508
+
480
509
  if (target.discussionKey !== null) {
481
510
  const discussion = discussions.find((candidate) => candidate.key === target.discussionKey);
482
511
 
483
512
  if (discussion) {
484
- onReply(discussion.rootId, body);
485
- if (invoke) onInvoke?.();
513
+ const commentId = onReply(discussion.rootId, body);
514
+
515
+ if (commentId) focusSavedComment(commentId, discussion.key);
516
+
517
+ if (invoke && commentId) onInvoke?.(commentId);
486
518
 
487
519
  return;
488
520
  }
489
521
  }
522
+
490
523
  const span = target.span ?? caretSpan();
491
524
 
492
525
  if (span) {
493
- onAnnotate(span, body);
494
- if (invoke) onInvoke?.();
526
+ const commentId = onAnnotate(span, body);
527
+
528
+ completeSavedComment(span, commentId, prompt, invoke);
529
+ }
530
+ };
531
+ const completeSavedComment = (
532
+ span: TextSpan,
533
+ commentId: string | void,
534
+ prompt: boolean,
535
+ invoke: boolean,
536
+ ): void => {
537
+ if (!prompt && commentId) {
538
+ focusSavedComment(commentId, spanKey(span));
495
539
  }
540
+
541
+ if (invoke && (prompt || commentId)) onInvoke?.(commentId || undefined);
496
542
  };
497
543
  /** A new discussion on the typing anchor; the card renders under the span's last block. */
498
544
  const openNewCompose = (seed: string): void => {
@@ -503,6 +549,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
503
549
  agentInputTarget(Boolean(isPromptBlock?.(head.blockIndex)), Boolean(heldSpan)) === "none"
504
550
  )
505
551
  return;
552
+
506
553
  if (isPromptBlock?.(head.blockIndex) && span) {
507
554
  openCompose({
508
555
  kind: "prompt",
@@ -522,6 +569,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
522
569
  editAnnotationId: null,
523
570
  });
524
571
  }
572
+
525
573
  if (isPromptBlock?.(head.blockIndex)) promptDraft.current = "";
526
574
  };
527
575
 
@@ -531,14 +579,17 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
531
579
  const target = composeRef.current;
532
580
 
533
581
  if (!target) return;
582
+
534
583
  if (target.kind === "prompt") {
535
584
  promptDraft.current = composeTextRef.current;
536
585
 
537
586
  return closeCompose();
538
587
  }
588
+
539
589
  if (isStandaloneSlashQuery(composeText) || composeText.trim().length === 0) {
540
590
  return closeCompose();
541
591
  }
592
+
542
593
  saveComment(composeText, false);
543
594
  };
544
595
 
@@ -547,6 +598,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
547
598
  const drag = dragging.current;
548
599
 
549
600
  if (!drag) return;
601
+
550
602
  let next = { head: pointer, anchor: drag.anchor };
551
603
 
552
604
  if (drag.wordMode) {
@@ -561,7 +613,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
561
613
  : { anchor: snapped.end, head: snapped.start };
562
614
  }
563
615
  }
616
+
564
617
  if (comparePositions(next.head, drag.head) === 0) return;
618
+
565
619
  drag.head = next.head;
566
620
  setCursor(next.head.blockIndex);
567
621
  setCaret({ head: next.head, anchor: next.anchor });
@@ -574,19 +628,23 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
574
628
  // on screen - the mark follows the pointer across blocks.
575
629
  const handleRootDrag = (event: TerminalMouseEvent): void => {
576
630
  if (!dragging.current) return;
631
+
577
632
  dragPointer.current = { x: event.x, y: event.y };
578
633
  const position = positionAt(allGeometry(), event.x, event.y);
579
634
 
580
635
  if (position) extendSelectionTo(position);
636
+
581
637
  if (edgeScrollTimer.current === null) {
582
638
  edgeScrollTimer.current = setInterval(() => {
583
639
  const pointer = dragPointer.current;
584
640
  const viewport = dragViewportRef.current?.();
585
641
 
586
642
  if (!dragging.current || !pointer || !viewport) return;
643
+
587
644
  const direction = pointer.y <= viewport.top ? -1 : pointer.y >= viewport.bottom ? 1 : 0;
588
645
 
589
646
  if (direction === 0 || !viewport.scrollBy(direction)) return;
647
+
590
648
  const next = positionAt(allGeometry(), pointer.x, pointer.y);
591
649
 
592
650
  if (next) extendSelectionTo(next);
@@ -596,7 +654,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
596
654
  const endDrag = (): void => {
597
655
  dragging.current = null;
598
656
  dragPointer.current = null;
657
+
599
658
  if (edgeScrollTimer.current !== null) clearInterval(edgeScrollTimer.current);
659
+
600
660
  edgeScrollTimer.current = null;
601
661
  };
602
662
 
@@ -606,6 +666,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
606
666
  const target = discussions.find((discussion) => discussion.key === key);
607
667
 
608
668
  if (!target) return;
669
+
609
670
  blurSaveCompose();
610
671
  setCursor(target.blockIndex);
611
672
  setFocusedDiscussion(key);
@@ -615,6 +676,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
615
676
 
616
677
  const handleSlashKey = (key: KeyEvent, activeCompose: ComposeState): boolean => {
617
678
  if (!slashActive || slashItems.length === 0) return false;
679
+
618
680
  const selected = Math.min(slashIndex, slashItems.length - 1);
619
681
 
620
682
  if (key.name === "up") {
@@ -622,11 +684,13 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
622
684
 
623
685
  return true;
624
686
  }
687
+
625
688
  if (key.name === "down") {
626
689
  setSlashIndex(Math.min(slashItems.length - 1, selected + 1));
627
690
 
628
691
  return true;
629
692
  }
693
+
630
694
  if (key.name === "return" || key.name === "tab") {
631
695
  const inserted = insertSlashItem(composeText, caretOffset, slashItems[selected]!.name);
632
696
 
@@ -643,8 +707,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
643
707
  const handlePremountKey = (key: KeyEvent, activeCompose: ComposeState): void => {
644
708
  if (key.name === "return") {
645
709
  if (key.super && onInvoke) return;
710
+
646
711
  if (key.meta || key.ctrl || key.super)
647
712
  return saveComment(activeCompose.seed, isAgentInvokeKey(key, onInvoke));
713
+
648
714
  const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
649
715
 
650
716
  composeRef.current = grown;
@@ -653,6 +719,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
653
719
 
654
720
  return setCompose(grown);
655
721
  }
722
+
656
723
  const sequence = printableSequence(key);
657
724
 
658
725
  if (sequence) {
@@ -671,11 +738,13 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
671
738
 
672
739
  return;
673
740
  }
741
+
674
742
  if (isAgentInvokeKey(key, onInvoke)) {
675
743
  key.preventDefault();
676
744
 
677
745
  return saveComment(readComposerText.current?.() ?? activeCompose.seed, true);
678
746
  }
747
+
679
748
  // the textarea owns every key while open; the view takes dismiss (which
680
749
  // also releases the discussion focus), the slash palette, and pre-mount input
681
750
  if (key.name === "escape") {
@@ -683,6 +752,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
683
752
 
684
753
  return closeCompose();
685
754
  }
755
+
686
756
  // backspace on an already empty draft undoes it: the card dissolves
687
757
  // and the caret sits back on the still-held selection, ready to re-type
688
758
  // (an edit of an existing comment is never deleted this way)
@@ -693,7 +763,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
693
763
  ) {
694
764
  return closeCompose();
695
765
  }
766
+
696
767
  if (handleSlashKey(key, activeCompose)) return;
768
+
697
769
  if (!composerReady.current) handlePremountKey(key, activeCompose);
698
770
  };
699
771
 
@@ -704,6 +776,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
704
776
 
705
777
  for (const discussion of discussions) {
706
778
  if (discussion.blockIndex !== cursor) continue;
779
+
707
780
  if (next.has(discussion.key)) next.delete(discussion.key);
708
781
  else next.add(discussion.key);
709
782
  }
@@ -720,8 +793,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
720
793
 
721
794
  return true;
722
795
  }
796
+
723
797
  if ((key.super || key.meta || key.ctrl) && (key.name === "[" || key.name === "]")) {
724
798
  if (discussions.length === 0) return true;
799
+
725
800
  const currentIndex = discussions.findIndex(
726
801
  (discussion) => discussion.key === focusedDiscussion,
727
802
  );
@@ -736,6 +811,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
736
811
 
737
812
  return true;
738
813
  }
814
+
739
815
  // enter replies into the focused discussion, else the cursor block's discussion
740
816
  if (key.name === "return") {
741
817
  const replyTarget =
@@ -772,15 +848,19 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
772
848
  };
773
849
 
774
850
  if (target !== null) return moveTo(head.blockIndex, target);
851
+
775
852
  if (extend && head.char !== (delta === 1 ? text.length : 0)) {
776
853
  // the block edge first, then the next step crosses into the neighbour
777
854
  return moveTo(head.blockIndex, delta === 1 ? text.length : 0);
778
855
  }
856
+
779
857
  // the neighbour is the next block the caret may rest on: headers are skipped
780
858
  const neighbour = nearestAnnotatable(source, head.blockIndex, delta);
781
859
 
782
860
  if (neighbour === head.blockIndex) return;
861
+
783
862
  if (delta === 1) return moveTo(neighbour, 0);
863
+
784
864
  const previousText = blockText(neighbour);
785
865
 
786
866
  moveTo(
@@ -794,6 +874,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
794
874
  const visualTarget = verticalTextPosition(head, vertical);
795
875
 
796
876
  if (!visualTarget && nextBlock === head.blockIndex) return;
877
+
797
878
  const nextHead = visualTarget?.position ?? {
798
879
  blockIndex: nextBlock,
799
880
  char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
@@ -817,11 +898,13 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
817
898
 
818
899
  return true;
819
900
  }
901
+
820
902
  if (key.name === "right" || (key.ctrl && key.name === "l")) {
821
903
  moveCaretHorizontal(1, Boolean(key.shift || key.meta || key.ctrl));
822
904
 
823
905
  return true;
824
906
  }
907
+
825
908
  if (key.name === "left" || (key.ctrl && key.name === "h")) {
826
909
  moveCaretHorizontal(-1, Boolean(key.shift || key.meta || key.ctrl));
827
910
 
@@ -836,6 +919,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
836
919
  const sequence = printableSequence(key);
837
920
 
838
921
  if (!sequence) return;
922
+
839
923
  const discussion = discussions.find((candidate) => candidate.key === focusedDiscussion);
840
924
 
841
925
  if (discussion) {
@@ -851,36 +935,57 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
851
935
  editAnnotationId: editingOwn ? last.id : null,
852
936
  });
853
937
  }
938
+
854
939
  openNewCompose(sequence);
855
940
  };
856
941
 
942
+ const invokeFocusedComment = (): void => {
943
+ const discussion = discussions.find((candidate) => candidate.key === focusedDiscussion);
944
+ const focusedCommentId = focusedAnnotationId ?? localFocus?.annotationId;
945
+ const commentId =
946
+ discussion?.annotations.find((annotation) => annotation.id === focusedCommentId)?.id ??
947
+ discussion?.annotations.at(-1)?.id;
948
+
949
+ onInvoke?.(commentId);
950
+ };
951
+
857
952
  useSharedKeyboard((key) => {
858
953
  if (suspended) return;
954
+
859
955
  if (key.ctrl && key.name === "q") return onExit();
956
+
860
957
  const activeCompose = composeRef.current;
861
958
 
862
959
  if (activeCompose) return handleComposeKey(key, activeCompose);
960
+
863
961
  if (isAgentInvokeKey(key, onInvoke)) {
864
962
  key.preventDefault();
865
963
 
866
- return onInvoke?.();
964
+ return invokeFocusedComment();
867
965
  }
966
+
868
967
  if (key.name === "escape") {
869
968
  // from type mode esc only enters nav, so a held mark survives for `c`
870
969
  if (!navModeRef.current) return setNavMode(true);
970
+
871
971
  if (focusedDiscussion !== null) setFocusedDiscussion(null);
872
972
 
873
973
  return collapseCaret();
874
974
  }
975
+
875
976
  if (navModeRef.current) {
876
977
  if (handleCaretKey(key)) return;
978
+
877
979
  if (key.name === "z") return toggleFoldAtCursor();
980
+
878
981
  if (onNavCommand?.(key, heldSpan)) return;
982
+
879
983
  if (key.name === "c") {
880
984
  setNavMode(false);
881
985
 
882
986
  return openNewCompose("");
883
987
  }
988
+
884
989
  if (printableSequence(key)) {
885
990
  setNavMode(false);
886
991
  startTyping(key);
@@ -888,8 +993,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
888
993
 
889
994
  return;
890
995
  }
996
+
891
997
  if (handleDiscussionVerb(key)) return;
998
+
892
999
  if (handleCaretKey(key)) return;
1000
+
893
1001
  startTyping(key);
894
1002
  });
895
1003
 
@@ -951,6 +1059,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
951
1059
  tokens={tokens}
952
1060
  authorLabel={resolveAuthorLabel?.(annotation)}
953
1061
  action={annotationAction?.(annotation.id)}
1062
+ onFocus={() => {
1063
+ blurSaveCompose();
1064
+ focusSavedComment(annotation.id, discussion.key);
1065
+ setCursor(discussion.blockIndex);
1066
+ }}
954
1067
  />
955
1068
  ),
956
1069
  };
@@ -968,6 +1081,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
968
1081
  focused={focusedDiscussion === discussion.key}
969
1082
  onFocus={() => {
970
1083
  if (composeRef.current?.discussionKey === discussion.key) return;
1084
+
971
1085
  blurSaveCompose();
972
1086
  setFocusedDiscussion(discussion.key);
973
1087
  setCursor(discussion.blockIndex);
@@ -986,8 +1100,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
986
1100
  };
987
1101
 
988
1102
  for (const discussion of discussions) pushSpan(discussion.span);
1103
+
989
1104
  if (compose) pushSpan(compose.span);
1105
+
990
1106
  if (caretIsSelection && !compose) pushSpan(heldSpan);
1107
+
991
1108
  // the idle caret cell sits at the head, also at the end of a held mark;
992
1109
  // an open card takes the cursor with it, so no cell is painted then
993
1110
  if (blockIndex === head.blockIndex && !compose && textLength > 0) {
@@ -1025,6 +1142,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1025
1142
 
1026
1143
  return;
1027
1144
  }
1145
+
1028
1146
  nodes.push(
1029
1147
  <DiscussionCard
1030
1148
  key="compose-new"
@@ -1041,6 +1159,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1041
1159
 
1042
1160
  for (const discussion of discussions) {
1043
1161
  if (discussion.blockIndex !== blockIndex || !endsInLine(discussion.span.end.char)) continue;
1162
+
1044
1163
  if (
1045
1164
  composePending &&
1046
1165
  composeStart &&
@@ -1049,15 +1168,19 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1049
1168
  pushComposeCard();
1050
1169
  composePending = false;
1051
1170
  }
1171
+
1052
1172
  const composingHere = Boolean(composeHere && compose.discussionKey === discussion.key);
1053
1173
 
1054
1174
  if (folded.has(discussion.key) && !composingHere) {
1055
1175
  nodes.push(foldedSummaryFor(discussion));
1056
1176
  continue;
1057
1177
  }
1178
+
1058
1179
  nodes.push(discussionCardFor(discussion, composingHere));
1180
+
1059
1181
  if (composingHere) nodes.push(paletteNode);
1060
1182
  }
1183
+
1061
1184
  if (composePending) pushComposeCard();
1062
1185
 
1063
1186
  return nodes;
@@ -1105,6 +1228,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1105
1228
  active.blockIndex === blockIndex
1106
1229
  )
1107
1230
  return;
1231
+
1108
1232
  const position = { blockIndex, char: 0 };
1109
1233
 
1110
1234
  openCompose({
@@ -1123,12 +1247,15 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1123
1247
  const restoredPrompt = useRef<number | null>(null);
1124
1248
  const restorePrompt = useEffectEvent(() => {
1125
1249
  if (!promptRestoreRequest || suspended || !onInvoke) return;
1250
+
1126
1251
  if (restoredPrompt.current === promptRestoreRequest.id) return;
1252
+
1127
1253
  restoredPrompt.current = promptRestoreRequest.id;
1128
1254
  const active = composeRef.current;
1129
1255
  const newerDraft = active?.kind === "prompt" ? composeTextRef.current : promptDraft.current;
1130
1256
 
1131
1257
  if (active?.kind !== "prompt") blurSaveCompose();
1258
+
1132
1259
  promptDraft.current = [promptRestoreRequest.text, newerDraft].filter(Boolean).join("\n");
1133
1260
  focusPrompt(source.count - 1);
1134
1261
  });
@@ -1139,10 +1266,14 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1139
1266
  const focusedReply = useRef<string | null>(null);
1140
1267
  const continueConversation = useEffectEvent(() => {
1141
1268
  if (!promptFocusRequest || suspended || !onInvoke) return;
1269
+
1142
1270
  if (focusedReply.current === promptFocusRequest.replyId) return;
1271
+
1143
1272
  focusedReply.current = promptFocusRequest.replyId;
1273
+
1144
1274
  // An arriving answer cannot replace a draft or a marked passage under review.
1145
1275
  if (composeRef.current || heldSpan || dragging.current || promptDraft.current) return;
1276
+
1146
1277
  focusPrompt(promptFocusRequest.blockIndex);
1147
1278
  });
1148
1279
 
@@ -1151,6 +1282,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1151
1282
  }, [promptFocusRequest?.replyId, promptFocusRequest?.blockIndex, suspended]);
1152
1283
  const onPromptMouseDown = (event: TerminalMouseEvent): void => {
1153
1284
  if (!onInvoke || suspended) return;
1285
+
1154
1286
  const blockIndex = source.count - 1;
1155
1287
  const geometry = allGeometry();
1156
1288
  const promptLine = geometry.find((entry) => isPromptBlock?.(entry.blockIndex));
@@ -1168,9 +1300,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1168
1300
  (viewport && event.y >= viewport.bottom)
1169
1301
  )
1170
1302
  return;
1303
+
1171
1304
  if (composeRef.current?.kind === "prompt") {
1172
1305
  promptDraft.current = composeTextRef.current;
1173
1306
  } else blurSaveCompose();
1307
+
1174
1308
  endDrag();
1175
1309
  focusPrompt(blockIndex);
1176
1310
  };
@@ -1180,6 +1314,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1180
1314
  const pressed = positionAt(allGeometry(), event.x, event.y);
1181
1315
 
1182
1316
  if (!pressed || !source.annotatable(pressed.blockIndex)) return;
1317
+
1183
1318
  setNavMode(false);
1184
1319
  const stamp = { time: Date.now(), x: event.x, y: event.y };
1185
1320
  const wordMode = isDoubleClick(lastClick.current, stamp);
@@ -1231,7 +1366,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1231
1366
 
1232
1367
  function isAgentInvokeKey(
1233
1368
  key: { name: string; ctrl?: boolean; meta?: boolean; super?: boolean },
1234
- onInvoke?: () => void,
1369
+ onInvoke?: (commentId?: string) => void,
1235
1370
  ): boolean {
1236
1371
  return Boolean(
1237
1372
  onInvoke &&
@@ -73,9 +73,11 @@ test("deleted file caret scrolls one row and stays at the file boundaries", asyn
73
73
  const box = findScrollbox(setup.renderer.root);
74
74
 
75
75
  if (!box) throw new Error("file diff scrollbox missing");
76
+
76
77
  for (let offset = 0; offset < 1000; offset += 20) {
77
78
  box.scrollTo({ x: 0, y: offset });
78
79
  await settle(setup);
80
+
79
81
  if (setup.captureCharFrame().includes("row 130")) break;
80
82
  }
81
83
  const target = locateText(setup, "row 130");
@@ -127,6 +129,7 @@ test("deleted file caret scrolls one row and stays at the file boundaries", asyn
127
129
  .findIndex((line) => line.includes("▎"));
128
130
 
129
131
  if (marker === box.viewport.screenY) anchored = true;
132
+
130
133
  if (anchored && box.scrollTop > 0) expect(marker).toBe(box.viewport.screenY);
131
134
  }
132
135
  expect(anchored).toBe(true);