@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.8
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 +5 -5
- package/src/App.tsx +148 -337
- package/src/keymap.test.ts +263 -0
- package/src/keymap.ts +167 -0
- package/src/session-controller.ts +345 -0
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.8",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
11
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
12
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
10
|
+
"@cueloop/daemon": "0.1.0-alpha.8",
|
|
11
|
+
"@cueloop/extension-api": "0.1.0-alpha.8",
|
|
12
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.8",
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.8",
|
|
14
14
|
"@opentui/core": "^0.4.3",
|
|
15
15
|
"@opentui/react": "^0.4.3",
|
|
16
16
|
"@opentui/ssh": "0.4.5",
|
package/src/App.tsx
CHANGED
|
@@ -1,28 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The cueloop TUI (#22): Ledger IA reduced to its terminal shape - header,
|
|
3
3
|
* center artifact pane (projection renderer), right review rail, footer hint
|
|
4
|
-
* bar. Thin renderer over
|
|
5
|
-
*
|
|
4
|
+
* bar. Thin renderer over the review-session controller: daemon IO and the
|
|
5
|
+
* mutation verbs live in session-controller.ts, the keyboard grammar in
|
|
6
|
+
* keymap.ts; local state is view state only (cursor, span, overlays).
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import React, {
|
|
9
|
+
import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
9
10
|
import { useKeyboard } from "@opentui/react";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
cutBlock,
|
|
13
|
-
makeAnchor,
|
|
14
|
-
restoreBlock,
|
|
15
|
-
restoreLine,
|
|
16
|
-
type Annotation,
|
|
17
|
-
type ReviewSession,
|
|
18
|
-
type VerdictKind,
|
|
19
|
-
} from "@cueloop/schema";
|
|
11
|
+
import { type Annotation, type ReviewSession, type VerdictKind } from "@cueloop/schema";
|
|
20
12
|
import {
|
|
21
13
|
blockRuns,
|
|
22
|
-
buildDisplay,
|
|
23
14
|
displayText,
|
|
24
15
|
marksByDisplay,
|
|
25
|
-
nextWorkBlock,
|
|
26
16
|
overlayMarks,
|
|
27
17
|
spanKey,
|
|
28
18
|
startSpan,
|
|
@@ -31,14 +21,13 @@ import {
|
|
|
31
21
|
type SpanState,
|
|
32
22
|
type StyleRun,
|
|
33
23
|
} from "./view";
|
|
34
|
-
import {
|
|
35
|
-
import { editInEditor } from "./editor";
|
|
24
|
+
import { type DiffRow } from "./view-diff";
|
|
36
25
|
import { DARK as T } from "./theme";
|
|
37
26
|
import { highlightCode, type CodeToken } from "./syntax";
|
|
38
|
-
import { DEFAULT_KEYS,
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
27
|
+
import { DEFAULT_KEYS, loadConfig } from "./config";
|
|
28
|
+
import { returnPaneFor } from "./herdr";
|
|
29
|
+
import { createReviewController } from "./session-controller";
|
|
30
|
+
import { reduceKey, type Intent, type KeyState } from "./keymap";
|
|
42
31
|
|
|
43
32
|
export interface AppProps {
|
|
44
33
|
home?: string;
|
|
@@ -57,9 +46,6 @@ type Mode =
|
|
|
57
46
|
| { m: "compose"; kind: "comment" | "suggestion"; dispIdx: number; start: number; end: number; text: string }
|
|
58
47
|
| { m: "submit"; verdict: VerdictKind; summary: string };
|
|
59
48
|
|
|
60
|
-
/** Verbs that write session state; an observer never reaches their handlers. */
|
|
61
|
-
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit"]);
|
|
62
|
-
|
|
63
49
|
const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
|
|
64
50
|
const VERDICT_LABEL: Record<VerdictKind, string> = {
|
|
65
51
|
comment: "Comment",
|
|
@@ -68,352 +54,177 @@ const VERDICT_LABEL: Record<VerdictKind, string> = {
|
|
|
68
54
|
};
|
|
69
55
|
|
|
70
56
|
export function App({ home, sessionId, readOnly = false, onExit }: AppProps): React.ReactNode {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
57
|
+
const controller = useMemo(
|
|
58
|
+
() => createReviewController({ home, sessionId, readOnly, onExit }),
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
[home, sessionId],
|
|
61
|
+
);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
controller.connect();
|
|
64
|
+
return () => controller.close();
|
|
65
|
+
}, [controller]);
|
|
66
|
+
const { session, inbox, status, error, completion } = useSyncExternalStore(
|
|
67
|
+
controller.subscribe,
|
|
68
|
+
controller.getSnapshot,
|
|
69
|
+
controller.getSnapshot,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// ── view state ──────────────────────────────
|
|
75
73
|
const [cursor, setCursor] = useState(0);
|
|
76
74
|
const [inboxCursor, setInboxCursor] = useState(0);
|
|
77
75
|
const [mode, setMode] = useState<Mode>({ m: "normal" });
|
|
78
76
|
const [focusedAnn, setFocusedAnn] = useState<string | undefined>(undefined);
|
|
79
|
-
const [status, setStatus] = useState("");
|
|
80
|
-
/**
|
|
81
|
-
* Post-submit lifecycle (a review pane should hand you back to the agent,
|
|
82
|
-
* not linger): idle → prompt (auto-close off: offer the choice) or
|
|
83
|
-
* counting (configured delay) → exit. esc dismisses to the resolved view.
|
|
84
|
-
*/
|
|
85
|
-
const [completion, setCompletion] = useState<
|
|
86
|
-
{ phase: "idle" } | { phase: "prompt" } | { phase: "counting"; remaining: number } | { phase: "dismissed" }
|
|
87
|
-
>({ phase: "idle" });
|
|
88
|
-
const autoCloseRef = useRef<AutoClose>("off");
|
|
89
|
-
/** Close the review and, inside herdr, bounce focus back to the agent. */
|
|
90
|
-
const finishReview = useCallback(() => {
|
|
91
|
-
const pane = returnPaneFor(session?.artifact.meta.herdrPane);
|
|
92
|
-
if (pane) focusHerdrPane(pane);
|
|
93
|
-
onExit?.(0);
|
|
94
|
-
}, [session, onExit]);
|
|
95
77
|
// live mirror of overlay input text: refs commit synchronously, so the
|
|
96
78
|
// RETURN handler never reads a stale value mid-typing
|
|
97
79
|
const liveInput = useRef("");
|
|
98
80
|
// keymap from layered config; theme overrides land on the shared tokens
|
|
99
81
|
const keysRef = useRef(DEFAULT_KEYS);
|
|
100
|
-
const obsidianRef = useRef<ObsidianConfig | null>(null);
|
|
101
|
-
const exportersRef = useRef<Map<string, Exporter>>(new Map());
|
|
102
82
|
useEffect(() => {
|
|
103
83
|
const cfg = loadConfig({ repoRoot: session?.workspace.repoRoot });
|
|
104
84
|
keysRef.current = cfg.keys;
|
|
105
|
-
autoCloseRef.current = cfg.ui.autoClose;
|
|
106
85
|
Object.assign(T, cfg.theme);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const registry = new Registry();
|
|
110
|
-
void registry.load("obsidian", createObsidianExtension(cfg.integrations.obsidian)).then((rec) => {
|
|
111
|
-
exportersRef.current = rec.exporters;
|
|
112
|
-
});
|
|
113
|
-
}, [session?.workspace.repoRoot]);
|
|
114
|
-
|
|
115
|
-
// ── daemon wiring ───────────────────────────
|
|
116
|
-
useEffect(() => {
|
|
117
|
-
let closed = false;
|
|
118
|
-
void (async () => {
|
|
119
|
-
try {
|
|
120
|
-
const client = await DaemonClient.connect({ home, autostart: true });
|
|
121
|
-
if (closed) return void client.close();
|
|
122
|
-
clientRef.current = client;
|
|
123
|
-
client.onEvent((e) => {
|
|
124
|
-
// another controller/observer changed state: re-fetch
|
|
125
|
-
if (session && e.sessionId === session.id) void refreshSession(e.sessionId);
|
|
126
|
-
if (!sessionId) void refreshInbox();
|
|
127
|
-
});
|
|
128
|
-
await client.subscribe();
|
|
129
|
-
if (sessionId) {
|
|
130
|
-
setSession(await client.sessionGet(sessionId));
|
|
131
|
-
} else {
|
|
132
|
-
setInbox(await client.sessionList({ status: "pending" }));
|
|
133
|
-
}
|
|
134
|
-
} catch (err) {
|
|
135
|
-
setError(err instanceof Error ? err.message : String(err));
|
|
136
|
-
}
|
|
137
|
-
})();
|
|
138
|
-
return () => {
|
|
139
|
-
closed = true;
|
|
140
|
-
clientRef.current?.close();
|
|
141
|
-
};
|
|
142
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
143
|
-
}, [home, sessionId]);
|
|
144
|
-
|
|
145
|
-
const refreshSession = useCallback(async (id: string) => {
|
|
146
|
-
const c = clientRef.current;
|
|
147
|
-
if (c) setSession(await c.sessionGet(id));
|
|
148
|
-
}, []);
|
|
149
|
-
const refreshInbox = useCallback(async () => {
|
|
150
|
-
const c = clientRef.current;
|
|
151
|
-
if (c) setInbox(await c.sessionList({ status: "pending" }));
|
|
152
|
-
}, []);
|
|
86
|
+
controller.applyConfig(cfg);
|
|
87
|
+
}, [session?.workspace.repoRoot, controller]);
|
|
153
88
|
|
|
154
89
|
// ── derived view model ──────────────────────
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
() => (session ? buildDisplay(session.artifact.content, session.workingCopy) : []),
|
|
158
|
-
[session],
|
|
159
|
-
);
|
|
90
|
+
const display = controller.display();
|
|
91
|
+
const rows = controller.rows();
|
|
160
92
|
const marks = useMemo(
|
|
161
93
|
() => (session ? marksByDisplay(session.annotations, display, focusedAnn) : new Map()),
|
|
162
94
|
[session, display, focusedAnn],
|
|
163
95
|
);
|
|
164
96
|
const resolved = session?.status === "resolved";
|
|
165
97
|
const isDiff = session?.artifact.type === "diff";
|
|
166
|
-
const rows = useMemo(() => (session && isDiff ? diffRows(session.artifact.content) : []), [session, isDiff]);
|
|
167
|
-
|
|
168
|
-
const apply = useCallback((p: Promise<ReviewSession>) => {
|
|
169
|
-
p.then(setSession).catch((e: unknown) => setStatus(String(e instanceof Error ? e.message : e)));
|
|
170
|
-
}, []);
|
|
171
|
-
|
|
172
|
-
// ── verbs ───────────────────────────────────
|
|
173
|
-
const verbCut = useCallback(() => {
|
|
174
|
-
if (!session || resolved) return;
|
|
175
|
-
const d = display[cursor];
|
|
176
|
-
if (!d) return;
|
|
177
|
-
const c = clientRef.current!;
|
|
178
|
-
if (d.type === "del") {
|
|
179
|
-
const line = restoreLine(nextWorkBlock(display, cursor), working.split("\n").length);
|
|
180
|
-
// restoreBlock returns undefined when the block structure round-trips
|
|
181
|
-
// to the submitted revision - the working copy is gone
|
|
182
|
-
const restored = restoreBlock(session.artifact.content, working, d.base!, line);
|
|
183
|
-
apply(c.sessionSetWorkingCopy(session.id, restored));
|
|
184
|
-
setStatus("cut restored");
|
|
185
|
-
} else if (d.work) {
|
|
186
|
-
apply(c.sessionSetWorkingCopy(session.id, cutBlock(working, d.work)));
|
|
187
|
-
setStatus("block cut - it serializes into the diff");
|
|
188
|
-
}
|
|
189
|
-
}, [session, resolved, display, cursor, working, apply]);
|
|
190
|
-
|
|
191
|
-
const verbEdit = useCallback(() => {
|
|
192
|
-
if (!session || resolved) return;
|
|
193
|
-
try {
|
|
194
|
-
const result = editInEditor(working, "plan.md");
|
|
195
|
-
if (result.changed) {
|
|
196
|
-
apply(clientRef.current!.sessionSetWorkingCopy(session.id, result.content));
|
|
197
|
-
setStatus("edits tracked - one diff");
|
|
198
|
-
} else setStatus("no changes");
|
|
199
|
-
} catch (err) {
|
|
200
|
-
setStatus(err instanceof Error ? err.message : String(err));
|
|
201
|
-
}
|
|
202
|
-
}, [session, resolved, working, apply]);
|
|
203
|
-
|
|
204
|
-
const saveCompose = useCallback(
|
|
205
|
-
(m: Extract<Mode, { m: "compose" }>, liveText: string) => {
|
|
206
|
-
if (!session || !liveText.trim()) return setMode({ m: "normal" });
|
|
207
|
-
let anchor;
|
|
208
|
-
if (session.artifact.type === "diff") {
|
|
209
|
-
anchor = { ...diffRowAnchor(rows, m.dispIdx), blockIndex: m.dispIdx };
|
|
210
|
-
} else {
|
|
211
|
-
const workBlocks = display.filter((d) => d.work).map((d) => d.work!);
|
|
212
|
-
const workIdx = display.slice(0, m.dispIdx + 1).filter((d) => d.work).length - 1;
|
|
213
|
-
anchor = makeAnchor(workBlocks, workIdx, m.start, m.end);
|
|
214
|
-
}
|
|
215
|
-
apply(
|
|
216
|
-
clientRef.current!.sessionAnnotate(session.id, {
|
|
217
|
-
id: `a_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`,
|
|
218
|
-
kind: m.kind,
|
|
219
|
-
anchor,
|
|
220
|
-
body: liveText.trim(),
|
|
221
|
-
}),
|
|
222
|
-
);
|
|
223
|
-
setMode({ m: "normal" });
|
|
224
|
-
setStatus(m.kind === "suggestion" ? "suggestion added - the agent applies it" : "comment added");
|
|
225
|
-
},
|
|
226
|
-
[session, display, rows, apply],
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
const submit = useCallback(
|
|
230
|
-
(verdict: VerdictKind, summary: string) => {
|
|
231
|
-
if (!session) return;
|
|
232
|
-
clientRef.current!
|
|
233
|
-
.sessionResolve(session.id, verdict, summary)
|
|
234
|
-
.then((resolved) => {
|
|
235
|
-
setSession(resolved);
|
|
236
|
-
setStatus(`review submitted - ${verdict.replace("_", " ")}`);
|
|
237
|
-
// notes-vault export: guarded by config, default is manual (no-op)
|
|
238
|
-
const obsidian = obsidianRef.current;
|
|
239
|
-
const exporter = exportersRef.current.get("obsidian");
|
|
240
|
-
if (obsidian && exporter && shouldExport(obsidian.exportOn, verdict)) {
|
|
241
|
-
void exporter(resolved).then((r) => {
|
|
242
|
-
setStatus(r.success && r.path ? `exported to ${r.path}` : `export failed: ${r.error ?? "unknown"}`);
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
// hand the reviewer back to the agent: prompt or count down per config;
|
|
246
|
-
// inside herdr with a known return pane, closing is the default
|
|
247
|
-
const delay = autoCloseRef.current;
|
|
248
|
-
const returns = returnPaneFor(resolved.artifact.meta.herdrPane) !== undefined;
|
|
249
|
-
if (delay === 0) finishReview();
|
|
250
|
-
else if (typeof delay === "number") setCompletion({ phase: "counting", remaining: delay });
|
|
251
|
-
else if (returns) setCompletion({ phase: "counting", remaining: 3 });
|
|
252
|
-
else setCompletion({ phase: "prompt" });
|
|
253
|
-
})
|
|
254
|
-
.catch((e: unknown) => setStatus(String(e instanceof Error ? e.message : e)));
|
|
255
|
-
setMode({ m: "normal" });
|
|
256
|
-
},
|
|
257
|
-
[session, finishReview],
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
useEffect(() => {
|
|
261
|
-
if (completion.phase !== "counting") return;
|
|
262
|
-
if (completion.remaining <= 0) return void finishReview();
|
|
263
|
-
const timer = setTimeout(
|
|
264
|
-
() => setCompletion((c) => (c.phase === "counting" ? { phase: "counting", remaining: c.remaining - 1 } : c)),
|
|
265
|
-
1000,
|
|
266
|
-
);
|
|
267
|
-
return () => clearTimeout(timer);
|
|
268
|
-
}, [completion, finishReview]);
|
|
269
98
|
|
|
270
|
-
// ── keyboard grammar
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
99
|
+
// ── keyboard grammar: build state, reduce, dispatch ──
|
|
100
|
+
const dispatch = (intent: Intent): void => {
|
|
101
|
+
switch (intent.t) {
|
|
102
|
+
case "exit":
|
|
103
|
+
return void onExit?.(0);
|
|
104
|
+
case "status":
|
|
105
|
+
return controller.setStatus(intent.msg);
|
|
106
|
+
case "move": {
|
|
107
|
+
const len = isDiff ? rows.length : display.length;
|
|
108
|
+
if (intent.to === "down") setCursor((c) => Math.min(len - 1, c + 1));
|
|
109
|
+
else if (intent.to === "up") setCursor((c) => Math.max(0, c - 1));
|
|
110
|
+
else if (intent.to === "top") setCursor(0);
|
|
111
|
+
else setCursor(len - 1);
|
|
112
|
+
return;
|
|
283
113
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (name === "a" && completion.phase === "prompt") {
|
|
289
|
-
// opt in: close in 3s from now on, persisted to the user config
|
|
290
|
-
try {
|
|
291
|
-
persistAutoClose(3);
|
|
292
|
-
} catch {
|
|
293
|
-
// a read-only config dir must not block closing the review
|
|
294
|
-
}
|
|
295
|
-
autoCloseRef.current = 3;
|
|
296
|
-
return setCompletion({ phase: "counting", remaining: 3 });
|
|
114
|
+
case "inboxMove": {
|
|
115
|
+
const len = inbox?.length ?? 0;
|
|
116
|
+
setInboxCursor((c) => (intent.to === "down" ? Math.min(len - 1, c + 1) : Math.max(0, c - 1)));
|
|
117
|
+
return;
|
|
297
118
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (action === "quit") return onExit?.(0);
|
|
303
|
-
if (readOnly && action && MUTATING_ACTIONS.has(action)) return setStatus("observer - read-only");
|
|
304
|
-
|
|
305
|
-
// inbox mode
|
|
306
|
-
if (!session) {
|
|
307
|
-
if (!inbox?.length) return;
|
|
308
|
-
if (name === "j" || name === "down") setInboxCursor((c) => Math.min(inbox.length - 1, c + 1));
|
|
309
|
-
else if (name === "k" || name === "up") setInboxCursor((c) => Math.max(0, c - 1));
|
|
310
|
-
else if (name === "return" || name === "enter") {
|
|
311
|
-
const s = inbox[inboxCursor];
|
|
312
|
-
if (s) setSession(s);
|
|
119
|
+
case "openSession": {
|
|
120
|
+
const s = inbox?.[inboxCursor];
|
|
121
|
+
if (s) controller.open(s.id);
|
|
122
|
+
return;
|
|
313
123
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
124
|
+
case "startSpan": {
|
|
125
|
+
const d = display[cursor];
|
|
126
|
+
if (!d?.work) return;
|
|
127
|
+
const span = startSpan(cursor, displayText(d));
|
|
128
|
+
if (span) setMode({ m: "span", span });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
case "spanKey":
|
|
132
|
+
if (mode.m === "span") {
|
|
133
|
+
setMode({ m: "span", span: spanKey(mode.span, intent.name, displayText(display[mode.span.dispIdx]!)) });
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
case "openCompose": {
|
|
326
137
|
liveInput.current = "";
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
setMode({ m: "
|
|
138
|
+
if (intent.from === "span" && mode.m === "span") {
|
|
139
|
+
setMode({
|
|
140
|
+
m: "compose",
|
|
141
|
+
kind: intent.kind,
|
|
142
|
+
dispIdx: mode.span.dispIdx,
|
|
143
|
+
start: mode.span.start,
|
|
144
|
+
end: mode.span.end,
|
|
145
|
+
text: "",
|
|
146
|
+
});
|
|
147
|
+
} else if (isDiff) {
|
|
148
|
+
const row = rows[cursor];
|
|
149
|
+
if (row) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: row.text.length, text: "" });
|
|
150
|
+
} else {
|
|
151
|
+
const d = display[cursor];
|
|
152
|
+
if (d) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: displayText(d).length, text: "" });
|
|
342
153
|
}
|
|
343
|
-
|
|
344
|
-
setStatus("plan-only verb - diff review uses c on a line");
|
|
154
|
+
return;
|
|
345
155
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const vis = display;
|
|
350
|
-
if (mode.m === "span") {
|
|
351
|
-
if (name === "escape") return setMode({ m: "normal" });
|
|
352
|
-
if (["l", "h", "w", "b", "$", "0"].includes(name)) {
|
|
353
|
-
return setMode({ m: "span", span: spanKey(mode.span, name, displayText(vis[mode.span.dispIdx]!)) });
|
|
354
|
-
}
|
|
355
|
-
if (name === "c" || name === "s") {
|
|
356
|
-
if (readOnly) return setStatus("observer - read-only");
|
|
156
|
+
case "openSubmit":
|
|
157
|
+
if (!session) return;
|
|
357
158
|
liveInput.current = "";
|
|
358
|
-
return setMode({
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
159
|
+
return void setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
|
|
160
|
+
case "cut":
|
|
161
|
+
return controller.cut(cursor);
|
|
162
|
+
case "edit":
|
|
163
|
+
return controller.edit();
|
|
164
|
+
case "nextAnn":
|
|
165
|
+
case "prevAnn": {
|
|
166
|
+
const anns = session?.annotations ?? [];
|
|
167
|
+
if (!anns.length) return;
|
|
168
|
+
const idx = anns.findIndex((a) => a.id === focusedAnn);
|
|
169
|
+
const next = idx === -1 ? 0 : (idx + (intent.t === "nextAnn" ? 1 : -1) + anns.length) % anns.length;
|
|
170
|
+
return void setFocusedAnn(anns[next]!.id);
|
|
366
171
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
172
|
+
case "removeAnnotation":
|
|
173
|
+
if (focusedAnn) {
|
|
174
|
+
controller.removeAnnotation(focusedAnn);
|
|
175
|
+
setFocusedAnn(undefined);
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
case "closeOverlay":
|
|
179
|
+
return void setMode({ m: "normal" });
|
|
180
|
+
case "saveCompose": {
|
|
181
|
+
if (mode.m !== "compose") return;
|
|
182
|
+
const body = liveInput.current.trim();
|
|
183
|
+
if (session && body) controller.annotate(mode.kind, mode.dispIdx, mode.start, mode.end, body);
|
|
184
|
+
return void setMode({ m: "normal" });
|
|
380
185
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
kind: action === "suggest" ? "suggestion" : "comment",
|
|
389
|
-
dispIdx: cursor,
|
|
390
|
-
start: 0,
|
|
391
|
-
end: displayText(d).length,
|
|
392
|
-
text: "",
|
|
393
|
-
});
|
|
394
|
-
} else if (action === "cut") {
|
|
395
|
-
if (resolved) return setStatus("review submitted - read-only");
|
|
396
|
-
verbCut();
|
|
397
|
-
} else if (action === "edit") {
|
|
398
|
-
if (resolved) return setStatus("review submitted - read-only");
|
|
399
|
-
verbEdit();
|
|
400
|
-
} else if (action === "next_annotation" || action === "prev_annotation") {
|
|
401
|
-
const anns = session.annotations;
|
|
402
|
-
if (!anns.length) return setStatus("no annotations");
|
|
403
|
-
const idx = anns.findIndex((a) => a.id === focusedAnn);
|
|
404
|
-
const next = idx === -1 ? 0 : (idx + (action === "next_annotation" ? 1 : -1) + anns.length) % anns.length;
|
|
405
|
-
setFocusedAnn(anns[next]!.id);
|
|
406
|
-
} else if (action === "delete_annotation") {
|
|
407
|
-
if (resolved || !focusedAnn) return;
|
|
408
|
-
apply(clientRef.current!.sessionRemoveAnnotation(session.id, focusedAnn));
|
|
409
|
-
setFocusedAnn(undefined);
|
|
410
|
-
setStatus("annotation deleted");
|
|
411
|
-
} else if (action === "submit") {
|
|
412
|
-
if (!resolved) {
|
|
413
|
-
liveInput.current = "";
|
|
414
|
-
setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
|
|
186
|
+
case "submitVerdict":
|
|
187
|
+
if (mode.m === "submit") controller.submit(mode.verdict, liveInput.current);
|
|
188
|
+
return void setMode({ m: "normal" });
|
|
189
|
+
case "cycleVerdict": {
|
|
190
|
+
if (mode.m !== "submit") return;
|
|
191
|
+
const idx = (VERDICTS.indexOf(mode.verdict) + intent.dir + VERDICTS.length) % VERDICTS.length;
|
|
192
|
+
return void setMode({ ...mode, verdict: VERDICTS[idx]! });
|
|
415
193
|
}
|
|
194
|
+
case "finishReview":
|
|
195
|
+
return controller.finishReview();
|
|
196
|
+
case "optInAutoClose":
|
|
197
|
+
return controller.optInAutoClose();
|
|
198
|
+
case "dismissCompletion":
|
|
199
|
+
return controller.dismissCompletion();
|
|
416
200
|
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
useKeyboard((key) => {
|
|
204
|
+
const state: KeyState = {
|
|
205
|
+
keys: keysRef.current,
|
|
206
|
+
readOnly,
|
|
207
|
+
overlay:
|
|
208
|
+
mode.m === "compose"
|
|
209
|
+
? "compose"
|
|
210
|
+
: mode.m === "submit"
|
|
211
|
+
? "submit"
|
|
212
|
+
: completion.phase === "prompt"
|
|
213
|
+
? "completion-prompt"
|
|
214
|
+
: completion.phase === "counting"
|
|
215
|
+
? "completion-counting"
|
|
216
|
+
: "none",
|
|
217
|
+
view: !session ? "inbox" : isDiff ? "diff" : "plan",
|
|
218
|
+
spanMode: mode.m === "span",
|
|
219
|
+
resolved: !!resolved,
|
|
220
|
+
hasInboxItems: !!inbox?.length,
|
|
221
|
+
annotationCount: session?.annotations.length ?? 0,
|
|
222
|
+
hasFocusedAnnotation: focusedAnn !== undefined,
|
|
223
|
+
cursorAnnotatable: isDiff
|
|
224
|
+
? rows[cursor] !== undefined && rows[cursor]!.t !== "file" && rows[cursor]!.t !== "hunk"
|
|
225
|
+
: !!display[cursor]?.work,
|
|
226
|
+
};
|
|
227
|
+
for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift })) dispatch(intent);
|
|
417
228
|
});
|
|
418
229
|
|
|
419
230
|
// ── render ──────────────────────────────────
|