@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 CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.7",
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.7",
11
- "@cueloop/extension-api": "0.1.0-alpha.7",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.7",
13
- "@cueloop/schema": "0.1.0-alpha.7",
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 daemon state: every mutation round-trips through
5
- * DaemonClient; local state is view state only (cursor, span, overlays).
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, { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
+ import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
9
10
  import { useKeyboard } from "@opentui/react";
10
- import { DaemonClient } from "@cueloop/daemon/client";
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 { diffRows, diffRowAnchor, diffRowLocation, type DiffRow } from "./view-diff";
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, actionFor, loadConfig, persistAutoClose, type AutoClose } from "./config";
39
- import { focusHerdrPane, returnPaneFor } from "./herdr";
40
- import { Registry, type Exporter } from "@cueloop/extension-api";
41
- import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
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 clientRef = useRef<DaemonClient | null>(null);
72
- const [error, setError] = useState<string | null>(null);
73
- const [inbox, setInbox] = useState<ReviewSession[] | null>(null);
74
- const [session, setSession] = useState<ReviewSession | null>(null);
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
- obsidianRef.current = cfg.integrations.obsidian;
108
- // bundled integrations register through the public extension API
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 working = session ? (session.workingCopy ?? session.artifact.content) : "";
156
- const display = useMemo(
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
- useKeyboard((key) => {
272
- const name = key.name;
273
- // compose/submit overlays own the keys via focused inputs; only escape here
274
- if (mode.m === "compose" || mode.m === "submit") {
275
- if (name === "escape") setMode({ m: "normal" });
276
- else if (name === "return" || name === "enter") {
277
- if (mode.m === "compose") saveCompose(mode, liveInput.current);
278
- else submit(mode.verdict, liveInput.current);
279
- } else if (mode.m === "submit" && (name === "left" || name === "right")) {
280
- const dir = name === "left" ? -1 : 1;
281
- const idx = (VERDICTS.indexOf(mode.verdict) + dir + VERDICTS.length) % VERDICTS.length;
282
- setMode({ ...mode, verdict: VERDICTS[idx]! });
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
- return;
285
- }
286
- if (completion.phase === "prompt" || completion.phase === "counting") {
287
- if (name === "return" || name === "enter" || name === "q") return finishReview();
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
- if (name === "escape") return setCompletion({ phase: "dismissed" });
299
- return;
300
- }
301
- const action = actionFor(keysRef.current, name, !!key.shift);
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
- return;
315
- }
316
-
317
- if (session.artifact.type === "diff") {
318
- if (action === "down") setCursor((c) => Math.min(rows.length - 1, c + 1));
319
- else if (action === "up") setCursor((c) => Math.max(0, c - 1));
320
- else if (action === "top") setCursor(0);
321
- else if (action === "bottom") setCursor(rows.length - 1);
322
- else if (action === "comment") {
323
- if (resolved) return setStatus("review submitted - read-only");
324
- const row = rows[cursor];
325
- if (!row || row.t === "file" || row.t === "hunk") return setStatus("move to a code line to comment");
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
- setMode({ m: "compose", kind: "comment", dispIdx: cursor, start: 0, end: row.text.length, text: "" });
328
- } else if (action === "next_annotation" || action === "prev_annotation") {
329
- const anns = session.annotations;
330
- if (!anns.length) return setStatus("no annotations");
331
- const idx = anns.findIndex((a) => a.id === focusedAnn);
332
- const next = idx === -1 ? 0 : (idx + (action === "next_annotation" ? 1 : -1) + anns.length) % anns.length;
333
- setFocusedAnn(anns[next]!.id);
334
- } else if (action === "delete_annotation") {
335
- if (resolved || !focusedAnn) return;
336
- apply(clientRef.current!.sessionRemoveAnnotation(session.id, focusedAnn));
337
- setFocusedAnn(undefined);
338
- } else if (action === "submit") {
339
- if (!resolved) {
340
- liveInput.current = "";
341
- setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
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
- } else if (action === "span" || action === "cut" || action === "edit" || action === "suggest") {
344
- setStatus("plan-only verb - diff review uses c on a line");
154
+ return;
345
155
  }
346
- return;
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
- m: "compose",
360
- kind: name === "s" ? "suggestion" : "comment",
361
- dispIdx: mode.span.dispIdx,
362
- start: mode.span.start,
363
- end: mode.span.end,
364
- text: "",
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
- return;
368
- }
369
-
370
- // normal mode
371
- if (action === "down") setCursor((c) => Math.min(vis.length - 1, c + 1));
372
- else if (action === "up") setCursor((c) => Math.max(0, c - 1));
373
- else if (action === "top") setCursor(0);
374
- else if (action === "bottom") setCursor(vis.length - 1);
375
- else if (action === "span") {
376
- const d = vis[cursor];
377
- if (d?.work) {
378
- const span = startSpan(cursor, displayText(d));
379
- if (span) setMode({ m: "span", span });
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
- } else if (action === "comment" || action === "suggest") {
382
- if (resolved) return setStatus("review submitted - read-only");
383
- const d = vis[cursor];
384
- if (!d?.work) return setStatus("text is cut - restore it first");
385
- liveInput.current = "";
386
- setMode({
387
- m: "compose",
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 ──────────────────────────────────