@cueloop/client 0.1.0-alpha.96 → 0.1.0-alpha.98

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 (32) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  3. package/src/annotations/components/AnnotationCards.tsx +29 -6
  4. package/src/annotations/use-annotation-surface.tsx +245 -30
  5. package/src/app/App.thread-agent.test.ts +12 -0
  6. package/src/app/App.tsx +217 -97
  7. package/src/app/app-screens.tsx +2 -0
  8. package/src/app/components/AppShell.zoom.test.tsx +4 -3
  9. package/src/app/intent-dispatch.test.ts +1 -0
  10. package/src/diff/components/AnnotatableFileView.tsx +6 -0
  11. package/src/diff/components/DiffContentView.tsx +9 -0
  12. package/src/markdown/components/ThreadView.tsx +60 -16
  13. package/src/settings/config.test.ts +15 -0
  14. package/src/settings/config.ts +14 -4
  15. package/src/stories/__snapshots__/stories.test.tsx.snap +74 -0
  16. package/src/thread/agent-continuation.test.ts +32 -0
  17. package/src/thread/agent-continuation.ts +13 -0
  18. package/src/thread/agent-transcript.test.ts +161 -0
  19. package/src/thread/agent-transcript.ts +332 -0
  20. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  21. package/src/thread/components/AgentConfigControls.test.tsx +110 -0
  22. package/src/thread/components/AgentConfigControls.tsx +114 -0
  23. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  24. package/src/thread/components/AgentThreadPane.test.tsx +780 -0
  25. package/src/thread/components/AgentThreadPane.tsx +356 -0
  26. package/src/thread/components/ThreadFooter.tsx +3 -0
  27. package/src/thread/components/ThreadTree.test.tsx +41 -0
  28. package/src/thread/components/ThreadTree.tsx +25 -5
  29. package/src/thread/thread-controller.ts +34 -0
  30. package/src/thread/use-thread-agent.test.tsx +65 -0
  31. package/src/thread/use-thread-agent.ts +101 -0
  32. package/src/workbench/components/GridTabContent.tsx +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.96",
3
+ "version": "0.1.0-alpha.98",
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.96",
31
- "@cueloop/extension-api": "0.1.0-alpha.96",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.96",
33
- "@cueloop/schema": "0.1.0-alpha.96",
30
+ "@cueloop/daemon": "0.1.0-alpha.98",
31
+ "@cueloop/extension-api": "0.1.0-alpha.98",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.98",
33
+ "@cueloop/schema": "0.1.0-alpha.98",
34
34
  "@opentui/core": "^0.5.12",
35
35
  "@opentui/keymap": "0.5.12",
36
36
  "@opentui/react": "^0.5.12",
@@ -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
65
  onReady: () => 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);
@@ -107,7 +108,9 @@ export function Composer({
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,15 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
226
254
 
227
255
  return { head: start, anchor: start };
228
256
  });
257
+ const focusRequested = useEffectEvent(() => onFocusAnnotation?.(undefined));
258
+ useEffect(() => {
259
+ if (requestedBlock === undefined) return;
260
+ const next = { blockIndex: requestedBlock.blockIndex, char: 0 };
261
+
262
+ setCursor(requestedBlock.blockIndex);
263
+ setCaret({ head: next, anchor: next });
264
+ focusRequested();
265
+ }, [requestedBlock]);
229
266
  const [compose, setCompose] = useState<ComposeState | null>(null);
230
267
  const dragPointer = useRef<{ x: number; y: number } | null>(null);
231
268
  const edgeScrollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -291,6 +328,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
291
328
  const [slashIndex, setSlashIndex] = useState(0);
292
329
  const composerReady = useRef(false);
293
330
  const composeRef = useRef<ComposeState | null>(null);
331
+ const promptDraft = useRef("");
294
332
  // every visual line registers its renderable so a drag can hit-test any
295
333
  // row on screen, across blocks (rows without text resolve to the block above)
296
334
  const lineRenderables = useRef(
@@ -317,9 +355,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
317
355
  return span ? tightenSpan(span, textLengthOf) : null;
318
356
  })();
319
357
  const caretIsSelection = heldSpan !== null;
320
- /** The typing anchor: the held selection (char-precise), else the marked word. */
358
+ /** Agent comments require a held selection; legacy surfaces may use the caret word. */
321
359
  const caretSpan = (): TextSpan | null => {
322
360
  if (heldSpan) return heldSpan;
361
+ if (onInvoke) return isPromptBlock?.(head.blockIndex) ? { start: head, end: head } : null;
323
362
  const word = wordRangeAt(blockText(head.blockIndex), head.char);
324
363
 
325
364
  return word
@@ -327,7 +366,9 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
327
366
  start: { blockIndex: cursor, char: word.start },
328
367
  end: { blockIndex: cursor, char: word.end },
329
368
  }
330
- : null;
369
+ : blockText(head.blockIndex).length === 0
370
+ ? { start: head, end: head }
371
+ : null;
331
372
  };
332
373
  const collapseCaret = (): void => setCaret({ head, anchor: head });
333
374
  const spanQuote = (span: TextSpan): string => {
@@ -444,16 +485,19 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
444
485
  if (slashActive) setSlashIndex(0);
445
486
  }, [slashActive]);
446
487
 
447
- const saveComment = (body: string): void => {
488
+ const saveComment = (body: string, invoke = false): void => {
448
489
  // the body saves verbatim - typed newlines are the author's choice;
449
490
  // trimming only decides whether the draft is empty enough to discard
450
491
  const target = composeRef.current;
492
+ const prompt = target?.kind === "prompt";
451
493
 
494
+ if (prompt && !invoke) return;
452
495
  closeCompose();
453
496
  collapseCaret();
454
497
  if (!target || body.trim().length === 0) return;
455
498
  if (target.editAnnotationId !== null) {
456
499
  onUpdateAnnotation(target.editAnnotationId, body);
500
+ if (invoke) onInvoke?.();
457
501
 
458
502
  return;
459
503
  }
@@ -462,35 +506,64 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
462
506
 
463
507
  if (discussion) {
464
508
  onReply(discussion.rootId, body);
509
+ if (invoke) onInvoke?.();
465
510
 
466
511
  return;
467
512
  }
468
513
  }
469
514
  const span = target.span ?? caretSpan();
470
515
 
471
- if (span) onAnnotate(span, body);
516
+ if (span) {
517
+ onAnnotate(span, body);
518
+ if (invoke) onInvoke?.();
519
+ }
472
520
  };
473
521
  /** A new discussion on the typing anchor; the card renders under the span's last block. */
474
522
  const openNewCompose = (seed: string): void => {
475
523
  const span = caretSpan();
476
524
 
477
- openCompose({
478
- blockIndex: span?.end.blockIndex ?? head.blockIndex,
479
- discussionKey: null,
480
- span,
481
- seed,
482
- editAnnotationId: null,
483
- });
525
+ if (
526
+ onInvoke &&
527
+ agentInputTarget(Boolean(isPromptBlock?.(head.blockIndex)), Boolean(heldSpan)) === "none"
528
+ )
529
+ return;
530
+ if (isPromptBlock?.(head.blockIndex) && span) {
531
+ openCompose({
532
+ kind: "prompt",
533
+ blockIndex: head.blockIndex,
534
+ discussionKey: null,
535
+ span,
536
+ seed: promptDraft.current + seed,
537
+ editAnnotationId: null,
538
+ });
539
+ } else {
540
+ openCompose({
541
+ kind: "comment",
542
+ blockIndex: span?.end.blockIndex ?? head.blockIndex,
543
+ discussionKey: null,
544
+ span,
545
+ seed,
546
+ editAnnotationId: null,
547
+ });
548
+ }
549
+ if (isPromptBlock?.(head.blockIndex)) promptDraft.current = "";
484
550
  };
485
551
 
486
552
  // clicking away commits the draft (blur-save); only a standalone "/query" is a palette
487
553
  // artifact, so prose that merely ends in a "/name" still saves
488
554
  const blurSaveCompose = (): void => {
489
- if (!composeRef.current) return;
555
+ const target = composeRef.current;
556
+
557
+ if (!target) return;
558
+ if (target.kind === "prompt") {
559
+ promptDraft.current = composeTextRef.current;
560
+
561
+ return closeCompose();
562
+ }
490
563
  if (isStandaloneSlashQuery(composeText) || composeText.trim().length === 0) {
491
564
  return closeCompose();
492
565
  }
493
- saveComment(composeText);
566
+ saveComment(composeText, false);
494
567
  };
495
568
 
496
569
  /** Extend the held selection to the pointer's position (word mode snaps both ends). */
@@ -590,10 +663,12 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
590
663
  return false;
591
664
  };
592
665
 
593
- /** Pre-mount window: buffer printables, honor a fast cmd+enter or newline. */
666
+ /** Pre-mount window: buffer printables, honor a fast comment save, agent invocation, or newline. */
594
667
  const handlePremountKey = (key: KeyEvent, activeCompose: ComposeState): void => {
595
668
  if (key.name === "return") {
596
- if (key.super || key.meta || key.ctrl) return saveComment(activeCompose.seed);
669
+ if (key.super && onInvoke) return;
670
+ if (key.meta || key.ctrl || key.super)
671
+ return saveComment(activeCompose.seed, isAgentInvokeKey(key, onInvoke));
597
672
  const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
598
673
 
599
674
  composeRef.current = grown;
@@ -615,6 +690,16 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
615
690
  };
616
691
 
617
692
  const handleComposeKey = (key: KeyEvent, activeCompose: ComposeState): void => {
693
+ if (onInvoke && (key.name === "return" || key.name === "enter") && key.super) {
694
+ key.preventDefault();
695
+
696
+ return;
697
+ }
698
+ if (isAgentInvokeKey(key, onInvoke)) {
699
+ key.preventDefault();
700
+
701
+ return saveComment(composerReady.current ? composeTextRef.current : activeCompose.seed, true);
702
+ }
618
703
  // the textarea owns every key while open; the view takes dismiss (which
619
704
  // also releases the discussion focus), the slash palette, and pre-mount input
620
705
  if (key.name === "escape") {
@@ -683,6 +768,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
683
768
 
684
769
  if (replyTarget) {
685
770
  openCompose({
771
+ kind: "comment",
686
772
  blockIndex: replyTarget.blockIndex,
687
773
  discussionKey: replyTarget.key,
688
774
  span: null,
@@ -778,9 +864,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
778
864
 
779
865
  if (discussion) {
780
866
  const last = discussion.annotations.at(-1)!;
781
- const editingOwn = last.author === undefined;
867
+ const editingOwn = last.author === undefined && !isAnnotationReadOnly?.(last.id);
782
868
 
783
869
  return openCompose({
870
+ kind: "comment",
784
871
  blockIndex: discussion.blockIndex,
785
872
  discussionKey: discussion.key,
786
873
  span: null,
@@ -797,6 +884,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
797
884
  const activeCompose = composeRef.current;
798
885
 
799
886
  if (activeCompose) return handleComposeKey(key, activeCompose);
887
+ if (isAgentInvokeKey(key, onInvoke)) {
888
+ key.preventDefault();
889
+ return onInvoke?.();
890
+ }
800
891
  if (key.name === "escape") {
801
892
  // from type mode esc only enters nav, so a held mark survives for `c`
802
893
  if (!navModeRef.current) return setNavMode(true);
@@ -833,9 +924,10 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
833
924
  <Composer
834
925
  key={compose.seed}
835
926
  seed={compose.seed}
836
- glyph="●"
927
+ glyph={compose.kind === "prompt" ? null : "●"}
837
928
  tokens={tokens}
838
929
  onSave={saveComment}
930
+ agentEnabled={Boolean(onInvoke)}
839
931
  onReady={() => (composerReady.current = true)}
840
932
  onInput={(text, caret) => {
841
933
  setDraft(text);
@@ -877,6 +969,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
877
969
  annotation={annotation}
878
970
  tokens={tokens}
879
971
  authorLabel={resolveAuthorLabel?.(annotation)}
972
+ action={annotationAction?.(annotation.id)}
880
973
  />
881
974
  ),
882
975
  };
@@ -930,7 +1023,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
930
1023
  line: VisualLine,
931
1024
  isLastLine: boolean,
932
1025
  ): React.ReactNode[] => {
933
- const endsInLine = (end: number): boolean => end - 1 >= line.start && end - 1 < line.end;
1026
+ const endsInLine = (end: number): boolean =>
1027
+ (end === 0 && line.start === 0) || (end - 1 >= line.start && end - 1 < line.end);
934
1028
  const nodes: React.ReactNode[] = [];
935
1029
  const composeHere = compose && compose.blockIndex === blockIndex;
936
1030
  const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
@@ -939,6 +1033,16 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
939
1033
  const composeStart = compose?.span?.start;
940
1034
 
941
1035
  const pushComposeCard = (): void => {
1036
+ if (compose?.kind === "prompt") {
1037
+ nodes.push(
1038
+ <box key="compose-prompt" style={{ paddingLeft: 2 }}>
1039
+ {composerNode}
1040
+ </box>,
1041
+ );
1042
+ nodes.push(paletteNode);
1043
+
1044
+ return;
1045
+ }
942
1046
  nodes.push(
943
1047
  <DiscussionCard
944
1048
  key="compose-new"
@@ -992,6 +1096,99 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
992
1096
  } else lineRenderables.current.delete(key);
993
1097
  };
994
1098
 
1099
+ const focusPrompt = (blockIndex: number): void => {
1100
+ const position = { blockIndex, char: 0 };
1101
+
1102
+ setNavMode(false);
1103
+ setFocusedDiscussion(null);
1104
+ setCursor(blockIndex);
1105
+ setCaret({ head: position, anchor: position });
1106
+ openCompose({
1107
+ kind: "prompt",
1108
+ blockIndex,
1109
+ discussionKey: null,
1110
+ span: { start: position, end: position },
1111
+ seed: promptDraft.current,
1112
+ editAnnotationId: null,
1113
+ });
1114
+ promptDraft.current = "";
1115
+ };
1116
+ const movePromptDraft = useEffectEvent(() => {
1117
+ const active = composeRef.current;
1118
+ const blockIndex = source.count - 1;
1119
+
1120
+ if (
1121
+ active?.kind !== "prompt" ||
1122
+ !isPromptBlock?.(blockIndex) ||
1123
+ active.blockIndex === blockIndex
1124
+ )
1125
+ return;
1126
+ const position = { blockIndex, char: 0 };
1127
+
1128
+ openCompose({
1129
+ ...active,
1130
+ blockIndex,
1131
+ span: { start: position, end: position },
1132
+ seed: composeTextRef.current,
1133
+ });
1134
+ setCursor(blockIndex);
1135
+ setCaret({ head: position, anchor: position });
1136
+ });
1137
+ useEffect(() => {
1138
+ movePromptDraft();
1139
+ }, [source.count]);
1140
+ const restoredPrompt = useRef<number | null>(null);
1141
+ const restorePrompt = useEffectEvent(() => {
1142
+ if (!promptRestoreRequest || suspended || !onInvoke) return;
1143
+ if (restoredPrompt.current === promptRestoreRequest.id) return;
1144
+ restoredPrompt.current = promptRestoreRequest.id;
1145
+ const active = composeRef.current;
1146
+ const newerDraft = active?.kind === "prompt" ? composeTextRef.current : promptDraft.current;
1147
+
1148
+ if (active?.kind !== "prompt") blurSaveCompose();
1149
+ promptDraft.current = [promptRestoreRequest.text, newerDraft].filter(Boolean).join("\n");
1150
+ focusPrompt(source.count - 1);
1151
+ });
1152
+ useEffect(() => {
1153
+ restorePrompt();
1154
+ }, [promptRestoreRequest?.id, suspended]);
1155
+ const focusedReply = useRef<string | null>(null);
1156
+ const continueConversation = useEffectEvent(() => {
1157
+ if (!promptFocusRequest || suspended || !onInvoke) return;
1158
+ if (focusedReply.current === promptFocusRequest.replyId) return;
1159
+ focusedReply.current = promptFocusRequest.replyId;
1160
+ // An arriving answer cannot replace a draft or a marked passage under review.
1161
+ if (composeRef.current || heldSpan || dragging.current || promptDraft.current) return;
1162
+ focusPrompt(promptFocusRequest.blockIndex);
1163
+ });
1164
+ useEffect(() => {
1165
+ continueConversation();
1166
+ }, [promptFocusRequest?.replyId, promptFocusRequest?.blockIndex, suspended]);
1167
+ const onPromptMouseDown = (event: TerminalMouseEvent): void => {
1168
+ if (!onInvoke || suspended) return;
1169
+ const blockIndex = source.count - 1;
1170
+ const geometry = allGeometry();
1171
+ const promptLine = geometry.find((entry) => isPromptBlock?.(entry.blockIndex));
1172
+ const lastReplyLine = geometry
1173
+ .filter((entry) => entry.blockIndex === blockIndex - 1)
1174
+ .sort((left, right) => right.y - left.y)[0];
1175
+ const promptStart = promptLine ? promptLine.y - 1 : lastReplyLine && lastReplyLine.y + 1;
1176
+ const viewport = dragViewport?.();
1177
+
1178
+ if (
1179
+ !isPromptBlock?.(blockIndex) ||
1180
+ promptStart === undefined ||
1181
+ event.y < promptStart ||
1182
+ (viewport && event.y >= viewport.bottom)
1183
+ )
1184
+ return;
1185
+ if (composeRef.current?.kind === "prompt") {
1186
+ promptDraft.current = composeTextRef.current;
1187
+ } else blurSaveCompose();
1188
+ endDrag();
1189
+ focusPrompt(blockIndex);
1190
+ };
1191
+
995
1192
  const onLineMouseDown = (event: TerminalMouseEvent): void => {
996
1193
  blurSaveCompose();
997
1194
  const pressed = positionAt(allGeometry(), event.x, event.y);
@@ -1033,10 +1230,28 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
1033
1230
  spanQuote,
1034
1231
  registerLine,
1035
1232
  onLineMouseDown,
1036
- rootMouseProps: { onMouseDrag: handleRootDrag, onMouseDragEnd: endDrag, onMouseUp: endDrag },
1233
+ rootMouseProps: {
1234
+ onMouseDown: onPromptMouseDown,
1235
+ onMouseDrag: handleRootDrag,
1236
+ onMouseDragEnd: endDrag,
1237
+ onMouseUp: endDrag,
1238
+ },
1037
1239
  rangesFor,
1038
1240
  cardsAfterLine,
1039
1241
  jumpToDiscussion,
1040
1242
  blurSaveCompose,
1041
1243
  };
1042
1244
  }
1245
+
1246
+ function isAgentInvokeKey(
1247
+ key: { name: string; ctrl?: boolean; meta?: boolean; super?: boolean },
1248
+ onInvoke?: () => void,
1249
+ ): boolean {
1250
+ return Boolean(
1251
+ onInvoke &&
1252
+ (key.name === "return" || key.name === "enter") &&
1253
+ key.ctrl &&
1254
+ !key.meta &&
1255
+ !key.super,
1256
+ );
1257
+ }
@@ -0,0 +1,12 @@
1
+ import { expect, test } from "bun:test";
2
+ import { canRunThreadAgent } from "./App";
3
+
4
+ test("experimental agent UI requires a local owner and an enabled flag", () => {
5
+ const local = { enabled: true, owner: true, editing: false, shared: false };
6
+
7
+ expect(canRunThreadAgent(local)).toBe(true);
8
+ expect(canRunThreadAgent({ ...local, enabled: false })).toBe(false);
9
+ expect(canRunThreadAgent({ ...local, owner: false })).toBe(false);
10
+ expect(canRunThreadAgent({ ...local, editing: true })).toBe(false);
11
+ expect(canRunThreadAgent({ ...local, shared: true })).toBe(false);
12
+ });