@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71

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 (248) 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 +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1043 -798
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +495 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +290 -0
  41. package/src/components/AppShell.zoom.test.tsx +135 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +153 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
@@ -0,0 +1,700 @@
1
+ /**
2
+ * The intent reducer: one effectful handler per keymap Intent, lifted out of
3
+ * the App component so the 30-case grammar can be read and tested on its own.
4
+ * keymap.ts turns keys into Intents (pure); this turns an Intent into the
5
+ * matching controller call or view-state update. App builds the dependency
6
+ * bag once per render and hands it here; nothing in this module reaches back
7
+ * into React beyond the setters it is given.
8
+ */
9
+
10
+ import type { Dispatch, MutableRefObject, SetStateAction } from "react";
11
+ import { isAddressed, isAgentNote, type Thread, type VerdictKind } from "@cueloop/schema";
12
+ import { displayText, spanKey, startSpan, type DisplayBlock, type SpanState } from "./view-plan";
13
+ import type { DiffRow } from "./view-diff";
14
+ import type { ReviewController } from "./thread-controller";
15
+ import type { Intent } from "./keymap";
16
+ import type { TreeRow } from "./tree-view";
17
+ import { quickActionBody, type QuickAction } from "./config";
18
+ import { VERDICTS } from "./components/ConfirmCard";
19
+
20
+ /** Which pane of the session tree / review the keyboard grammar is aimed at. */
21
+ export type RailTab = "review" | "agent" | "tree";
22
+
23
+ /** The one overlay/mode the TUI is in; every compose/submit/edit flow is a variant. */
24
+ export type Mode =
25
+ | { type: "normal" }
26
+ | { type: "span"; span: SpanState }
27
+ | { type: "spanActions"; span: SpanState; index: number }
28
+ | {
29
+ type: "compose";
30
+ kind: "comment";
31
+ displayIndex: number;
32
+ start: number;
33
+ end: number;
34
+ text: string;
35
+ }
36
+ | { type: "railEdit"; id: string; text: string }
37
+ | { type: "submit"; verdict: VerdictKind; summary: string }
38
+ | { type: "confirmDelete"; sessionId: string; title: string }
39
+ | { type: "rename"; authorId: string; text: string }
40
+ | { type: "renameThread"; sessionId: string; text: string }
41
+ | { type: "nameSelf"; text: string }
42
+ | { type: "treePrompt"; ask: TreeAsk; entryId?: string; text: string };
43
+
44
+ /** What a tree prompt asks for: a branch name, a checkpoint name, or the summary a move back leaves. */
45
+ export type TreeAsk = "branch" | "label" | "navigate";
46
+
47
+ /** The marked span for span mode and its quick-actions sub-mode; null otherwise. */
48
+ export function activeSpanState(mode: Mode): SpanState | null {
49
+ return mode.type === "span" || mode.type === "spanActions" ? mode.span : null;
50
+ }
51
+
52
+ /**
53
+ * Annotations that still count as feedback: agent notes never do, and an
54
+ * annotation a revision already addressed is settled - it neither blocks the
55
+ * verdict default nor re-enters the next feedback document.
56
+ */
57
+ export function reviewerAnnotations(session: Thread) {
58
+ return session.annotations.filter(
59
+ (annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
60
+ );
61
+ }
62
+
63
+ export function defaultVerdict(session: Thread): VerdictKind {
64
+ return reviewerAnnotations(session).length || session.workingCopy !== undefined
65
+ ? "request_changes"
66
+ : "approve";
67
+ }
68
+
69
+ /**
70
+ * Everything the reducer reads or writes, supplied fresh each render: the
71
+ * controller and exit hook, the derived reads (view rows, cursor, mode,
72
+ * session), the refs it pokes, the state setters, and the three App-owned
73
+ * selection helpers it composes.
74
+ */
75
+ export interface IntentDispatchDeps {
76
+ controller: ReviewController;
77
+ onExit?: (code: number) => void;
78
+
79
+ isDiff: boolean;
80
+ display: DisplayBlock[];
81
+ rows: DiffRow[];
82
+ cursor: number;
83
+ inbox: Thread[] | null;
84
+ inboxCursor: number;
85
+ mode: Mode;
86
+ session: Thread | null;
87
+ focusedAnnotationId: string | undefined;
88
+ /** The curation item selected for undo, if any. */
89
+ selectedCurationId: string | undefined;
90
+ railTab: RailTab;
91
+ /** The tree row selected in the session tree; the target of go. */
92
+ selectedEntryId: string | undefined;
93
+ /** Planner-local author renames, for seeding the rename prompt. */
94
+ authorNames: Record<string, string>;
95
+ /** Marker-popover quick actions, in list order; picking one inserts a preset comment. */
96
+ quickActions: QuickAction[];
97
+ /** Persist an author rename and update the live overrides (App-owned). */
98
+ renameAuthor: (id: string, name: string) => void;
99
+ /** Rename a thread's title through the daemon. */
100
+ renameThread: (id: string, title: string) => void;
101
+
102
+ liveInput: MutableRefObject<string>;
103
+
104
+ setCursor: Dispatch<SetStateAction<number>>;
105
+ setInboxCursor: Dispatch<SetStateAction<number>>;
106
+ setMode: Dispatch<SetStateAction<Mode>>;
107
+ setRailTab: Dispatch<SetStateAction<RailTab>>;
108
+ setSelectedEntryId: Dispatch<SetStateAction<string | undefined>>;
109
+ setFocusedAnnotationId: Dispatch<SetStateAction<string | undefined>>;
110
+ setSelectedCurationId: Dispatch<SetStateAction<string | undefined>>;
111
+ setPulsedAnnotationId: Dispatch<SetStateAction<string | null>>;
112
+
113
+ selectCardFromDocument: (annotationId: string) => void;
114
+ runEditorHandOff: () => void;
115
+ openCardEdit: (annotationId: string) => void;
116
+ /** Flip split/stacked diff and persist it (App-owned); split lays out only when wide/zoomed. */
117
+ toggleDiffView: () => void;
118
+ }
119
+
120
+ type IntentOfType<Kind extends Intent["type"]> = Extract<Intent, { type: Kind }>;
121
+
122
+ function handleExit(_intent: IntentOfType<"exit">, deps: IntentDispatchDeps): void {
123
+ deps.onExit?.(0);
124
+ }
125
+
126
+ function handleStatus(intent: IntentOfType<"status">, deps: IntentDispatchDeps): void {
127
+ deps.controller.setStatus(intent.message);
128
+ }
129
+
130
+ /** A diff cursor rests on code lines only; file and hunk headers are structural dividers. */
131
+ function isCodeRow(row: DiffRow | undefined): boolean {
132
+ return row?.kind === "ctx" || row?.kind === "add" || row?.kind === "del";
133
+ }
134
+
135
+ /**
136
+ * A landable diff row: any code line, plus a collapsed file's band (a file row with
137
+ * no body before the next file or the end) so the cursor can reach it to expand it.
138
+ */
139
+ function isLandableRow(rows: DiffRow[], index: number): boolean {
140
+ const row = rows[index];
141
+
142
+ if (isCodeRow(row)) return true;
143
+ if (row?.kind !== "file") return false;
144
+ const next = rows[index + 1];
145
+
146
+ return next === undefined || next.kind === "file";
147
+ }
148
+
149
+ /** The next landable index in the move direction, skipping expanded headers; stays put at an edge. */
150
+ function nextCodeRowIndex(rows: DiffRow[], from: number, to: IntentOfType<"move">["to"]): number {
151
+ if (to === "top") {
152
+ for (let index = 0; index < rows.length; index++) if (isLandableRow(rows, index)) return index;
153
+
154
+ return from;
155
+ }
156
+ if (to === "bottom") {
157
+ for (let index = rows.length - 1; index >= 0; index--) {
158
+ if (isLandableRow(rows, index)) return index;
159
+ }
160
+
161
+ return from;
162
+ }
163
+ const step = to === "down" ? 1 : -1;
164
+
165
+ for (let index = from + step; index >= 0 && index < rows.length; index += step) {
166
+ if (isLandableRow(rows, index)) return index;
167
+ }
168
+
169
+ return from;
170
+ }
171
+
172
+ function handleMove(intent: IntentOfType<"move">, deps: IntentDispatchDeps): void {
173
+ if (deps.isDiff) {
174
+ deps.setCursor((current) => nextCodeRowIndex(deps.rows, current, intent.to));
175
+
176
+ return;
177
+ }
178
+ const navigableCount = deps.display.length;
179
+
180
+ if (intent.to === "down") deps.setCursor((current) => Math.min(navigableCount - 1, current + 1));
181
+ else if (intent.to === "up") deps.setCursor((current) => Math.max(0, current - 1));
182
+ else if (intent.to === "top") deps.setCursor(0);
183
+ else deps.setCursor(navigableCount - 1);
184
+ }
185
+
186
+ function handleInboxMove(intent: IntentOfType<"inboxMove">, deps: IntentDispatchDeps): void {
187
+ const navigableCount = deps.inbox?.length ?? 0;
188
+
189
+ deps.setInboxCursor((current) =>
190
+ intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1),
191
+ );
192
+ }
193
+
194
+ function handleOpenSession(_intent: IntentOfType<"openSession">, deps: IntentDispatchDeps): void {
195
+ const selected = deps.inbox?.[deps.inboxCursor];
196
+
197
+ if (selected) deps.controller.open(selected.id);
198
+ }
199
+
200
+ function handleRequestDeleteSession(
201
+ _intent: IntentOfType<"requestDeleteSession">,
202
+ deps: IntentDispatchDeps,
203
+ ): void {
204
+ const selected = deps.inbox?.[deps.inboxCursor];
205
+
206
+ if (selected)
207
+ deps.setMode({
208
+ type: "confirmDelete",
209
+ sessionId: selected.id,
210
+ title: selected.artifact.meta.title ?? selected.id,
211
+ });
212
+ }
213
+
214
+ function handleOpenRename(_intent: IntentOfType<"openRename">, deps: IntentDispatchDeps): void {
215
+ const focused = deps.session?.annotations.find(
216
+ (annotation) => annotation.id === deps.focusedAnnotationId,
217
+ );
218
+
219
+ if (!focused?.author) {
220
+ deps.controller.setStatus("that is your own note - nothing to rename");
221
+
222
+ return;
223
+ }
224
+ deps.setMode({
225
+ type: "rename",
226
+ authorId: focused.author,
227
+ text: deps.authorNames[focused.author] ?? "",
228
+ });
229
+ }
230
+
231
+ function handleConfirmDialog(
232
+ _intent: IntentOfType<"confirmDialog">,
233
+ deps: IntentDispatchDeps,
234
+ ): void {
235
+ const { mode, controller } = deps;
236
+
237
+ if (mode.type === "confirmDelete") controller.deleteSession(mode.sessionId);
238
+ else if (mode.type === "rename") deps.renameAuthor(mode.authorId, mode.text.trim());
239
+ else if (mode.type === "renameThread") deps.renameThread(mode.sessionId, mode.text.trim());
240
+ else if (mode.type === "nameSelf") controller.setSelfName(mode.text.trim());
241
+ else if (mode.type === "treePrompt") confirmTreePrompt(mode, deps);
242
+ deps.setMode({ type: "normal" });
243
+ }
244
+
245
+ function handleStartSpan(_intent: IntentOfType<"startSpan">, deps: IntentDispatchDeps): void {
246
+ const block = deps.display[deps.cursor];
247
+
248
+ if (!block?.work) return;
249
+ const span = startSpan(deps.cursor, displayText(block));
250
+
251
+ if (span) deps.setMode({ type: "span", span });
252
+ }
253
+
254
+ function handleSpanKey(intent: IntentOfType<"spanKey">, deps: IntentDispatchDeps): void {
255
+ const { mode } = deps;
256
+
257
+ if (mode.type === "span") {
258
+ const span = spanKey(
259
+ mode.span,
260
+ intent.name,
261
+ displayText(deps.display[mode.span.displayIndex]!),
262
+ );
263
+
264
+ deps.setMode({ type: "span", span });
265
+ }
266
+ }
267
+
268
+ function handleSpanCut(_intent: IntentOfType<"spanCut">, deps: IntentDispatchDeps): void {
269
+ // the block the span sits in, cut whole (partial-span cut is not modeled)
270
+ const { mode } = deps;
271
+
272
+ if (mode.type === "span") {
273
+ deps.controller.cut(mode.span.displayIndex);
274
+ deps.setMode({ type: "normal" });
275
+ }
276
+ }
277
+
278
+ function handleOpenSpanActions(
279
+ _intent: IntentOfType<"openSpanActions">,
280
+ deps: IntentDispatchDeps,
281
+ ): void {
282
+ const { mode } = deps;
283
+
284
+ if (mode.type === "span") deps.setMode({ type: "spanActions", span: mode.span, index: 0 });
285
+ }
286
+
287
+ function handleMoveSpanAction(
288
+ intent: IntentOfType<"moveSpanAction">,
289
+ deps: IntentDispatchDeps,
290
+ ): void {
291
+ const { mode } = deps;
292
+
293
+ if (mode.type === "spanActions") {
294
+ const index = Math.max(
295
+ 0,
296
+ Math.min(deps.quickActions.length - 1, mode.index + intent.direction),
297
+ );
298
+
299
+ deps.setMode({ ...mode, index });
300
+ }
301
+ }
302
+
303
+ function handleCloseSpanActions(
304
+ _intent: IntentOfType<"closeSpanActions">,
305
+ deps: IntentDispatchDeps,
306
+ ): void {
307
+ const { mode } = deps;
308
+
309
+ if (mode.type === "spanActions") deps.setMode({ type: "span", span: mode.span });
310
+ }
311
+
312
+ function handlePickSpanAction(
313
+ intent: IntentOfType<"pickSpanAction">,
314
+ deps: IntentDispatchDeps,
315
+ ): void {
316
+ const { mode, session, controller } = deps;
317
+
318
+ if (mode.type !== "spanActions") return;
319
+ const action = deps.quickActions[intent.index ?? mode.index];
320
+
321
+ if (session && action) {
322
+ const body = quickActionBody(action);
323
+ const annotationId = controller.annotate(
324
+ "comment",
325
+ mode.span.displayIndex,
326
+ mode.span.start,
327
+ mode.span.end,
328
+ body,
329
+ );
330
+
331
+ if (annotationId) deps.setFocusedAnnotationId(annotationId);
332
+ }
333
+ deps.setMode({ type: "normal" });
334
+ }
335
+
336
+ function handleOpenCompose(intent: IntentOfType<"openCompose">, deps: IntentDispatchDeps): void {
337
+ const { mode } = deps;
338
+
339
+ deps.liveInput.current = "";
340
+ if (intent.from === "span" && mode.type === "span") {
341
+ deps.setMode({
342
+ type: "compose",
343
+ kind: intent.kind,
344
+ displayIndex: mode.span.displayIndex,
345
+ start: mode.span.start,
346
+ end: mode.span.end,
347
+ text: "",
348
+ });
349
+ } else if (deps.isDiff) {
350
+ const row = deps.rows[deps.cursor];
351
+
352
+ if (row)
353
+ deps.setMode({
354
+ type: "compose",
355
+ kind: intent.kind,
356
+ displayIndex: deps.cursor,
357
+ start: 0,
358
+ end: row.text.length,
359
+ text: "",
360
+ });
361
+ } else {
362
+ const block = deps.display[deps.cursor];
363
+
364
+ if (block)
365
+ deps.setMode({
366
+ type: "compose",
367
+ kind: intent.kind,
368
+ displayIndex: deps.cursor,
369
+ start: 0,
370
+ end: displayText(block).length,
371
+ text: "",
372
+ });
373
+ }
374
+ }
375
+
376
+ function handleOpenSubmit(_intent: IntentOfType<"openSubmit">, deps: IntentDispatchDeps): void {
377
+ const { session } = deps;
378
+
379
+ if (!session) return;
380
+ deps.liveInput.current = "";
381
+ deps.setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
382
+ }
383
+
384
+ function handleShare(_intent: IntentOfType<"share">, deps: IntentDispatchDeps): void {
385
+ deps.controller.share();
386
+ }
387
+
388
+ function handleCut(_intent: IntentOfType<"cut">, deps: IntentDispatchDeps): void {
389
+ deps.controller.cut(deps.cursor);
390
+ }
391
+
392
+ function handleRejectHunk(_intent: IntentOfType<"rejectHunk">, deps: IntentDispatchDeps): void {
393
+ deps.controller.toggleRejectHunk(deps.cursor);
394
+ }
395
+
396
+ function handleRejectChange(_intent: IntentOfType<"rejectChange">, deps: IntentDispatchDeps): void {
397
+ deps.controller.toggleRejectChange(deps.cursor);
398
+ }
399
+
400
+ function handleFoldFile(_intent: IntentOfType<"foldFile">, deps: IntentDispatchDeps): void {
401
+ const file = deps.rows[deps.cursor]?.file;
402
+
403
+ if (!file || deps.controller.isFileCollapsed(file)) return;
404
+ deps.controller.setFileCollapsed(file, true);
405
+ // the file band is unchanged in index by its own collapse, so land the cursor on it
406
+ const headerIndex = deps.controller
407
+ .rows()
408
+ .findIndex((row) => row.kind === "file" && row.file === file);
409
+
410
+ if (headerIndex >= 0) deps.setCursor(headerIndex);
411
+ }
412
+
413
+ function handleUnfoldFile(_intent: IntentOfType<"unfoldFile">, deps: IntentDispatchDeps): void {
414
+ const file = deps.rows[deps.cursor]?.file;
415
+
416
+ if (!file || !deps.controller.isFileCollapsed(file)) return;
417
+ deps.controller.setFileCollapsed(file, false);
418
+ // drop the cursor onto the file's first code line now that its body is back
419
+ const firstCode = deps.controller.rows().findIndex((row) => row.file === file && isCodeRow(row));
420
+
421
+ if (firstCode >= 0) deps.setCursor(firstCode);
422
+ }
423
+
424
+ function handleToggleDiffView(
425
+ _intent: IntentOfType<"toggleDiffView">,
426
+ deps: IntentDispatchDeps,
427
+ ): void {
428
+ deps.toggleDiffView();
429
+ }
430
+
431
+ function handleRestoreCuration(
432
+ _intent: IntentOfType<"restoreCuration">,
433
+ deps: IntentDispatchDeps,
434
+ ): void {
435
+ // undo the selected curated-out item, or the last rejected when none is
436
+ // selected, so a bare `u` reads as "undo my last curation"
437
+ const items = deps.controller.curationItems();
438
+
439
+ if (!items.length) return;
440
+ const targetId = deps.selectedCurationId ?? items[items.length - 1]!.id;
441
+
442
+ deps.controller.restoreCuration(targetId);
443
+ deps.setSelectedCurationId(undefined);
444
+ }
445
+
446
+ function handleEdit(_intent: IntentOfType<"edit">, deps: IntentDispatchDeps): void {
447
+ deps.runEditorHandOff();
448
+ }
449
+
450
+ function handleEditCard(_intent: IntentOfType<"editCard">, deps: IntentDispatchDeps): void {
451
+ if (deps.focusedAnnotationId) deps.openCardEdit(deps.focusedAnnotationId);
452
+ }
453
+
454
+ function handleAnnotationCycle(
455
+ intent: IntentOfType<"nextAnnotation" | "prevAnnotation">,
456
+ deps: IntentDispatchDeps,
457
+ ): void {
458
+ // cycle open cards only - addressed ones are out of the rail
459
+ const annotations = (deps.session?.annotations ?? []).filter(
460
+ (annotation) => !isAddressed(annotation),
461
+ );
462
+
463
+ if (!annotations.length) return;
464
+ const focusedIndex = annotations.findIndex(
465
+ (annotation) => annotation.id === deps.focusedAnnotationId,
466
+ );
467
+ const nextIndex =
468
+ focusedIndex === -1
469
+ ? 0
470
+ : (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) %
471
+ annotations.length;
472
+
473
+ deps.selectCardFromDocument(annotations[nextIndex]!.id);
474
+ }
475
+
476
+ function handleWalkStart(_intent: IntentOfType<"walkStart">, deps: IntentDispatchDeps): void {
477
+ deps.controller.walkStart();
478
+ }
479
+
480
+ function handleWalkForward(_intent: IntentOfType<"walkForward">, deps: IntentDispatchDeps): void {
481
+ deps.controller.walkForward();
482
+ }
483
+
484
+ function handleWalkBack(_intent: IntentOfType<"walkBack">, deps: IntentDispatchDeps): void {
485
+ deps.controller.walkBack();
486
+ }
487
+
488
+ function handleWalkLeave(_intent: IntentOfType<"walkLeave">, deps: IntentDispatchDeps): void {
489
+ deps.controller.walkLeave();
490
+ }
491
+
492
+ function handleRemoveAnnotation(
493
+ _intent: IntentOfType<"removeAnnotation">,
494
+ deps: IntentDispatchDeps,
495
+ ): void {
496
+ if (deps.focusedAnnotationId) {
497
+ deps.controller.removeAnnotation(deps.focusedAnnotationId);
498
+ deps.setFocusedAnnotationId(undefined);
499
+ }
500
+ }
501
+
502
+ function handleDeselect(_intent: IntentOfType<"deselect">, deps: IntentDispatchDeps): void {
503
+ deps.setFocusedAnnotationId(undefined);
504
+ deps.setPulsedAnnotationId(null);
505
+ }
506
+
507
+ function handleCloseOverlay(_intent: IntentOfType<"closeOverlay">, deps: IntentDispatchDeps): void {
508
+ deps.setMode({ type: "normal" });
509
+ }
510
+
511
+ function handleSaveCompose(_intent: IntentOfType<"saveCompose">, deps: IntentDispatchDeps): void {
512
+ const { mode, session, controller } = deps;
513
+ const body = deps.liveInput.current.trim();
514
+
515
+ if (mode.type === "railEdit") {
516
+ if (session && body) controller.updateAnnotation(mode.id, body);
517
+ deps.setMode({ type: "normal" });
518
+
519
+ return;
520
+ }
521
+ if (mode.type !== "compose") return;
522
+ if (session && body) {
523
+ const annotationId = controller.annotate(
524
+ mode.kind,
525
+ mode.displayIndex,
526
+ mode.start,
527
+ mode.end,
528
+ body,
529
+ );
530
+
531
+ if (annotationId) deps.setFocusedAnnotationId(annotationId);
532
+ }
533
+ deps.setMode({ type: "normal" });
534
+ }
535
+
536
+ function handleSubmitVerdict(
537
+ _intent: IntentOfType<"submitVerdict">,
538
+ deps: IntentDispatchDeps,
539
+ ): void {
540
+ const { mode } = deps;
541
+
542
+ if (mode.type === "submit") deps.controller.submit(mode.verdict, deps.liveInput.current);
543
+ deps.setMode({ type: "normal" });
544
+ }
545
+
546
+ function handleCycleVerdict(intent: IntentOfType<"cycleVerdict">, deps: IntentDispatchDeps): void {
547
+ const { mode } = deps;
548
+
549
+ if (mode.type !== "submit") return;
550
+ const verdictIndex =
551
+ (VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
552
+
553
+ deps.setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
554
+ }
555
+
556
+ function handleFinishReview(_intent: IntentOfType<"finishReview">, deps: IntentDispatchDeps): void {
557
+ deps.controller.finishReview();
558
+ }
559
+
560
+ function handleOptInAutoClose(
561
+ _intent: IntentOfType<"optInAutoClose">,
562
+ deps: IntentDispatchDeps,
563
+ ): void {
564
+ deps.controller.optInAutoClose();
565
+ }
566
+
567
+ function handleDismissCompletion(
568
+ _intent: IntentOfType<"dismissCompletion">,
569
+ deps: IntentDispatchDeps,
570
+ ): void {
571
+ deps.controller.dismissCompletion();
572
+ }
573
+
574
+ function confirmTreePrompt(
575
+ mode: Extract<Mode, { type: "treePrompt" }>,
576
+ deps: IntentDispatchDeps,
577
+ ): void {
578
+ if (mode.ask === "branch") deps.controller.branch(mode.text);
579
+ else if (mode.ask === "label") deps.controller.labelTip(mode.text);
580
+ else if (mode.entryId !== undefined) deps.controller.goToEntry(mode.entryId, mode.text.trim());
581
+ }
582
+
583
+ function handleToggleTree(_intent: IntentOfType<"toggleTree">, deps: IntentDispatchDeps): void {
584
+ deps.setRailTab(deps.railTab === "tree" ? "review" : "tree");
585
+ }
586
+
587
+ /** The tree rows in drawing order; the selection falls back to the current tip. */
588
+ function selectedTreeIndex(deps: IntentDispatchDeps, rows: TreeRow[]): number {
589
+ const byId = rows.findIndex((row) => row.entryId === deps.selectedEntryId);
590
+
591
+ return byId === -1 ? rows.findIndex((row) => row.isCurrentTip) : byId;
592
+ }
593
+
594
+ function handleTreeMove(intent: IntentOfType<"treeMove">, deps: IntentDispatchDeps): void {
595
+ const rows = deps.controller.treeRows();
596
+
597
+ if (rows.length === 0) return;
598
+ const current = selectedTreeIndex(deps, rows);
599
+ const next = Math.min(rows.length - 1, Math.max(0, current + intent.direction));
600
+
601
+ deps.setSelectedEntryId(rows[next]!.entryId);
602
+ }
603
+
604
+ function handleTreeGo(_intent: IntentOfType<"treeGo">, deps: IntentDispatchDeps): void {
605
+ const rows = deps.controller.treeRows();
606
+ const row = rows[selectedTreeIndex(deps, rows)];
607
+
608
+ if (!row) return deps.controller.setStatus("select an entry in the tree first");
609
+ if (row.isCurrentTip) return deps.controller.setStatus("already at the tip");
610
+ // a switch needs no summary; a move back may leave one, so it asks
611
+ if (row.tips.length > 0) return deps.controller.goToEntry(row.entryId);
612
+ deps.setMode({ type: "treePrompt", ask: "navigate", entryId: row.entryId, text: "" });
613
+ }
614
+
615
+ function handleTreeBranch(_intent: IntentOfType<"treeBranch">, deps: IntentDispatchDeps): void {
616
+ deps.setMode({ type: "treePrompt", ask: "branch", text: "" });
617
+ }
618
+
619
+ function handleTreeLabel(_intent: IntentOfType<"treeLabel">, deps: IntentDispatchDeps): void {
620
+ deps.setMode({ type: "treePrompt", ask: "label", text: "" });
621
+ }
622
+
623
+ function handleTreeFork(_intent: IntentOfType<"treeFork">, deps: IntentDispatchDeps): void {
624
+ deps.controller.fork();
625
+ }
626
+
627
+ function handleTreeForkShare(
628
+ _intent: IntentOfType<"treeForkShare">,
629
+ deps: IntentDispatchDeps,
630
+ ): void {
631
+ deps.controller.forkAndShare();
632
+ }
633
+
634
+ type IntentHandlers = {
635
+ [Kind in Intent["type"]]: (intent: IntentOfType<Kind>, deps: IntentDispatchDeps) => void;
636
+ };
637
+
638
+ const intentHandlers: IntentHandlers = {
639
+ exit: handleExit,
640
+ status: handleStatus,
641
+ move: handleMove,
642
+ inboxMove: handleInboxMove,
643
+ openSession: handleOpenSession,
644
+ requestDeleteSession: handleRequestDeleteSession,
645
+ openRename: handleOpenRename,
646
+ confirmDialog: handleConfirmDialog,
647
+ startSpan: handleStartSpan,
648
+ spanKey: handleSpanKey,
649
+ spanCut: handleSpanCut,
650
+ openSpanActions: handleOpenSpanActions,
651
+ moveSpanAction: handleMoveSpanAction,
652
+ closeSpanActions: handleCloseSpanActions,
653
+ pickSpanAction: handlePickSpanAction,
654
+ openCompose: handleOpenCompose,
655
+ openSubmit: handleOpenSubmit,
656
+ share: handleShare,
657
+ cut: handleCut,
658
+ rejectHunk: handleRejectHunk,
659
+ rejectChange: handleRejectChange,
660
+ foldFile: handleFoldFile,
661
+ unfoldFile: handleUnfoldFile,
662
+ toggleDiffView: handleToggleDiffView,
663
+ restoreCuration: handleRestoreCuration,
664
+ edit: handleEdit,
665
+ editCard: handleEditCard,
666
+ nextAnnotation: handleAnnotationCycle,
667
+ prevAnnotation: handleAnnotationCycle,
668
+ walkStart: handleWalkStart,
669
+ walkForward: handleWalkForward,
670
+ walkBack: handleWalkBack,
671
+ walkLeave: handleWalkLeave,
672
+ removeAnnotation: handleRemoveAnnotation,
673
+ deselect: handleDeselect,
674
+ closeOverlay: handleCloseOverlay,
675
+ saveCompose: handleSaveCompose,
676
+ submitVerdict: handleSubmitVerdict,
677
+ cycleVerdict: handleCycleVerdict,
678
+ finishReview: handleFinishReview,
679
+ optInAutoClose: handleOptInAutoClose,
680
+ dismissCompletion: handleDismissCompletion,
681
+ toggleTree: handleToggleTree,
682
+ treeMove: handleTreeMove,
683
+ treeGo: handleTreeGo,
684
+ treeBranch: handleTreeBranch,
685
+ treeLabel: handleTreeLabel,
686
+ treeFork: handleTreeFork,
687
+ treeForkShare: handleTreeForkShare,
688
+ };
689
+
690
+ function dispatchIntent<Kind extends Intent["type"]>(
691
+ intent: IntentOfType<Kind>,
692
+ deps: IntentDispatchDeps,
693
+ ): void {
694
+ intentHandlers[intent.type](intent, deps);
695
+ }
696
+
697
+ /** Build the intent handler for one render from its dependency bag. */
698
+ export function createIntentDispatch(deps: IntentDispatchDeps): (intent: Intent) => void {
699
+ return (intent: Intent): void => dispatchIntent(intent, deps);
700
+ }