@cueloop/client 0.1.0-alpha.63 → 0.1.0-alpha.64

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/config.ts CHANGED
@@ -12,6 +12,7 @@ import { join } from "node:path";
12
12
  import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obsidian";
13
13
  import { writeFileSync, mkdirSync } from "node:fs";
14
14
  import { dirname } from "node:path";
15
+ import * as v from "valibot";
15
16
  import { DARK, type Theme } from "./theme";
16
17
  import { DEFAULT_THEME_NAME, isThemeName, themeForName, type ThemeName } from "./theme-presets";
17
18
  import { REVIEW_DEFAULT_WIDTH, clampWidth, type ReviewPanelMode } from "./review-panel";
@@ -112,7 +113,7 @@ export interface CueloopConfig {
112
113
  }
113
114
 
114
115
  /** Every action in the grammar, with its default binding(s). */
115
- export const DEFAULT_KEYS: Record<string, string[]> = {
116
+ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
116
117
  down: ["j", "down"],
117
118
  up: ["k", "up"],
118
119
  top: ["g"],
@@ -136,100 +137,87 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
136
137
  review_narrower: ["["],
137
138
  };
138
139
 
139
- function parseToml(text: string): Record<string, unknown> {
140
- // Bun ships a native TOML parser
141
- return Bun.TOML.parse(text) as Record<string, unknown>;
140
+ const ConfigDocumentSchema = v.object({
141
+ actions: v.optional(v.array(v.unknown())),
142
+ authors: v.optional(v.unknown()),
143
+ integrations: v.optional(v.unknown()),
144
+ keys: v.optional(v.unknown()),
145
+ theme: v.optional(v.unknown()),
146
+ ui: v.optional(v.unknown()),
147
+ });
148
+
149
+ const QuickActionSchema = v.object({
150
+ prompt: v.pipe(
151
+ v.string(),
152
+ v.check((prompt) => Boolean(prompt.trim())),
153
+ ),
154
+ metadata: v.optional(v.string()),
155
+ });
156
+
157
+ const AuthorsSchema = v.record(v.string(), v.unknown());
158
+ const KeysSchema = v.record(v.string(), v.unknown());
159
+ const KeyComboSchema = v.union([v.string(), v.array(v.string())]);
160
+ // Every field falls back to undefined on its own, so one bad value in a
161
+ // section never discards the section's other settings.
162
+ const UiSchema = v.object({
163
+ auto_close: v.fallback(
164
+ v.optional(v.union([v.literal("off"), v.pipe(v.number(), v.minValue(0))])),
165
+ undefined,
166
+ ),
167
+ editor: v.fallback(v.optional(v.string()), undefined),
168
+ review_width: v.fallback(v.optional(v.pipe(v.number(), v.finite())), undefined),
169
+ review_state: v.fallback(v.optional(v.picklist(["expanded", "compact", "hidden"])), undefined),
170
+ theme: v.fallback(v.optional(v.string()), undefined),
171
+ });
172
+ const ObsidianSchema = v.object({
173
+ vault: v.fallback(v.optional(v.string()), undefined),
174
+ folder: v.fallback(v.optional(v.string()), undefined),
175
+ filenameFormat: v.fallback(v.optional(v.string()), undefined),
176
+ separator: v.fallback(v.optional(v.picklist(["space", "dash", "underscore"])), undefined),
177
+ exportOn: v.fallback(v.optional(v.picklist(["approve", "resolve", "manual"])), undefined),
178
+ });
179
+ const IntegrationsSchema = v.object({ obsidian: v.optional(ObsidianSchema) });
180
+ const ThemeOverridesSchema = v.record(v.string(), v.unknown());
181
+
182
+ function isThemeToken(token: string): token is keyof Theme {
183
+ return token in DARK;
142
184
  }
143
185
 
144
- /**
145
- * Parse a `[[actions]]` array-of-tables. A table without a non-empty `prompt`
146
- * is skipped; any valid table REPLACES the defaults (the whole set is the
147
- * user's, not merged). Returns undefined when nothing usable is present.
148
- */
149
- function parseActions(raw: unknown): QuickAction[] | undefined {
150
- if (!Array.isArray(raw)) return undefined;
151
- const actions: QuickAction[] = [];
152
-
153
- for (const entry of raw) {
154
- if (!entry || typeof entry !== "object") continue;
155
- const table = entry as Record<string, unknown>;
156
- const prompt = table["prompt"];
157
-
158
- if (typeof prompt !== "string" || !prompt.trim()) continue;
159
- const metadata = table["metadata"];
160
-
161
- actions.push(
162
- typeof metadata === "string" && metadata.trim() ? { prompt, metadata } : { prompt },
163
- );
164
- }
165
-
166
- return actions.length ? actions : undefined;
186
+ function parseToml(text: string) {
187
+ return v.parse(ConfigDocumentSchema, Bun.TOML.parse(text));
167
188
  }
168
189
 
169
- function mergeAuthors(target: Record<string, string>, raw: unknown): void {
170
- const authors = raw as Record<string, unknown> | undefined;
190
+ function parseActions(entries: readonly unknown[] | undefined): QuickAction[] | undefined {
191
+ if (!entries) return undefined;
192
+ const actions: QuickAction[] = [];
171
193
 
172
- if (!authors) return;
173
- for (const [id, value] of Object.entries(authors)) {
174
- if (typeof value === "string") target[id] = value;
175
- }
176
- }
194
+ for (const entry of entries) {
195
+ const result = v.safeParse(QuickActionSchema, entry);
177
196
 
178
- function mergeKeys(target: Record<string, string[]>, raw: unknown): void {
179
- const keys = raw as KeymapConfig | undefined;
197
+ if (!result.success) continue;
198
+ const { prompt, metadata } = result.output;
180
199
 
181
- if (!keys) return;
182
- for (const [action, combo] of Object.entries(keys)) {
183
- target[action] = Array.isArray(combo) ? combo : [combo];
200
+ actions.push(metadata?.trim() ? { prompt, metadata } : { prompt });
184
201
  }
185
- }
186
202
 
187
- function mergeAutoClose(target: CueloopConfig["ui"], value: unknown): void {
188
- if (value === undefined) return;
189
- if (value === "off") target.autoClose = "off";
190
- else if (typeof value === "number" && value >= 0) target.autoClose = value;
191
- }
192
-
193
- function mergeUi(target: CueloopConfig["ui"], raw: unknown): void {
194
- const ui = raw as
195
- | { auto_close?: unknown; editor?: unknown; review_width?: unknown; review_state?: unknown }
196
- | undefined;
197
-
198
- if (!ui) return;
199
- mergeAutoClose(target, ui.auto_close);
200
- if (typeof ui.editor === "string" && ui.editor.trim()) target.editor = ui.editor.trim();
201
- if (typeof ui.review_width === "number" && Number.isFinite(ui.review_width)) {
202
- target.reviewWidth = clampWidth(ui.review_width);
203
- }
204
- if (
205
- ui.review_state === "expanded" ||
206
- ui.review_state === "compact" ||
207
- ui.review_state === "hidden"
208
- ) {
209
- target.reviewState = ui.review_state;
210
- }
203
+ return actions.length ? actions : undefined;
211
204
  }
212
205
 
213
- function mergeObsidian(target: ObsidianConfig, raw: unknown): void {
214
- const integrations = raw as Record<string, unknown> | undefined;
215
- const obsidian = integrations?.["obsidian"] as Record<string, unknown> | undefined;
216
-
217
- if (!obsidian) return;
218
- if (typeof obsidian["vault"] === "string") target.vault = obsidian["vault"];
219
- if (typeof obsidian["folder"] === "string") target.folder = obsidian["folder"];
220
- if (typeof obsidian["filenameFormat"] === "string")
221
- target.filenameFormat = obsidian["filenameFormat"];
222
- const separator = obsidian["separator"];
223
-
224
- if (separator === "space" || separator === "dash" || separator === "underscore")
225
- target.separator = separator;
226
- const exportOn = obsidian["exportOn"];
227
-
228
- if (exportOn === "approve" || exportOn === "resolve" || exportOn === "manual")
229
- target.exportOn = exportOn;
206
+ function mergeObsidian(
207
+ target: ObsidianConfig,
208
+ obsidian: v.InferOutput<typeof ObsidianSchema>,
209
+ ): void {
210
+ if (obsidian.vault !== undefined) target.vault = obsidian.vault;
211
+ if (obsidian.folder !== undefined) target.folder = obsidian.folder;
212
+ if (obsidian.filenameFormat !== undefined) target.filenameFormat = obsidian.filenameFormat;
213
+ if (obsidian.separator !== undefined) target.separator = obsidian.separator;
214
+ if (obsidian.exportOn !== undefined) target.exportOn = obsidian.exportOn;
230
215
  }
231
216
 
232
- function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig {
217
+ function layer(
218
+ base: CueloopConfig,
219
+ raw: v.InferOutput<typeof ConfigDocumentSchema>,
220
+ ): CueloopConfig {
233
221
  const out: CueloopConfig = {
234
222
  keys: { ...base.keys },
235
223
  theme: { ...base.theme },
@@ -239,13 +227,38 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
239
227
  actions: [...base.actions],
240
228
  integrations: { obsidian: { ...base.integrations.obsidian } },
241
229
  };
242
- const actions = parseActions(raw["actions"]);
230
+ const actions = parseActions(raw.actions);
231
+ const authors = v.safeParse(AuthorsSchema, raw.authors);
232
+ const keys = v.safeParse(KeysSchema, raw.keys);
233
+ const ui = v.safeParse(UiSchema, raw.ui);
234
+ const integrations = v.safeParse(IntegrationsSchema, raw.integrations);
243
235
 
244
236
  if (actions) out.actions = actions;
245
- mergeAuthors(out.authors, raw["authors"]);
246
- mergeKeys(out.keys, raw["keys"]);
247
- mergeUi(out.ui, raw["ui"]);
248
- mergeObsidian(out.integrations.obsidian, raw["integrations"]);
237
+ if (authors.success) {
238
+ for (const [id, value] of Object.entries(authors.output)) {
239
+ const name = v.safeParse(v.string(), value);
240
+
241
+ if (name.success) out.authors[id] = name.output;
242
+ }
243
+ }
244
+ if (keys.success) {
245
+ for (const [action, value] of Object.entries(keys.output)) {
246
+ const combo = v.safeParse(KeyComboSchema, value);
247
+
248
+ if (combo.success)
249
+ out.keys[action] = Array.isArray(combo.output) ? combo.output : [combo.output];
250
+ }
251
+ }
252
+ if (ui.success) {
253
+ if (ui.output.auto_close !== undefined) out.ui.autoClose = ui.output.auto_close;
254
+ if (ui.output.editor?.trim()) out.ui.editor = ui.output.editor.trim();
255
+ if (ui.output.review_width !== undefined)
256
+ out.ui.reviewWidth = clampWidth(ui.output.review_width);
257
+ if (ui.output.review_state !== undefined) out.ui.reviewState = ui.output.review_state;
258
+ }
259
+ if (integrations.success && integrations.output.obsidian) {
260
+ mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
261
+ }
249
262
 
250
263
  return out;
251
264
  }
@@ -284,10 +297,18 @@ export function loadConfig(
284
297
  const raw = parseToml(readFileSync(path, "utf8"));
285
298
 
286
299
  config = layer(config, raw);
287
- const rawTheme = (raw["ui"] as { theme?: unknown } | undefined)?.theme;
288
-
289
- if (typeof rawTheme === "string" && isThemeName(rawTheme)) themeName = rawTheme;
290
- collectThemeOverrides(raw["theme"], themeOverrides);
300
+ const ui = v.safeParse(UiSchema, raw.ui);
301
+ const rawTheme = ui.success ? ui.output.theme : undefined;
302
+ const parsedThemeOverrides = v.safeParse(ThemeOverridesSchema, raw.theme);
303
+
304
+ if (rawTheme && isThemeName(rawTheme)) themeName = rawTheme;
305
+ if (parsedThemeOverrides.success) {
306
+ for (const [token, value] of Object.entries(parsedThemeOverrides.output)) {
307
+ const override = v.safeParse(v.string(), value);
308
+
309
+ if (override.success && isThemeToken(token)) themeOverrides[token] = override.output;
310
+ }
311
+ }
291
312
  } catch {
292
313
  // a broken config never blocks a review; defaults win
293
314
  }
@@ -299,14 +320,6 @@ export function loadConfig(
299
320
  return config;
300
321
  }
301
322
 
302
- /** Merge a raw `[theme]` table's known string tokens into the accumulated overrides. */
303
- function collectThemeOverrides(raw: unknown, into: Partial<Record<keyof Theme, string>>): void {
304
- if (!raw || typeof raw !== "object") return;
305
- for (const [token, value] of Object.entries(raw)) {
306
- if (token in DARK && typeof value === "string") into[token as keyof Theme] = value;
307
- }
308
- }
309
-
310
323
  /** Reverse lookup: key name (+shift) → action, per the loaded keymap. */
311
324
  export function actionFor(
312
325
  keys: Record<string, string[]>,
package/src/editor.ts CHANGED
@@ -21,7 +21,11 @@ export interface EditResult {
21
21
  const DEFAULT_EDITOR = "nano";
22
22
 
23
23
  /** GUI editors fork and return immediately unless given their wait flag. */
24
- const GUI_WAIT_FLAGS: Record<string, string[]> = {
24
+ interface GuiWaitFlags {
25
+ [editor: string]: string[];
26
+ }
27
+
28
+ const GUI_WAIT_FLAGS: GuiWaitFlags = {
25
29
  code: ["--wait"],
26
30
  "code-insiders": ["--wait"],
27
31
  codium: ["--wait"],
@@ -8,7 +8,7 @@ const factory = loadGhosttyTerminals();
8
8
  describe("ghostty-terminal FFI", () => {
9
9
  test("reports platform support without throwing", () => {
10
10
  // Assert - null on unsupported platforms, a factory where a dylib ships
11
- expect(factory === null || typeof factory.create === "function").toBe(true);
11
+ expect(factory === null || factory.create !== undefined).toBe(true);
12
12
  });
13
13
 
14
14
  test.skipIf(!factory)("writes VT bytes and reads back decoded cells", () => {
package/src/herdr.ts CHANGED
@@ -6,6 +6,12 @@
6
6
  * is best-effort - a failure never blocks closing the review.
7
7
  */
8
8
 
9
+ import * as v from "valibot";
10
+
11
+ const HerdrPaneSchema = v.object({
12
+ result: v.optional(v.object({ pane: v.optional(v.object({ tab_id: v.optional(v.string()) })) })),
13
+ });
14
+
9
15
  /** Focus the tab holding the target pane. Synchronous: runs just before exit. */
10
16
  export function focusHerdrPane(binPath: string, paneId: string): boolean {
11
17
  try {
@@ -15,8 +21,10 @@ export function focusHerdrPane(binPath: string, paneId: string): boolean {
15
21
  });
16
22
 
17
23
  if (got.exitCode !== 0) return false;
18
- const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: { tab_id?: string } } };
19
- const tab = parsed.result?.pane?.tab_id;
24
+ const parsed = v.safeParse(HerdrPaneSchema, JSON.parse(got.stdout.toString()));
25
+
26
+ if (!parsed.success) return false;
27
+ const tab = parsed.output.result?.pane?.tab_id;
20
28
 
21
29
  if (!tab) return false;
22
30
 
@@ -6,54 +6,106 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, mock, test } from "bun:test";
9
- import type { ReviewSession } from "@cueloop/schema";
9
+ import type { SetStateAction } from "react";
10
+ import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
10
11
  import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
12
+ import type { ControllerSnapshot, CurationItem, ReviewController } from "./session-controller";
11
13
  import type { DisplayBlock } from "./view-plan";
12
14
 
13
15
  function block(text: string): DisplayBlock {
14
- return { text, kind: "text", work: true } as unknown as DisplayBlock;
16
+ return { type: "same", kind: "p", work: { kind: "p", text, lineStart: 0, lineEnd: 0 } };
15
17
  }
16
18
 
17
- function sessionWith(annotationIds: string[]): ReviewSession {
19
+ function annotation(id: string, overrides: Partial<Annotation> = {}): Annotation {
18
20
  return {
19
- annotations: annotationIds.map((id) => ({ id })),
20
- workingCopy: undefined,
21
- } as unknown as ReviewSession;
21
+ id,
22
+ kind: "comment",
23
+ anchor: { quote: "", prefix: "", suffix: "" },
24
+ body: "",
25
+ createdAt: "2026-01-01T00:00:00.000Z",
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ function sessionWith(
31
+ annotations: Annotation[],
32
+ overrides: Partial<ReviewSession> = {},
33
+ ): ReviewSession {
34
+ return {
35
+ schemaVersion: SCHEMA_VERSION,
36
+ id: "ses_1",
37
+ workspace: { repoRoot: "/repo", branch: "main" },
38
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
39
+ revisions: [],
40
+ annotations,
41
+ verdict: null,
42
+ status: "pending",
43
+ createdAt: "2026-01-01T00:00:00.000Z",
44
+ ...overrides,
45
+ };
22
46
  }
23
47
 
48
+ const EMPTY_SNAPSHOT: ControllerSnapshot = {
49
+ session: null,
50
+ inbox: null,
51
+ status: "",
52
+ toast: null,
53
+ error: null,
54
+ completion: { phase: "idle" },
55
+ editOrphanCount: 0,
56
+ walk: null,
57
+ };
58
+
24
59
  /** A controller where every verb is a mock; annotate returns undefined by default. */
25
- function baseController() {
60
+ function baseController(): ReviewController {
26
61
  return {
62
+ readOnly: false,
63
+ subscribe: mock(() => () => {}),
64
+ getSnapshot: mock(() => EMPTY_SNAPSHOT),
65
+ connect: mock(),
66
+ close: mock(),
67
+ applyConfig: mock(),
27
68
  setStatus: mock(),
69
+ showToast: mock(),
70
+ dismissToast: mock(),
71
+ display: mock(() => []),
72
+ rows: mock(() => []),
73
+ files: mock(() => []),
74
+ working: mock(() => ""),
28
75
  open: mock(),
29
76
  deleteSession: mock(),
30
77
  setSelfName: mock(),
31
78
  cut: mock(),
79
+ toggleRejectHunk: mock(),
80
+ toggleRejectChange: mock(),
81
+ rejectedRows: mock(() => new Set<number>()),
32
82
  curationItems: mock(() => []),
33
83
  restoreCuration: mock(),
34
- annotate: mock(),
35
- annotatePrototype: mock(),
84
+ edit: mock(),
85
+ annotate: mock(() => undefined),
86
+ annotatePrototype: mock(() => undefined),
36
87
  updateAnnotation: mock(),
37
88
  removeAnnotation: mock(),
89
+ setWorkingCopy: mock(),
38
90
  walkStart: mock(),
39
91
  walkForward: mock(),
40
92
  walkBack: mock(),
41
93
  walkLeave: mock(),
42
94
  submit: mock(),
43
95
  share: mock(),
96
+ pullShared: mock(() => Promise.resolve()),
97
+ startSharePoll: mock(() => () => {}),
44
98
  finishReview: mock(),
45
- optInAutoClose: mock(),
46
99
  dismissCompletion: mock(),
100
+ optInAutoClose: mock(),
47
101
  saveReviewPanel: mock(),
48
102
  };
49
103
  }
50
104
 
51
105
  /** A deps bag where every effect is a mock and every read has a plain default. */
52
106
  function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
53
- const controller = baseController();
54
-
55
107
  return {
56
- controller: controller as unknown as IntentDispatchDeps["controller"],
108
+ controller: baseController(),
57
109
  onExit: mock(),
58
110
  isDiff: false,
59
111
  display: [],
@@ -90,20 +142,24 @@ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDe
90
142
  };
91
143
  }
92
144
 
145
+ function isUpdater(action: SetStateAction<number>): action is (current: number) => number {
146
+ return typeof action === "function";
147
+ }
148
+
93
149
  describe("move", () => {
94
150
  test("down clamps at the last row and up clamps at the first", () => {
95
151
  // Arrange
96
- const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
152
+ const setCursor = mock((_action: SetStateAction<number>) => {});
153
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")], setCursor });
97
154
  const dispatch = createIntentDispatch(deps);
98
155
 
99
156
  // Act
100
157
  dispatch({ type: "move", to: "down" });
101
158
 
102
159
  // Assert
103
- const advance = (deps.setCursor as ReturnType<typeof mock>).mock.calls[0]![0] as (
104
- current: number,
105
- ) => number;
160
+ const advance = setCursor.mock.calls[0]![0];
106
161
 
162
+ if (!isUpdater(advance)) throw new Error("expected setCursor to receive an updater");
107
163
  expect(advance(2)).toBe(2); // already at the end, stays
108
164
  expect(advance(0)).toBe(1);
109
165
  });
@@ -124,7 +180,10 @@ describe("move", () => {
124
180
  describe("annotation navigation", () => {
125
181
  test("nextAnnotation wraps from the last annotation back to the first", () => {
126
182
  // Arrange
127
- const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a3" });
183
+ const deps = makeDeps({
184
+ session: sessionWith([annotation("a1"), annotation("a2"), annotation("a3")]),
185
+ focusedAnnotationId: "a3",
186
+ });
128
187
  const dispatch = createIntentDispatch(deps);
129
188
 
130
189
  // Act
@@ -136,7 +195,10 @@ describe("annotation navigation", () => {
136
195
 
137
196
  test("prevAnnotation wraps from the first annotation to the last", () => {
138
197
  // Arrange
139
- const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a1" });
198
+ const deps = makeDeps({
199
+ session: sessionWith([annotation("a1"), annotation("a2"), annotation("a3")]),
200
+ focusedAnnotationId: "a1",
201
+ });
140
202
  const dispatch = createIntentDispatch(deps);
141
203
 
142
204
  // Act
@@ -148,12 +210,11 @@ describe("annotation navigation", () => {
148
210
 
149
211
  test("cycling skips annotations a revision already addressed", () => {
150
212
  // Arrange: a2 is addressed, so next from a1 lands on a3
151
- const session = sessionWith(["a1", "a2", "a3"]);
152
-
153
- (session.annotations[1] as { resolution?: object }).resolution = {
154
- revision: 2,
155
- source: "agent",
156
- };
213
+ const session = sessionWith([
214
+ annotation("a1"),
215
+ annotation("a2", { resolution: { revision: 2, source: "agent" } }),
216
+ annotation("a3"),
217
+ ]);
157
218
  const deps = makeDeps({ session, focusedAnnotationId: "a1" });
158
219
  const dispatch = createIntentDispatch(deps);
159
220
 
@@ -233,7 +294,7 @@ describe("marker-actions popover", () => {
233
294
  mode: { type: "spanActions", span, index: 1 },
234
295
  session: sessionWith([]),
235
296
  quickActions: [{ prompt: "Needs a test" }, { prompt: "YAGNI", metadata: "cut scope" }],
236
- controller: { ...baseController(), annotate } as unknown as IntentDispatchDeps["controller"],
297
+ controller: { ...baseController(), annotate },
237
298
  });
238
299
  const dispatch = createIntentDispatch(deps);
239
300
 
@@ -253,7 +314,7 @@ describe("marker-actions popover", () => {
253
314
  mode: { type: "spanActions", span, index: 0 },
254
315
  session: sessionWith([]),
255
316
  quickActions: [{ prompt: "Needs a test" }, { prompt: "Extract the duplication" }],
256
- controller: { ...baseController(), annotate } as unknown as IntentDispatchDeps["controller"],
317
+ controller: { ...baseController(), annotate },
257
318
  });
258
319
  const dispatch = createIntentDispatch(deps);
259
320
 
@@ -320,28 +381,15 @@ describe("share", () => {
320
381
  });
321
382
 
322
383
  describe("restoreCuration", () => {
323
- const items = [
324
- {
325
- id: "diff:f#0#hunk",
326
- source: "diff" as const,
327
- label: "f:1 - hunk",
328
- preview: [],
329
- revealIndex: 1,
330
- },
331
- {
332
- id: "diff:f#1#2",
333
- source: "diff" as const,
334
- label: "f:9 - change",
335
- preview: [],
336
- revealIndex: 9,
337
- },
384
+ const items: CurationItem[] = [
385
+ { id: "diff:f#0#hunk", source: "diff", preview: [], revealIndex: 1 },
386
+ { id: "diff:f#1#2", source: "diff", preview: [], revealIndex: 9 },
338
387
  ];
339
388
 
340
389
  test("restores the selected item and clears the selection", () => {
341
390
  // Arrange
342
- const deps = makeDeps({ selectedCurationId: "diff:f#1#2" });
343
-
344
- (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
391
+ const controller = { ...baseController(), curationItems: mock(() => items) };
392
+ const deps = makeDeps({ selectedCurationId: "diff:f#1#2", controller });
345
393
  const dispatch = createIntentDispatch(deps);
346
394
 
347
395
  // Act
@@ -354,9 +402,8 @@ describe("restoreCuration", () => {
354
402
 
355
403
  test("with nothing selected, undoes the last removal", () => {
356
404
  // Arrange
357
- const deps = makeDeps({ selectedCurationId: undefined });
358
-
359
- (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
405
+ const controller = { ...baseController(), curationItems: mock(() => items) };
406
+ const deps = makeDeps({ selectedCurationId: undefined, controller });
360
407
  const dispatch = createIntentDispatch(deps);
361
408
 
362
409
  // Act
@@ -369,8 +416,6 @@ describe("restoreCuration", () => {
369
416
  test("does nothing when there is nothing curated out", () => {
370
417
  // Arrange
371
418
  const deps = makeDeps();
372
-
373
- (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue([]);
374
419
  const dispatch = createIntentDispatch(deps);
375
420
 
376
421
  // Act
@@ -398,7 +443,9 @@ describe("exit", () => {
398
443
  describe("inbox delete", () => {
399
444
  test("requestDeleteSession opens the confirm on the cursor row", () => {
400
445
  // Arrange
401
- const inbox = [{ id: "ses_1", artifact: { meta: { title: "Plan A" } } }] as never;
446
+ const inbox = [
447
+ sessionWith([], { artifact: { type: "plan", content: "", meta: { title: "Plan A" } } }),
448
+ ];
402
449
  const deps = makeDeps({ inbox, inboxCursor: 0 });
403
450
 
404
451
  // Act
@@ -428,7 +475,7 @@ describe("inbox delete", () => {
428
475
  describe("rename author", () => {
429
476
  test("openRename seeds the prompt with the author's current local name", () => {
430
477
  // Arrange
431
- const session = { annotations: [{ id: "a1", author: "SHA256:x" }] } as never;
478
+ const session = sessionWith([annotation("a1", { author: "SHA256:x" })]);
432
479
  const deps = makeDeps({
433
480
  session,
434
481
  focusedAnnotationId: "a1",
@@ -448,7 +495,7 @@ describe("rename author", () => {
448
495
 
449
496
  test("openRename on your own note does nothing but explain", () => {
450
497
  // Arrange
451
- const session = { annotations: [{ id: "a1" }] } as never;
498
+ const session = sessionWith([annotation("a1")]);
452
499
  const deps = makeDeps({ session, focusedAnnotationId: "a1" });
453
500
 
454
501
  // Act
@@ -526,9 +526,11 @@ function handleResizeReviewPanel(
526
526
  deps.controller.saveReviewPanel({ width: next });
527
527
  }
528
528
 
529
- const intentHandlers: {
529
+ type IntentHandlers = {
530
530
  [Kind in Intent["type"]]: (intent: IntentOfType<Kind>, deps: IntentDispatchDeps) => void;
531
- } = {
531
+ };
532
+
533
+ const intentHandlers: IntentHandlers = {
532
534
  exit: handleExit,
533
535
  status: handleStatus,
534
536
  move: handleMove,
@@ -572,14 +574,14 @@ const intentHandlers: {
572
574
  resizeReviewPanel: handleResizeReviewPanel,
573
575
  };
574
576
 
577
+ function dispatchIntent<Kind extends Intent["type"]>(
578
+ intent: IntentOfType<Kind>,
579
+ deps: IntentDispatchDeps,
580
+ ): void {
581
+ intentHandlers[intent.type](intent, deps);
582
+ }
583
+
575
584
  /** Build the intent handler for one render from its dependency bag. */
576
585
  export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent) => void {
577
- return (intent: Intent): void => {
578
- const handler = intentHandlers[intent.type] as (
579
- intent: Intent,
580
- deps: IntentDispatchDeps,
581
- ) => void;
582
-
583
- handler(intent, deps);
584
- };
586
+ return (intent: Intent): void => dispatchIntent(intent, deps);
585
587
  }