@cueloop/client 0.1.0-alpha.97 → 0.1.0-alpha.99

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 (76) hide show
  1. package/package.json +11 -11
  2. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  3. package/src/annotations/components/AnnotationCards.tsx +31 -8
  4. package/src/annotations/use-annotation-surface.tsx +257 -31
  5. package/src/app/App.deleted-scroll.test.tsx +1 -0
  6. package/src/app/App.diff.test.tsx +1 -0
  7. package/src/app/App.identity.test.tsx +1 -0
  8. package/src/app/App.regression.test.tsx +5 -0
  9. package/src/app/App.share-dialog.test.tsx +5 -0
  10. package/src/app/App.submit-confirm.test.tsx +3 -0
  11. package/src/app/App.test.tsx +1 -0
  12. package/src/app/App.thread-agent.test.ts +12 -0
  13. package/src/app/App.tsx +223 -97
  14. package/src/app/App.visual.test.tsx +2 -0
  15. package/src/app/App.workbench.test.tsx +53 -0
  16. package/src/app/app-screens.tsx +3 -0
  17. package/src/app/components/AppShell.tsx +1 -0
  18. package/src/app/components/AppShell.zoom.test.tsx +4 -3
  19. package/src/app/intent-dispatch.test.ts +1 -0
  20. package/src/app/run.ts +1 -0
  21. package/src/diff/components/AnnotatableFileView.test.tsx +2 -0
  22. package/src/diff/components/AnnotatableFileView.tsx +8 -0
  23. package/src/diff/components/DiffContentView.scroll.test.tsx +1 -0
  24. package/src/diff/components/DiffContentView.tsx +10 -0
  25. package/src/diff/diff-intraline.test.ts +1 -0
  26. package/src/diff/diff-intraline.ts +2 -0
  27. package/src/diff/split-diff.ts +2 -0
  28. package/src/integrations/components/ShareDialog.tsx +1 -0
  29. package/src/markdown/components/ThreadView.tsx +62 -16
  30. package/src/markdown/mark-runs.ts +1 -0
  31. package/src/markdown/markdown-highlight.ts +2 -0
  32. package/src/performance/otlp-trace.test.ts +3 -0
  33. package/src/performance/otlp-trace.ts +4 -0
  34. package/src/performance/perf-report.ts +1 -0
  35. package/src/performance/perf-timings.ts +1 -0
  36. package/src/settings/components/quick-actions-editor.test.tsx +3 -0
  37. package/src/settings/config.test.ts +15 -0
  38. package/src/settings/config.ts +14 -4
  39. package/src/stories/__snapshots__/stories.test.tsx.snap +74 -0
  40. package/src/stories/stories-app.tsx +3 -0
  41. package/src/stories/story.ts +2 -0
  42. package/src/terminal/pty.ts +1 -0
  43. package/src/thread/agent-continuation.test.ts +32 -0
  44. package/src/thread/agent-continuation.ts +13 -0
  45. package/src/thread/agent-transcript.test.ts +161 -0
  46. package/src/thread/agent-transcript.ts +336 -0
  47. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  48. package/src/thread/components/AgentConfigControls.test.tsx +112 -0
  49. package/src/thread/components/AgentConfigControls.tsx +114 -0
  50. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  51. package/src/thread/components/AgentThreadPane.test.tsx +829 -0
  52. package/src/thread/components/AgentThreadPane.tsx +367 -0
  53. package/src/thread/components/ThreadFooter.test.tsx +2 -0
  54. package/src/thread/components/ThreadFooter.tsx +3 -0
  55. package/src/thread/components/ThreadTree.test.tsx +41 -0
  56. package/src/thread/components/ThreadTree.tsx +25 -5
  57. package/src/thread/components/thread-tree.test.ts +3 -0
  58. package/src/thread/components/thread-tree.ts +4 -0
  59. package/src/thread/thread-controller.curate.test.ts +1 -0
  60. package/src/thread/thread-controller.live-diff.test.ts +7 -0
  61. package/src/thread/thread-controller.share.test.ts +1 -0
  62. package/src/thread/thread-controller.ts +42 -0
  63. package/src/thread/use-thread-agent.test.tsx +208 -0
  64. package/src/thread/use-thread-agent.ts +111 -0
  65. package/src/ui/components/PromptDialog.test.tsx +4 -0
  66. package/src/ui/components/RootOverlay.tsx +1 -0
  67. package/src/ui/components/primitives/Dialog.test.tsx +1 -0
  68. package/src/workbench/components/ChangesColumn.tsx +1 -0
  69. package/src/workbench/components/ChangesFileTree.test.tsx +2 -0
  70. package/src/workbench/components/FileContentsView.tsx +2 -0
  71. package/src/workbench/components/GridTabContent.tsx +7 -0
  72. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  73. package/src/workbench/components/ProjectTreeView.tsx +1 -0
  74. package/src/workbench/components/editor-grid.test.ts +11 -0
  75. package/src/workbench/components/editor-grid.ts +10 -0
  76. package/src/workbench/use-changes-workbench.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.97",
3
+ "version": "0.1.0-alpha.99",
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,18 +27,18 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.97",
31
- "@cueloop/extension-api": "0.1.0-alpha.97",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.97",
33
- "@cueloop/schema": "0.1.0-alpha.97",
34
- "@opentui/core": "^0.5.12",
35
- "@opentui/keymap": "0.5.12",
36
- "@opentui/react": "^0.5.12",
37
- "@opentui/ssh": "0.5.12",
38
- "@pierre/diffs": "^1.4.1",
30
+ "@cueloop/daemon": "0.1.0-alpha.99",
31
+ "@cueloop/extension-api": "0.1.0-alpha.99",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.99",
33
+ "@cueloop/schema": "0.1.0-alpha.99",
34
+ "@opentui/core": "^0.5.14",
35
+ "@opentui/keymap": "0.5.14",
36
+ "@opentui/react": "^0.5.14",
37
+ "@opentui/ssh": "0.5.14",
38
+ "@pierre/diffs": "^1.5.1",
39
39
  "@tanstack/virtual-core": "^3.17.11",
40
40
  "diff": "^9",
41
- "puppeteer-core": "^25.11.0",
41
+ "puppeteer-core": "^25.12.0",
42
42
  "react": "^19.3.0",
43
43
  "valibot": "^1.5.0",
44
44
  "zustand": "^5.0.15"
@@ -0,0 +1,42 @@
1
+ import { expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { CommentRow } from "./AnnotationCards";
5
+ import { DARK } from "../../appearance/theme";
6
+ import { TooltipProvider } from "../../ui/components/Tooltip";
7
+ import { locateText, settle } from "../../testing/test-support";
8
+
9
+ test("submitted comment checks stay visible on hover and navigate to the reply", async () => {
10
+ let navigations = 0;
11
+ const setup = await testRender(
12
+ <TooltipProvider>
13
+ <CommentRow
14
+ annotation={{
15
+ id: "submitted",
16
+ kind: "comment",
17
+ body: "Explain the change",
18
+ anchor: { quote: "change", prefix: "", suffix: "", blockIndex: 0, start: 0, end: 6 },
19
+ createdAt: "2026-10-04",
20
+ }}
21
+ tokens={DARK}
22
+ action={{ label: "View reply", run: () => navigations++ }}
23
+ />
24
+ </TooltipProvider>,
25
+ { width: 60, height: 8 },
26
+ );
27
+
28
+ try {
29
+ await settle(setup);
30
+ const checks = locateText(setup, "✓✓");
31
+
32
+ await setup.mockMouse.moveTo(checks.column, checks.row);
33
+ await settle(setup);
34
+ expect(setup.captureCharFrame()).toContain("✓✓");
35
+ expect(setup.captureCharFrame()).toContain("View reply");
36
+ expect(setup.captureCharFrame()).not.toContain("↓");
37
+ await setup.mockMouse.click(checks.column, checks.row);
38
+ expect(navigations).toBe(1);
39
+ } finally {
40
+ setup.renderer.destroy();
41
+ }
42
+ });
@@ -26,9 +26,8 @@ import { referenceStyleFor } from "../../ui/components/syntax-highlight";
26
26
 
27
27
  /* -------------------------------------------------------------- composer */
28
28
 
29
- // cmd+enter sends (super under the kitty protocol, meta where cmd arrives
30
- // ESC-prefixed, ctrl as the fallback where the terminal itself consumes
31
- // cmd+enter); plain enter breaks the line
29
+ // Option+Enter saves a comment; Ctrl+Enter may additionally invoke the agent.
30
+ // Plain Enter breaks the line. Command+Enter belongs to the terminal.
32
31
  const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
33
32
  { name: "return", super: true, action: "submit" },
34
33
  { name: "return", meta: true, action: "submit" },
@@ -57,14 +56,16 @@ export function Composer({
57
56
  onReady,
58
57
  onInput,
59
58
  placeholder,
59
+ agentEnabled = false,
60
60
  }: {
61
61
  seed: string;
62
- glyph: string;
62
+ glyph: string | null;
63
63
  tokens: Theme;
64
64
  onSave: (body: string) => void;
65
- onReady: () => void;
65
+ onReady: (readText: () => string) => void;
66
66
  onInput: (text: string, caret: number) => void;
67
67
  placeholder?: string;
68
+ agentEnabled?: boolean;
68
69
  }): React.ReactNode {
69
70
  const editorRef = useRef<TextareaRenderable | null>(null);
70
71
  const pastedImageCount = useRef(0);
@@ -101,13 +102,15 @@ export function Composer({
101
102
  editor.editBuffer.setSyntaxStyle(referenceStyleFor(tokens).style);
102
103
  paintReferences(editor);
103
104
  setRows(composeRowCount(editor.plainText, editor.width));
104
- onReady();
105
+ onReady(() => editorRef.current?.plainText ?? "");
105
106
  // eslint-disable-next-line react-hooks/exhaustive-deps
106
107
  }, []);
107
108
 
108
109
  return (
109
110
  <box style={{ flexDirection: "row" }}>
110
- <text selectable={false} fg={tokens.text} style={{ flexShrink: 0 }}>{`${glyph} `}</text>
111
+ {glyph !== null ? (
112
+ <text selectable={false} fg={tokens.text} style={{ flexShrink: 0 }}>{`${glyph} `}</text>
113
+ ) : null}
111
114
  <textarea
112
115
  ref={editorRef}
113
116
  focused
@@ -115,7 +118,11 @@ export function Composer({
115
118
  placeholder={placeholder}
116
119
  placeholderColor={tokens.textDim}
117
120
  cursorStyle={{ style: "block", blinking: true }}
118
- keyBindings={COMPOSE_KEY_BINDINGS}
121
+ keyBindings={
122
+ agentEnabled
123
+ ? COMPOSE_KEY_BINDINGS.filter((binding) => !binding.super)
124
+ : COMPOSE_KEY_BINDINGS
125
+ }
119
126
  onSubmit={() => onSave(editorRef.current?.plainText ?? "")}
120
127
  onContentChange={() => {
121
128
  const editor = editorRef.current;
@@ -304,11 +311,13 @@ export function CommentRow({
304
311
  annotation,
305
312
  tokens,
306
313
  authorLabel,
314
+ action,
307
315
  }: {
308
316
  annotation: Annotation;
309
317
  tokens: Theme;
310
318
  /** The author's resolved display name, shown as a tooltip when the dot is hovered. */
311
319
  authorLabel?: string;
320
+ action?: { label: string; run: () => void };
312
321
  }): React.ReactNode {
313
322
  // own comments (no author) wear the filled dot, collaborators the outline
314
323
  const own = annotation.author === undefined;
@@ -334,6 +343,20 @@ export function CommentRow({
334
343
  <text fg={tokens.text} style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}>
335
344
  {line}
336
345
  </text>
346
+ {lineIndex === 0 && action ? (
347
+ <text
348
+ fg={tokens.blue}
349
+ onMouseUp={action.run}
350
+ onMouseOver={(event: TerminalMouseEvent) => {
351
+ showTooltip(action.label, event.x, event.y);
352
+ }}
353
+ onMouseOut={() => {
354
+ hideTooltip();
355
+ }}
356
+ >
357
+ {action.label === "Retry" ? "Retry" : "✓✓"}
358
+ </text>
359
+ ) : null}
337
360
  </box>
338
361
  ))}
339
362
  </box>
@@ -3,7 +3,7 @@
3
3
  * list of text lines it does not know how to paint. A character-precise caret
4
4
  * sits in the text; click/drag marks (word mode on double-click); typing opens a
5
5
  * composer anchored at the caret word or held selection; "/" opens the quick-action
6
- * palette; enter replies, tab folds, esc dismisses; cmd+enter sends. The plan
6
+ * palette; enter replies, tab folds, esc dismisses; Option+Enter saves, Ctrl+Enter invokes. The plan
7
7
  * thread view and the diff sheet both drive this hook and only paint their own
8
8
  * rows, so marking and commenting behave identically on prose and on code.
9
9
  *
@@ -12,11 +12,11 @@
12
12
  * cards this hook builds under the visual line a span ends on.
13
13
  */
14
14
 
15
- import React, { useContext, useEffect, useRef, useState } from "react";
15
+ import React, { useContext, useEffect, useEffectEvent, useRef, useState } from "react";
16
16
  import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
17
17
  import { flushSync } from "@opentui/react";
18
18
  import { useSharedKeyboard } from "../keyboard/use-shared-keyboard";
19
- import type { Annotation, Thread } from "@cueloop/schema";
19
+ import { agentInputTarget, type Annotation, type Thread } from "@cueloop/schema";
20
20
  import type { Mark } from "../markdown/view-plan";
21
21
  import type { QuickAction } from "../settings/config";
22
22
  import type { Theme } from "../appearance/theme";
@@ -64,15 +64,27 @@ export interface LineSource {
64
64
  annotatable(blockIndex: number): boolean;
65
65
  }
66
66
 
67
- export interface ComposeState {
68
- /** The block the card renders under: where the span ends. */
67
+ /** Prompt drafts cannot carry a discussion or edit target; comment drafts retain those targets. */
68
+ export type ComposeState = { blockIndex: number; seed: string } & (
69
+ | { kind: "prompt"; discussionKey: null; editAnnotationId: null; span: TextSpan }
70
+ | {
71
+ kind: "comment";
72
+ discussionKey: string | null;
73
+ editAnnotationId: string | null;
74
+ span: TextSpan | null;
75
+ }
76
+ );
77
+
78
+ /** A completed reply requests an empty continuation prompt once per reply ID. */
79
+ export interface PromptFocusRequest {
80
+ replyId: string;
69
81
  blockIndex: number;
70
- /** Reply target; null composes a new discussion. */
71
- discussionKey: string | null;
72
- span: TextSpan | null;
73
- seed: string;
74
- /** When set, the composer rewrites this annotation instead of appending. */
75
- editAnnotationId: string | null;
82
+ }
83
+
84
+ /** Rejected prompt text returns to the visible composer once per request. */
85
+ export interface PromptRestoreRequest {
86
+ id: number;
87
+ text: string;
76
88
  }
77
89
 
78
90
  export interface AnnotationSurfaceOptions {
@@ -112,6 +124,14 @@ export interface AnnotationSurfaceOptions {
112
124
  onAnnotate: (span: TextSpan, body: string) => void;
113
125
  onReply: (rootAnnotationId: string, body: string) => void;
114
126
  onUpdateAnnotation: (id: string, body: string) => void;
127
+ isAnnotationReadOnly?: (id: string) => boolean;
128
+ annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
129
+ requestedBlock?: { blockIndex: number };
130
+ onInvoke?: () => void;
131
+ /** The final prompt block accepts unmarked typing without creating a discussion. */
132
+ isPromptBlock?: (blockIndex: number) => boolean;
133
+ promptFocusRequest?: PromptFocusRequest;
134
+ promptRestoreRequest?: PromptRestoreRequest;
115
135
  /** The visible scroll viewport used to keep a held mouse mark moving at its edges. */
116
136
  dragViewport?: () => {
117
137
  top: number;
@@ -153,6 +173,7 @@ export interface AnnotationSurface {
153
173
  onLineMouseDown: (event: TerminalMouseEvent) => void;
154
174
  /** Drag routing for the view root, so a fast flick off a row never strands the gesture. */
155
175
  rootMouseProps: {
176
+ onMouseDown: (event: TerminalMouseEvent) => void;
156
177
  onMouseDrag: (event: TerminalMouseEvent) => void;
157
178
  onMouseDragEnd: () => void;
158
179
  onMouseUp: () => void;
@@ -210,6 +231,13 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
210
231
  onAnnotate,
211
232
  onReply,
212
233
  onUpdateAnnotation,
234
+ isAnnotationReadOnly,
235
+ annotationAction,
236
+ requestedBlock,
237
+ onInvoke,
238
+ isPromptBlock,
239
+ promptFocusRequest,
240
+ promptRestoreRequest,
213
241
  dragViewport,
214
242
  resolveAuthorLabel,
215
243
  onNavCommand,
@@ -226,6 +254,16 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
226
254
 
227
255
  return { head: start, anchor: start };
228
256
  });
257
+ const focusRequested = useEffectEvent(() => onFocusAnnotation?.(undefined));
258
+
259
+ useEffect(() => {
260
+ if (requestedBlock === undefined) return;
261
+ const next = { blockIndex: requestedBlock.blockIndex, char: 0 };
262
+
263
+ setCursor(requestedBlock.blockIndex);
264
+ setCaret({ head: next, anchor: next });
265
+ focusRequested();
266
+ }, [requestedBlock]);
229
267
  const [compose, setCompose] = useState<ComposeState | null>(null);
230
268
  const dragPointer = useRef<{ x: number; y: number } | null>(null);
231
269
  const edgeScrollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -290,7 +328,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
290
328
  const [caretOffset, setCaretOffset] = useState(0);
291
329
  const [slashIndex, setSlashIndex] = useState(0);
292
330
  const composerReady = useRef(false);
331
+ const readComposerText = useRef<(() => string) | null>(null);
293
332
  const composeRef = useRef<ComposeState | null>(null);
333
+ const promptDraft = useRef("");
294
334
  // every visual line registers its renderable so a drag can hit-test any
295
335
  // row on screen, across blocks (rows without text resolve to the block above)
296
336
  const lineRenderables = useRef(
@@ -317,9 +357,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
317
357
  return span ? tightenSpan(span, textLengthOf) : null;
318
358
  })();
319
359
  const caretIsSelection = heldSpan !== null;
320
- /** The typing anchor: the held selection (char-precise), else the marked word. */
360
+ /** Agent comments require a held selection; legacy surfaces may use the caret word. */
321
361
  const caretSpan = (): TextSpan | null => {
322
362
  if (heldSpan) return heldSpan;
363
+ if (onInvoke) return isPromptBlock?.(head.blockIndex) ? { start: head, end: head } : null;
323
364
  const word = wordRangeAt(blockText(head.blockIndex), head.char);
324
365
 
325
366
  return word
@@ -327,7 +368,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
327
368
  start: { blockIndex: cursor, char: word.start },
328
369
  end: { blockIndex: cursor, char: word.end },
329
370
  }
330
- : null;
371
+ : blockText(head.blockIndex).length === 0
372
+ ? { start: head, end: head }
373
+ : null;
331
374
  };
332
375
  const collapseCaret = (): void => setCaret({ head, anchor: head });
333
376
  const spanQuote = (span: TextSpan): string => {
@@ -420,6 +463,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
420
463
  if (observer) return onObserverBlocked?.("observer");
421
464
  if (resolved) return onObserverBlocked?.("resolved");
422
465
  composerReady.current = false;
466
+ readComposerText.current = null;
423
467
  composeRef.current = state;
424
468
  setCompose(state);
425
469
  setDraft(state.seed);
@@ -428,6 +472,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
428
472
  };
429
473
  const closeCompose = (): void => {
430
474
  composeRef.current = null;
475
+ readComposerText.current = null;
431
476
  setCompose(null);
432
477
  setDraft("");
433
478
  setCaretOffset(0);
@@ -444,16 +489,19 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
444
489
  if (slashActive) setSlashIndex(0);
445
490
  }, [slashActive]);
446
491
 
447
- const saveComment = (body: string): void => {
492
+ const saveComment = (body: string, invoke = false): void => {
448
493
  // the body saves verbatim - typed newlines are the author's choice;
449
494
  // trimming only decides whether the draft is empty enough to discard
450
495
  const target = composeRef.current;
496
+ const prompt = target?.kind === "prompt";
451
497
 
498
+ if (prompt && !invoke) return;
452
499
  closeCompose();
453
500
  collapseCaret();
454
501
  if (!target || body.trim().length === 0) return;
455
502
  if (target.editAnnotationId !== null) {
456
503
  onUpdateAnnotation(target.editAnnotationId, body);
504
+ if (invoke) onInvoke?.();
457
505
 
458
506
  return;
459
507
  }
@@ -462,35 +510,64 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
462
510
 
463
511
  if (discussion) {
464
512
  onReply(discussion.rootId, body);
513
+ if (invoke) onInvoke?.();
465
514
 
466
515
  return;
467
516
  }
468
517
  }
469
518
  const span = target.span ?? caretSpan();
470
519
 
471
- if (span) onAnnotate(span, body);
520
+ if (span) {
521
+ onAnnotate(span, body);
522
+ if (invoke) onInvoke?.();
523
+ }
472
524
  };
473
525
  /** A new discussion on the typing anchor; the card renders under the span's last block. */
474
526
  const openNewCompose = (seed: string): void => {
475
527
  const span = caretSpan();
476
528
 
477
- openCompose({
478
- blockIndex: span?.end.blockIndex ?? head.blockIndex,
479
- discussionKey: null,
480
- span,
481
- seed,
482
- editAnnotationId: null,
483
- });
529
+ if (
530
+ onInvoke &&
531
+ agentInputTarget(Boolean(isPromptBlock?.(head.blockIndex)), Boolean(heldSpan)) === "none"
532
+ )
533
+ return;
534
+ if (isPromptBlock?.(head.blockIndex) && span) {
535
+ openCompose({
536
+ kind: "prompt",
537
+ blockIndex: head.blockIndex,
538
+ discussionKey: null,
539
+ span,
540
+ seed: promptDraft.current + seed,
541
+ editAnnotationId: null,
542
+ });
543
+ } else {
544
+ openCompose({
545
+ kind: "comment",
546
+ blockIndex: span?.end.blockIndex ?? head.blockIndex,
547
+ discussionKey: null,
548
+ span,
549
+ seed,
550
+ editAnnotationId: null,
551
+ });
552
+ }
553
+ if (isPromptBlock?.(head.blockIndex)) promptDraft.current = "";
484
554
  };
485
555
 
486
556
  // clicking away commits the draft (blur-save); only a standalone "/query" is a palette
487
557
  // artifact, so prose that merely ends in a "/name" still saves
488
558
  const blurSaveCompose = (): void => {
489
- if (!composeRef.current) return;
559
+ const target = composeRef.current;
560
+
561
+ if (!target) return;
562
+ if (target.kind === "prompt") {
563
+ promptDraft.current = composeTextRef.current;
564
+
565
+ return closeCompose();
566
+ }
490
567
  if (isStandaloneSlashQuery(composeText) || composeText.trim().length === 0) {
491
568
  return closeCompose();
492
569
  }
493
- saveComment(composeText);
570
+ saveComment(composeText, false);
494
571
  };
495
572
 
496
573
  /** Extend the held selection to the pointer's position (word mode snaps both ends). */
@@ -590,10 +667,12 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
590
667
  return false;
591
668
  };
592
669
 
593
- /** Pre-mount window: buffer printables, honor a fast cmd+enter or newline. */
670
+ /** Pre-mount window: buffer printables, honor a fast comment save, agent invocation, or newline. */
594
671
  const handlePremountKey = (key: KeyEvent, activeCompose: ComposeState): void => {
595
672
  if (key.name === "return") {
596
- if (key.super || key.meta || key.ctrl) return saveComment(activeCompose.seed);
673
+ if (key.super && onInvoke) return;
674
+ if (key.meta || key.ctrl || key.super)
675
+ return saveComment(activeCompose.seed, isAgentInvokeKey(key, onInvoke));
597
676
  const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
598
677
 
599
678
  composeRef.current = grown;
@@ -615,6 +694,16 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
615
694
  };
616
695
 
617
696
  const handleComposeKey = (key: KeyEvent, activeCompose: ComposeState): void => {
697
+ if (onInvoke && (key.name === "return" || key.name === "enter") && key.super) {
698
+ key.preventDefault();
699
+
700
+ return;
701
+ }
702
+ if (isAgentInvokeKey(key, onInvoke)) {
703
+ key.preventDefault();
704
+
705
+ return saveComment(readComposerText.current?.() ?? activeCompose.seed, true);
706
+ }
618
707
  // the textarea owns every key while open; the view takes dismiss (which
619
708
  // also releases the discussion focus), the slash palette, and pre-mount input
620
709
  if (key.name === "escape") {
@@ -683,6 +772,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
683
772
 
684
773
  if (replyTarget) {
685
774
  openCompose({
775
+ kind: "comment",
686
776
  blockIndex: replyTarget.blockIndex,
687
777
  discussionKey: replyTarget.key,
688
778
  span: null,
@@ -778,9 +868,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
778
868
 
779
869
  if (discussion) {
780
870
  const last = discussion.annotations.at(-1)!;
781
- const editingOwn = last.author === undefined;
871
+ const editingOwn = last.author === undefined && !isAnnotationReadOnly?.(last.id);
782
872
 
783
873
  return openCompose({
874
+ kind: "comment",
784
875
  blockIndex: discussion.blockIndex,
785
876
  discussionKey: discussion.key,
786
877
  span: null,
@@ -797,6 +888,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
797
888
  const activeCompose = composeRef.current;
798
889
 
799
890
  if (activeCompose) return handleComposeKey(key, activeCompose);
891
+ if (isAgentInvokeKey(key, onInvoke)) {
892
+ key.preventDefault();
893
+
894
+ return onInvoke?.();
895
+ }
800
896
  if (key.name === "escape") {
801
897
  // from type mode esc only enters nav, so a held mark survives for `c`
802
898
  if (!navModeRef.current) return setNavMode(true);
@@ -833,10 +929,14 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
833
929
  <Composer
834
930
  key={compose.seed}
835
931
  seed={compose.seed}
836
- glyph="●"
932
+ glyph={compose.kind === "prompt" ? null : "●"}
837
933
  tokens={tokens}
838
934
  onSave={saveComment}
839
- onReady={() => (composerReady.current = true)}
935
+ agentEnabled={Boolean(onInvoke)}
936
+ onReady={(readText) => {
937
+ readComposerText.current = readText;
938
+ composerReady.current = true;
939
+ }}
840
940
  onInput={(text, caret) => {
841
941
  setDraft(text);
842
942
  setCaretOffset(caret);
@@ -877,6 +977,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
877
977
  annotation={annotation}
878
978
  tokens={tokens}
879
979
  authorLabel={resolveAuthorLabel?.(annotation)}
980
+ action={annotationAction?.(annotation.id)}
880
981
  />
881
982
  ),
882
983
  };
@@ -930,7 +1031,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
930
1031
  line: VisualLine,
931
1032
  isLastLine: boolean,
932
1033
  ): React.ReactNode[] => {
933
- const endsInLine = (end: number): boolean => end - 1 >= line.start && end - 1 < line.end;
1034
+ const endsInLine = (end: number): boolean =>
1035
+ (end === 0 && line.start === 0) || (end - 1 >= line.start && end - 1 < line.end);
934
1036
  const nodes: React.ReactNode[] = [];
935
1037
  const composeHere = compose && compose.blockIndex === blockIndex;
936
1038
  const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
@@ -939,6 +1041,16 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
939
1041
  const composeStart = compose?.span?.start;
940
1042
 
941
1043
  const pushComposeCard = (): void => {
1044
+ if (compose?.kind === "prompt") {
1045
+ nodes.push(
1046
+ <box key="compose-prompt" style={{ paddingLeft: 2 }}>
1047
+ {composerNode}
1048
+ </box>,
1049
+ );
1050
+ nodes.push(paletteNode);
1051
+
1052
+ return;
1053
+ }
942
1054
  nodes.push(
943
1055
  <DiscussionCard
944
1056
  key="compose-new"
@@ -992,6 +1104,102 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
992
1104
  } else lineRenderables.current.delete(key);
993
1105
  };
994
1106
 
1107
+ const focusPrompt = (blockIndex: number): void => {
1108
+ const position = { blockIndex, char: 0 };
1109
+
1110
+ setNavMode(false);
1111
+ setFocusedDiscussion(null);
1112
+ setCursor(blockIndex);
1113
+ setCaret({ head: position, anchor: position });
1114
+ openCompose({
1115
+ kind: "prompt",
1116
+ blockIndex,
1117
+ discussionKey: null,
1118
+ span: { start: position, end: position },
1119
+ seed: promptDraft.current,
1120
+ editAnnotationId: null,
1121
+ });
1122
+ promptDraft.current = "";
1123
+ };
1124
+ const movePromptDraft = useEffectEvent(() => {
1125
+ const active = composeRef.current;
1126
+ const blockIndex = source.count - 1;
1127
+
1128
+ if (
1129
+ active?.kind !== "prompt" ||
1130
+ !isPromptBlock?.(blockIndex) ||
1131
+ active.blockIndex === blockIndex
1132
+ )
1133
+ return;
1134
+ const position = { blockIndex, char: 0 };
1135
+
1136
+ openCompose({
1137
+ ...active,
1138
+ blockIndex,
1139
+ span: { start: position, end: position },
1140
+ seed: composeTextRef.current,
1141
+ });
1142
+ setCursor(blockIndex);
1143
+ setCaret({ head: position, anchor: position });
1144
+ });
1145
+
1146
+ useEffect(() => {
1147
+ movePromptDraft();
1148
+ }, [source.count]);
1149
+ const restoredPrompt = useRef<number | null>(null);
1150
+ const restorePrompt = useEffectEvent(() => {
1151
+ if (!promptRestoreRequest || suspended || !onInvoke) return;
1152
+ if (restoredPrompt.current === promptRestoreRequest.id) return;
1153
+ restoredPrompt.current = promptRestoreRequest.id;
1154
+ const active = composeRef.current;
1155
+ const newerDraft = active?.kind === "prompt" ? composeTextRef.current : promptDraft.current;
1156
+
1157
+ if (active?.kind !== "prompt") blurSaveCompose();
1158
+ promptDraft.current = [promptRestoreRequest.text, newerDraft].filter(Boolean).join("\n");
1159
+ focusPrompt(source.count - 1);
1160
+ });
1161
+
1162
+ useEffect(() => {
1163
+ restorePrompt();
1164
+ }, [promptRestoreRequest?.id, suspended]);
1165
+ const focusedReply = useRef<string | null>(null);
1166
+ const continueConversation = useEffectEvent(() => {
1167
+ if (!promptFocusRequest || suspended || !onInvoke) return;
1168
+ if (focusedReply.current === promptFocusRequest.replyId) return;
1169
+ focusedReply.current = promptFocusRequest.replyId;
1170
+ // An arriving answer cannot replace a draft or a marked passage under review.
1171
+ if (composeRef.current || heldSpan || dragging.current || promptDraft.current) return;
1172
+ focusPrompt(promptFocusRequest.blockIndex);
1173
+ });
1174
+
1175
+ useEffect(() => {
1176
+ continueConversation();
1177
+ }, [promptFocusRequest?.replyId, promptFocusRequest?.blockIndex, suspended]);
1178
+ const onPromptMouseDown = (event: TerminalMouseEvent): void => {
1179
+ if (!onInvoke || suspended) return;
1180
+ const blockIndex = source.count - 1;
1181
+ const geometry = allGeometry();
1182
+ const promptLine = geometry.find((entry) => isPromptBlock?.(entry.blockIndex));
1183
+ const lastReplyLine = geometry
1184
+ .filter((entry) => entry.blockIndex === blockIndex - 1)
1185
+ .sort((left, right) => right.y - left.y)[0];
1186
+ const promptStart = promptLine ? promptLine.y - 1 : lastReplyLine && lastReplyLine.y + 1;
1187
+ const viewport = dragViewport?.();
1188
+
1189
+ if (
1190
+ !isPromptBlock?.(blockIndex) ||
1191
+ promptStart === undefined ||
1192
+ event.y < promptStart ||
1193
+ (viewport && event.y >= viewport.bottom)
1194
+ )
1195
+ return;
1196
+ if (composeRef.current?.kind === "prompt") {
1197
+ promptDraft.current = composeTextRef.current;
1198
+ } else blurSaveCompose();
1199
+ endDrag();
1200
+ focusPrompt(blockIndex);
1201
+ };
1202
+
995
1203
  const onLineMouseDown = (event: TerminalMouseEvent): void => {
996
1204
  blurSaveCompose();
997
1205
  const pressed = positionAt(allGeometry(), event.x, event.y);
@@ -1033,10 +1241,28 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1033
1241
  spanQuote,
1034
1242
  registerLine,
1035
1243
  onLineMouseDown,
1036
- rootMouseProps: { onMouseDrag: handleRootDrag, onMouseDragEnd: endDrag, onMouseUp: endDrag },
1244
+ rootMouseProps: {
1245
+ onMouseDown: onPromptMouseDown,
1246
+ onMouseDrag: handleRootDrag,
1247
+ onMouseDragEnd: endDrag,
1248
+ onMouseUp: endDrag,
1249
+ },
1037
1250
  rangesFor,
1038
1251
  cardsAfterLine,
1039
1252
  jumpToDiscussion,
1040
1253
  blurSaveCompose,
1041
1254
  };
1042
1255
  }
1256
+
1257
+ function isAgentInvokeKey(
1258
+ key: { name: string; ctrl?: boolean; meta?: boolean; super?: boolean },
1259
+ onInvoke?: () => void,
1260
+ ): boolean {
1261
+ return Boolean(
1262
+ onInvoke &&
1263
+ (key.name === "return" || key.name === "enter") &&
1264
+ key.ctrl &&
1265
+ !key.meta &&
1266
+ !key.super,
1267
+ );
1268
+ }
@@ -100,6 +100,7 @@ test("deleted file caret scrolls one row and stays at the file boundaries", asyn
100
100
 
101
101
  for (let index = 0; index < 12; index++) {
102
102
  const before = box.scrollTop;
103
+
103
104
  await press(setup, "down");
104
105
  steps.push(box.scrollTop - before);
105
106
  const caretRow = setup