@cueloop/client 0.1.0-alpha.16 → 0.1.0-alpha.17

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/src/App.tsx CHANGED
@@ -1,23 +1,19 @@
1
1
  /**
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.
8
- *
9
- * The plan review grammar: selection is the entry primitive (mouse drag or
10
- * keyboard span on one native renderer selection), compose happens inline
11
- * under the anchor, annotation text lives in the rail while the document
12
- * keeps only the kind-colored highlight, and the rail edits what the
13
- * document selects - one selected id drives both sides.
2
+ * The cueloop TUI: state wiring, keymap dispatch, and layout composition -
3
+ * nothing else. Rendering lives in components/, daemon IO and the mutation
4
+ * verbs in session-controller.ts, the keyboard grammar in keymap.ts with
5
+ * binding resolution and status hints from key-bindings.ts, and theming in
6
+ * the ThemeProvider. Selection is the entry primitive (mouse drag or keyboard
7
+ * span on one native renderer selection); annotation text lives in the rail
8
+ * while the document keeps only the highlight, and one selected id drives
9
+ * both sides.
14
10
  */
15
11
 
16
12
  import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
17
13
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
18
14
  import type { Clock } from "@opentui/core";
19
15
  import { isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
20
- import { displayText, marksByDisplay, spanKey, startSpan, type Mark, type SpanState } from "./view";
16
+ import { displayText, marksByDisplay, spanKey, startSpan, type Mark, type SpanState } from "./view-plan";
21
17
  import { noteForFile, viewedCount } from "./walk";
22
18
  import { DARK, dimmedTheme } from "./theme";
23
19
  import { DEFAULT_KEYS, loadConfig } from "./config";
@@ -51,11 +47,11 @@ export interface AppProps {
51
47
  }
52
48
 
53
49
  type Mode =
54
- | { m: "normal" }
55
- | { m: "span"; span: SpanState }
56
- | { m: "compose"; kind: "comment" | "suggestion"; dispIdx: number; start: number; end: number; text: string }
57
- | { m: "railEdit"; id: string; text: string }
58
- | { m: "submit"; verdict: VerdictKind; summary: string };
50
+ | { type: "normal" }
51
+ | { type: "span"; span: SpanState }
52
+ | { type: "compose"; kind: "comment" | "suggestion"; displayIndex: number; start: number; end: number; text: string }
53
+ | { type: "railEdit"; id: string; text: string }
54
+ | { type: "submit"; verdict: VerdictKind; summary: string };
59
55
 
60
56
  /** Reviewer-authored annotations only: agent notes never count as feedback. */
61
57
  function reviewerAnnotations(session: ReviewSession) {
@@ -87,11 +83,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
87
83
  // ── view state ──────────────────────────────
88
84
  const [cursor, setCursor] = useState(0);
89
85
  const [inboxCursor, setInboxCursor] = useState(0);
90
- const [mode, setMode] = useState<Mode>({ m: "normal" });
91
- const [focusedAnn, setFocusedAnn] = useState<string | undefined>(undefined);
86
+ const [mode, setMode] = useState<Mode>({ type: "normal" });
87
+ const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
92
88
  const [railTab, setRailTab] = useState<"review" | "agent">("review");
93
89
  // ~2s focus pulse on the document highlight when a rail card is activated
94
- const [pulseAnn, setPulseAnn] = useState<string | null>(null);
90
+ const [pulsedAnnotationId, setPulsedAnnotationId] = useState<string | null>(null);
95
91
  const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
96
92
  // live mirror of overlay input text: refs commit synchronously, so the
97
93
  // RETURN handler never reads a stale value mid-typing
@@ -103,11 +99,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
103
99
  const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
104
100
  const [theme, setTheme] = useState(DARK);
105
101
  useEffect(() => {
106
- const cfg = loadConfig({ repoRoot: session?.workspace.repoRoot });
107
- keysRef.current = cfg.keys;
108
- keyBindings.setKeys(cfg.keys);
109
- setTheme(cfg.theme);
110
- controller.applyConfig(cfg);
102
+ const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
103
+ keysRef.current = config.keys;
104
+ keyBindings.setKeys(config.keys);
105
+ setTheme(config.theme);
106
+ controller.applyConfig(config);
111
107
  }, [session?.workspace.repoRoot, controller, keyBindings]);
112
108
  useEffect(
113
109
  () => () => {
@@ -120,8 +116,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
120
116
  const display = controller.display();
121
117
  const rows = controller.rows();
122
118
  const marks = useMemo(
123
- () => (session ? marksByDisplay(session.annotations, display, pulseAnn ?? undefined) : new Map<number, Mark[]>()),
124
- [session, display, pulseAnn],
119
+ () => (session ? marksByDisplay(session.annotations, display, pulsedAnnotationId ?? undefined) : new Map<number, Mark[]>()),
120
+ [session, display, pulsedAnnotationId],
125
121
  );
126
122
  /** Annotation ids whose anchor resolved against the working copy. */
127
123
  const resolvedIds = useMemo(() => {
@@ -143,34 +139,34 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
143
139
  // out of span mode clears the renderer selection (compose paints its own
144
140
  // mark, and a mouse drag never changes the mode, so it survives)
145
141
  useEffect(() => {
146
- if (mode.m === "span") planSheetRef.current?.driveSpanSelection(mode.span);
142
+ if (mode.type === "span") planSheetRef.current?.driveSpanSelection(mode.span);
147
143
  else planSheetRef.current?.clearSelection();
148
144
  }, [mode]);
149
145
 
150
146
  // ── selection symmetry: one selected id, both sides ──
151
147
  const pulse = (id: string): void => {
152
- setPulseAnn(id);
148
+ setPulsedAnnotationId(id);
153
149
  if (pulseTimer.current) clearTimeout(pulseTimer.current);
154
- pulseTimer.current = setTimeout(() => setPulseAnn(null), 2000);
150
+ pulseTimer.current = setTimeout(() => setPulsedAnnotationId(null), 2000);
155
151
  };
156
152
 
157
153
  /** Card activation scrolls the document to the anchor and pulses it. */
158
154
  const revealAnchor = (annotationId: string): void => {
159
- for (const [dispIdx, blockMarks] of marks) {
155
+ for (const [displayIndex, blockMarks] of marks) {
160
156
  if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
161
- setCursor(dispIdx);
162
- planSheetRef.current?.revealBlock(dispIdx);
157
+ setCursor(displayIndex);
158
+ planSheetRef.current?.revealBlock(displayIndex);
163
159
  return;
164
160
  }
165
161
  };
166
162
 
167
163
  const selectCardFromDocument = (annotationId: string): void => {
168
- setFocusedAnn(annotationId);
164
+ setFocusedAnnotationId(annotationId);
169
165
  railRef.current?.revealCard(annotationId);
170
166
  };
171
167
 
172
168
  const selectCardFromRail = (annotationId: string): void => {
173
- setFocusedAnn(annotationId);
169
+ setFocusedAnnotationId(annotationId);
174
170
  pulse(annotationId);
175
171
  revealAnchor(annotationId);
176
172
  };
@@ -181,7 +177,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
181
177
  const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
182
178
  if (!annotation) return;
183
179
  liveInput.current = annotation.body;
184
- setMode({ m: "railEdit", id: annotation.id, text: annotation.body });
180
+ setMode({ type: "railEdit", id: annotation.id, text: annotation.body });
185
181
  };
186
182
 
187
183
  /** The $EDITOR hand-off releases the terminal: suspend, edit, resume. */
@@ -196,64 +192,64 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
196
192
 
197
193
  // ── keyboard grammar: build state, reduce, dispatch ──
198
194
  const dispatch = (intent: Intent): void => {
199
- switch (intent.t) {
195
+ switch (intent.type) {
200
196
  case "exit":
201
197
  return void onExit?.(0);
202
198
  case "status":
203
- return controller.setStatus(intent.msg);
199
+ return controller.setStatus(intent.message);
204
200
  case "move": {
205
- const len = isDiff ? rows.length : display.length;
206
- if (intent.to === "down") setCursor((c) => Math.min(len - 1, c + 1));
207
- else if (intent.to === "up") setCursor((c) => Math.max(0, c - 1));
201
+ const navigableCount = isDiff ? rows.length : display.length;
202
+ if (intent.to === "down") setCursor((current) => Math.min(navigableCount - 1, current + 1));
203
+ else if (intent.to === "up") setCursor((current) => Math.max(0, current - 1));
208
204
  else if (intent.to === "top") setCursor(0);
209
- else setCursor(len - 1);
205
+ else setCursor(navigableCount - 1);
210
206
  return;
211
207
  }
212
208
  case "inboxMove": {
213
- const len = inbox?.length ?? 0;
214
- setInboxCursor((c) => (intent.to === "down" ? Math.min(len - 1, c + 1) : Math.max(0, c - 1)));
209
+ const navigableCount = inbox?.length ?? 0;
210
+ setInboxCursor((current) => (intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1)));
215
211
  return;
216
212
  }
217
213
  case "openSession": {
218
- const s = inbox?.[inboxCursor];
219
- if (s) controller.open(s.id);
214
+ const selected = inbox?.[inboxCursor];
215
+ if (selected) controller.open(selected.id);
220
216
  return;
221
217
  }
222
218
  case "startSpan": {
223
- const d = display[cursor];
224
- if (!d?.work) return;
225
- const span = startSpan(cursor, displayText(d));
226
- if (span) setMode({ m: "span", span });
219
+ const block = display[cursor];
220
+ if (!block?.work) return;
221
+ const span = startSpan(cursor, displayText(block));
222
+ if (span) setMode({ type: "span", span });
227
223
  return;
228
224
  }
229
225
  case "spanKey":
230
- if (mode.m === "span") {
231
- const span = spanKey(mode.span, intent.name, displayText(display[mode.span.dispIdx]!));
232
- setMode({ m: "span", span });
226
+ if (mode.type === "span") {
227
+ const span = spanKey(mode.span, intent.name, displayText(display[mode.span.displayIndex]!));
228
+ setMode({ type: "span", span });
233
229
  }
234
230
  return;
235
231
  case "openCompose": {
236
232
  liveInput.current = "";
237
- if (intent.from === "span" && mode.m === "span") {
233
+ if (intent.from === "span" && mode.type === "span") {
238
234
  setMode({
239
- m: "compose",
235
+ type: "compose",
240
236
  kind: intent.kind,
241
- dispIdx: mode.span.dispIdx,
237
+ displayIndex: mode.span.displayIndex,
242
238
  start: mode.span.start,
243
239
  end: mode.span.end,
244
240
  text: "",
245
241
  });
246
242
  } else if (isDiff) {
247
243
  const row = rows[cursor];
248
- if (row) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: row.text.length, text: "" });
244
+ if (row) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: row.text.length, text: "" });
249
245
  } else {
250
246
  // a mouse drag leaves a native selection; it wins over the cursor block
251
247
  const native = planSheetRef.current?.readSelection() ?? null;
252
248
  if (native) {
253
- setMode({ m: "compose", kind: intent.kind, ...native, text: "" });
249
+ setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
254
250
  } else {
255
- const d = display[cursor];
256
- if (d) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: displayText(d).length, text: "" });
251
+ const block = display[cursor];
252
+ if (block) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: displayText(block).length, text: "" });
257
253
  }
258
254
  }
259
255
  return;
@@ -263,21 +259,24 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
263
259
  liveInput.current = "";
264
260
  // the confirm card lives in the review tab; opening submit reveals it
265
261
  setRailTab("review");
266
- return void setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
262
+ return void setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
267
263
  case "cut":
268
264
  return controller.cut(cursor);
269
265
  case "edit":
270
266
  return runEditorHandOff();
271
267
  case "editCard":
272
- if (focusedAnn) openCardEdit(focusedAnn);
268
+ if (focusedAnnotationId) openCardEdit(focusedAnnotationId);
273
269
  return;
274
- case "nextAnn":
275
- case "prevAnn": {
276
- const anns = session?.annotations ?? [];
277
- if (!anns.length) return;
278
- const idx = anns.findIndex((a) => a.id === focusedAnn);
279
- const next = idx === -1 ? 0 : (idx + (intent.t === "nextAnn" ? 1 : -1) + anns.length) % anns.length;
280
- return void selectCardFromDocument(anns[next]!.id);
270
+ case "nextAnnotation":
271
+ case "prevAnnotation": {
272
+ const annotations = session?.annotations ?? [];
273
+ if (!annotations.length) return;
274
+ const focusedIndex = annotations.findIndex((annotation) => annotation.id === focusedAnnotationId);
275
+ const nextIndex =
276
+ focusedIndex === -1
277
+ ? 0
278
+ : (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) % annotations.length;
279
+ return void selectCardFromDocument(annotations[nextIndex]!.id);
281
280
  }
282
281
  case "walkStart":
283
282
  return controller.walkStart();
@@ -288,38 +287,38 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
288
287
  case "walkLeave":
289
288
  return controller.walkLeave();
290
289
  case "removeAnnotation":
291
- if (focusedAnn) {
292
- controller.removeAnnotation(focusedAnn);
293
- setFocusedAnn(undefined);
290
+ if (focusedAnnotationId) {
291
+ controller.removeAnnotation(focusedAnnotationId);
292
+ setFocusedAnnotationId(undefined);
294
293
  }
295
294
  return;
296
295
  case "deselect":
297
296
  planSheetRef.current?.clearSelection();
298
- setFocusedAnn(undefined);
299
- setPulseAnn(null);
297
+ setFocusedAnnotationId(undefined);
298
+ setPulsedAnnotationId(null);
300
299
  return;
301
300
  case "closeOverlay":
302
- return void setMode({ m: "normal" });
301
+ return void setMode({ type: "normal" });
303
302
  case "saveCompose": {
304
303
  const body = liveInput.current.trim();
305
- if (mode.m === "railEdit") {
304
+ if (mode.type === "railEdit") {
306
305
  if (session && body) controller.updateAnnotation(mode.id, body);
307
- return void setMode({ m: "normal" });
306
+ return void setMode({ type: "normal" });
308
307
  }
309
- if (mode.m !== "compose") return;
308
+ if (mode.type !== "compose") return;
310
309
  if (session && body) {
311
- const annotationId = controller.annotate(mode.kind, mode.dispIdx, mode.start, mode.end, body);
312
- if (annotationId) setFocusedAnn(annotationId);
310
+ const annotationId = controller.annotate(mode.kind, mode.displayIndex, mode.start, mode.end, body);
311
+ if (annotationId) setFocusedAnnotationId(annotationId);
313
312
  }
314
- return void setMode({ m: "normal" });
313
+ return void setMode({ type: "normal" });
315
314
  }
316
315
  case "submitVerdict":
317
- if (mode.m === "submit") controller.submit(mode.verdict, liveInput.current);
318
- return void setMode({ m: "normal" });
316
+ if (mode.type === "submit") controller.submit(mode.verdict, liveInput.current);
317
+ return void setMode({ type: "normal" });
319
318
  case "cycleVerdict": {
320
- if (mode.m !== "submit") return;
321
- const idx = (VERDICTS.indexOf(mode.verdict) + intent.dir + VERDICTS.length) % VERDICTS.length;
322
- return void setMode({ ...mode, verdict: VERDICTS[idx]! });
319
+ if (mode.type !== "submit") return;
320
+ const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.dir + VERDICTS.length) % VERDICTS.length;
321
+ return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
323
322
  }
324
323
  case "finishReview":
325
324
  return controller.finishReview();
@@ -331,9 +330,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
331
330
  };
332
331
 
333
332
  const overlay: KeyState["overlay"] =
334
- mode.m === "compose" || mode.m === "railEdit"
333
+ mode.type === "compose" || mode.type === "railEdit"
335
334
  ? "compose"
336
- : mode.m === "submit"
335
+ : mode.type === "submit"
337
336
  ? "submit"
338
337
  : completion.phase === "prompt"
339
338
  ? "completion-prompt"
@@ -349,14 +348,14 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
349
348
  readOnly,
350
349
  overlay,
351
350
  view: !session ? "inbox" : isDiff ? "diff" : "plan",
352
- spanMode: mode.m === "span",
351
+ spanMode: mode.type === "span",
353
352
  resolved: !!resolved,
354
353
  hasInboxItems: !!inbox?.length,
355
354
  annotationCount: session?.annotations.length ?? 0,
356
- hasFocusedAnnotation: focusedAnn !== undefined,
355
+ hasFocusedAnnotation: focusedAnnotationId !== undefined,
357
356
  walkAtEnd: walk !== null && walk.index >= walkFileList.length,
358
357
  cursorAnnotatable: isDiff
359
- ? rows[cursor] !== undefined && rows[cursor]!.t !== "file" && rows[cursor]!.t !== "hunk"
358
+ ? rows[cursor] !== undefined && rows[cursor]!.kind !== "file" && rows[cursor]!.kind !== "hunk"
360
359
  : !!display[cursor]?.work,
361
360
  };
362
361
  keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
@@ -387,53 +386,53 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
387
386
  );
388
387
  }
389
388
 
390
- const s = session!;
391
- const pendingCount = reviewerAnnotations(s).length + (s.workingCopy !== undefined ? 1 : 0);
389
+ const activeSession = session!;
390
+ const pendingCount = reviewerAnnotations(activeSession).length + (activeSession.workingCopy !== undefined ? 1 : 0);
392
391
 
393
- if ((completion.phase === "prompt" || completion.phase === "counting") && s.verdict) {
392
+ if ((completion.phase === "prompt" || completion.phase === "counting") && activeSession.verdict) {
394
393
  return (
395
394
  <ThemeProvider theme={theme}>
396
395
  <CompletionOverlay
397
- verdict={s.verdict.kind}
396
+ verdict={activeSession.verdict.kind}
398
397
  completion={completion}
399
398
  status={status}
400
- returnsTo={returnPaneFor(s.artifact.meta.herdrPane) ? (s.artifact.meta.agent ?? "the agent") : undefined}
399
+ returnsTo={returnPaneFor(activeSession.artifact.meta.herdrPane) ? (activeSession.artifact.meta.agent ?? "the agent") : undefined}
401
400
  />
402
401
  </ThemeProvider>
403
402
  );
404
403
  }
405
404
 
406
405
  const composeState =
407
- mode.m === "compose" && !isDiff
406
+ mode.type === "compose" && !isDiff
408
407
  ? {
409
408
  kind: mode.kind,
410
- dispIdx: mode.dispIdx,
411
- quote: displayText(display[mode.dispIdx]!).slice(mode.start, mode.end),
409
+ displayIndex: mode.displayIndex,
410
+ quote: displayText(display[mode.displayIndex]!).slice(mode.start, mode.end),
412
411
  draft: {
413
412
  text: mode.text,
414
413
  onInput: (text: string) => {
415
414
  liveInput.current = text;
416
415
  setMode({ ...mode, text });
417
416
  },
418
- onSave: () => dispatch({ t: "saveCompose" }),
419
- onCancel: () => dispatch({ t: "closeOverlay" }),
417
+ onSave: () => dispatch({ type: "saveCompose" }),
418
+ onCancel: () => dispatch({ type: "closeOverlay" }),
420
419
  },
421
420
  }
422
421
  : null;
423
422
 
424
423
  const activeSpan =
425
- mode.m === "span"
426
- ? { dispIdx: mode.span.dispIdx, start: mode.span.start, end: mode.span.end }
427
- : mode.m === "compose" && !isDiff
424
+ mode.type === "span"
425
+ ? { displayIndex: mode.span.displayIndex, start: mode.span.start, end: mode.span.end }
426
+ : mode.type === "compose" && !isDiff
428
427
  ? // the compose anchor stays painted selection-style while the box is open
429
- { dispIdx: mode.dispIdx, start: mode.start, end: mode.end }
428
+ { displayIndex: mode.displayIndex, start: mode.start, end: mode.end }
430
429
  : null;
431
430
 
432
- const onLineActivate = (dispIdx: number): void => {
431
+ const onLineActivate = (displayIndex: number): void => {
433
432
  // releasing a drag-selection lands here too; a live selection is not a click
434
433
  if (renderer?.hasSelection) return;
435
- setCursor(dispIdx);
436
- const annotationId = marks.get(dispIdx)?.[0]?.annotationId;
434
+ setCursor(displayIndex);
435
+ const annotationId = marks.get(displayIndex)?.[0]?.annotationId;
437
436
  if (annotationId) selectCardFromDocument(annotationId);
438
437
  };
439
438
 
@@ -447,19 +446,19 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
447
446
  const onSubmitRequest = (): void => {
448
447
  if (readOnly) return controller.setStatus("observer - read-only");
449
448
  if (resolved) return;
450
- dispatch({ t: "openSubmit" });
449
+ dispatch({ type: "openSubmit" });
451
450
  };
452
451
 
453
452
  const submitConfirmState =
454
- mode.m === "submit"
453
+ mode.type === "submit"
455
454
  ? {
456
455
  verdict: mode.verdict,
457
456
  summary: mode.summary,
458
- annotationCount: reviewerAnnotations(s).length,
459
- blockingCount: reviewerAnnotations(s).filter(annotationBlocking).length,
457
+ annotationCount: reviewerAnnotations(activeSession).length,
458
+ blockingCount: reviewerAnnotations(activeSession).filter(annotationBlocking).length,
460
459
  // walk coverage keeps partial passes honest at the verdict
461
460
  viewedSummary:
462
- isDiff && s.viewedPaths !== undefined
461
+ isDiff && activeSession.viewedPaths !== undefined
463
462
  ? `${viewedCount(walkFileList, viewedPaths)}/${walkFileList.length} files viewed`
464
463
  : undefined,
465
464
  onInput: (summary: string) => {
@@ -467,45 +466,45 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
467
466
  setMode({ ...mode, summary });
468
467
  },
469
468
  onSelectVerdict: (verdict: VerdictKind) => setMode({ ...mode, verdict }),
470
- onSubmit: () => dispatch({ t: "submitVerdict" }),
471
- onCancel: () => dispatch({ t: "closeOverlay" }),
469
+ onSubmit: () => dispatch({ type: "submitVerdict" }),
470
+ onCancel: () => dispatch({ type: "closeOverlay" }),
472
471
  }
473
472
  : null;
474
473
 
475
474
  const cardEditState =
476
- mode.m === "railEdit"
475
+ mode.type === "railEdit"
477
476
  ? {
478
477
  id: mode.id,
479
478
  text: mode.text,
480
479
  onInput: (text: string) => {
481
480
  liveInput.current = text;
482
- setMode({ m: "railEdit", id: mode.id, text });
481
+ setMode({ type: "railEdit", id: mode.id, text });
483
482
  },
484
- onSave: () => dispatch({ t: "saveCompose" }),
485
- onCancel: () => dispatch({ t: "closeOverlay" }),
483
+ onSave: () => dispatch({ type: "saveCompose" }),
484
+ onCancel: () => dispatch({ type: "closeOverlay" }),
486
485
  }
487
486
  : null;
488
487
 
489
488
  const headerItems: BreadcrumbItem[] = [
490
489
  { label: "cueloop", tone: "accent" },
491
- { label: `${s.artifact.meta.title ?? s.artifact.meta.planPath ?? s.id} · rev ${s.revisions.length}`, tone: "dim" },
492
- ...(resolved ? [{ label: `resolved: ${s.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
490
+ { label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
491
+ ...(resolved ? [{ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
493
492
  ...(readOnly ? [{ label: "observer", tone: "dim" as const }] : []),
494
493
  ...(status ? [{ label: status, tone: "accent" as const }] : []),
495
494
  ];
496
495
 
497
- keyBindings.setContext({ overlay, spanMode: mode.m === "span" });
496
+ keyBindings.setContext({ overlay, spanMode: mode.type === "span" });
498
497
  const hintMode: HintMode = readOnly
499
498
  ? "read-only"
500
- : mode.m === "submit"
499
+ : mode.type === "submit"
501
500
  ? "submit"
502
- : mode.m === "span"
501
+ : mode.type === "span"
503
502
  ? "span"
504
- : mode.m === "compose" || mode.m === "railEdit"
503
+ : mode.type === "compose" || mode.type === "railEdit"
505
504
  ? "compose"
506
505
  : walking
507
506
  ? "walk"
508
- : focusedAnn !== undefined
507
+ : focusedAnnotationId !== undefined
509
508
  ? "card"
510
509
  : "normal";
511
510
  const railWidth = terminalWidth >= 100 ? 34 : 28;
@@ -520,14 +519,14 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
520
519
  <DiffSheet
521
520
  rows={rows}
522
521
  cursor={cursor}
523
- annotations={s.annotations}
524
- focusedAnnotationId={focusedAnn}
522
+ annotations={activeSession.annotations}
523
+ focusedAnnotationId={focusedAnnotationId}
525
524
  theme={walking ? dimmedTheme(theme) : undefined}
526
525
  />
527
526
  ) : (
528
527
  <PlanSheet
529
528
  ref={planSheetRef}
530
- session={s}
529
+ session={activeSession}
531
530
  display={display}
532
531
  marks={marks}
533
532
  cursor={cursor}
@@ -540,8 +539,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
540
539
  )}
541
540
  <ReviewRail
542
541
  ref={railRef}
543
- session={s}
544
- selectedId={focusedAnn}
542
+ session={activeSession}
543
+ selectedId={focusedAnnotationId}
545
544
  resolvedIds={isDiff ? null : resolvedIds}
546
545
  railTab={railTab}
547
546
  pendingCount={pendingCount}
@@ -554,10 +553,10 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
554
553
  width={railWidth}
555
554
  />
556
555
  </box>
557
- {mode.m === "compose" && isDiff ? (
556
+ {mode.type === "compose" && isDiff ? (
558
557
  <ComposeBar
559
558
  kind={mode.kind}
560
- quote={rows[mode.dispIdx]?.text ?? ""}
559
+ quote={rows[mode.displayIndex]?.text ?? ""}
561
560
  text={mode.text}
562
561
  onInput={(text) => {
563
562
  liveInput.current = text;
@@ -574,15 +573,15 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
574
573
  viewedPaths={viewedPaths}
575
574
  note={
576
575
  walkFileList[walk.index] !== undefined
577
- ? noteForFile(s.annotations, walkFileList[walk.index]!.path)
576
+ ? noteForFile(activeSession.annotations, walkFileList[walk.index]!.path)
578
577
  : undefined
579
578
  }
580
579
  terminalWidth={terminalWidth}
581
580
  onSubmitRequest={() => {
582
- dispatch({ t: "walkLeave" });
583
- dispatch({ t: "openSubmit" });
581
+ dispatch({ type: "walkLeave" });
582
+ dispatch({ type: "openSubmit" });
584
583
  }}
585
- onBack={() => dispatch({ t: "walkBack" })}
584
+ onBack={() => dispatch({ type: "walkBack" })}
586
585
  />
587
586
  ) : null}
588
587
  </box>
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The guided walk in the virtual terminal (#83): the wizard steps every
2
+ * The guided walk in the virtual terminal: the wizard steps every
3
3
  * changed file with a plain progress title, ] marks viewed and persists with
4
4
  * the session, esc keeps progress, w resumes at the first unviewed file
5
5
  * (across App instances - the daemon round-trip), the agent-note block
@@ -15,7 +15,7 @@ import { useComponentTheme } from "./theme-context";
15
15
  import { Card } from "./primitives/Card";
16
16
  import { Button } from "./primitives/Button";
17
17
  import { Toolbar } from "./primitives/Toolbar";
18
- import { truncate } from "./format";
18
+ import { truncateToSingleLine } from "./truncate-text";
19
19
 
20
20
  export interface AnnotationDraft {
21
21
  text: string;
@@ -101,7 +101,7 @@ export function AnnotationCard({ id, kind, quote, draft, saved, theme }: Annotat
101
101
  const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
102
102
  return (
103
103
  <Card
104
- title={` ${verb} "${truncate(quote, 40)}" `}
104
+ title={` ${verb} "${truncateToSingleLine(quote, 40)}" `}
105
105
  contentRows={editorRows(draft.text) + 1}
106
106
  borderColor={kindColor}
107
107
  marginLeft={2}
@@ -125,11 +125,11 @@ export function AnnotationCard({ id, kind, quote, draft, saved, theme }: Annotat
125
125
  {card.isBlocking ? <span fg={tokens.red}> · BLOCKING</span> : null}
126
126
  <span fg={tokens.textDim}>{card.isOrphan ? " · ORPHANED" : " · pending"}</span>
127
127
  </text>
128
- <text fg={tokens.textDim}> "{truncate(quote, 26)}"</text>
128
+ <text fg={tokens.textDim}> "{truncateToSingleLine(quote, 26)}"</text>
129
129
  {card.editing ? (
130
130
  <DraftEditor draft={card.editing} theme={theme} />
131
131
  ) : (
132
- <text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}> {truncate(card.body, 28)}</text>
132
+ <text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}> {truncateToSingleLine(card.body, 28)}</text>
133
133
  )}
134
134
  </box>
135
135
  );
@@ -8,7 +8,7 @@ import React, { useRef } from "react";
8
8
  import type { KeyBinding, TextareaRenderable } from "@opentui/core";
9
9
  import type { Theme } from "../theme";
10
10
  import { useComponentTheme } from "./theme-context";
11
- import { truncate } from "./format";
11
+ import { truncateToSingleLine } from "./truncate-text";
12
12
 
13
13
  export interface ComposeBarProps {
14
14
  kind: "comment" | "suggestion";
@@ -30,7 +30,7 @@ export function ComposeBar({ kind, quote, text, onInput, theme }: ComposeBarProp
30
30
  return (
31
31
  <box style={{ height: 1 + editorRows, backgroundColor: tokens.elevated, flexDirection: "column", paddingLeft: 1 }}>
32
32
  <text fg={kind === "suggestion" ? tokens.green : tokens.accent}>
33
- {kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncate(quote, 60)}” · ⏎ save · esc cancel
33
+ {kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncateToSingleLine(quote, 60)}” · ⏎ save · esc cancel
34
34
  </text>
35
35
  <textarea
36
36
  ref={editorRef}