@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.
- package/package.json +11 -11
- package/src/annotations/components/AnnotationCards.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +31 -8
- package/src/annotations/use-annotation-surface.tsx +257 -31
- package/src/app/App.deleted-scroll.test.tsx +1 -0
- package/src/app/App.diff.test.tsx +1 -0
- package/src/app/App.identity.test.tsx +1 -0
- package/src/app/App.regression.test.tsx +5 -0
- package/src/app/App.share-dialog.test.tsx +5 -0
- package/src/app/App.submit-confirm.test.tsx +3 -0
- package/src/app/App.test.tsx +1 -0
- package/src/app/App.thread-agent.test.ts +12 -0
- package/src/app/App.tsx +223 -97
- package/src/app/App.visual.test.tsx +2 -0
- package/src/app/App.workbench.test.tsx +53 -0
- package/src/app/app-screens.tsx +3 -0
- package/src/app/components/AppShell.tsx +1 -0
- package/src/app/components/AppShell.zoom.test.tsx +4 -3
- package/src/app/intent-dispatch.test.ts +1 -0
- package/src/app/run.ts +1 -0
- package/src/diff/components/AnnotatableFileView.test.tsx +2 -0
- package/src/diff/components/AnnotatableFileView.tsx +8 -0
- package/src/diff/components/DiffContentView.scroll.test.tsx +1 -0
- package/src/diff/components/DiffContentView.tsx +10 -0
- package/src/diff/diff-intraline.test.ts +1 -0
- package/src/diff/diff-intraline.ts +2 -0
- package/src/diff/split-diff.ts +2 -0
- package/src/integrations/components/ShareDialog.tsx +1 -0
- package/src/markdown/components/ThreadView.tsx +62 -16
- package/src/markdown/mark-runs.ts +1 -0
- package/src/markdown/markdown-highlight.ts +2 -0
- package/src/performance/otlp-trace.test.ts +3 -0
- package/src/performance/otlp-trace.ts +4 -0
- package/src/performance/perf-report.ts +1 -0
- package/src/performance/perf-timings.ts +1 -0
- package/src/settings/components/quick-actions-editor.test.tsx +3 -0
- package/src/settings/config.test.ts +15 -0
- package/src/settings/config.ts +14 -4
- package/src/stories/__snapshots__/stories.test.tsx.snap +74 -0
- package/src/stories/stories-app.tsx +3 -0
- package/src/stories/story.ts +2 -0
- package/src/terminal/pty.ts +1 -0
- package/src/thread/agent-continuation.test.ts +32 -0
- package/src/thread/agent-continuation.ts +13 -0
- package/src/thread/agent-transcript.test.ts +161 -0
- package/src/thread/agent-transcript.ts +336 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +112 -0
- package/src/thread/components/AgentConfigControls.tsx +114 -0
- package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
- package/src/thread/components/AgentThreadPane.test.tsx +829 -0
- package/src/thread/components/AgentThreadPane.tsx +367 -0
- package/src/thread/components/ThreadFooter.test.tsx +2 -0
- package/src/thread/components/ThreadFooter.tsx +3 -0
- package/src/thread/components/ThreadTree.test.tsx +41 -0
- package/src/thread/components/ThreadTree.tsx +25 -5
- package/src/thread/components/thread-tree.test.ts +3 -0
- package/src/thread/components/thread-tree.ts +4 -0
- package/src/thread/thread-controller.curate.test.ts +1 -0
- package/src/thread/thread-controller.live-diff.test.ts +7 -0
- package/src/thread/thread-controller.share.test.ts +1 -0
- package/src/thread/thread-controller.ts +42 -0
- package/src/thread/use-thread-agent.test.tsx +208 -0
- package/src/thread/use-thread-agent.ts +111 -0
- package/src/ui/components/PromptDialog.test.tsx +4 -0
- package/src/ui/components/RootOverlay.tsx +1 -0
- package/src/ui/components/primitives/Dialog.test.tsx +1 -0
- package/src/workbench/components/ChangesColumn.tsx +1 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +2 -0
- package/src/workbench/components/FileContentsView.tsx +2 -0
- package/src/workbench/components/GridTabContent.tsx +7 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
- package/src/workbench/components/ProjectTreeView.tsx +1 -0
- package/src/workbench/components/editor-grid.test.ts +11 -0
- package/src/workbench/components/editor-grid.ts +10 -0
- 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.
|
|
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.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
34
|
-
"@opentui/core": "^0.5.
|
|
35
|
-
"@opentui/keymap": "0.5.
|
|
36
|
-
"@opentui/react": "^0.5.
|
|
37
|
-
"@opentui/ssh": "0.5.
|
|
38
|
-
"@pierre/diffs": "^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.
|
|
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
|
-
//
|
|
30
|
-
//
|
|
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
|
-
|
|
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={
|
|
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;
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
/**
|
|
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
|
-
:
|
|
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)
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =>
|
|
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: {
|
|
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
|