@cueloop/client 0.1.0-alpha.59 → 0.1.0-alpha.60

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.
Files changed (98) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +7 -0
  3. package/src/App.diff.test.tsx +11 -0
  4. package/src/App.export.test.tsx +5 -0
  5. package/src/App.plan-review.test.tsx +42 -0
  6. package/src/App.prototype.test.tsx +11 -0
  7. package/src/App.readonly.test.tsx +10 -0
  8. package/src/App.rendering.test.tsx +4 -0
  9. package/src/App.review-panel.test.tsx +7 -0
  10. package/src/App.submit-confirm.test.tsx +11 -0
  11. package/src/App.test.tsx +13 -0
  12. package/src/App.toast.test.tsx +2 -0
  13. package/src/App.tsx +27 -0
  14. package/src/App.walk.test.tsx +10 -0
  15. package/src/app-key-state.ts +1 -0
  16. package/src/app-screens.tsx +4 -0
  17. package/src/app-view-model.ts +21 -0
  18. package/src/attribution.ts +4 -0
  19. package/src/clipboard.ts +3 -0
  20. package/src/components/AnnotationCard.tsx +9 -0
  21. package/src/components/Breadcrumb.tsx +1 -0
  22. package/src/components/CodeBlock.tsx +1 -0
  23. package/src/components/CompletionOverlay.tsx +1 -0
  24. package/src/components/ConfirmCard.tsx +2 -0
  25. package/src/components/ConfirmDialog.tsx +2 -0
  26. package/src/components/DiffSheet.scroll.test.tsx +12 -0
  27. package/src/components/DiffSheet.syntax.test.tsx +3 -0
  28. package/src/components/DiffSheet.tsx +19 -0
  29. package/src/components/InboxList.tsx +2 -0
  30. package/src/components/KeybindsDialog.tsx +1 -0
  31. package/src/components/MarkerPopover.tsx +1 -0
  32. package/src/components/MenuBar.tsx +1 -0
  33. package/src/components/PlanSheet.tsx +26 -0
  34. package/src/components/PromptDialog.tsx +2 -0
  35. package/src/components/PrototypeSheet.tsx +27 -0
  36. package/src/components/ReviewPanel.tsx +2 -0
  37. package/src/components/ReviewRail.tsx +1 -0
  38. package/src/components/SettingsDialog.tsx +6 -0
  39. package/src/components/SettingsRows.tsx +4 -0
  40. package/src/components/Toast.tsx +1 -0
  41. package/src/components/agent-launcher.test.tsx +1 -0
  42. package/src/components/agent-launcher.tsx +2 -0
  43. package/src/components/diff-sheet-layout.test.ts +1 -0
  44. package/src/components/diff-sheet-layout.ts +17 -0
  45. package/src/components/plan-sheet-run-style.ts +4 -0
  46. package/src/components/primitives/Button.tsx +1 -0
  47. package/src/components/primitives/Card.tsx +1 -0
  48. package/src/components/primitives/Dialog.tsx +2 -0
  49. package/src/components/primitives/StatusBar.tsx +1 -0
  50. package/src/components/primitives/Tabs.tsx +4 -0
  51. package/src/components/quick-actions-editor.tsx +2 -0
  52. package/src/components/stories-app.tsx +1 -0
  53. package/src/components/stories.test.tsx +4 -0
  54. package/src/components/story.ts +6 -0
  55. package/src/components/syntax-highlight.ts +10 -0
  56. package/src/components/terminal-pane.ts +12 -0
  57. package/src/components/theme-context.tsx +1 -0
  58. package/src/components/truncate-text.ts +1 -0
  59. package/src/config.test.ts +18 -0
  60. package/src/config.ts +27 -0
  61. package/src/diff-hunk-curate.ts +20 -0
  62. package/src/diff-intraline.test.ts +1 -0
  63. package/src/diff-intraline.ts +14 -0
  64. package/src/diff-syntax.ts +22 -0
  65. package/src/editor.test.ts +6 -0
  66. package/src/editor.ts +12 -0
  67. package/src/ghostty-terminal.test.ts +2 -0
  68. package/src/ghostty-terminal.ts +8 -0
  69. package/src/herdr.test.ts +2 -0
  70. package/src/herdr.ts +3 -0
  71. package/src/integrations.ts +2 -0
  72. package/src/intent-dispatch.test.ts +6 -0
  73. package/src/intent-dispatch.ts +37 -0
  74. package/src/key-bindings.test.ts +4 -0
  75. package/src/key-bindings.ts +22 -0
  76. package/src/keymap.test.ts +13 -0
  77. package/src/keymap.ts +43 -0
  78. package/src/kitty-image.ts +9 -0
  79. package/src/prototype-browser.test.ts +1 -0
  80. package/src/prototype-browser.ts +22 -0
  81. package/src/pty.test.ts +3 -0
  82. package/src/pty.ts +15 -0
  83. package/src/run.ts +1 -0
  84. package/src/serve.test.ts +1 -0
  85. package/src/serve.ts +3 -0
  86. package/src/session-controller.curate.test.ts +7 -0
  87. package/src/session-controller.share.test.ts +11 -0
  88. package/src/session-controller.ts +79 -0
  89. package/src/share.ts +7 -0
  90. package/src/test-support.test.ts +5 -0
  91. package/src/test-support.ts +10 -0
  92. package/src/theme-presets.ts +1 -0
  93. package/src/use-settings-dialog.tsx +12 -0
  94. package/src/view-diff.test.ts +3 -0
  95. package/src/view-diff.ts +5 -0
  96. package/src/view-plan.test.ts +7 -0
  97. package/src/view-plan.ts +59 -0
  98. package/src/walk.ts +4 -0
@@ -58,12 +58,14 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
58
58
 
59
59
  for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
60
60
  const row = rows[rowIndex]!;
61
+
61
62
  if (row.kind === "file" || row.kind === "hunk") {
62
63
  flush();
63
64
  filetype = row.kind === "file" ? filetypeForPath(row.file) : filetype;
64
65
  continue;
65
66
  }
66
67
  const text = stripTrailingNewline(row.text);
68
+
67
69
  if (row.kind === "ctx" || row.kind === "del") {
68
70
  oldLines.push(text);
69
71
  oldRowIndexByLine.push(rowIndex);
@@ -74,6 +76,7 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
74
76
  }
75
77
  }
76
78
  flush();
79
+
77
80
  return jobs;
78
81
  }
79
82
 
@@ -84,48 +87,61 @@ export function highlightJobs(rows: DiffRow[]): HighlightJob[] {
84
87
  */
85
88
  export function spansByLine(source: string, highlights: SimpleHighlight[]): SyntaxSpan[][] {
86
89
  const lineStarts: number[] = [0];
90
+
87
91
  for (let index = 0; index < source.length; index++) {
88
92
  if (source[index] === "\n") lineStarts.push(index + 1);
89
93
  }
90
94
  const lineOf = (offset: number): number => {
91
95
  let low = 0;
92
96
  let high = lineStarts.length - 1;
97
+
93
98
  while (low < high) {
94
99
  const mid = (low + high + 1) >> 1;
100
+
95
101
  if (lineStarts[mid]! <= offset) low = mid;
96
102
  else high = mid - 1;
97
103
  }
104
+
98
105
  return low;
99
106
  };
100
107
  // Per-line char->group, later highlights overriding, then coalesce into spans.
101
108
  const groupsByLine: Array<Array<string | undefined>> = lineStarts.map((start, line) => {
102
109
  const end = line + 1 < lineStarts.length ? lineStarts[line + 1]! - 1 : source.length;
110
+
103
111
  return Array.from({ length: Math.max(0, end - start) }, () => undefined);
104
112
  });
113
+
105
114
  for (const [start, end, group] of highlights) {
106
115
  // A capture can span newlines (block comments, template literals); color the
107
116
  // covered columns on every line it crosses, not just the starting line.
108
117
  let cursor = start;
118
+
109
119
  while (cursor < end) {
110
120
  const line = lineOf(cursor);
111
121
  const lineStart = lineStarts[line]!;
112
122
  const lineEnd = line + 1 < lineStarts.length ? lineStarts[line + 1]! - 1 : source.length;
113
123
  const chars = groupsByLine[line]!;
114
124
  const to = Math.min(end, lineEnd) - lineStart;
125
+
115
126
  for (let column = cursor - lineStart; column < to; column++) chars[column] = group;
116
127
  cursor = lineEnd + 1;
117
128
  }
118
129
  }
130
+
119
131
  return groupsByLine.map((chars) => {
120
132
  const spans: SyntaxSpan[] = [];
133
+
121
134
  for (let column = 0; column < chars.length; column++) {
122
135
  const group = chars[column];
136
+
123
137
  if (group === undefined) continue;
124
138
  const previous = spans[spans.length - 1];
139
+
125
140
  if (previous && previous.group === group && previous.end === column)
126
141
  previous.end = column + 1;
127
142
  else spans.push({ start: column, end: column + 1, group });
128
143
  }
144
+
129
145
  return spans;
130
146
  });
131
147
  }
@@ -138,17 +154,23 @@ export function spansByLine(source: string, highlights: SimpleHighlight[]): Synt
138
154
  export async function highlightDiffRows(rows: DiffRow[]): Promise<Map<number, SyntaxSpan[]>> {
139
155
  const spansByRow = new Map<number, SyntaxSpan[]>();
140
156
  const jobs = highlightJobs(rows);
157
+
141
158
  if (!jobs.length) return spansByRow;
142
159
  const client = getTreeSitterClient();
160
+
143
161
  await client.initialize();
144
162
  for (const job of jobs) {
145
163
  const result = await client.highlightOnce(job.source, job.filetype);
164
+
146
165
  if (!result.highlights) continue;
147
166
  const lineSpans = spansByLine(job.source, result.highlights);
167
+
148
168
  job.rowIndexByLine.forEach((rowIndex, line) => {
149
169
  const spans = lineSpans[line];
170
+
150
171
  if (spans && spans.length) spansByRow.set(rowIndex, spans);
151
172
  });
152
173
  }
174
+
153
175
  return spansByRow;
154
176
  }
@@ -53,6 +53,7 @@ describe("resolveEditorCommand", () => {
53
53
 
54
54
  describe("editInEditor", () => {
55
55
  let dir: string;
56
+
56
57
  beforeEach(() => {
57
58
  dir = mkdtempSync(join(tmpdir(), "cueloop-edit-test-"));
58
59
  });
@@ -63,13 +64,16 @@ describe("editInEditor", () => {
63
64
  /** A runnable editor stub with a chosen base name and body. */
64
65
  function stubEditor(name: string, body: string): string {
65
66
  const script = join(dir, name);
67
+
66
68
  writeFileSync(script, `#!/bin/sh\n${body}\n`);
67
69
  chmodSync(script, 0o755);
70
+
68
71
  return script;
69
72
  }
70
73
 
71
74
  const instant = (): (() => number) => {
72
75
  let value = 0;
76
+
73
77
  return () => (value += 5);
74
78
  };
75
79
 
@@ -94,6 +98,7 @@ describe("editInEditor", () => {
94
98
  confirmSaved: (label, path) => {
95
99
  askedLabel = label;
96
100
  writeFileSync(path, "saved in the GUI");
101
+
97
102
  return true;
98
103
  },
99
104
  });
@@ -125,6 +130,7 @@ describe("editInEditor", () => {
125
130
  now: instant(),
126
131
  confirmSaved: () => {
127
132
  asked = true;
133
+
128
134
  return true;
129
135
  },
130
136
  });
package/src/editor.ts CHANGED
@@ -69,6 +69,7 @@ export function resolveEditor(
69
69
  env: Record<string, string | undefined> = process.env,
70
70
  ): string {
71
71
  const candidates = [configuredEditor, env.CUELOOP_EDITOR, env.VISUAL, env.EDITOR];
72
+
72
73
  return candidates.find((candidate) => candidate && candidate.trim())?.trim() ?? DEFAULT_EDITOR;
73
74
  }
74
75
 
@@ -80,14 +81,18 @@ function editorBaseName(command: string): string {
80
81
  export function resolveEditorCommand(rawEditor: string): ResolvedEditor {
81
82
  const parts = rawEditor.trim().split(/\s+/);
82
83
  const base = editorBaseName(parts[0] ?? "");
84
+
83
85
  if (TERMINAL_EDITORS.has(base)) return { argv: parts, waits: true };
84
86
  const waitFlags = GUI_WAIT_FLAGS[base];
87
+
85
88
  if (waitFlags) {
86
89
  const alreadyWaits = parts.some(
87
90
  (part) => part === "--wait" || part === "-w" || part === "--block",
88
91
  );
92
+
89
93
  return { argv: alreadyWaits ? parts : [...parts, ...waitFlags], waits: true };
90
94
  }
95
+
91
96
  return { argv: parts, waits: false };
92
97
  }
93
98
 
@@ -97,6 +102,7 @@ const NO_WAIT_THRESHOLD_MS = 1000;
97
102
  function suspectsNoWait(resolved: ResolvedEditor, elapsedMs: number, unchanged: boolean): boolean {
98
103
  if (resolved.waits) return false; // holds the terminal or has a wait flag - trust the exit
99
104
  if (!unchanged) return false; // edits landed, so it waited after all
105
+
100
106
  return elapsedMs < NO_WAIT_THRESHOLD_MS;
101
107
  }
102
108
 
@@ -114,6 +120,7 @@ function promptSaved(editorLabel: string, path: string): boolean {
114
120
  const answer = prompt(
115
121
  `${editorLabel} returned immediately and the plan is unchanged - a GUI editor needs its wait flag. Save and close ${path}, then press Enter to load your edits (or type n to skip):`,
116
122
  );
123
+
117
124
  return answer !== null && answer.trim().toLowerCase() !== "n";
118
125
  }
119
126
 
@@ -127,6 +134,7 @@ export function editInEditor(
127
134
  const resolved = resolveEditorCommand(resolveEditor(handOff.editor, env));
128
135
  const dir = mkdtempSync(join(tmpdir(), "cueloop-edit-"));
129
136
  const path = join(dir, filename);
137
+
130
138
  writeFileSync(path, content);
131
139
  try {
132
140
  const startedAt = now();
@@ -135,12 +143,16 @@ export function editInEditor(
135
143
  stdout: "inherit",
136
144
  stderr: "inherit",
137
145
  });
146
+
138
147
  if (editorProcess.exitCode !== 0) throw new Error(`editor exited ${editorProcess.exitCode}`);
139
148
  let next = readFileSync(path, "utf8");
149
+
140
150
  if (suspectsNoWait(resolved, now() - startedAt, next === content)) {
141
151
  const confirm = handOff.confirmSaved ?? promptSaved;
152
+
142
153
  if (confirm(resolved.argv[0] ?? "the editor", path)) next = readFileSync(path, "utf8");
143
154
  }
155
+
144
156
  return { content: next, changed: next !== content };
145
157
  } finally {
146
158
  rmSync(dir, { recursive: true, force: true });
@@ -14,6 +14,7 @@ describe("ghostty-terminal FFI", () => {
14
14
  test.skipIf(!factory)("writes VT bytes and reads back decoded cells", () => {
15
15
  // Arrange
16
16
  const term = factory!.create(80, 24)!;
17
+
17
18
  expect(term).not.toBeNull();
18
19
 
19
20
  // Act - plain text then bold + palette-green "GO"
@@ -38,6 +39,7 @@ describe("ghostty-terminal FFI", () => {
38
39
 
39
40
  // Assert - cursor advanced past the three glyphs on row 0
40
41
  const cursor = term.readCursor();
42
+
41
43
  expect(cursor.y).toBe(0);
42
44
  expect(cursor.x).toBe(3);
43
45
  term.free();
@@ -56,6 +56,7 @@ function nativeLibraryPath(): string | null {
56
56
  const suffix = process.platform === "darwin" ? "dylib" : "so";
57
57
  const dir = `${process.platform}-${process.arch}`;
58
58
  const path = join(import.meta.dir, "..", "native", dir, `libcueloopvt.${suffix}`);
59
+
59
60
  return existsSync(path) ? path : null;
60
61
  }
61
62
 
@@ -65,14 +66,17 @@ function nativeLibraryPath(): string | null {
65
66
  */
66
67
  export function loadGhosttyTerminals(): GhosttyTerminalFactory | null {
67
68
  const path = nativeLibraryPath();
69
+
68
70
  if (!path) return null;
69
71
  try {
70
72
  const lib = dlopen(path, CVT_SYMBOLS);
73
+
71
74
  return new GhosttyTerminalFactory(lib.symbols);
72
75
  } catch (error) {
73
76
  // The file exists but failed to load (bad arch, missing symbol) - a real
74
77
  // fault, not the expected no-prebuilt case; surface it, then degrade.
75
78
  console.error(`cueloop: failed to load ${path}:`, error);
79
+
76
80
  return null;
77
81
  }
78
82
  }
@@ -83,6 +87,7 @@ export class GhosttyTerminalFactory {
83
87
  /** A fresh terminal sized to cols x rows, or null if allocation failed. */
84
88
  create(cols: number, rows: number): GhosttyTerminal | null {
85
89
  const handle = this.lib.cvt_new(cols, rows);
90
+
86
91
  return handle ? new GhosttyTerminal(this.lib, handle) : null;
87
92
  }
88
93
  }
@@ -126,6 +131,7 @@ export class GhosttyTerminal {
126
131
  if (this.freed) return null;
127
132
  if (this.lib.cvt_cell(this.handle, x, y, ptr(this.out)) !== 0) return null;
128
133
  const flags = this.out[13]!;
134
+
129
135
  return {
130
136
  codepoint: this.outView.getUint32(0, true),
131
137
  fg: decodeColor(this.out[7]!, this.out[4]!, this.out[5]!, this.out[6]!),
@@ -145,6 +151,7 @@ export class GhosttyTerminal {
145
151
  if (this.freed) return { x: 0, y: 0, visible: false };
146
152
  this.lib.cvt_cursor(this.handle, ptr(this.cursorOut));
147
153
  const view = new DataView(this.cursorOut.buffer);
154
+
148
155
  return {
149
156
  x: view.getUint16(0, true),
150
157
  y: view.getUint16(2, true),
@@ -164,5 +171,6 @@ export class GhosttyTerminal {
164
171
  function decodeColor(kind: number, r: number, g: number, b: number): GhosttyColor {
165
172
  if (kind === 2) return { kind: "rgb", r, g, b };
166
173
  if (kind === 1) return { kind: "palette", index: r };
174
+
167
175
  return { kind: "default" };
168
176
  }
package/src/herdr.test.ts CHANGED
@@ -18,6 +18,7 @@ describe("focusHerdrPane", () => {
18
18
  // Arrange
19
19
  const log = join(dir, "calls.log");
20
20
  const stub = join(dir, "herdr");
21
+
21
22
  writeFileSync(
22
23
  stub,
23
24
  `#!/bin/sh
@@ -30,6 +31,7 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
30
31
  // Assert
31
32
  expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
32
33
  const calls = readFileSync(log, "utf8").trim().split("\n");
34
+
33
35
  expect(calls[0]).toBe("pane get w1:p1");
34
36
  expect(calls[1]).toBe("tab focus w1:t3");
35
37
  });
package/src/herdr.ts CHANGED
@@ -13,10 +13,13 @@ export function focusHerdrPane(binPath: string, paneId: string): boolean {
13
13
  stdout: "pipe",
14
14
  stderr: "ignore",
15
15
  });
16
+
16
17
  if (got.exitCode !== 0) return false;
17
18
  const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: { tab_id?: string } } };
18
19
  const tab = parsed.result?.pane?.tab_id;
20
+
19
21
  if (!tab) return false;
22
+
20
23
  return (
21
24
  Bun.spawnSync([binPath, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" })
22
25
  .exitCode === 0
@@ -25,6 +25,8 @@ export async function loadBundledExporters(
25
25
  const obsidian = integrations.obsidian;
26
26
  const record = await registry.load("obsidian", createObsidianExtension(obsidian));
27
27
  const run = record.exporters.get("obsidian");
28
+
28
29
  if (!run) return [];
30
+
29
31
  return [{ name: "obsidian", run, runsOn: (verdict) => shouldExport(obsidian.exportOn, verdict) }];
30
32
  }
@@ -51,6 +51,7 @@ function baseController() {
51
51
  /** A deps bag where every effect is a mock and every read has a plain default. */
52
52
  function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
53
53
  const controller = baseController();
54
+
54
55
  return {
55
56
  controller: controller as unknown as IntentDispatchDeps["controller"],
56
57
  onExit: mock(),
@@ -102,6 +103,7 @@ describe("move", () => {
102
103
  const advance = (deps.setCursor as ReturnType<typeof mock>).mock.calls[0]![0] as (
103
104
  current: number,
104
105
  ) => number;
106
+
105
107
  expect(advance(2)).toBe(2); // already at the end, stays
106
108
  expect(advance(0)).toBe(1);
107
109
  });
@@ -147,6 +149,7 @@ describe("annotation navigation", () => {
147
149
  test("cycling skips annotations a revision already addressed", () => {
148
150
  // Arrange: a2 is addressed, so next from a1 lands on a3
149
151
  const session = sessionWith(["a1", "a2", "a3"]);
152
+
150
153
  (session.annotations[1] as { resolution?: object }).resolution = {
151
154
  revision: 2,
152
155
  source: "agent",
@@ -337,6 +340,7 @@ describe("restoreCuration", () => {
337
340
  test("restores the selected item and clears the selection", () => {
338
341
  // Arrange
339
342
  const deps = makeDeps({ selectedCurationId: "diff:f#1#2" });
343
+
340
344
  (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
341
345
  const dispatch = createIntentDispatch(deps);
342
346
 
@@ -351,6 +355,7 @@ describe("restoreCuration", () => {
351
355
  test("with nothing selected, undoes the last removal", () => {
352
356
  // Arrange
353
357
  const deps = makeDeps({ selectedCurationId: undefined });
358
+
354
359
  (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
355
360
  const dispatch = createIntentDispatch(deps);
356
361
 
@@ -364,6 +369,7 @@ describe("restoreCuration", () => {
364
369
  test("does nothing when there is nothing curated out", () => {
365
370
  // Arrange
366
371
  const deps = makeDeps();
372
+
367
373
  (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue([]);
368
374
  const dispatch = createIntentDispatch(deps);
369
375
 
@@ -126,6 +126,7 @@ function handleStatus(intent: IntentOfType<"status">, deps: IntentDispatchDeps):
126
126
 
127
127
  function handleMove(intent: IntentOfType<"move">, deps: IntentDispatchDeps): void {
128
128
  const navigableCount = deps.isDiff ? deps.rows.length : deps.display.length;
129
+
129
130
  if (intent.to === "down") deps.setCursor((current) => Math.min(navigableCount - 1, current + 1));
130
131
  else if (intent.to === "up") deps.setCursor((current) => Math.max(0, current - 1));
131
132
  else if (intent.to === "top") deps.setCursor(0);
@@ -134,6 +135,7 @@ function handleMove(intent: IntentOfType<"move">, deps: IntentDispatchDeps): voi
134
135
 
135
136
  function handleInboxMove(intent: IntentOfType<"inboxMove">, deps: IntentDispatchDeps): void {
136
137
  const navigableCount = deps.inbox?.length ?? 0;
138
+
137
139
  deps.setInboxCursor((current) =>
138
140
  intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1),
139
141
  );
@@ -141,6 +143,7 @@ function handleInboxMove(intent: IntentOfType<"inboxMove">, deps: IntentDispatch
141
143
 
142
144
  function handleOpenSession(_intent: IntentOfType<"openSession">, deps: IntentDispatchDeps): void {
143
145
  const selected = deps.inbox?.[deps.inboxCursor];
146
+
144
147
  if (selected) deps.controller.open(selected.id);
145
148
  }
146
149
 
@@ -149,6 +152,7 @@ function handleRequestDeleteSession(
149
152
  deps: IntentDispatchDeps,
150
153
  ): void {
151
154
  const selected = deps.inbox?.[deps.inboxCursor];
155
+
152
156
  if (selected)
153
157
  deps.setMode({
154
158
  type: "confirmDelete",
@@ -161,8 +165,10 @@ function handleOpenRename(_intent: IntentOfType<"openRename">, deps: IntentDispa
161
165
  const focused = deps.session?.annotations.find(
162
166
  (annotation) => annotation.id === deps.focusedAnnotationId,
163
167
  );
168
+
164
169
  if (!focused?.author) {
165
170
  deps.controller.setStatus("that is your own note - nothing to rename");
171
+
166
172
  return;
167
173
  }
168
174
  deps.setMode({
@@ -177,6 +183,7 @@ function handleConfirmDialog(
177
183
  deps: IntentDispatchDeps,
178
184
  ): void {
179
185
  const { mode, controller } = deps;
186
+
180
187
  if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
181
188
  else if (mode.type === "rename") deps.renameAuthor(mode.authorId, mode.text.trim());
182
189
  else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
@@ -185,19 +192,23 @@ function handleConfirmDialog(
185
192
 
186
193
  function handleStartSpan(_intent: IntentOfType<"startSpan">, deps: IntentDispatchDeps): void {
187
194
  const block = deps.display[deps.cursor];
195
+
188
196
  if (!block?.work) return;
189
197
  const span = startSpan(deps.cursor, displayText(block));
198
+
190
199
  if (span) deps.setMode({ type: "span", span });
191
200
  }
192
201
 
193
202
  function handleSpanKey(intent: IntentOfType<"spanKey">, deps: IntentDispatchDeps): void {
194
203
  const { mode } = deps;
204
+
195
205
  if (mode.type === "span") {
196
206
  const span = spanKey(
197
207
  mode.span,
198
208
  intent.name,
199
209
  displayText(deps.display[mode.span.displayIndex]!),
200
210
  );
211
+
201
212
  deps.setMode({ type: "span", span });
202
213
  }
203
214
  }
@@ -205,6 +216,7 @@ function handleSpanKey(intent: IntentOfType<"spanKey">, deps: IntentDispatchDeps
205
216
  function handleSpanCut(_intent: IntentOfType<"spanCut">, deps: IntentDispatchDeps): void {
206
217
  // the block the span sits in, cut whole (partial-span cut is not modeled)
207
218
  const { mode } = deps;
219
+
208
220
  if (mode.type === "span") {
209
221
  deps.controller.cut(mode.span.displayIndex);
210
222
  deps.setMode({ type: "normal" });
@@ -216,6 +228,7 @@ function handleOpenSpanActions(
216
228
  deps: IntentDispatchDeps,
217
229
  ): void {
218
230
  const { mode } = deps;
231
+
219
232
  if (mode.type === "span") deps.setMode({ type: "spanActions", span: mode.span, index: 0 });
220
233
  }
221
234
 
@@ -224,11 +237,13 @@ function handleMoveSpanAction(
224
237
  deps: IntentDispatchDeps,
225
238
  ): void {
226
239
  const { mode } = deps;
240
+
227
241
  if (mode.type === "spanActions") {
228
242
  const index = Math.max(
229
243
  0,
230
244
  Math.min(deps.quickActions.length - 1, mode.index + intent.direction),
231
245
  );
246
+
232
247
  deps.setMode({ ...mode, index });
233
248
  }
234
249
  }
@@ -238,6 +253,7 @@ function handleCloseSpanActions(
238
253
  deps: IntentDispatchDeps,
239
254
  ): void {
240
255
  const { mode } = deps;
256
+
241
257
  if (mode.type === "spanActions") deps.setMode({ type: "span", span: mode.span });
242
258
  }
243
259
 
@@ -246,8 +262,10 @@ function handlePickSpanAction(
246
262
  deps: IntentDispatchDeps,
247
263
  ): void {
248
264
  const { mode, session, controller } = deps;
265
+
249
266
  if (mode.type !== "spanActions") return;
250
267
  const action = deps.quickActions[intent.index ?? mode.index];
268
+
251
269
  if (session && action) {
252
270
  const body = quickActionBody(action);
253
271
  const annotationId = controller.annotate(
@@ -257,6 +275,7 @@ function handlePickSpanAction(
257
275
  mode.span.end,
258
276
  body,
259
277
  );
278
+
260
279
  if (annotationId) deps.setFocusedAnnotationId(annotationId);
261
280
  }
262
281
  deps.setMode({ type: "normal" });
@@ -264,6 +283,7 @@ function handlePickSpanAction(
264
283
 
265
284
  function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDispatchDeps): void {
266
285
  const { mode } = deps;
286
+
267
287
  deps.liveInput.current = "";
268
288
  if (intent.from === "span" && mode.type === "span") {
269
289
  deps.setMode({
@@ -276,6 +296,7 @@ function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDisp
276
296
  });
277
297
  } else if (deps.isDiff) {
278
298
  const row = deps.rows[deps.cursor];
299
+
279
300
  if (row)
280
301
  deps.setMode({
281
302
  type: "compose",
@@ -288,10 +309,12 @@ function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDisp
288
309
  } else {
289
310
  // a mouse drag leaves a native selection; it wins over the cursor block
290
311
  const native = deps.planSheetRef.current?.readSelection() ?? null;
312
+
291
313
  if (native) {
292
314
  deps.setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
293
315
  } else {
294
316
  const block = deps.display[deps.cursor];
317
+
295
318
  if (block)
296
319
  deps.setMode({
297
320
  type: "compose",
@@ -307,6 +330,7 @@ function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDisp
307
330
 
308
331
  function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispatchDeps): void {
309
332
  const { session } = deps;
333
+
310
334
  if (!session) return;
311
335
  deps.liveInput.current = "";
312
336
  // the confirm card lives in the expanded review rail; a compact or hidden
@@ -340,8 +364,10 @@ function handleRestoreCuration(
340
364
  // undo the selected curated-out item, or the last rejected when none is
341
365
  // selected, so a bare `u` reads as "undo my last curation"
342
366
  const items = deps.controller.curationItems();
367
+
343
368
  if (!items.length) return;
344
369
  const targetId = deps.selectedCurationId ?? items[items.length - 1]!.id;
370
+
345
371
  deps.controller.restoreCuration(targetId);
346
372
  deps.setSelectedCurationId(undefined);
347
373
  }
@@ -362,6 +388,7 @@ function handleAnnotationCycle(
362
388
  const annotations = (deps.session?.annotations ?? []).filter(
363
389
  (annotation) => !isAddressed(annotation),
364
390
  );
391
+
365
392
  if (!annotations.length) return;
366
393
  const focusedIndex = annotations.findIndex(
367
394
  (annotation) => annotation.id === deps.focusedAnnotationId,
@@ -371,6 +398,7 @@ function handleAnnotationCycle(
371
398
  ? 0
372
399
  : (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) %
373
400
  annotations.length;
401
+
374
402
  deps.selectCardFromDocument(annotations[nextIndex]!.id);
375
403
  }
376
404
 
@@ -413,9 +441,11 @@ function handleCloseOverlay(_intent: IntentOfType<"closeOverlay">, deps: IntentD
413
441
  function handleSaveCompose(_intent: IntentOfType<"saveCompose">, deps: IntentDispatchDeps): void {
414
442
  const { mode, session, controller } = deps;
415
443
  const body = deps.liveInput.current.trim();
444
+
416
445
  if (mode.type === "railEdit") {
417
446
  if (session && body) controller.updateAnnotation(mode.id, body);
418
447
  deps.setMode({ type: "normal" });
448
+
419
449
  return;
420
450
  }
421
451
  if (mode.type !== "compose") return;
@@ -427,6 +457,7 @@ function handleSaveCompose(_intent: IntentOfType<"saveCompose">, deps: IntentDis
427
457
  mode.end,
428
458
  body,
429
459
  );
460
+
430
461
  if (annotationId) deps.setFocusedAnnotationId(annotationId);
431
462
  }
432
463
  deps.setMode({ type: "normal" });
@@ -437,15 +468,18 @@ function handleSubmitVerdict(
437
468
  deps: IntentDispatchDeps,
438
469
  ): void {
439
470
  const { mode } = deps;
471
+
440
472
  if (mode.type === "submit") deps.controller.submit(mode.verdict, deps.liveInput.current);
441
473
  deps.setMode({ type: "normal" });
442
474
  }
443
475
 
444
476
  function handleCycleVerdict(intent: IntentOfType<"cycleVerdict">, deps: IntentDispatchDeps): void {
445
477
  const { mode } = deps;
478
+
446
479
  if (mode.type !== "submit") return;
447
480
  const verdictIndex =
448
481
  (VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
482
+
449
483
  deps.setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
450
484
  }
451
485
 
@@ -472,6 +506,7 @@ function handleCycleReviewPanel(
472
506
  deps: IntentDispatchDeps,
473
507
  ): void {
474
508
  const next = cycleReviewPanelMode(deps.reviewMode);
509
+
475
510
  deps.setReviewMode(next);
476
511
  deps.controller.saveReviewPanel({ mode: next });
477
512
  }
@@ -485,6 +520,7 @@ function handleResizeReviewPanel(
485
520
  deps.reviewWidth + intent.direction * REVIEW_RESIZE_STEP,
486
521
  deps.terminalWidth,
487
522
  );
523
+
488
524
  deps.reviewWidthRef.current = next;
489
525
  deps.setReviewWidth(next);
490
526
  deps.controller.saveReviewPanel({ width: next });
@@ -543,6 +579,7 @@ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent)
543
579
  intent: Intent,
544
580
  deps: IntentDispatchDeps,
545
581
  ) => void;
582
+
546
583
  handler(intent, deps);
547
584
  };
548
585
  }
@@ -8,7 +8,9 @@ function bindings(
8
8
  context: { overlay?: "none" | "walk" | "compose" | "submit"; spanMode?: boolean } = {},
9
9
  ): KeyBindings {
10
10
  const resolver = new KeyBindings(DEFAULT_KEYS);
11
+
11
12
  resolver.setContext({ overlay: context.overlay ?? "none", spanMode: context.spanMode ?? false });
13
+
12
14
  return resolver;
13
15
  }
14
16
 
@@ -32,6 +34,7 @@ describe("grammar-layer resolution", () => {
32
34
  ["q", false, "quit"],
33
35
  ["z", false, undefined],
34
36
  ];
37
+
35
38
  for (const [name, shift, action] of table) {
36
39
  test(`${shift ? "shift+" : ""}${name} -> ${action}`, () => {
37
40
  expect(bindings().resolveAction({ name, shift })).toBe(action!);
@@ -142,6 +145,7 @@ describe("getActiveKeys-generated status hints", () => {
142
145
  test("a rebound key shows its real binding in the hint", () => {
143
146
  // Arrange
144
147
  const resolver = new KeyBindings({ ...DEFAULT_KEYS, comment: ["m"] });
148
+
145
149
  resolver.setContext({ overlay: "none", spanMode: false });
146
150
 
147
151
  // Assert