@cueloop/client 0.1.0-alpha.11 → 0.1.0-alpha.13
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 +7 -7
- package/src/App.autoclose.test.tsx +24 -35
- package/src/App.diff.test.tsx +5 -18
- package/src/App.export.test.tsx +8 -21
- package/src/App.plan-review.test.tsx +24 -53
- package/src/App.readonly.test.tsx +3 -18
- package/src/App.rendering.test.tsx +5 -13
- package/src/App.submit-confirm.test.tsx +7 -25
- package/src/App.test.tsx +13 -52
- package/src/App.tsx +197 -910
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +136 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +50 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +22 -0
- package/src/components/ConfirmCard.tsx +130 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +42 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +309 -0
- package/src/components/ReviewRail.stories.tsx +103 -0
- package/src/components/ReviewRail.tsx +158 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1189 -0
- package/src/components/format.ts +6 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +58 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +52 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/stories-app.tsx +94 -0
- package/src/components/stories.test.tsx +51 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/key-bindings.test.ts +103 -0
- package/src/key-bindings.ts +332 -0
- package/src/keymap.ts +8 -2
- package/src/run.ts +3 -1
- package/src/session-controller.ts +28 -7
- package/src/test-support.ts +87 -0
- package/src/theme.ts +3 -0
- package/src/view.test.ts +35 -13
- package/src/view.ts +41 -64
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
package/src/App.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The cueloop TUI (#22, #71):
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The cueloop TUI (#22, #71, #86): state wiring, keymap dispatch, and layout
|
|
3
|
+
* composition - nothing else. Rendering lives in components/ (PlanSheet,
|
|
4
|
+
* DiffSheet, ReviewRail, ...), daemon IO and the mutation verbs live in
|
|
5
|
+
* session-controller.ts, the keyboard grammar in keymap.ts with binding
|
|
6
|
+
* resolution and status hints from key-bindings.ts, and theming flows
|
|
7
|
+
* through the ThemeProvider - config themes swap the provider value.
|
|
7
8
|
*
|
|
8
9
|
* The plan review grammar: selection is the entry primitive (mouse drag or
|
|
9
10
|
* keyboard span on one native renderer selection), compose happens inline
|
|
@@ -13,31 +14,26 @@
|
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
16
|
-
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
17
|
-
import type {
|
|
18
|
-
import { type
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
composeBoxHeight,
|
|
22
|
-
displayText,
|
|
23
|
-
marksByDisplay,
|
|
24
|
-
overlayMarks,
|
|
25
|
-
revisionDelta,
|
|
26
|
-
spanKey,
|
|
27
|
-
startSpan,
|
|
28
|
-
wrapRuns,
|
|
29
|
-
type DisplayBlock,
|
|
30
|
-
type Mark,
|
|
31
|
-
type SpanState,
|
|
32
|
-
type StyleRun,
|
|
33
|
-
} from "./view";
|
|
34
|
-
import { type DiffRow } from "./view-diff";
|
|
35
|
-
import { DARK as T } from "./theme";
|
|
36
|
-
import { highlightCode, type CodeToken } from "./syntax";
|
|
17
|
+
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
18
|
+
import type { Clock } from "@opentui/core";
|
|
19
|
+
import { type ReviewSession, type VerdictKind } from "@cueloop/schema";
|
|
20
|
+
import { displayText, marksByDisplay, spanKey, startSpan, type Mark, type SpanState } from "./view";
|
|
21
|
+
import { DARK } from "./theme";
|
|
37
22
|
import { DEFAULT_KEYS, loadConfig } from "./config";
|
|
38
23
|
import { returnPaneFor } from "./herdr";
|
|
39
24
|
import { createReviewController } from "./session-controller";
|
|
40
25
|
import { reduceKey, type Intent, type KeyState } from "./keymap";
|
|
26
|
+
import { KeyBindings, type HintMode } from "./key-bindings";
|
|
27
|
+
import { ThemeProvider } from "./components/theme-context";
|
|
28
|
+
import { StatusBar } from "./components/primitives/StatusBar";
|
|
29
|
+
import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
|
|
30
|
+
import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
|
|
31
|
+
import { DiffSheet } from "./components/DiffSheet";
|
|
32
|
+
import { ReviewRail, annotationBlocking, type ReviewRailHandle } from "./components/ReviewRail";
|
|
33
|
+
import { VERDICTS } from "./components/ConfirmCard";
|
|
34
|
+
import { CompletionOverlay } from "./components/CompletionOverlay";
|
|
35
|
+
import { InboxList } from "./components/InboxList";
|
|
36
|
+
import { ComposeBar } from "./components/ComposeBar";
|
|
41
37
|
|
|
42
38
|
export interface AppProps {
|
|
43
39
|
home?: string;
|
|
@@ -48,6 +44,8 @@ export interface AppProps {
|
|
|
48
44
|
*/
|
|
49
45
|
readOnly?: boolean;
|
|
50
46
|
onExit?: (code: number) => void;
|
|
47
|
+
/** Timer source for the auto-close countdown; tests inject a ManualClock. */
|
|
48
|
+
clock?: Clock;
|
|
51
49
|
}
|
|
52
50
|
|
|
53
51
|
type Mode =
|
|
@@ -57,35 +55,13 @@ type Mode =
|
|
|
57
55
|
| { m: "railEdit"; id: string; text: string }
|
|
58
56
|
| { m: "submit"; verdict: VerdictKind; summary: string };
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
|
|
63
|
-
/** Selector words in the rail confirm card - one word per verdict. */
|
|
64
|
-
const VERDICT_LABEL: Record<VerdictKind, string> = {
|
|
65
|
-
comment: "Comment",
|
|
66
|
-
approve: "Approve",
|
|
67
|
-
request_changes: "Changes",
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
/** One rendered plan line, registered for the native selection primitive. */
|
|
71
|
-
interface PlanLineRef {
|
|
72
|
-
renderable: TextRenderable;
|
|
73
|
-
dispIdx: number;
|
|
74
|
-
lineIndex: number;
|
|
75
|
-
/** Block-text offset of the line's first positioned run; null for pure-del lines. */
|
|
76
|
-
lineStart: number | null;
|
|
77
|
-
/** Screen columns before the run text (cursor glyph + list marker). */
|
|
78
|
-
prefixColumns: number;
|
|
79
|
-
/** Length of the positioned run text in this line. */
|
|
80
|
-
textLength: number;
|
|
58
|
+
function defaultVerdict(session: ReviewSession): VerdictKind {
|
|
59
|
+
return session.annotations.length || session.workingCopy !== undefined ? "request_changes" : "approve";
|
|
81
60
|
}
|
|
82
61
|
|
|
83
|
-
|
|
84
|
-
const RAIL_CARD_HEIGHT = 4;
|
|
85
|
-
|
|
86
|
-
export function App({ home, sessionId, readOnly = false, onExit }: AppProps): React.ReactNode {
|
|
62
|
+
export function App({ home, sessionId, readOnly = false, onExit, clock }: AppProps): React.ReactNode {
|
|
87
63
|
const controller = useMemo(
|
|
88
|
-
() => createReviewController({ home, sessionId, readOnly, onExit }),
|
|
64
|
+
() => createReviewController({ home, sessionId, readOnly, onExit, clock }),
|
|
89
65
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
66
|
[home, sessionId],
|
|
91
67
|
);
|
|
@@ -99,31 +75,33 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
99
75
|
controller.getSnapshot,
|
|
100
76
|
);
|
|
101
77
|
const renderer = useRenderer();
|
|
78
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
102
79
|
|
|
103
80
|
// ── view state ──────────────────────────────
|
|
104
81
|
const [cursor, setCursor] = useState(0);
|
|
105
82
|
const [inboxCursor, setInboxCursor] = useState(0);
|
|
106
83
|
const [mode, setMode] = useState<Mode>({ m: "normal" });
|
|
107
84
|
const [focusedAnn, setFocusedAnn] = useState<string | undefined>(undefined);
|
|
108
|
-
const [railTab, setRailTab] = useState<
|
|
85
|
+
const [railTab, setRailTab] = useState<"review" | "agent">("review");
|
|
109
86
|
// ~2s focus pulse on the document highlight when a rail card is activated
|
|
110
87
|
const [pulseAnn, setPulseAnn] = useState<string | null>(null);
|
|
111
88
|
const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
112
89
|
// live mirror of overlay input text: refs commit synchronously, so the
|
|
113
90
|
// RETURN handler never reads a stale value mid-typing
|
|
114
91
|
const liveInput = useRef("");
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
const railScrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
119
|
-
// keymap from layered config; theme overrides land on the shared tokens
|
|
92
|
+
const planSheetRef = useRef<PlanSheetHandle | null>(null);
|
|
93
|
+
const railRef = useRef<ReviewRailHandle | null>(null);
|
|
94
|
+
// keymap from layered config; the loaded theme swaps the provider value
|
|
120
95
|
const keysRef = useRef(DEFAULT_KEYS);
|
|
96
|
+
const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
|
|
97
|
+
const [theme, setTheme] = useState(DARK);
|
|
121
98
|
useEffect(() => {
|
|
122
99
|
const cfg = loadConfig({ repoRoot: session?.workspace.repoRoot });
|
|
123
100
|
keysRef.current = cfg.keys;
|
|
124
|
-
|
|
101
|
+
keyBindings.setKeys(cfg.keys);
|
|
102
|
+
setTheme(cfg.theme);
|
|
125
103
|
controller.applyConfig(cfg);
|
|
126
|
-
}, [session?.workspace.repoRoot, controller]);
|
|
104
|
+
}, [session?.workspace.repoRoot, controller, keyBindings]);
|
|
127
105
|
useEffect(
|
|
128
106
|
() => () => {
|
|
129
107
|
if (pulseTimer.current) clearTimeout(pulseTimer.current);
|
|
@@ -149,62 +127,14 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
149
127
|
const resolved = session?.status === "resolved";
|
|
150
128
|
const isDiff = session?.artifact.type === "diff";
|
|
151
129
|
|
|
152
|
-
// ── the native selection: keyboard driver ───
|
|
153
|
-
const lineRefFor = (dispIdx: number, offset: number): PlanLineRef | null => {
|
|
154
|
-
for (const meta of lineRefs.current.values()) {
|
|
155
|
-
if (meta.dispIdx !== dispIdx || meta.lineStart === null) continue;
|
|
156
|
-
if (offset >= meta.lineStart && offset < meta.lineStart + meta.textLength) return meta;
|
|
157
|
-
}
|
|
158
|
-
return null;
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
/** Anchor/extend the renderer's native selection from keyboard span offsets. */
|
|
162
|
-
const driveNativeSelection = (span: SpanState): void => {
|
|
163
|
-
if (!renderer) return;
|
|
164
|
-
const startMeta = lineRefFor(span.dispIdx, span.start);
|
|
165
|
-
const endMeta = lineRefFor(span.dispIdx, span.end - 1);
|
|
166
|
-
if (!startMeta || !endMeta) return;
|
|
167
|
-
const startColumn = startMeta.prefixColumns + (span.start - startMeta.lineStart!);
|
|
168
|
-
const endColumn = endMeta.prefixColumns + (span.end - 1 - endMeta.lineStart!);
|
|
169
|
-
renderer.startSelection(startMeta.renderable, startMeta.renderable.x + startColumn, startMeta.renderable.y);
|
|
170
|
-
renderer.updateSelection(endMeta.renderable, endMeta.renderable.x + endColumn + 1, endMeta.renderable.y);
|
|
171
|
-
};
|
|
172
130
|
// driving needs committed layout, so it runs after render; any transition
|
|
173
131
|
// out of span mode clears the renderer selection (compose paints its own
|
|
174
132
|
// mark, and a mouse drag never changes the mode, so it survives)
|
|
175
133
|
useEffect(() => {
|
|
176
|
-
if (mode.m === "span")
|
|
177
|
-
else
|
|
178
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
134
|
+
if (mode.m === "span") planSheetRef.current?.driveSpanSelection(mode.span);
|
|
135
|
+
else planSheetRef.current?.clearSelection();
|
|
179
136
|
}, [mode]);
|
|
180
137
|
|
|
181
|
-
/**
|
|
182
|
-
* Read the native selection (mouse drag) back as a block range. The quote
|
|
183
|
-
* anchors in the FIRST selected block - quote anchors resolve within one
|
|
184
|
-
* block. Columns map 1:1 onto text offsets except inside word-diffed mod
|
|
185
|
-
* blocks, where inline del runs shift the screen columns.
|
|
186
|
-
*/
|
|
187
|
-
const readNativeSelection = (): { dispIdx: number; start: number; end: number } | null => {
|
|
188
|
-
if (!renderer?.hasSelection) return null;
|
|
189
|
-
const ordered = [...lineRefs.current.values()].sort(
|
|
190
|
-
(a, b) => a.dispIdx - b.dispIdx || a.lineIndex - b.lineIndex,
|
|
191
|
-
);
|
|
192
|
-
let found: { dispIdx: number; start: number; end: number } | null = null;
|
|
193
|
-
for (const meta of ordered) {
|
|
194
|
-
if (meta.lineStart === null) continue;
|
|
195
|
-
const selection = meta.renderable.getSelection();
|
|
196
|
-
if (!selection || selection.end <= selection.start) continue;
|
|
197
|
-
const startInText = Math.min(meta.textLength, Math.max(0, selection.start - meta.prefixColumns));
|
|
198
|
-
const endInText = Math.min(meta.textLength, Math.max(0, selection.end - meta.prefixColumns));
|
|
199
|
-
if (endInText <= startInText) continue;
|
|
200
|
-
const blockStart = meta.lineStart + startInText;
|
|
201
|
-
const blockEnd = meta.lineStart + endInText;
|
|
202
|
-
if (!found) found = { dispIdx: meta.dispIdx, start: blockStart, end: blockEnd };
|
|
203
|
-
else if (found.dispIdx === meta.dispIdx) found.end = Math.max(found.end, blockEnd);
|
|
204
|
-
}
|
|
205
|
-
return found;
|
|
206
|
-
};
|
|
207
|
-
|
|
208
138
|
// ── selection symmetry: one selected id, both sides ──
|
|
209
139
|
const pulse = (id: string): void => {
|
|
210
140
|
setPulseAnn(id);
|
|
@@ -212,28 +142,19 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
212
142
|
pulseTimer.current = setTimeout(() => setPulseAnn(null), 2000);
|
|
213
143
|
};
|
|
214
144
|
|
|
215
|
-
const revealRailCard = (annotationId: string): void => {
|
|
216
|
-
const index = session?.annotations.findIndex((annotation) => annotation.id === annotationId) ?? -1;
|
|
217
|
-
if (index >= 0) railScrollRef.current?.scrollTo(Math.max(0, index * RAIL_CARD_HEIGHT - 1));
|
|
218
|
-
};
|
|
219
|
-
|
|
220
145
|
/** Card activation scrolls the document to the anchor and pulses it. */
|
|
221
146
|
const revealAnchor = (annotationId: string): void => {
|
|
222
147
|
for (const [dispIdx, blockMarks] of marks) {
|
|
223
148
|
if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
|
|
224
149
|
setCursor(dispIdx);
|
|
225
|
-
|
|
226
|
-
docScrollRef.current?.scrollChildIntoView(`plan-block-${dispIdx}`);
|
|
227
|
-
} catch {
|
|
228
|
-
// reveal is best-effort; selection state is already correct
|
|
229
|
-
}
|
|
150
|
+
planSheetRef.current?.revealBlock(dispIdx);
|
|
230
151
|
return;
|
|
231
152
|
}
|
|
232
153
|
};
|
|
233
154
|
|
|
234
155
|
const selectCardFromDocument = (annotationId: string): void => {
|
|
235
156
|
setFocusedAnn(annotationId);
|
|
236
|
-
|
|
157
|
+
railRef.current?.revealCard(annotationId);
|
|
237
158
|
};
|
|
238
159
|
|
|
239
160
|
const selectCardFromRail = (annotationId: string): void => {
|
|
@@ -251,6 +172,16 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
251
172
|
setMode({ m: "railEdit", id: annotation.id, text: annotation.body });
|
|
252
173
|
};
|
|
253
174
|
|
|
175
|
+
/** The $EDITOR hand-off releases the terminal: suspend, edit, resume. */
|
|
176
|
+
const runEditorHandOff = (): void => {
|
|
177
|
+
renderer?.suspend();
|
|
178
|
+
try {
|
|
179
|
+
controller.edit();
|
|
180
|
+
} finally {
|
|
181
|
+
renderer?.resume();
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
254
185
|
// ── keyboard grammar: build state, reduce, dispatch ──
|
|
255
186
|
const dispatch = (intent: Intent): void => {
|
|
256
187
|
switch (intent.t) {
|
|
@@ -287,7 +218,6 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
287
218
|
if (mode.m === "span") {
|
|
288
219
|
const span = spanKey(mode.span, intent.name, displayText(display[mode.span.dispIdx]!));
|
|
289
220
|
setMode({ m: "span", span });
|
|
290
|
-
driveNativeSelection(span);
|
|
291
221
|
}
|
|
292
222
|
return;
|
|
293
223
|
case "openCompose": {
|
|
@@ -306,7 +236,7 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
306
236
|
if (row) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: row.text.length, text: "" });
|
|
307
237
|
} else {
|
|
308
238
|
// a mouse drag leaves a native selection; it wins over the cursor block
|
|
309
|
-
const native =
|
|
239
|
+
const native = planSheetRef.current?.readSelection() ?? null;
|
|
310
240
|
if (native) {
|
|
311
241
|
setMode({ m: "compose", kind: intent.kind, ...native, text: "" });
|
|
312
242
|
} else {
|
|
@@ -325,7 +255,7 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
325
255
|
case "cut":
|
|
326
256
|
return controller.cut(cursor);
|
|
327
257
|
case "edit":
|
|
328
|
-
return
|
|
258
|
+
return runEditorHandOff();
|
|
329
259
|
case "editCard":
|
|
330
260
|
if (focusedAnn) openCardEdit(focusedAnn);
|
|
331
261
|
return;
|
|
@@ -344,7 +274,7 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
344
274
|
}
|
|
345
275
|
return;
|
|
346
276
|
case "deselect":
|
|
347
|
-
|
|
277
|
+
planSheetRef.current?.clearSelection();
|
|
348
278
|
setFocusedAnn(undefined);
|
|
349
279
|
setPulseAnn(null);
|
|
350
280
|
return;
|
|
@@ -380,20 +310,22 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
380
310
|
}
|
|
381
311
|
};
|
|
382
312
|
|
|
313
|
+
const overlay: KeyState["overlay"] =
|
|
314
|
+
mode.m === "compose" || mode.m === "railEdit"
|
|
315
|
+
? "compose"
|
|
316
|
+
: mode.m === "submit"
|
|
317
|
+
? "submit"
|
|
318
|
+
: completion.phase === "prompt"
|
|
319
|
+
? "completion-prompt"
|
|
320
|
+
: completion.phase === "counting"
|
|
321
|
+
? "completion-counting"
|
|
322
|
+
: "none";
|
|
323
|
+
|
|
383
324
|
useKeyboard((key) => {
|
|
384
325
|
const state: KeyState = {
|
|
385
326
|
keys: keysRef.current,
|
|
386
327
|
readOnly,
|
|
387
|
-
overlay
|
|
388
|
-
mode.m === "compose" || mode.m === "railEdit"
|
|
389
|
-
? "compose"
|
|
390
|
-
: mode.m === "submit"
|
|
391
|
-
? "submit"
|
|
392
|
-
: completion.phase === "prompt"
|
|
393
|
-
? "completion-prompt"
|
|
394
|
-
: completion.phase === "counting"
|
|
395
|
-
? "completion-counting"
|
|
396
|
-
: "none",
|
|
328
|
+
overlay,
|
|
397
329
|
view: !session ? "inbox" : isDiff ? "diff" : "plan",
|
|
398
330
|
spanMode: mode.m === "span",
|
|
399
331
|
resolved: !!resolved,
|
|
@@ -404,25 +336,47 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
404
336
|
? rows[cursor] !== undefined && rows[cursor]!.t !== "file" && rows[cursor]!.t !== "hunk"
|
|
405
337
|
: !!display[cursor]?.work,
|
|
406
338
|
};
|
|
407
|
-
|
|
339
|
+
keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
|
|
340
|
+
const action = keyBindings.resolveAction({ name: key.name, shift: !!key.shift });
|
|
341
|
+
for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift }, action)) dispatch(intent);
|
|
408
342
|
});
|
|
409
343
|
|
|
410
344
|
// ── render ──────────────────────────────────
|
|
411
|
-
if (error)
|
|
412
|
-
|
|
413
|
-
|
|
345
|
+
if (error) {
|
|
346
|
+
return (
|
|
347
|
+
<ThemeProvider theme={theme}>
|
|
348
|
+
<text fg={theme.red}>cueloop: {error}</text>
|
|
349
|
+
</ThemeProvider>
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
if (!session && !inbox) {
|
|
353
|
+
return (
|
|
354
|
+
<ThemeProvider theme={theme}>
|
|
355
|
+
<text fg={theme.textDim}>connecting to the daemon…</text>
|
|
356
|
+
</ThemeProvider>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
if (!session && inbox) {
|
|
360
|
+
return (
|
|
361
|
+
<ThemeProvider theme={theme}>
|
|
362
|
+
<InboxList inbox={inbox} cursor={inboxCursor} />
|
|
363
|
+
</ThemeProvider>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
414
366
|
|
|
415
367
|
const s = session!;
|
|
416
368
|
const pendingCount = s.annotations.length + (s.workingCopy !== undefined ? 1 : 0);
|
|
417
369
|
|
|
418
370
|
if ((completion.phase === "prompt" || completion.phase === "counting") && s.verdict) {
|
|
419
371
|
return (
|
|
420
|
-
<
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
372
|
+
<ThemeProvider theme={theme}>
|
|
373
|
+
<CompletionOverlay
|
|
374
|
+
verdict={s.verdict.kind}
|
|
375
|
+
completion={completion}
|
|
376
|
+
status={status}
|
|
377
|
+
returnsTo={returnPaneFor(s.artifact.meta.herdrPane) ? (s.artifact.meta.agent ?? "the agent") : undefined}
|
|
378
|
+
/>
|
|
379
|
+
</ThemeProvider>
|
|
426
380
|
);
|
|
427
381
|
}
|
|
428
382
|
|
|
@@ -432,20 +386,25 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
432
386
|
kind: mode.kind,
|
|
433
387
|
dispIdx: mode.dispIdx,
|
|
434
388
|
quote: displayText(display[mode.dispIdx]!).slice(mode.start, mode.end),
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
389
|
+
draft: {
|
|
390
|
+
text: mode.text,
|
|
391
|
+
onInput: (text: string) => {
|
|
392
|
+
liveInput.current = text;
|
|
393
|
+
setMode({ ...mode, text });
|
|
394
|
+
},
|
|
395
|
+
onSave: () => dispatch({ t: "saveCompose" }),
|
|
396
|
+
onCancel: () => dispatch({ t: "closeOverlay" }),
|
|
439
397
|
},
|
|
440
|
-
onSave: () => dispatch({ t: "saveCompose" }),
|
|
441
|
-
onCancel: () => dispatch({ t: "closeOverlay" }),
|
|
442
398
|
}
|
|
443
399
|
: null;
|
|
444
400
|
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
401
|
+
const activeSpan =
|
|
402
|
+
mode.m === "span"
|
|
403
|
+
? { dispIdx: mode.span.dispIdx, start: mode.span.start, end: mode.span.end }
|
|
404
|
+
: mode.m === "compose" && !isDiff
|
|
405
|
+
? // the compose anchor stays painted selection-style while the box is open
|
|
406
|
+
{ dispIdx: mode.dispIdx, start: mode.start, end: mode.end }
|
|
407
|
+
: null;
|
|
449
408
|
|
|
450
409
|
const onLineActivate = (dispIdx: number): void => {
|
|
451
410
|
// releasing a drag-selection lands here too; a live selection is not a click
|
|
@@ -458,7 +417,7 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
458
417
|
const onEditRequest = (): void => {
|
|
459
418
|
if (readOnly) return controller.setStatus("observer - read-only");
|
|
460
419
|
if (resolved) return controller.setStatus("review submitted - read-only");
|
|
461
|
-
|
|
420
|
+
runEditorHandOff();
|
|
462
421
|
};
|
|
463
422
|
|
|
464
423
|
// clicking the rail Submit button: same read-only answer as the submit key
|
|
@@ -468,7 +427,7 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
468
427
|
dispatch({ t: "openSubmit" });
|
|
469
428
|
};
|
|
470
429
|
|
|
471
|
-
const submitConfirmState
|
|
430
|
+
const submitConfirmState =
|
|
472
431
|
mode.m === "submit"
|
|
473
432
|
? {
|
|
474
433
|
verdict: mode.verdict,
|
|
@@ -485,765 +444,93 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
|
|
|
485
444
|
}
|
|
486
445
|
: null;
|
|
487
446
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
<DiffPane rows={rows} cursor={cursor} annotations={s.annotations} focusedAnn={focusedAnn} />
|
|
502
|
-
) : (
|
|
503
|
-
<PlanPane
|
|
504
|
-
display={display}
|
|
505
|
-
marks={marks}
|
|
506
|
-
cursor={cursor}
|
|
507
|
-
mode={mode}
|
|
508
|
-
session={s}
|
|
509
|
-
editOrphanCount={editOrphanCount}
|
|
510
|
-
compose={composeState}
|
|
511
|
-
registerLine={registerLine}
|
|
512
|
-
onLineActivate={onLineActivate}
|
|
513
|
-
onEditRequest={onEditRequest}
|
|
514
|
-
docScrollRef={docScrollRef}
|
|
515
|
-
/>
|
|
516
|
-
)}
|
|
517
|
-
<Rail
|
|
518
|
-
session={s}
|
|
519
|
-
selectedId={focusedAnn}
|
|
520
|
-
resolvedIds={isDiff ? null : resolvedIds}
|
|
521
|
-
railTab={railTab}
|
|
522
|
-
pendingCount={pendingCount}
|
|
523
|
-
cardEdit={
|
|
524
|
-
mode.m === "railEdit"
|
|
525
|
-
? {
|
|
526
|
-
id: mode.id,
|
|
527
|
-
text: mode.text,
|
|
528
|
-
onInput: (text: string) => {
|
|
529
|
-
liveInput.current = text;
|
|
530
|
-
setMode({ m: "railEdit", id: mode.id, text });
|
|
531
|
-
},
|
|
532
|
-
onSave: () => dispatch({ t: "saveCompose" }),
|
|
533
|
-
onCancel: () => dispatch({ t: "closeOverlay" }),
|
|
534
|
-
}
|
|
535
|
-
: null
|
|
536
|
-
}
|
|
537
|
-
submitConfirm={submitConfirmState}
|
|
538
|
-
onTab={setRailTab}
|
|
539
|
-
onSelectCard={selectCardFromRail}
|
|
540
|
-
onActivateCard={openCardEdit}
|
|
541
|
-
onSubmitRequest={onSubmitRequest}
|
|
542
|
-
railScrollRef={railScrollRef}
|
|
543
|
-
/>
|
|
544
|
-
</box>
|
|
545
|
-
{mode.m === "compose" && isDiff ? (
|
|
546
|
-
<ComposeBar mode={mode} quote={rows[mode.dispIdx]?.text ?? ""} onChange={(text) => { liveInput.current = text; setMode({ ...mode, text }); }} />
|
|
547
|
-
) : (
|
|
548
|
-
<box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1 }}>
|
|
549
|
-
<text fg={T.textDim}>
|
|
550
|
-
{readOnly
|
|
551
|
-
? "observer - read-only · j/k move · n/p annotations · q quit"
|
|
552
|
-
: mode.m === "submit"
|
|
553
|
-
? "verdict ←/→ · ⏎ submit · esc cancel"
|
|
554
|
-
: mode.m === "span"
|
|
555
|
-
? "span · l/h grow/shrink · w/b slide · $ end · c comment · s suggest · esc"
|
|
556
|
-
: mode.m === "compose" || mode.m === "railEdit"
|
|
557
|
-
? "typing · ⏎ save · esc cancel"
|
|
558
|
-
: focusedAnn !== undefined
|
|
559
|
-
? "card · e edit · x Cut · n/p cards · esc deselect · ⏎ submit"
|
|
560
|
-
: "j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit"}
|
|
561
|
-
</text>
|
|
562
|
-
</box>
|
|
563
|
-
)}
|
|
564
|
-
</box>
|
|
565
|
-
);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// ── panes ─────────────────────────────────────
|
|
569
|
-
|
|
570
|
-
interface ComposeState {
|
|
571
|
-
kind: "comment" | "suggestion";
|
|
572
|
-
dispIdx: number;
|
|
573
|
-
quote: string;
|
|
574
|
-
text: string;
|
|
575
|
-
onInput: (text: string) => void;
|
|
576
|
-
onSave: () => void;
|
|
577
|
-
onCancel: () => void;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function PlanPane({
|
|
581
|
-
display,
|
|
582
|
-
marks,
|
|
583
|
-
cursor,
|
|
584
|
-
mode,
|
|
585
|
-
session,
|
|
586
|
-
editOrphanCount,
|
|
587
|
-
compose,
|
|
588
|
-
registerLine,
|
|
589
|
-
onLineActivate,
|
|
590
|
-
onEditRequest,
|
|
591
|
-
docScrollRef,
|
|
592
|
-
}: {
|
|
593
|
-
display: DisplayBlock[];
|
|
594
|
-
marks: Map<number, Mark[]>;
|
|
595
|
-
cursor: number;
|
|
596
|
-
mode: Mode;
|
|
597
|
-
session: ReviewSession;
|
|
598
|
-
editOrphanCount: number;
|
|
599
|
-
compose: ComposeState | null;
|
|
600
|
-
registerLine: (key: string, renderable: TextRenderable | null, meta: Omit<PlanLineRef, "renderable">) => void;
|
|
601
|
-
onLineActivate: (dispIdx: number) => void;
|
|
602
|
-
onEditRequest: () => void;
|
|
603
|
-
docScrollRef: React.RefObject<ScrollBoxRenderable | null>;
|
|
604
|
-
}): React.ReactNode {
|
|
605
|
-
const width = 76;
|
|
606
|
-
const children: React.ReactNode[] = [];
|
|
607
|
-
for (let i = 0; i < display.length; i++) {
|
|
608
|
-
const d = display[i]!;
|
|
609
|
-
const isCursor = i === cursor;
|
|
610
|
-
const gap = topGap(display[i - 1], d);
|
|
611
|
-
if (d.kind === "code") {
|
|
612
|
-
children.push(
|
|
613
|
-
<CodeBlock key={i} id={`plan-block-${i}`} block={d} isCursor={isCursor} gap={gap} annotated={(marks.get(i) ?? []).length > 0} />,
|
|
614
|
-
);
|
|
615
|
-
} else {
|
|
616
|
-
const blockMarks = [...(marks.get(i) ?? [])];
|
|
617
|
-
if (mode.m === "span" && mode.span.dispIdx === i) {
|
|
618
|
-
blockMarks.push({ start: mode.span.start, end: mode.span.end, role: "kspan" });
|
|
619
|
-
}
|
|
620
|
-
// the compose anchor stays painted selection-style while the box is open
|
|
621
|
-
if (mode.m === "compose" && mode.dispIdx === i) {
|
|
622
|
-
blockMarks.push({ start: mode.start, end: mode.end, role: "kspan" });
|
|
623
|
-
}
|
|
624
|
-
const runs = overlayMarks(blockRuns(d, true), blockMarks);
|
|
625
|
-
const lines = wrapRuns(runs, width - marker(d).length);
|
|
626
|
-
const prefixColumns = 2 + marker(d).length;
|
|
627
|
-
children.push(
|
|
628
|
-
<box key={i} id={`plan-block-${i}`} style={{ flexDirection: "column", marginTop: gap }}>
|
|
629
|
-
{lines.map((line, li) => {
|
|
630
|
-
const positioned = line.filter((run) => run.start !== null);
|
|
631
|
-
const lineStart = positioned.length ? positioned[0]!.start : null;
|
|
632
|
-
const lastPositioned = positioned[positioned.length - 1];
|
|
633
|
-
const textLength =
|
|
634
|
-
lineStart !== null && lastPositioned ? lastPositioned.start! + lastPositioned.text.length - lineStart : 0;
|
|
635
|
-
return (
|
|
636
|
-
<text
|
|
637
|
-
key={li}
|
|
638
|
-
bg={isCursor ? T.cursorBg : undefined}
|
|
639
|
-
selectable
|
|
640
|
-
selectionBg={T.accent}
|
|
641
|
-
selectionFg={T.accentInk}
|
|
642
|
-
ref={(renderable: TextRenderable | null) =>
|
|
643
|
-
registerLine(`${i}:${li}`, renderable, { dispIdx: i, lineIndex: li, lineStart, prefixColumns, textLength })
|
|
644
|
-
}
|
|
645
|
-
onMouseUp={() => onLineActivate(i)}
|
|
646
|
-
>
|
|
647
|
-
<span fg={isCursor ? T.accent : T.textDim}>{li === 0 ? cursorGlyph(isCursor) : " "}</span>
|
|
648
|
-
<span fg={T.textDim}>{li === 0 ? marker(d) : " ".repeat(marker(d).length)}</span>
|
|
649
|
-
{line.map((r, ri) => (
|
|
650
|
-
<span key={ri} {...runStyle(r, d)}>
|
|
651
|
-
{r.text}
|
|
652
|
-
</span>
|
|
653
|
-
))}
|
|
654
|
-
{li === 0 && d.type !== "same" ? <span fg={tagColor(d)}> [{tagLabel(d)}]</span> : null}
|
|
655
|
-
</text>
|
|
656
|
-
);
|
|
657
|
-
})}
|
|
658
|
-
</box>,
|
|
659
|
-
);
|
|
660
|
-
}
|
|
661
|
-
if (compose && compose.dispIdx === i) {
|
|
662
|
-
children.push(
|
|
663
|
-
<AnnotationCard
|
|
664
|
-
key={`compose-${i}`}
|
|
665
|
-
kind={compose.kind}
|
|
666
|
-
quote={compose.quote}
|
|
667
|
-
draft={{ text: compose.text, onInput: compose.onInput, onSave: compose.onSave, onCancel: compose.onCancel }}
|
|
668
|
-
/>,
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
return (
|
|
673
|
-
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
674
|
-
<SheetHeader session={session} onEditRequest={onEditRequest} />
|
|
675
|
-
{editOrphanCount > 0 ? (
|
|
676
|
-
<box style={{ height: 1, backgroundColor: T.markCommentBg, paddingLeft: 2 }}>
|
|
677
|
-
<text fg={T.red}>
|
|
678
|
-
{editOrphanCount} annotation{editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.
|
|
679
|
-
</text>
|
|
680
|
-
</box>
|
|
681
|
-
) : null}
|
|
682
|
-
<scrollbox ref={docScrollRef} style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
|
|
683
|
-
{children}
|
|
684
|
-
</scrollbox>
|
|
685
|
-
</box>
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/** Sheet chrome: submitted-by + revision delta left, the Edit word-button right. */
|
|
690
|
-
function SheetHeader({ session, onEditRequest }: { session: ReviewSession; onEditRequest: () => void }): React.ReactNode {
|
|
691
|
-
const revisionCount = session.revisions.length;
|
|
692
|
-
const previous = revisionCount > 1 ? session.revisions[revisionCount - 2] : undefined;
|
|
693
|
-
const delta = previous ? revisionDelta(previous.content, session.artifact.content) : null;
|
|
694
|
-
return (
|
|
695
|
-
<box style={{ height: 1, flexDirection: "row", paddingLeft: 2, paddingRight: 1 }}>
|
|
696
|
-
<text fg={T.textDim}>
|
|
697
|
-
submitted by <span fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</span> · revision {revisionCount}
|
|
698
|
-
{delta ? (
|
|
699
|
-
<span fg={T.green}>
|
|
700
|
-
{" "}· v{revisionCount - 1}→v{revisionCount} +{delta.added} -{delta.removed}
|
|
701
|
-
</span>
|
|
702
|
-
) : null}
|
|
703
|
-
</text>
|
|
704
|
-
<box style={{ flexGrow: 1 }} />
|
|
705
|
-
<box onMouseUp={onEditRequest}>
|
|
706
|
-
<text fg={T.textDim}> Edit </text>
|
|
707
|
-
</box>
|
|
708
|
-
</box>
|
|
709
|
-
);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
function CodeBlock({
|
|
713
|
-
id,
|
|
714
|
-
block,
|
|
715
|
-
isCursor,
|
|
716
|
-
gap,
|
|
717
|
-
annotated,
|
|
718
|
-
}: {
|
|
719
|
-
id: string;
|
|
720
|
-
block: DisplayBlock;
|
|
721
|
-
isCursor: boolean;
|
|
722
|
-
gap: number;
|
|
723
|
-
annotated: boolean;
|
|
724
|
-
}): React.ReactNode {
|
|
725
|
-
const lang = (block.work ?? block.base)?.lang;
|
|
726
|
-
const content = displayText(block);
|
|
727
|
-
// Shiki tokens arrive async; plain verbatim lines render until then
|
|
728
|
-
const [tokens, setTokens] = useState<CodeToken[][] | null>(null);
|
|
729
|
-
useEffect(() => {
|
|
730
|
-
let live = true;
|
|
731
|
-
void highlightCode(content, lang).then((t) => {
|
|
732
|
-
if (live && t) setTokens(t);
|
|
733
|
-
});
|
|
734
|
-
return () => {
|
|
735
|
-
live = false;
|
|
736
|
-
};
|
|
737
|
-
}, [content, lang]);
|
|
738
|
-
const lines: CodeToken[][] = tokens ?? content.split("\n").map((l) => [{ content: l }]);
|
|
739
|
-
return (
|
|
740
|
-
<box id={id} style={{ flexDirection: "column", marginTop: gap }}>
|
|
741
|
-
<text>
|
|
742
|
-
<span fg={isCursor ? T.accent : T.textDim}>{cursorGlyph(isCursor)}</span>
|
|
743
|
-
<span fg={T.textDim}>{lang ?? "code"}</span>
|
|
744
|
-
{annotated ? <span fg={T.accent}> ◆</span> : null}
|
|
745
|
-
{block.type !== "same" ? <span fg={tagColor(block)}> [{tagLabel(block)}]</span> : null}
|
|
746
|
-
</text>
|
|
747
|
-
<box
|
|
748
|
-
style={{
|
|
749
|
-
flexDirection: "column",
|
|
750
|
-
backgroundColor: T.elevated,
|
|
751
|
-
paddingLeft: 2,
|
|
752
|
-
paddingRight: 2,
|
|
753
|
-
paddingTop: 1,
|
|
754
|
-
paddingBottom: 1,
|
|
755
|
-
marginLeft: 2,
|
|
756
|
-
}}
|
|
757
|
-
>
|
|
758
|
-
{lines.map((line, li) => (
|
|
759
|
-
<text key={li}>
|
|
760
|
-
{line.length === 0 || (line.length === 1 && line[0]!.content === "") ? (
|
|
761
|
-
" "
|
|
762
|
-
) : (
|
|
763
|
-
line.map((tok, ti) => (
|
|
764
|
-
<span key={ti} fg={tok.color ?? T.textMuted}>
|
|
765
|
-
{tok.content}
|
|
766
|
-
</span>
|
|
767
|
-
))
|
|
768
|
-
)}
|
|
769
|
-
</text>
|
|
770
|
-
))}
|
|
771
|
-
</box>
|
|
772
|
-
</box>
|
|
773
|
-
);
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/** Vertical rhythm: gaps live ABOVE blocks so boundaries never collapse. */
|
|
777
|
-
function topGap(prev: DisplayBlock | undefined, cur: DisplayBlock): number {
|
|
778
|
-
if (!prev) return 0;
|
|
779
|
-
const tightPair =
|
|
780
|
-
(cur.kind === "li" && prev.kind === "li") || (cur.kind === "oli" && prev.kind === "oli");
|
|
781
|
-
return tightPair ? 0 : 1;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// ── the rail ──────────────────────────────────
|
|
785
|
-
|
|
786
|
-
interface CardEditState {
|
|
787
|
-
id: string;
|
|
788
|
-
text: string;
|
|
789
|
-
onInput: (text: string) => void;
|
|
790
|
-
onSave: () => void;
|
|
791
|
-
onCancel: () => void;
|
|
792
|
-
}
|
|
447
|
+
const cardEditState =
|
|
448
|
+
mode.m === "railEdit"
|
|
449
|
+
? {
|
|
450
|
+
id: mode.id,
|
|
451
|
+
text: mode.text,
|
|
452
|
+
onInput: (text: string) => {
|
|
453
|
+
liveInput.current = text;
|
|
454
|
+
setMode({ m: "railEdit", id: mode.id, text });
|
|
455
|
+
},
|
|
456
|
+
onSave: () => dispatch({ t: "saveCompose" }),
|
|
457
|
+
onCancel: () => dispatch({ t: "closeOverlay" }),
|
|
458
|
+
}
|
|
459
|
+
: null;
|
|
793
460
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
461
|
+
const headerItems: BreadcrumbItem[] = [
|
|
462
|
+
{ label: "cueloop", tone: "accent" },
|
|
463
|
+
{ label: `${s.artifact.meta.title ?? s.artifact.meta.planPath ?? s.id} · rev ${s.revisions.length}`, tone: "dim" },
|
|
464
|
+
...(resolved ? [{ label: `resolved: ${s.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
|
|
465
|
+
...(readOnly ? [{ label: "observer", tone: "dim" as const }] : []),
|
|
466
|
+
...(status ? [{ label: status, tone: "accent" as const }] : []),
|
|
467
|
+
];
|
|
468
|
+
|
|
469
|
+
keyBindings.setContext({ overlay, spanMode: mode.m === "span" });
|
|
470
|
+
const hintMode: HintMode = readOnly
|
|
471
|
+
? "read-only"
|
|
472
|
+
: mode.m === "submit"
|
|
473
|
+
? "submit"
|
|
474
|
+
: mode.m === "span"
|
|
475
|
+
? "span"
|
|
476
|
+
: mode.m === "compose" || mode.m === "railEdit"
|
|
477
|
+
? "compose"
|
|
478
|
+
: focusedAnn !== undefined
|
|
479
|
+
? "card"
|
|
480
|
+
: "normal";
|
|
481
|
+
const railWidth = terminalWidth >= 100 ? 34 : 28;
|
|
805
482
|
|
|
806
|
-
function Rail({
|
|
807
|
-
session,
|
|
808
|
-
selectedId,
|
|
809
|
-
resolvedIds,
|
|
810
|
-
railTab,
|
|
811
|
-
pendingCount,
|
|
812
|
-
cardEdit,
|
|
813
|
-
submitConfirm,
|
|
814
|
-
onTab,
|
|
815
|
-
onSelectCard,
|
|
816
|
-
onActivateCard,
|
|
817
|
-
onSubmitRequest,
|
|
818
|
-
railScrollRef,
|
|
819
|
-
}: {
|
|
820
|
-
session: ReviewSession;
|
|
821
|
-
selectedId?: string;
|
|
822
|
-
/** Ids whose anchor resolved; null = orphan display off (diff view). */
|
|
823
|
-
resolvedIds: Set<string> | null;
|
|
824
|
-
railTab: RailTab;
|
|
825
|
-
pendingCount: number;
|
|
826
|
-
cardEdit: CardEditState | null;
|
|
827
|
-
submitConfirm: SubmitConfirmState | null;
|
|
828
|
-
onTab: (tab: RailTab) => void;
|
|
829
|
-
onSelectCard: (id: string) => void;
|
|
830
|
-
onActivateCard: (id: string) => void;
|
|
831
|
-
onSubmitRequest: () => void;
|
|
832
|
-
railScrollRef: React.RefObject<ScrollBoxRenderable | null>;
|
|
833
|
-
}): React.ReactNode {
|
|
834
483
|
return (
|
|
835
|
-
<
|
|
836
|
-
<box style={{
|
|
837
|
-
<
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
<box onMouseUp={() => onTab("agent")}>
|
|
842
|
-
<text fg={railTab === "agent" ? T.accent : T.textDim}>Agent</text>
|
|
843
|
-
</box>
|
|
844
|
-
</box>
|
|
845
|
-
<text> </text>
|
|
846
|
-
{railTab === "agent" ? (
|
|
847
|
-
<AgentTab session={session} />
|
|
848
|
-
) : (
|
|
849
|
-
<>
|
|
850
|
-
{session.workingCopy !== undefined ? <text fg={T.textDim}>± plan edits → one diff</text> : null}
|
|
851
|
-
{session.annotations.length === 0 ? (
|
|
852
|
-
<text fg={T.textDim}>no annotations yet</text>
|
|
853
|
-
) : (
|
|
854
|
-
<scrollbox ref={railScrollRef} style={{ flexGrow: 1 }} focused={false}>
|
|
855
|
-
{session.annotations.map((annotation) => (
|
|
856
|
-
<AnnotationCard
|
|
857
|
-
key={annotation.id}
|
|
858
|
-
kind={annotation.kind}
|
|
859
|
-
quote={annotation.anchor.quote}
|
|
860
|
-
saved={{
|
|
861
|
-
body: annotation.body,
|
|
862
|
-
selected: annotation.id === selectedId,
|
|
863
|
-
orphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
|
|
864
|
-
blocking: annotationBlocking(annotation),
|
|
865
|
-
editing:
|
|
866
|
-
cardEdit && cardEdit.id === annotation.id
|
|
867
|
-
? { text: cardEdit.text, onInput: cardEdit.onInput, onSave: cardEdit.onSave, onCancel: cardEdit.onCancel }
|
|
868
|
-
: null,
|
|
869
|
-
onMouseUp: () =>
|
|
870
|
-
annotation.id === selectedId ? onActivateCard(annotation.id) : onSelectCard(annotation.id),
|
|
871
|
-
}}
|
|
872
|
-
/>
|
|
873
|
-
))}
|
|
874
|
-
</scrollbox>
|
|
875
|
-
)}
|
|
876
|
-
<box style={{ flexGrow: 1 }} />
|
|
877
|
-
{/* the confirm card sits OUTSIDE the scrollbox: the annotation stack
|
|
878
|
-
above scrolls while the card stays pinned to the rail bottom */}
|
|
879
|
-
{session.status === "resolved" ? (
|
|
880
|
-
<text fg={T.green}>resolved: {session.verdict!.kind.replace("_", " ")}</text>
|
|
881
|
-
) : submitConfirm ? (
|
|
882
|
-
<SubmitConfirmCard confirm={submitConfirm} />
|
|
484
|
+
<ThemeProvider theme={theme}>
|
|
485
|
+
<box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg }}>
|
|
486
|
+
<Breadcrumb items={headerItems} />
|
|
487
|
+
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
488
|
+
{isDiff ? (
|
|
489
|
+
<DiffSheet rows={rows} cursor={cursor} annotations={s.annotations} focusedAnnotationId={focusedAnn} />
|
|
883
490
|
) : (
|
|
884
|
-
<
|
|
885
|
-
|
|
886
|
-
|
|
491
|
+
<PlanSheet
|
|
492
|
+
ref={planSheetRef}
|
|
493
|
+
session={s}
|
|
494
|
+
display={display}
|
|
495
|
+
marks={marks}
|
|
496
|
+
cursor={cursor}
|
|
497
|
+
activeSpan={activeSpan}
|
|
498
|
+
compose={composeState}
|
|
499
|
+
editOrphanCount={editOrphanCount}
|
|
500
|
+
onLineActivate={onLineActivate}
|
|
501
|
+
onEditRequest={onEditRequest}
|
|
502
|
+
/>
|
|
887
503
|
)}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
return SUBMIT_CONFIRM_CONTENT_ROWS + 2;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
/**
|
|
907
|
-
* The Submit button expanded into a bordered confirm card at the rail bottom:
|
|
908
|
-
* honest counts, the verdict selector (arrow keys or click), the optional
|
|
909
|
-
* summary, and plain word-buttons - key hints live in the status line only.
|
|
910
|
-
*/
|
|
911
|
-
function SubmitConfirmCard({ confirm }: { confirm: SubmitConfirmState }): React.ReactNode {
|
|
912
|
-
return (
|
|
913
|
-
<box
|
|
914
|
-
style={{
|
|
915
|
-
height: submitConfirmHeight(),
|
|
916
|
-
marginRight: 1,
|
|
917
|
-
border: true,
|
|
918
|
-
borderStyle: "rounded",
|
|
919
|
-
borderColor: T.accent,
|
|
920
|
-
backgroundColor: T.elevated,
|
|
921
|
-
flexDirection: "column",
|
|
922
|
-
paddingLeft: 1,
|
|
923
|
-
}}
|
|
924
|
-
title=" submit review "
|
|
925
|
-
>
|
|
926
|
-
<text fg={T.textDim}>{`${confirm.annotationCount} annotations · ${confirm.blockingCount} blocking`}</text>
|
|
927
|
-
<box style={{ height: 1 }} />
|
|
928
|
-
<box style={{ flexDirection: "row", height: 1 }}>
|
|
929
|
-
{VERDICTS.map((candidate) => (
|
|
930
|
-
<box key={candidate} style={{ paddingRight: 1 }} onMouseUp={() => confirm.onSelectVerdict(candidate)}>
|
|
931
|
-
<text fg={candidate === confirm.verdict ? verdictColor(candidate) : T.textDim}>
|
|
932
|
-
{candidate === confirm.verdict ? `[${VERDICT_LABEL[candidate]}]` : ` ${VERDICT_LABEL[candidate]} `}
|
|
933
|
-
</text>
|
|
934
|
-
</box>
|
|
935
|
-
))}
|
|
936
|
-
</box>
|
|
937
|
-
<box style={{ height: 1 }} />
|
|
938
|
-
<input focused value={confirm.summary} onInput={confirm.onInput} placeholder="summary for the agent (optional)" />
|
|
939
|
-
<box style={{ height: 1 }} />
|
|
940
|
-
<box style={{ flexDirection: "row", height: 1 }}>
|
|
941
|
-
<box style={{ backgroundColor: T.accent, marginRight: 2 }} onMouseUp={confirm.onSubmit}>
|
|
942
|
-
<text fg={T.accentInk}> Submit </text>
|
|
943
|
-
</box>
|
|
944
|
-
<box onMouseUp={confirm.onCancel}>
|
|
945
|
-
<text fg={T.textDim}> Cancel </text>
|
|
504
|
+
<ReviewRail
|
|
505
|
+
ref={railRef}
|
|
506
|
+
session={s}
|
|
507
|
+
selectedId={focusedAnn}
|
|
508
|
+
resolvedIds={isDiff ? null : resolvedIds}
|
|
509
|
+
railTab={railTab}
|
|
510
|
+
pendingCount={pendingCount}
|
|
511
|
+
cardEdit={cardEditState}
|
|
512
|
+
submitConfirm={submitConfirmState}
|
|
513
|
+
onTabChange={setRailTab}
|
|
514
|
+
onSelectCard={selectCardFromRail}
|
|
515
|
+
onActivateCard={openCardEdit}
|
|
516
|
+
onSubmitRequest={onSubmitRequest}
|
|
517
|
+
width={railWidth}
|
|
518
|
+
/>
|
|
946
519
|
</box>
|
|
520
|
+
{mode.m === "compose" && isDiff ? (
|
|
521
|
+
<ComposeBar
|
|
522
|
+
kind={mode.kind}
|
|
523
|
+
quote={rows[mode.dispIdx]?.text ?? ""}
|
|
524
|
+
text={mode.text}
|
|
525
|
+
onInput={(text) => {
|
|
526
|
+
liveInput.current = text;
|
|
527
|
+
setMode({ ...mode, text });
|
|
528
|
+
}}
|
|
529
|
+
/>
|
|
530
|
+
) : (
|
|
531
|
+
<StatusBar>{keyBindings.statusHint(hintMode)}</StatusBar>
|
|
532
|
+
)}
|
|
947
533
|
</box>
|
|
948
|
-
</
|
|
949
|
-
);
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
/** Agent tab: who submitted, where the session stands, which revision. */
|
|
953
|
-
function AgentTab({ session }: { session: ReviewSession }): React.ReactNode {
|
|
954
|
-
return (
|
|
955
|
-
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
956
|
-
<text fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</text>
|
|
957
|
-
<text fg={T.textDim}>status: {session.status}</text>
|
|
958
|
-
<text fg={T.textDim}>revision {session.revisions.length}</text>
|
|
959
|
-
</box>
|
|
960
|
-
);
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
/**
|
|
964
|
-
* The composer and the rail card are one component in two modes: draft props
|
|
965
|
-
* attached (bordered box inline in the document flow) or saved props attached
|
|
966
|
-
* (rail card, optionally editing its body in place). Compose, saved, and
|
|
967
|
-
* re-edit all share this rendering path.
|
|
968
|
-
*/
|
|
969
|
-
function AnnotationCard({
|
|
970
|
-
kind,
|
|
971
|
-
quote,
|
|
972
|
-
draft,
|
|
973
|
-
saved,
|
|
974
|
-
}: {
|
|
975
|
-
kind: string;
|
|
976
|
-
quote: string;
|
|
977
|
-
draft?: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void };
|
|
978
|
-
saved?: {
|
|
979
|
-
body: string;
|
|
980
|
-
selected: boolean;
|
|
981
|
-
orphan: boolean;
|
|
982
|
-
blocking: boolean;
|
|
983
|
-
editing: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void } | null;
|
|
984
|
-
onMouseUp: () => void;
|
|
985
|
-
};
|
|
986
|
-
}): React.ReactNode {
|
|
987
|
-
const color = kind === "suggestion" ? T.green : T.accent;
|
|
988
|
-
if (draft) {
|
|
989
|
-
const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
|
|
990
|
-
return (
|
|
991
|
-
<box
|
|
992
|
-
style={{
|
|
993
|
-
height: composeBoxHeight(),
|
|
994
|
-
marginLeft: 2,
|
|
995
|
-
marginRight: 2,
|
|
996
|
-
border: true,
|
|
997
|
-
borderStyle: "rounded",
|
|
998
|
-
borderColor: color,
|
|
999
|
-
backgroundColor: T.elevated,
|
|
1000
|
-
flexDirection: "column",
|
|
1001
|
-
paddingLeft: 1,
|
|
1002
|
-
}}
|
|
1003
|
-
title={` ${verb} "${truncate(quote, 40)}" `}
|
|
1004
|
-
>
|
|
1005
|
-
<input focused value={draft.text} onInput={draft.onInput} placeholder="write a note..." />
|
|
1006
|
-
<SaveCancelRow onSave={draft.onSave} onCancel={draft.onCancel} />
|
|
1007
|
-
</box>
|
|
1008
|
-
);
|
|
1009
|
-
}
|
|
1010
|
-
const card = saved!;
|
|
1011
|
-
return (
|
|
1012
|
-
<box
|
|
1013
|
-
style={{ flexDirection: "column", marginBottom: 1, backgroundColor: card.selected ? T.elevated : undefined }}
|
|
1014
|
-
onMouseUp={card.onMouseUp}
|
|
1015
|
-
>
|
|
1016
|
-
<text fg={card.selected ? T.text : color}>
|
|
1017
|
-
{card.selected ? "▸ " : " "}
|
|
1018
|
-
{kind.toUpperCase()}
|
|
1019
|
-
{card.blocking ? <span fg={T.red}> · BLOCKING</span> : null}
|
|
1020
|
-
<span fg={T.textDim}>{card.orphan ? " · ORPHANED" : " · pending"}</span>
|
|
1021
|
-
</text>
|
|
1022
|
-
<text fg={T.textDim}> "{truncate(quote, 26)}"</text>
|
|
1023
|
-
{card.editing ? (
|
|
1024
|
-
<>
|
|
1025
|
-
<input focused value={card.editing.text} onInput={card.editing.onInput} />
|
|
1026
|
-
<SaveCancelRow onSave={card.editing.onSave} onCancel={card.editing.onCancel} />
|
|
1027
|
-
</>
|
|
1028
|
-
) : (
|
|
1029
|
-
<text fg={card.orphan ? T.textDim : T.textMuted}> {truncate(card.body, 28)}</text>
|
|
1030
|
-
)}
|
|
1031
|
-
</box>
|
|
1032
|
-
);
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
function SaveCancelRow({ onSave, onCancel }: { onSave: () => void; onCancel: () => void }): React.ReactNode {
|
|
1036
|
-
return (
|
|
1037
|
-
<box style={{ flexDirection: "row", height: 1 }}>
|
|
1038
|
-
<box style={{ backgroundColor: T.accent, marginRight: 2 }} onMouseUp={onSave}>
|
|
1039
|
-
<text fg={T.accentInk}> Save ⏎ </text>
|
|
1040
|
-
</box>
|
|
1041
|
-
<box onMouseUp={onCancel}>
|
|
1042
|
-
<text fg={T.textDim}> Cancel esc </text>
|
|
1043
|
-
</box>
|
|
1044
|
-
</box>
|
|
1045
|
-
);
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
/** Forward-compatible: open annotation kinds may carry a blocking flag. */
|
|
1049
|
-
function annotationBlocking(annotation: Annotation): boolean {
|
|
1050
|
-
return (annotation as Annotation & { blocking?: boolean }).blocking === true;
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
function Inbox({ inbox, cursor }: { inbox: ReviewSession[]; cursor: number }): React.ReactNode {
|
|
1054
|
-
return (
|
|
1055
|
-
<box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg, padding: 1 }}>
|
|
1056
|
-
<text fg={T.accent}>cueloop · inbox ({inbox.length} pending)</text>
|
|
1057
|
-
<text> </text>
|
|
1058
|
-
{inbox.length === 0 ? (
|
|
1059
|
-
<text fg={T.textDim}>nothing waiting for review</text>
|
|
1060
|
-
) : (
|
|
1061
|
-
inbox.map((s, i) => (
|
|
1062
|
-
<text key={s.id} fg={i === cursor ? T.text : T.textMuted} bg={i === cursor ? T.cursorBg : undefined}>
|
|
1063
|
-
{i === cursor ? "▸ " : " "}
|
|
1064
|
-
{s.artifact.meta.title ?? s.id} · {s.workspace.branch} · {s.artifact.type}
|
|
1065
|
-
</text>
|
|
1066
|
-
))
|
|
1067
|
-
)}
|
|
1068
|
-
<box style={{ flexGrow: 1 }} />
|
|
1069
|
-
<text fg={T.textDim}>j/k move · ⏎ open · q quit</text>
|
|
1070
|
-
</box>
|
|
1071
|
-
);
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/** Bottom compose bar - the diff view still composes here. */
|
|
1075
|
-
function ComposeBar({
|
|
1076
|
-
mode,
|
|
1077
|
-
quote,
|
|
1078
|
-
onChange,
|
|
1079
|
-
}: {
|
|
1080
|
-
mode: Extract<Mode, { m: "compose" }>;
|
|
1081
|
-
quote: string;
|
|
1082
|
-
onChange: (text: string) => void;
|
|
1083
|
-
}): React.ReactNode {
|
|
1084
|
-
return (
|
|
1085
|
-
<box style={{ height: 2, backgroundColor: T.elevated, flexDirection: "column", paddingLeft: 1 }}>
|
|
1086
|
-
<text fg={mode.kind === "suggestion" ? T.green : T.accent}>
|
|
1087
|
-
{mode.kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncate(quote, 60)}” · ⏎ save · esc cancel
|
|
1088
|
-
</text>
|
|
1089
|
-
<input focused value={mode.text} onInput={onChange} />
|
|
1090
|
-
</box>
|
|
534
|
+
</ThemeProvider>
|
|
1091
535
|
);
|
|
1092
536
|
}
|
|
1093
|
-
|
|
1094
|
-
function DiffPane({
|
|
1095
|
-
rows,
|
|
1096
|
-
cursor,
|
|
1097
|
-
annotations,
|
|
1098
|
-
focusedAnn,
|
|
1099
|
-
}: {
|
|
1100
|
-
rows: DiffRow[];
|
|
1101
|
-
cursor: number;
|
|
1102
|
-
annotations: Annotation[];
|
|
1103
|
-
focusedAnn?: string;
|
|
1104
|
-
}): React.ReactNode {
|
|
1105
|
-
// simple windowing: keep the cursor in view without a scroll dependency
|
|
1106
|
-
const windowStart = Math.max(0, cursor - 12);
|
|
1107
|
-
const visible = rows.slice(windowStart, windowStart + 200);
|
|
1108
|
-
const annotated = new Map<number, Annotation>();
|
|
1109
|
-
for (const a of annotations) {
|
|
1110
|
-
const idx = rows.findIndex((r) => r.text === a.anchor.quote && (r.t === "ctx" || r.t === "add" || r.t === "del"));
|
|
1111
|
-
if (idx !== -1) annotated.set(idx, a);
|
|
1112
|
-
}
|
|
1113
|
-
return (
|
|
1114
|
-
<box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
1115
|
-
{visible.map((row, vi) => {
|
|
1116
|
-
const i = windowStart + vi;
|
|
1117
|
-
const isCursor = i === cursor;
|
|
1118
|
-
const ann = annotated.get(i);
|
|
1119
|
-
const gutter = row.t === "del" ? (row.oldLine?.toString() ?? "") : (row.newLine?.toString() ?? "");
|
|
1120
|
-
const sign = row.t === "add" ? "+" : row.t === "del" ? "-" : " ";
|
|
1121
|
-
if (row.t === "file") {
|
|
1122
|
-
return (
|
|
1123
|
-
<text key={i} fg={T.text} bg={isCursor ? T.cursorBg : T.panel}>
|
|
1124
|
-
{isCursor ? "▎" : " "}■ {row.text}
|
|
1125
|
-
</text>
|
|
1126
|
-
);
|
|
1127
|
-
}
|
|
1128
|
-
if (row.t === "hunk") {
|
|
1129
|
-
return (
|
|
1130
|
-
<text key={i} fg={T.blue} bg={isCursor ? T.cursorBg : undefined}>
|
|
1131
|
-
{isCursor ? "▎" : " "}{row.text}
|
|
1132
|
-
</text>
|
|
1133
|
-
);
|
|
1134
|
-
}
|
|
1135
|
-
const fg = row.t === "add" ? T.insFg : row.t === "del" ? T.delFg : T.textMuted;
|
|
1136
|
-
return (
|
|
1137
|
-
<box key={i} style={{ flexDirection: "column" }}>
|
|
1138
|
-
<text bg={isCursor ? T.cursorBg : ann ? T.markCommentBg : undefined}>
|
|
1139
|
-
<span fg={T.textDim}>{isCursor ? "▎" : " "}{gutter.padStart(4)} </span>
|
|
1140
|
-
<span fg={fg}>{sign}{row.text}</span>
|
|
1141
|
-
</text>
|
|
1142
|
-
{ann ? (
|
|
1143
|
-
<text>
|
|
1144
|
-
<span fg={T.textDim}>{" "}</span>
|
|
1145
|
-
<span fg={ann.id === focusedAnn ? T.text : T.accent}>◆ {truncate(ann.body, 70)}</span>
|
|
1146
|
-
</text>
|
|
1147
|
-
) : null}
|
|
1148
|
-
</box>
|
|
1149
|
-
);
|
|
1150
|
-
})}
|
|
1151
|
-
</box>
|
|
1152
|
-
);
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
function CompletionOverlay({
|
|
1156
|
-
verdict,
|
|
1157
|
-
completion,
|
|
1158
|
-
status,
|
|
1159
|
-
returnsTo,
|
|
1160
|
-
}: {
|
|
1161
|
-
verdict: VerdictKind;
|
|
1162
|
-
completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
|
|
1163
|
-
/** Latest status line (e.g. the vault-export path) stays visible here. */
|
|
1164
|
-
status: string;
|
|
1165
|
-
/** Where focus goes on close (the agent's herdr pane), when known. */
|
|
1166
|
-
returnsTo?: string;
|
|
1167
|
-
}): React.ReactNode {
|
|
1168
|
-
const approved = verdict === "approve";
|
|
1169
|
-
return (
|
|
1170
|
-
<box
|
|
1171
|
-
style={{
|
|
1172
|
-
width: "100%",
|
|
1173
|
-
height: "100%",
|
|
1174
|
-
backgroundColor: T.bg,
|
|
1175
|
-
flexDirection: "column",
|
|
1176
|
-
justifyContent: "center",
|
|
1177
|
-
alignItems: "center",
|
|
1178
|
-
}}
|
|
1179
|
-
>
|
|
1180
|
-
<text fg={approved ? T.green : T.accent}>{approved ? "✓ review approved" : "✎ feedback sent"}</text>
|
|
1181
|
-
<text> </text>
|
|
1182
|
-
<text fg={T.text}>The agent has your {approved ? "approval" : "feedback"} and is unblocked.</text>
|
|
1183
|
-
{status ? <text fg={T.textDim}>{status}</text> : null}
|
|
1184
|
-
<text> </text>
|
|
1185
|
-
{completion.phase === "counting" ? (
|
|
1186
|
-
<text fg={T.textDim}>
|
|
1187
|
-
closing in {completion.remaining}s{returnsTo ? ` - returning to ${returnsTo}` : ""} · ⏎ now · esc stay
|
|
1188
|
-
</text>
|
|
1189
|
-
) : (
|
|
1190
|
-
<text fg={T.textDim}>⏎ close · a always close after submit (3s) · esc stay</text>
|
|
1191
|
-
)}
|
|
1192
|
-
</box>
|
|
1193
|
-
);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
// ── small helpers ─────────────────────────────
|
|
1197
|
-
|
|
1198
|
-
function marker(d: DisplayBlock): string {
|
|
1199
|
-
if (d.kind === "li") return "- ";
|
|
1200
|
-
if (d.kind === "oli") return `${d.oliNum ?? 1}. `;
|
|
1201
|
-
if (d.kind === "quote") return "▏ ";
|
|
1202
|
-
return "";
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
function cursorGlyph(isCursor: boolean): string {
|
|
1206
|
-
return isCursor ? "▎ " : " ";
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
function tagLabel(d: DisplayBlock): string {
|
|
1210
|
-
return d.type === "del" ? "cut" : d.type === "add" ? "new" : "edited";
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
function tagColor(d: DisplayBlock): string {
|
|
1214
|
-
return d.type === "del" ? T.red : d.type === "add" ? T.green : T.accent;
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
function verdictColor(v: VerdictKind): string {
|
|
1218
|
-
return v === "approve" ? T.green : v === "request_changes" ? T.red : T.blue;
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
function defaultVerdict(s: ReviewSession): VerdictKind {
|
|
1222
|
-
return s.annotations.length || s.workingCopy !== undefined ? "request_changes" : "approve";
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
function runStyle(r: StyleRun, d: DisplayBlock): { fg?: string; bg?: string; attributes?: number } {
|
|
1226
|
-
const headingFg = d.kind === "h1" ? T.text : d.kind === "h2" || d.kind === "h3" ? T.accent : undefined;
|
|
1227
|
-
const struck = d.type === "del";
|
|
1228
|
-
switch (r.role) {
|
|
1229
|
-
case "ins":
|
|
1230
|
-
return { fg: T.insFg };
|
|
1231
|
-
case "del":
|
|
1232
|
-
return { fg: T.delFg };
|
|
1233
|
-
case "mark-comment":
|
|
1234
|
-
return { fg: T.text, bg: T.markCommentBg };
|
|
1235
|
-
case "mark-suggestion":
|
|
1236
|
-
return { fg: T.text, bg: T.markSuggestionBg };
|
|
1237
|
-
case "mark-focus":
|
|
1238
|
-
return { fg: T.accentInk, bg: T.accent };
|
|
1239
|
-
case "kspan":
|
|
1240
|
-
return { fg: T.accentInk, bg: T.accent };
|
|
1241
|
-
default:
|
|
1242
|
-
return { fg: struck ? T.red : (headingFg ?? (d.kind === "code" ? T.textMuted : T.text)) };
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
function truncate(s: string, n: number): string {
|
|
1247
|
-
const one = s.replace(/\n/g, " ");
|
|
1248
|
-
return one.length > n ? one.slice(0, n - 1) + "…" : one;
|
|
1249
|
-
}
|