@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61

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 (161) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +37 -33
  8. package/src/App.appearance.test.tsx +51 -0
  9. package/src/App.autoclose.test.tsx +73 -47
  10. package/src/App.diff.test.tsx +169 -22
  11. package/src/App.export.test.tsx +49 -37
  12. package/src/App.plan-review.test.tsx +714 -0
  13. package/src/App.prototype.test.tsx +194 -0
  14. package/src/App.readonly.test.tsx +69 -35
  15. package/src/App.rendering.test.tsx +59 -31
  16. package/src/App.review-panel.test.tsx +143 -0
  17. package/src/App.submit-confirm.test.tsx +229 -0
  18. package/src/App.test.tsx +139 -68
  19. package/src/App.toast.test.tsx +77 -0
  20. package/src/App.tsx +0 -0
  21. package/src/App.walk.test.tsx +299 -0
  22. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  23. package/src/app-key-state.ts +61 -0
  24. package/src/app-screens.tsx +270 -0
  25. package/src/app-view-model.ts +307 -0
  26. package/src/attribution.test.ts +31 -0
  27. package/src/attribution.ts +42 -0
  28. package/src/clipboard.test.ts +23 -0
  29. package/src/clipboard.ts +33 -0
  30. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  31. package/src/components/AnnotationCard.stories.tsx +94 -0
  32. package/src/components/AnnotationCard.tsx +250 -0
  33. package/src/components/Breadcrumb.stories.tsx +19 -0
  34. package/src/components/Breadcrumb.tsx +45 -0
  35. package/src/components/CodeBlock.stories.tsx +42 -0
  36. package/src/components/CodeBlock.tsx +84 -0
  37. package/src/components/CompletionOverlay.stories.tsx +23 -0
  38. package/src/components/CompletionOverlay.tsx +60 -0
  39. package/src/components/ConfirmCard.stories.tsx +35 -0
  40. package/src/components/ConfirmCard.tsx +129 -0
  41. package/src/components/ConfirmDialog.stories.tsx +21 -0
  42. package/src/components/ConfirmDialog.tsx +66 -0
  43. package/src/components/DiffSheet.scroll.test.tsx +135 -0
  44. package/src/components/DiffSheet.stories.tsx +78 -0
  45. package/src/components/DiffSheet.syntax.test.tsx +65 -0
  46. package/src/components/DiffSheet.tsx +374 -0
  47. package/src/components/InboxList.stories.tsx +31 -0
  48. package/src/components/InboxList.tsx +64 -0
  49. package/src/components/KeybindsDialog.stories.tsx +31 -0
  50. package/src/components/KeybindsDialog.tsx +59 -0
  51. package/src/components/MarkerPopover.stories.tsx +58 -0
  52. package/src/components/MarkerPopover.tsx +109 -0
  53. package/src/components/MenuBar.stories.tsx +37 -0
  54. package/src/components/MenuBar.tsx +75 -0
  55. package/src/components/PlanSheet.stories.tsx +113 -0
  56. package/src/components/PlanSheet.tsx +422 -0
  57. package/src/components/PromptDialog.stories.tsx +36 -0
  58. package/src/components/PromptDialog.tsx +80 -0
  59. package/src/components/PrototypeSheet.stories.tsx +20 -0
  60. package/src/components/PrototypeSheet.tsx +450 -0
  61. package/src/components/ReviewPanel.stories.tsx +86 -0
  62. package/src/components/ReviewPanel.tsx +103 -0
  63. package/src/components/ReviewRail.stories.tsx +218 -0
  64. package/src/components/ReviewRail.tsx +318 -0
  65. package/src/components/SettingsDialog.stories.tsx +111 -0
  66. package/src/components/SettingsDialog.tsx +147 -0
  67. package/src/components/SettingsRows.stories.tsx +18 -0
  68. package/src/components/SettingsRows.tsx +112 -0
  69. package/src/components/Toast.stories.tsx +12 -0
  70. package/src/components/Toast.tsx +45 -0
  71. package/src/components/WalkWizard.stories.tsx +100 -0
  72. package/src/components/WalkWizard.tsx +152 -0
  73. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  74. package/src/components/agent-launcher.stories.tsx +26 -0
  75. package/src/components/agent-launcher.test.tsx +50 -0
  76. package/src/components/agent-launcher.tsx +149 -0
  77. package/src/components/diff-sheet-layout.test.ts +128 -0
  78. package/src/components/diff-sheet-layout.ts +184 -0
  79. package/src/components/plan-sheet-run-style.ts +90 -0
  80. package/src/components/primitives/Button.stories.tsx +32 -0
  81. package/src/components/primitives/Button.tsx +52 -0
  82. package/src/components/primitives/Card.stories.tsx +25 -0
  83. package/src/components/primitives/Card.tsx +60 -0
  84. package/src/components/primitives/Dialog.stories.tsx +24 -0
  85. package/src/components/primitives/Dialog.tsx +66 -0
  86. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  87. package/src/components/primitives/StatusBar.tsx +23 -0
  88. package/src/components/primitives/Tabs.stories.tsx +29 -0
  89. package/src/components/primitives/Tabs.tsx +102 -0
  90. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  91. package/src/components/primitives/Toolbar.tsx +14 -0
  92. package/src/components/primitives/frame.test.ts +12 -0
  93. package/src/components/primitives/frame.ts +14 -0
  94. package/src/components/quick-actions-editor.stories.tsx +54 -0
  95. package/src/components/quick-actions-editor.tsx +138 -0
  96. package/src/components/stories-app.tsx +124 -0
  97. package/src/components/stories.test.tsx +66 -0
  98. package/src/components/story-fixtures.ts +98 -0
  99. package/src/components/story.ts +75 -0
  100. package/src/components/syntax-highlight.ts +102 -0
  101. package/src/components/terminal-pane.test.tsx +40 -0
  102. package/src/components/terminal-pane.ts +193 -0
  103. package/src/components/theme-context.stories.tsx +46 -0
  104. package/src/components/theme-context.tsx +37 -0
  105. package/src/components/truncate-text.ts +7 -0
  106. package/src/config.test.ts +448 -31
  107. package/src/config.ts +339 -57
  108. package/src/diff-hunk-curate.test.ts +315 -0
  109. package/src/diff-hunk-curate.ts +253 -0
  110. package/src/diff-intraline.test.ts +165 -0
  111. package/src/diff-intraline.ts +146 -0
  112. package/src/diff-syntax.test.ts +96 -0
  113. package/src/diff-syntax.ts +176 -0
  114. package/src/editor.test.ts +149 -0
  115. package/src/editor.ts +135 -9
  116. package/src/ghostty-terminal.test.ts +84 -0
  117. package/src/ghostty-terminal.ts +176 -0
  118. package/src/herdr.test.ts +9 -34
  119. package/src/herdr.ts +17 -21
  120. package/src/index.ts +10 -0
  121. package/src/integrations.test.ts +41 -0
  122. package/src/integrations.ts +32 -0
  123. package/src/intent-dispatch.test.ts +499 -0
  124. package/src/intent-dispatch.ts +585 -0
  125. package/src/key-bindings.test.ts +156 -0
  126. package/src/key-bindings.ts +452 -0
  127. package/src/keymap.test.ts +635 -0
  128. package/src/keymap.ts +404 -0
  129. package/src/kitty-image.test.ts +66 -0
  130. package/src/kitty-image.ts +138 -0
  131. package/src/prototype-browser.test.ts +56 -0
  132. package/src/prototype-browser.ts +289 -0
  133. package/src/pty.test.ts +81 -0
  134. package/src/pty.ts +237 -0
  135. package/src/review-panel.test.ts +113 -0
  136. package/src/review-panel.ts +83 -0
  137. package/src/run.ts +16 -5
  138. package/src/serve.test.ts +25 -9
  139. package/src/serve.ts +19 -8
  140. package/src/session-controller.curate.test.ts +260 -0
  141. package/src/session-controller.share.test.ts +320 -0
  142. package/src/session-controller.ts +993 -0
  143. package/src/share.ts +120 -0
  144. package/src/test-support.test.ts +60 -0
  145. package/src/test-support.ts +197 -0
  146. package/src/theme-presets.test.ts +51 -0
  147. package/src/theme-presets.ts +187 -0
  148. package/src/theme.ts +64 -17
  149. package/src/use-settings-dialog.tsx +275 -0
  150. package/src/version.ts +5 -0
  151. package/src/view-diff.test.ts +22 -8
  152. package/src/view-diff.ts +39 -23
  153. package/src/view-plan.regression.test.ts +60 -0
  154. package/src/view-plan.test.ts +406 -0
  155. package/src/view-plan.ts +554 -0
  156. package/src/walk.test.ts +121 -0
  157. package/src/walk.ts +74 -0
  158. package/src/syntax.test.ts +0 -41
  159. package/src/syntax.ts +0 -88
  160. package/src/view.test.ts +0 -157
  161. package/src/view.ts +0 -0
package/src/herdr.ts CHANGED
@@ -1,33 +1,29 @@
1
1
  /**
2
- * First-class herdr return (#23): a review opened beside an agent should hand
3
- * focus back to the agent's pane when it closes, not leave the reviewer on a
4
- * dead tab. Detection is the ambient env contract; the return target comes
5
- * from CUELOOP_RETURN_PANE (set by whoever opened this pane) or the session's
6
- * recorded herdrPane (set by the adapter hook). Everything here is
7
- * best-effort: focus failures never block closing the review.
2
+ * First-class herdr return: a review opened beside an agent hands focus back
3
+ * to the agent's pane when it closes, not leaving the reviewer on a dead tab.
4
+ * The env contract and the return-target rule live in @cueloop/schema; this
5
+ * module owns the one reviewer-side action that spawns the herdr CLI. Focus
6
+ * is best-effort - a failure never blocks closing the review.
8
7
  */
9
8
 
10
- export function insideHerdr(): boolean {
11
- return process.env.HERDR_ENV === "1";
12
- }
13
-
14
- export function returnPaneFor(sessionHerdrPane?: string): string | undefined {
15
- if (!insideHerdr()) return undefined;
16
- const pane = process.env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
17
- // returning to our own pane would be a no-op at best
18
- return pane && pane !== process.env.HERDR_PANE_ID ? pane : undefined;
19
- }
20
-
21
9
  /** Focus the tab holding the target pane. Synchronous: runs just before exit. */
22
- export function focusHerdrPane(paneId: string): boolean {
23
- const bin = process.env.HERDR_BIN_PATH ?? "herdr";
10
+ export function focusHerdrPane(binPath: string, paneId: string): boolean {
24
11
  try {
25
- const got = Bun.spawnSync([bin, "pane", "get", paneId], { stdout: "pipe", stderr: "ignore" });
12
+ const got = Bun.spawnSync([binPath, "pane", "get", paneId], {
13
+ stdout: "pipe",
14
+ stderr: "ignore",
15
+ });
16
+
26
17
  if (got.exitCode !== 0) return false;
27
18
  const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: { tab_id?: string } } };
28
19
  const tab = parsed.result?.pane?.tab_id;
20
+
29
21
  if (!tab) return false;
30
- return Bun.spawnSync([bin, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
22
+
23
+ return (
24
+ Bun.spawnSync([binPath, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" })
25
+ .exitCode === 0
26
+ );
31
27
  } catch {
32
28
  return false;
33
29
  }
package/src/index.ts CHANGED
@@ -1,2 +1,12 @@
1
1
  export { runClient, type RunClientOptions } from "./run";
2
2
  export { serveClient, type ServeHandle, type ServeOptions } from "./serve";
3
+ export { App, type AppProps } from "./App";
4
+ export {
5
+ collaboratorAnnotations,
6
+ publishShare,
7
+ pullShare,
8
+ pushShare,
9
+ shareIdFromLine,
10
+ type ShareResult,
11
+ type ShareTarget,
12
+ } from "./share";
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { OBSIDIAN_DEFAULTS } from "@cueloop/integration-obsidian";
3
+ import { loadBundledExporters } from "./integrations";
4
+ import type { IntegrationsConfig } from "./config";
5
+
6
+ function integrations(overrides: Partial<typeof OBSIDIAN_DEFAULTS> = {}): IntegrationsConfig {
7
+ return { obsidian: { ...OBSIDIAN_DEFAULTS, ...overrides } };
8
+ }
9
+
10
+ describe("loadBundledExporters", () => {
11
+ test("composes the obsidian integration into one named exporter", async () => {
12
+ // Act
13
+ const exporters = await loadBundledExporters(integrations());
14
+
15
+ // Assert
16
+ expect(exporters.map((exporter) => exporter.name)).toEqual(["obsidian"]);
17
+ });
18
+
19
+ test("runsOn mirrors the configured export policy", async () => {
20
+ // Arrange
21
+ const onApprove = (await loadBundledExporters(integrations({ exportOn: "approve" })))[0]!;
22
+
23
+ // Assert
24
+ expect(onApprove.runsOn("approve")).toBeTrue();
25
+ expect(onApprove.runsOn("request_changes")).toBeFalse();
26
+
27
+ // Arrange
28
+ const onResolve = (await loadBundledExporters(integrations({ exportOn: "resolve" })))[0]!;
29
+
30
+ // Assert
31
+ expect(onResolve.runsOn("approve")).toBeTrue();
32
+ expect(onResolve.runsOn("request_changes")).toBeTrue();
33
+
34
+ // Arrange
35
+ const manual = (await loadBundledExporters(integrations({ exportOn: "manual" })))[0]!;
36
+
37
+ // Assert
38
+ expect(manual.runsOn("approve")).toBeFalse();
39
+ expect(manual.runsOn("request_changes")).toBeFalse();
40
+ });
41
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Bundled integration wiring: composes the configured integrations into
3
+ * generic exporters plus their run policy, so the session controller depends
4
+ * only on the extension seam, never on an integration's own config type. This
5
+ * and config.ts are the only client modules that name a concrete integration.
6
+ */
7
+
8
+ import { Registry, type Exporter } from "@cueloop/extension-api";
9
+ import { createObsidianExtension, shouldExport } from "@cueloop/integration-obsidian";
10
+ import type { VerdictKind } from "@cueloop/schema";
11
+ import type { IntegrationsConfig } from "./config";
12
+
13
+ /** An exporter plus the policy deciding which verdicts trigger it. */
14
+ export interface BundledExporter {
15
+ name: string;
16
+ run: Exporter;
17
+ runsOn(verdict: VerdictKind): boolean;
18
+ }
19
+
20
+ /** Load the bundled integrations from config as generic exporters. */
21
+ export async function loadBundledExporters(
22
+ integrations: IntegrationsConfig,
23
+ ): Promise<BundledExporter[]> {
24
+ const registry = new Registry();
25
+ const obsidian = integrations.obsidian;
26
+ const record = await registry.load("obsidian", createObsidianExtension(obsidian));
27
+ const run = record.exporters.get("obsidian");
28
+
29
+ if (!run) return [];
30
+
31
+ return [{ name: "obsidian", run, runsOn: (verdict) => shouldExport(obsidian.exportOn, verdict) }];
32
+ }
@@ -0,0 +1,499 @@
1
+ /**
2
+ * The intent reducer in isolation: with the App component's dependencies
3
+ * mocked, each Intent maps to exactly the controller call or state update it
4
+ * should - the navigation clamps, the annotation wrap-around, the verdict
5
+ * cycle, and the submit-reveal fix, none of which needed a rendered TUI.
6
+ */
7
+
8
+ import { describe, expect, mock, test } from "bun:test";
9
+ import type { ReviewSession } from "@cueloop/schema";
10
+ import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
11
+ import type { DisplayBlock } from "./view-plan";
12
+
13
+ function block(text: string): DisplayBlock {
14
+ return { text, kind: "text", work: true } as unknown as DisplayBlock;
15
+ }
16
+
17
+ function sessionWith(annotationIds: string[]): ReviewSession {
18
+ return {
19
+ annotations: annotationIds.map((id) => ({ id })),
20
+ workingCopy: undefined,
21
+ } as unknown as ReviewSession;
22
+ }
23
+
24
+ /** A controller where every verb is a mock; annotate returns undefined by default. */
25
+ function baseController() {
26
+ return {
27
+ setStatus: mock(),
28
+ open: mock(),
29
+ deleteSession: mock(),
30
+ setSelfName: mock(),
31
+ cut: mock(),
32
+ curationItems: mock(() => []),
33
+ restoreCuration: mock(),
34
+ annotate: mock(),
35
+ annotatePrototype: mock(),
36
+ updateAnnotation: mock(),
37
+ removeAnnotation: mock(),
38
+ walkStart: mock(),
39
+ walkForward: mock(),
40
+ walkBack: mock(),
41
+ walkLeave: mock(),
42
+ submit: mock(),
43
+ share: mock(),
44
+ finishReview: mock(),
45
+ optInAutoClose: mock(),
46
+ dismissCompletion: mock(),
47
+ saveReviewPanel: mock(),
48
+ };
49
+ }
50
+
51
+ /** A deps bag where every effect is a mock and every read has a plain default. */
52
+ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
53
+ const controller = baseController();
54
+
55
+ return {
56
+ controller: controller as unknown as IntentDispatchDeps["controller"],
57
+ onExit: mock(),
58
+ isDiff: false,
59
+ display: [],
60
+ rows: [],
61
+ cursor: 0,
62
+ inbox: null,
63
+ inboxCursor: 0,
64
+ mode: { type: "normal" },
65
+ session: null,
66
+ reviewMode: "expanded",
67
+ reviewWidth: 34,
68
+ terminalWidth: 120,
69
+ focusedAnnotationId: undefined,
70
+ selectedCurationId: undefined,
71
+ authorNames: {},
72
+ quickActions: [],
73
+ renameAuthor: mock(),
74
+ liveInput: { current: "" },
75
+ reviewWidthRef: { current: 34 },
76
+ planSheetRef: { current: null },
77
+ setCursor: mock(),
78
+ setInboxCursor: mock(),
79
+ setMode: mock(),
80
+ setReviewMode: mock(),
81
+ setReviewWidth: mock(),
82
+ setRailTab: mock(),
83
+ setFocusedAnnotationId: mock(),
84
+ setSelectedCurationId: mock(),
85
+ setPulsedAnnotationId: mock(),
86
+ selectCardFromDocument: mock(),
87
+ runEditorHandOff: mock(),
88
+ openCardEdit: mock(),
89
+ ...overrides,
90
+ };
91
+ }
92
+
93
+ describe("move", () => {
94
+ test("down clamps at the last row and up clamps at the first", () => {
95
+ // Arrange
96
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
97
+ const dispatch = createIntentDispatch(deps);
98
+
99
+ // Act
100
+ dispatch({ type: "move", to: "down" });
101
+
102
+ // Assert
103
+ const advance = (deps.setCursor as ReturnType<typeof mock>).mock.calls[0]![0] as (
104
+ current: number,
105
+ ) => number;
106
+
107
+ expect(advance(2)).toBe(2); // already at the end, stays
108
+ expect(advance(0)).toBe(1);
109
+ });
110
+
111
+ test("bottom jumps to the last navigable row", () => {
112
+ // Arrange
113
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
114
+ const dispatch = createIntentDispatch(deps);
115
+
116
+ // Act
117
+ dispatch({ type: "move", to: "bottom" });
118
+
119
+ // Assert
120
+ expect(deps.setCursor).toHaveBeenCalledWith(2);
121
+ });
122
+ });
123
+
124
+ describe("annotation navigation", () => {
125
+ test("nextAnnotation wraps from the last annotation back to the first", () => {
126
+ // Arrange
127
+ const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a3" });
128
+ const dispatch = createIntentDispatch(deps);
129
+
130
+ // Act
131
+ dispatch({ type: "nextAnnotation" });
132
+
133
+ // Assert
134
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a1");
135
+ });
136
+
137
+ test("prevAnnotation wraps from the first annotation to the last", () => {
138
+ // Arrange
139
+ const deps = makeDeps({ session: sessionWith(["a1", "a2", "a3"]), focusedAnnotationId: "a1" });
140
+ const dispatch = createIntentDispatch(deps);
141
+
142
+ // Act
143
+ dispatch({ type: "prevAnnotation" });
144
+
145
+ // Assert
146
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
147
+ });
148
+
149
+ test("cycling skips annotations a revision already addressed", () => {
150
+ // 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
+ };
157
+ const deps = makeDeps({ session, focusedAnnotationId: "a1" });
158
+ const dispatch = createIntentDispatch(deps);
159
+
160
+ // Act
161
+ dispatch({ type: "nextAnnotation" });
162
+
163
+ // Assert
164
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
165
+ });
166
+ });
167
+
168
+ describe("cycleVerdict", () => {
169
+ test("advances through the verdict list in the submit overlay", () => {
170
+ // Arrange
171
+ const deps = makeDeps({ mode: { type: "submit", verdict: "approve", summary: "" } });
172
+ const dispatch = createIntentDispatch(deps);
173
+
174
+ // Act
175
+ dispatch({ type: "cycleVerdict", direction: 1 });
176
+
177
+ // Assert
178
+ expect(deps.setMode).toHaveBeenCalledWith({
179
+ type: "submit",
180
+ verdict: "request_changes",
181
+ summary: "",
182
+ });
183
+ });
184
+ });
185
+
186
+ describe("marker-actions popover", () => {
187
+ const span = { displayIndex: 3, wordIndex: 0, wordEnd: 0, start: 2, end: 9 };
188
+
189
+ test("spanCut cuts the span's block and closes the popover", () => {
190
+ // Arrange
191
+ const deps = makeDeps({ mode: { type: "span", span } });
192
+ const dispatch = createIntentDispatch(deps);
193
+
194
+ // Act
195
+ dispatch({ type: "spanCut" });
196
+
197
+ // Assert
198
+ expect(deps.controller.cut).toHaveBeenCalledWith(3);
199
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
200
+ });
201
+
202
+ test("openSpanActions enters the list at index 0", () => {
203
+ // Arrange
204
+ const deps = makeDeps({ mode: { type: "span", span } });
205
+ const dispatch = createIntentDispatch(deps);
206
+
207
+ // Act
208
+ dispatch({ type: "openSpanActions" });
209
+
210
+ // Assert
211
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "spanActions", span, index: 0 });
212
+ });
213
+
214
+ test("moveSpanAction clamps within the actions list", () => {
215
+ // Arrange
216
+ const deps = makeDeps({
217
+ mode: { type: "spanActions", span, index: 1 },
218
+ quickActions: [{ prompt: "one" }, { prompt: "two" }],
219
+ });
220
+ const dispatch = createIntentDispatch(deps);
221
+
222
+ // Act
223
+ dispatch({ type: "moveSpanAction", direction: 1 });
224
+
225
+ // Assert - already at the last row, so the index holds
226
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "spanActions", span, index: 1 });
227
+ });
228
+
229
+ test("pickSpanAction inserts the preset comment and returns to normal", () => {
230
+ // Arrange
231
+ const annotate = mock(() => "an_new");
232
+ const deps = makeDeps({
233
+ mode: { type: "spanActions", span, index: 1 },
234
+ session: sessionWith([]),
235
+ quickActions: [{ prompt: "Needs a test" }, { prompt: "YAGNI", metadata: "cut scope" }],
236
+ controller: { ...baseController(), annotate } as unknown as IntentDispatchDeps["controller"],
237
+ });
238
+ const dispatch = createIntentDispatch(deps);
239
+
240
+ // Act
241
+ dispatch({ type: "pickSpanAction" });
242
+
243
+ // Assert - metadata joins the prompt with a blank line
244
+ expect(annotate).toHaveBeenCalledWith("comment", 3, 2, 9, "YAGNI\n\ncut scope");
245
+ expect(deps.setFocusedAnnotationId).toHaveBeenCalledWith("an_new");
246
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
247
+ });
248
+
249
+ test("pickSpanAction honors an explicit index (mouse click)", () => {
250
+ // Arrange
251
+ const annotate = mock(() => "an_new");
252
+ const deps = makeDeps({
253
+ mode: { type: "spanActions", span, index: 0 },
254
+ session: sessionWith([]),
255
+ quickActions: [{ prompt: "Needs a test" }, { prompt: "Extract the duplication" }],
256
+ controller: { ...baseController(), annotate } as unknown as IntentDispatchDeps["controller"],
257
+ });
258
+ const dispatch = createIntentDispatch(deps);
259
+
260
+ // Act
261
+ dispatch({ type: "pickSpanAction", index: 1 });
262
+
263
+ // Assert
264
+ expect(annotate).toHaveBeenCalledWith("comment", 3, 2, 9, "Extract the duplication");
265
+ });
266
+
267
+ test("closeSpanActions returns to the span toolbar", () => {
268
+ // Arrange
269
+ const deps = makeDeps({ mode: { type: "spanActions", span, index: 2 } });
270
+ const dispatch = createIntentDispatch(deps);
271
+
272
+ // Act
273
+ dispatch({ type: "closeSpanActions" });
274
+
275
+ // Assert
276
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "span", span });
277
+ });
278
+ });
279
+
280
+ describe("openSubmit", () => {
281
+ test("force-opens the review rail so the confirm card can never be hidden", () => {
282
+ // Arrange
283
+ const deps = makeDeps({ session: sessionWith([]), reviewMode: "hidden" });
284
+ const dispatch = createIntentDispatch(deps);
285
+
286
+ // Act
287
+ dispatch({ type: "openSubmit" });
288
+
289
+ // Assert
290
+ expect(deps.setReviewMode).toHaveBeenCalledWith("expanded");
291
+ expect(deps.setRailTab).toHaveBeenCalledWith("review");
292
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "approve", summary: "" });
293
+ });
294
+
295
+ test("does nothing without a session", () => {
296
+ // Arrange
297
+ const deps = makeDeps({ session: null });
298
+ const dispatch = createIntentDispatch(deps);
299
+
300
+ // Act
301
+ dispatch({ type: "openSubmit" });
302
+
303
+ // Assert
304
+ expect(deps.setMode).not.toHaveBeenCalled();
305
+ });
306
+ });
307
+
308
+ describe("share", () => {
309
+ test("dispatches to the controller's share", () => {
310
+ // Arrange
311
+ const deps = makeDeps();
312
+ const dispatch = createIntentDispatch(deps);
313
+
314
+ // Act
315
+ dispatch({ type: "share" });
316
+
317
+ // Assert
318
+ expect(deps.controller.share).toHaveBeenCalled();
319
+ });
320
+ });
321
+
322
+ 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
+ },
338
+ ];
339
+
340
+ test("restores the selected item and clears the selection", () => {
341
+ // Arrange
342
+ const deps = makeDeps({ selectedCurationId: "diff:f#1#2" });
343
+
344
+ (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
345
+ const dispatch = createIntentDispatch(deps);
346
+
347
+ // Act
348
+ dispatch({ type: "restoreCuration" });
349
+
350
+ // Assert
351
+ expect(deps.controller.restoreCuration).toHaveBeenCalledWith("diff:f#1#2");
352
+ expect(deps.setSelectedCurationId).toHaveBeenCalledWith(undefined);
353
+ });
354
+
355
+ test("with nothing selected, undoes the last removal", () => {
356
+ // Arrange
357
+ const deps = makeDeps({ selectedCurationId: undefined });
358
+
359
+ (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue(items);
360
+ const dispatch = createIntentDispatch(deps);
361
+
362
+ // Act
363
+ dispatch({ type: "restoreCuration" });
364
+
365
+ // Assert
366
+ expect(deps.controller.restoreCuration).toHaveBeenCalledWith("diff:f#1#2");
367
+ });
368
+
369
+ test("does nothing when there is nothing curated out", () => {
370
+ // Arrange
371
+ const deps = makeDeps();
372
+
373
+ (deps.controller.curationItems as ReturnType<typeof mock>).mockReturnValue([]);
374
+ const dispatch = createIntentDispatch(deps);
375
+
376
+ // Act
377
+ dispatch({ type: "restoreCuration" });
378
+
379
+ // Assert
380
+ expect(deps.controller.restoreCuration).not.toHaveBeenCalled();
381
+ });
382
+ });
383
+
384
+ describe("exit", () => {
385
+ test("calls the exit hook with 0", () => {
386
+ // Arrange
387
+ const deps = makeDeps();
388
+ const dispatch = createIntentDispatch(deps);
389
+
390
+ // Act
391
+ dispatch({ type: "exit" });
392
+
393
+ // Assert
394
+ expect(deps.onExit).toHaveBeenCalledWith(0);
395
+ });
396
+ });
397
+
398
+ describe("inbox delete", () => {
399
+ test("requestDeleteSession opens the confirm on the cursor row", () => {
400
+ // Arrange
401
+ const inbox = [{ id: "ses_1", artifact: { meta: { title: "Plan A" } } }] as never;
402
+ const deps = makeDeps({ inbox, inboxCursor: 0 });
403
+
404
+ // Act
405
+ createIntentDispatch(deps)({ type: "requestDeleteSession" });
406
+
407
+ // Assert
408
+ expect(deps.setMode).toHaveBeenCalledWith({
409
+ type: "confirmDelete",
410
+ sessionId: "ses_1",
411
+ title: "Plan A",
412
+ });
413
+ });
414
+
415
+ test("confirmDialog deletes the session and closes", () => {
416
+ // Arrange
417
+ const deps = makeDeps({ mode: { type: "confirmDelete", sessionId: "ses_1", title: "Plan A" } });
418
+
419
+ // Act
420
+ createIntentDispatch(deps)({ type: "confirmDialog" });
421
+
422
+ // Assert
423
+ expect(deps.controller.deleteSession).toHaveBeenCalledWith("ses_1");
424
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
425
+ });
426
+ });
427
+
428
+ describe("rename author", () => {
429
+ test("openRename seeds the prompt with the author's current local name", () => {
430
+ // Arrange
431
+ const session = { annotations: [{ id: "a1", author: "SHA256:x" }] } as never;
432
+ const deps = makeDeps({
433
+ session,
434
+ focusedAnnotationId: "a1",
435
+ authorNames: { "SHA256:x": "Alex" },
436
+ });
437
+
438
+ // Act
439
+ createIntentDispatch(deps)({ type: "openRename" });
440
+
441
+ // Assert
442
+ expect(deps.setMode).toHaveBeenCalledWith({
443
+ type: "rename",
444
+ authorId: "SHA256:x",
445
+ text: "Alex",
446
+ });
447
+ });
448
+
449
+ test("openRename on your own note does nothing but explain", () => {
450
+ // Arrange
451
+ const session = { annotations: [{ id: "a1" }] } as never;
452
+ const deps = makeDeps({ session, focusedAnnotationId: "a1" });
453
+
454
+ // Act
455
+ createIntentDispatch(deps)({ type: "openRename" });
456
+
457
+ // Assert
458
+ expect(deps.controller.setStatus).toHaveBeenCalled();
459
+ expect(deps.setMode).not.toHaveBeenCalled();
460
+ });
461
+
462
+ test("confirmDialog in rename mode persists the trimmed name and closes", () => {
463
+ // Arrange
464
+ const deps = makeDeps({ mode: { type: "rename", authorId: "SHA256:x", text: " Alex " } });
465
+
466
+ // Act
467
+ createIntentDispatch(deps)({ type: "confirmDialog" });
468
+
469
+ // Assert
470
+ expect(deps.renameAuthor).toHaveBeenCalledWith("SHA256:x", "Alex");
471
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
472
+ });
473
+ });
474
+
475
+ describe("collaborator self-name", () => {
476
+ test("confirmDialog in nameSelf mode records the trimmed name and closes", () => {
477
+ // Arrange
478
+ const deps = makeDeps({ mode: { type: "nameSelf", text: " Robin " } });
479
+
480
+ // Act
481
+ createIntentDispatch(deps)({ type: "confirmDialog" });
482
+
483
+ // Assert
484
+ expect(deps.controller.setSelfName).toHaveBeenCalledWith("Robin");
485
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
486
+ });
487
+
488
+ test("an empty name still closes - the collaborator stays anonymous", () => {
489
+ // Arrange
490
+ const deps = makeDeps({ mode: { type: "nameSelf", text: " " } });
491
+
492
+ // Act
493
+ createIntentDispatch(deps)({ type: "confirmDialog" });
494
+
495
+ // Assert
496
+ expect(deps.controller.setSelfName).toHaveBeenCalledWith("");
497
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
498
+ });
499
+ });