@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90

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 (316) 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 +42 -34
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +77 -51
  10. package/src/App.diff.test.tsx +259 -45
  11. package/src/App.export.test.tsx +58 -41
  12. package/src/App.focus.test.ts +89 -0
  13. package/src/App.identity.test.tsx +78 -0
  14. package/src/App.plan-render.test.tsx +209 -0
  15. package/src/App.plan-review.test.tsx +330 -161
  16. package/src/App.prototype-markdown.test.tsx +128 -0
  17. package/src/App.prototype.test.tsx +176 -0
  18. package/src/App.readonly.test.tsx +129 -67
  19. package/src/App.regression.test.tsx +94 -0
  20. package/src/App.rendering.test.tsx +61 -35
  21. package/src/App.share-dialog.test.tsx +172 -0
  22. package/src/App.submit-confirm.test.tsx +264 -0
  23. package/src/App.test.tsx +402 -126
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1402 -1060
  26. package/src/App.visual.test.tsx +57 -0
  27. package/src/App.walk.test.tsx +299 -0
  28. package/src/App.workbench.test.tsx +774 -0
  29. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  30. package/src/annotation-palette.ts +62 -0
  31. package/src/ansi-screen.test.ts +28 -0
  32. package/src/ansi-screen.ts +72 -0
  33. package/src/app-key-state.ts +61 -0
  34. package/src/app-screens.tsx +577 -0
  35. package/src/app-view-model.ts +168 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/client-extension-registry.test.tsx +61 -0
  39. package/src/client-extension-registry.ts +159 -0
  40. package/src/clipboard.test.ts +23 -0
  41. package/src/clipboard.ts +33 -0
  42. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  43. package/src/components/AnnotatableFileView.test.tsx +102 -0
  44. package/src/components/AnnotatableFileView.tsx +134 -0
  45. package/src/components/AnnotationCards.stories.tsx +86 -0
  46. package/src/components/AnnotationCards.tsx +341 -0
  47. package/src/components/AnsiScreen.stories.tsx +12 -0
  48. package/src/components/AnsiScreen.tsx +28 -0
  49. package/src/components/AppHeader.stories.tsx +50 -0
  50. package/src/components/AppHeader.tsx +83 -0
  51. package/src/components/AppShell.brand.test.tsx +58 -0
  52. package/src/components/AppShell.extensions.test.tsx +191 -0
  53. package/src/components/AppShell.stories.tsx +89 -0
  54. package/src/components/AppShell.tsx +362 -0
  55. package/src/components/AppShell.zoom.test.tsx +135 -0
  56. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  57. package/src/components/BareWorkbenchFileView.tsx +64 -0
  58. package/src/components/Breadcrumb.stories.tsx +19 -0
  59. package/src/components/Breadcrumb.tsx +45 -0
  60. package/src/components/ChangesColumn.stories.tsx +44 -0
  61. package/src/components/ChangesColumn.tsx +199 -0
  62. package/src/components/ChangesFileTree.test.tsx +61 -0
  63. package/src/components/CodeBlock.stories.tsx +42 -0
  64. package/src/components/CodeBlock.tsx +84 -0
  65. package/src/components/CompletionOverlay.stories.tsx +37 -0
  66. package/src/components/CompletionOverlay.tsx +77 -0
  67. package/src/components/ConfirmCard.stories.tsx +37 -0
  68. package/src/components/ConfirmCard.tsx +150 -0
  69. package/src/components/ConfirmDialog.stories.tsx +21 -0
  70. package/src/components/ConfirmDialog.tsx +72 -0
  71. package/src/components/DiffContentView.scroll.test.tsx +149 -0
  72. package/src/components/DiffContentView.split.test.tsx +140 -0
  73. package/src/components/DiffContentView.stories.tsx +80 -0
  74. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  75. package/src/components/DiffContentView.tsx +906 -0
  76. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  77. package/src/components/DiscussionMarkerRail.tsx +186 -0
  78. package/src/components/EditorGrid.stories.tsx +59 -0
  79. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  80. package/src/components/EditorGrid.tsx +426 -0
  81. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  82. package/src/components/ExtensionZone.stories.tsx +28 -0
  83. package/src/components/ExtensionZone.tsx +145 -0
  84. package/src/components/FileContentsView.stories.tsx +16 -0
  85. package/src/components/FileContentsView.test.tsx +29 -0
  86. package/src/components/FileContentsView.tsx +98 -0
  87. package/src/components/GridTabContent.stories.tsx +59 -0
  88. package/src/components/GridTabContent.tsx +182 -0
  89. package/src/components/HandleChipsInput.stories.tsx +24 -0
  90. package/src/components/HandleChipsInput.test.tsx +35 -0
  91. package/src/components/HandleChipsInput.tsx +145 -0
  92. package/src/components/KeybindsDialog.stories.tsx +31 -0
  93. package/src/components/KeybindsDialog.tsx +103 -0
  94. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  95. package/src/components/MarkdownGridBlock.tsx +72 -0
  96. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  97. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  98. package/src/components/MarkdownThreadEditor.tsx +162 -0
  99. package/src/components/MenuBar.stories.tsx +28 -0
  100. package/src/components/MenuBar.tsx +44 -0
  101. package/src/components/NavModeHint.stories.tsx +40 -0
  102. package/src/components/NavModeHint.tsx +31 -0
  103. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  104. package/src/components/OverlayScrollbar.test.tsx +139 -0
  105. package/src/components/OverlayScrollbar.tsx +129 -0
  106. package/src/components/PaneGrid.stories.tsx +84 -0
  107. package/src/components/PaneGrid.tsx +128 -0
  108. package/src/components/PanelColumn.stories.tsx +25 -0
  109. package/src/components/PanelColumn.tsx +98 -0
  110. package/src/components/ProjectTreeView.stories.tsx +25 -0
  111. package/src/components/ProjectTreeView.test.tsx +75 -0
  112. package/src/components/ProjectTreeView.tsx +125 -0
  113. package/src/components/PromptDialog.stories.tsx +36 -0
  114. package/src/components/PromptDialog.test.tsx +61 -0
  115. package/src/components/PromptDialog.tsx +99 -0
  116. package/src/components/PrototypeContentView.stories.tsx +19 -0
  117. package/src/components/PrototypeContentView.tsx +446 -0
  118. package/src/components/RootOverlay.stories.tsx +45 -0
  119. package/src/components/RootOverlay.tsx +48 -0
  120. package/src/components/ScrollArea.stories.tsx +20 -0
  121. package/src/components/ScrollArea.tsx +44 -0
  122. package/src/components/SettingsDialog.stories.tsx +117 -0
  123. package/src/components/SettingsDialog.tsx +186 -0
  124. package/src/components/SettingsRows.stories.tsx +18 -0
  125. package/src/components/SettingsRows.tsx +112 -0
  126. package/src/components/ShareDialog.stories.tsx +121 -0
  127. package/src/components/ShareDialog.tsx +552 -0
  128. package/src/components/ShellHeader.stories.tsx +50 -0
  129. package/src/components/ShellHeader.tsx +137 -0
  130. package/src/components/SlashComposer.stories.tsx +37 -0
  131. package/src/components/SlashComposer.tsx +87 -0
  132. package/src/components/ThreadFooter.stories.tsx +30 -0
  133. package/src/components/ThreadFooter.test.tsx +47 -0
  134. package/src/components/ThreadFooter.tsx +76 -0
  135. package/src/components/ThreadTree.stories.tsx +46 -0
  136. package/src/components/ThreadTree.tsx +255 -0
  137. package/src/components/ThreadView.selection.test.tsx +951 -0
  138. package/src/components/ThreadView.slash.test.tsx +146 -0
  139. package/src/components/ThreadView.stories.tsx +26 -0
  140. package/src/components/ThreadView.tsx +525 -0
  141. package/src/components/Toast.stories.tsx +19 -0
  142. package/src/components/Toast.tsx +52 -0
  143. package/src/components/Tooltip.stories.tsx +26 -0
  144. package/src/components/Tooltip.tsx +72 -0
  145. package/src/components/TreePane.stories.tsx +106 -0
  146. package/src/components/TreePane.tsx +157 -0
  147. package/src/components/WalkWizard.stories.tsx +100 -0
  148. package/src/components/WalkWizard.tsx +150 -0
  149. package/src/components/WelcomePlayground.stories.tsx +12 -0
  150. package/src/components/WelcomePlayground.test.tsx +49 -0
  151. package/src/components/WelcomePlayground.tsx +139 -0
  152. package/src/components/WorkspacePanels.stories.tsx +36 -0
  153. package/src/components/WorkspacePanels.tsx +116 -0
  154. package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
  155. package/src/components/diff-content-view-layout.test.ts +198 -0
  156. package/src/components/diff-content-view-layout.ts +84 -0
  157. package/src/components/editor-grid.test.ts +86 -0
  158. package/src/components/editor-grid.ts +138 -0
  159. package/src/components/file-tree.test.ts +60 -0
  160. package/src/components/file-tree.ts +112 -0
  161. package/src/components/menu-control.stories.tsx +35 -0
  162. package/src/components/menu-control.tsx +43 -0
  163. package/src/components/pages/Workspace.stories.tsx +181 -0
  164. package/src/components/primitives/Button.stories.tsx +32 -0
  165. package/src/components/primitives/Button.tsx +59 -0
  166. package/src/components/primitives/Card.stories.tsx +25 -0
  167. package/src/components/primitives/Card.tsx +63 -0
  168. package/src/components/primitives/Dialog.stories.tsx +24 -0
  169. package/src/components/primitives/Dialog.test.tsx +37 -0
  170. package/src/components/primitives/Dialog.tsx +71 -0
  171. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  172. package/src/components/primitives/DialogActions.tsx +39 -0
  173. package/src/components/primitives/IconButton.stories.tsx +36 -0
  174. package/src/components/primitives/IconButton.tsx +62 -0
  175. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  176. package/src/components/primitives/StatusBar.tsx +23 -0
  177. package/src/components/primitives/Switch.stories.tsx +17 -0
  178. package/src/components/primitives/Switch.tsx +32 -0
  179. package/src/components/primitives/Tabs.stories.tsx +29 -0
  180. package/src/components/primitives/Tabs.tsx +102 -0
  181. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  182. package/src/components/primitives/Toolbar.tsx +14 -0
  183. package/src/components/primitives/Tree.stories.tsx +124 -0
  184. package/src/components/primitives/Tree.tsx +100 -0
  185. package/src/components/primitives/frame.test.ts +12 -0
  186. package/src/components/primitives/frame.ts +17 -0
  187. package/src/components/primitives/icons.ts +61 -0
  188. package/src/components/primitives/tree-model.test.ts +99 -0
  189. package/src/components/primitives/tree-model.ts +137 -0
  190. package/src/components/quick-actions-editor.stories.tsx +58 -0
  191. package/src/components/quick-actions-editor.test.tsx +117 -0
  192. package/src/components/quick-actions-editor.tsx +226 -0
  193. package/src/components/share-dialog-store.ts +58 -0
  194. package/src/components/stories-app.tsx +193 -0
  195. package/src/components/stories.test.tsx +74 -0
  196. package/src/components/story-fixtures.ts +102 -0
  197. package/src/components/story-tree.test.ts +108 -0
  198. package/src/components/story.ts +159 -0
  199. package/src/components/syntax-highlight.ts +133 -0
  200. package/src/components/terminal-pane.test.tsx +40 -0
  201. package/src/components/terminal-pane.ts +199 -0
  202. package/src/components/theme-context.stories.tsx +46 -0
  203. package/src/components/theme-context.tsx +37 -0
  204. package/src/components/thread-tree.test.ts +134 -0
  205. package/src/components/thread-tree.ts +109 -0
  206. package/src/components/truncate-text.ts +7 -0
  207. package/src/components/truncate-title.test.ts +31 -0
  208. package/src/components/truncate-title.ts +15 -0
  209. package/src/config-identity.test.ts +78 -0
  210. package/src/config.test.ts +579 -34
  211. package/src/config.ts +533 -57
  212. package/src/diff-fold.test.ts +78 -0
  213. package/src/diff-fold.ts +126 -0
  214. package/src/diff-hunk-curate.test.ts +315 -0
  215. package/src/diff-hunk-curate.ts +153 -0
  216. package/src/diff-intraline.test.ts +238 -0
  217. package/src/diff-intraline.ts +236 -0
  218. package/src/diff-syntax.test.ts +112 -0
  219. package/src/diff-syntax.ts +179 -0
  220. package/src/discussions.ts +79 -0
  221. package/src/editor.test.ts +149 -0
  222. package/src/editor.ts +139 -9
  223. package/src/ghostty-terminal.test.ts +85 -0
  224. package/src/ghostty-terminal.ts +199 -0
  225. package/src/github-identity.test.ts +58 -0
  226. package/src/github-identity.ts +41 -0
  227. package/src/herdr.test.ts +9 -34
  228. package/src/herdr.ts +25 -21
  229. package/src/index.ts +28 -0
  230. package/src/integrations.test.ts +41 -0
  231. package/src/integrations.ts +32 -0
  232. package/src/intent-dispatch.test.ts +724 -0
  233. package/src/intent-dispatch.ts +690 -0
  234. package/src/key-bindings.test.ts +156 -0
  235. package/src/key-bindings.ts +459 -0
  236. package/src/keymap.test.ts +456 -115
  237. package/src/keymap.ts +315 -81
  238. package/src/kitty-image.test.ts +66 -0
  239. package/src/kitty-image.ts +138 -0
  240. package/src/launch-layout.test.ts +67 -0
  241. package/src/launch-layout.ts +47 -0
  242. package/src/mark-runs.ts +121 -0
  243. package/src/markdown-editor-syntax-style.ts +50 -0
  244. package/src/markdown-grid.test.ts +101 -0
  245. package/src/markdown-grid.ts +203 -0
  246. package/src/markdown-highlight.test.ts +61 -0
  247. package/src/markdown-highlight.ts +122 -0
  248. package/src/pasted-image.test.ts +67 -0
  249. package/src/pasted-image.ts +60 -0
  250. package/src/perf/otlp-trace.test.ts +116 -0
  251. package/src/perf/otlp-trace.ts +130 -0
  252. package/src/perf/perf-report.ts +17 -0
  253. package/src/perf/perf-timings.test.ts +18 -0
  254. package/src/perf/perf-timings.ts +38 -0
  255. package/src/prototype-browser.test.ts +81 -0
  256. package/src/prototype-browser.ts +329 -0
  257. package/src/prototype-pixels.tsx +35 -0
  258. package/src/pty.test.ts +81 -0
  259. package/src/pty.ts +245 -0
  260. package/src/ready-signal.test.ts +85 -0
  261. package/src/ready-signal.ts +52 -0
  262. package/src/run.ts +85 -12
  263. package/src/serve.test.ts +24 -12
  264. package/src/serve.ts +52 -10
  265. package/src/share.ts +237 -0
  266. package/src/skills.test.ts +74 -0
  267. package/src/skills.ts +75 -0
  268. package/src/slash-palette.test.ts +103 -0
  269. package/src/slash-palette.ts +129 -0
  270. package/src/split-diff.test.ts +110 -0
  271. package/src/split-diff.ts +149 -0
  272. package/src/test-support.test.ts +264 -0
  273. package/src/test-support.ts +369 -0
  274. package/src/theme-presets.test.ts +51 -0
  275. package/src/theme-presets.ts +219 -0
  276. package/src/theme.ts +88 -19
  277. package/src/thread-chords.test.ts +79 -0
  278. package/src/thread-chords.ts +163 -0
  279. package/src/thread-code-syntax.ts +45 -0
  280. package/src/thread-controller.curate.test.ts +476 -0
  281. package/src/thread-controller.derivation.test.ts +62 -0
  282. package/src/thread-controller.live-diff.test.ts +322 -0
  283. package/src/thread-controller.optimistic.test.ts +69 -0
  284. package/src/thread-controller.share.test.ts +561 -0
  285. package/src/thread-controller.switch-cache.test.ts +176 -0
  286. package/src/thread-controller.tree.test.ts +261 -0
  287. package/src/thread-controller.ts +2050 -0
  288. package/src/thread-selection.test.ts +210 -0
  289. package/src/thread-selection.ts +243 -0
  290. package/src/tree-view.test.ts +128 -0
  291. package/src/tree-view.ts +153 -0
  292. package/src/use-annotation-surface.tsx +975 -0
  293. package/src/use-changes-workbench.ts +190 -0
  294. package/src/use-frame-measure.ts +41 -0
  295. package/src/use-remember-layout.ts +31 -0
  296. package/src/use-repo-changes.ts +87 -0
  297. package/src/use-settings-dialog.tsx +364 -0
  298. package/src/use-terminal-virtualizer.ts +259 -0
  299. package/src/use-thread-body-editing.tsx +58 -0
  300. package/src/version.ts +5 -0
  301. package/src/view-diff.test.ts +279 -9
  302. package/src/view-diff.ts +460 -24
  303. package/src/view-plan.regression.test.ts +60 -0
  304. package/src/view-plan.test.ts +545 -0
  305. package/src/view-plan.ts +1019 -0
  306. package/src/visual/contrast.test.ts +35 -0
  307. package/src/visual/contrast.ts +33 -0
  308. package/src/walk.test.ts +121 -0
  309. package/src/walk.ts +74 -0
  310. package/src/workbench-snapshot.test.ts +99 -0
  311. package/src/workbench-snapshot.ts +59 -0
  312. package/src/session-controller.ts +0 -398
  313. package/src/syntax.test.ts +0 -41
  314. package/src/syntax.ts +0 -88
  315. package/src/view.test.ts +0 -157
  316. package/src/view.ts +0 -364
package/src/keymap.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The keyboard grammar as a pure reducer (#70): view state in, intents out.
2
+ * The keyboard grammar as a pure reducer: view state in, intents out.
3
3
  * App builds a KeyState per key event, calls reduceKey, and dispatches the
4
- * intents to controller verbs and view-state setters - no branch bodies live
4
+ * intents to controller primitives and view-state setters - no branch bodies live
5
5
  * in the key handler. Diff and plan reviews share one path for annotation
6
6
  * navigation, deletion, and submit; the read-only rule is one gate here.
7
7
  */
@@ -9,44 +9,92 @@
9
9
  import { actionFor } from "./config";
10
10
 
11
11
  export type Intent =
12
- | { t: "exit" }
13
- | { t: "status"; msg: string }
14
- | { t: "move"; to: "down" | "up" | "top" | "bottom" }
15
- | { t: "inboxMove"; to: "down" | "up" }
16
- | { t: "openSession" }
17
- | { t: "startSpan" }
18
- | { t: "spanKey"; name: string }
19
- | { t: "openCompose"; kind: "comment" | "suggestion"; from: "cursor" | "span" }
20
- | { t: "openSubmit" }
21
- | { t: "cut" }
22
- | { t: "edit" }
23
- | { t: "editCard" }
24
- | { t: "nextAnn" }
25
- | { t: "prevAnn" }
26
- | { t: "removeAnnotation" }
27
- | { t: "deselect" }
28
- | { t: "closeOverlay" }
29
- | { t: "saveCompose" }
30
- | { t: "submitVerdict" }
31
- | { t: "cycleVerdict"; dir: -1 | 1 }
32
- | { t: "finishReview" }
33
- | { t: "optInAutoClose" }
34
- | { t: "dismissCompletion" };
12
+ | { type: "exit" }
13
+ | { type: "status"; message: string }
14
+ | { type: "move"; to: "down" | "up" | "top" | "bottom" }
15
+ | { type: "inboxMove"; to: "down" | "up" }
16
+ | { type: "openSession" }
17
+ | { type: "requestDeleteSession" }
18
+ | { type: "openRename" }
19
+ | { type: "confirmDialog" }
20
+ | { type: "startSpan" }
21
+ | { type: "spanKey"; name: string }
22
+ | { type: "spanCut" }
23
+ | { type: "openSpanActions" }
24
+ | { type: "moveSpanAction"; direction: -1 | 1 }
25
+ | { type: "pickSpanAction"; index?: number }
26
+ | { type: "closeSpanActions" }
27
+ | { type: "openCompose"; kind: "comment"; from: "cursor" | "span" }
28
+ | { type: "openSubmit" }
29
+ | { type: "share" }
30
+ | { type: "cut" }
31
+ | { type: "edit" }
32
+ | { type: "editCard" }
33
+ | { type: "rejectHunk" }
34
+ | { type: "rejectChange" }
35
+ | { type: "restoreCuration" }
36
+ | { type: "foldFile" }
37
+ | { type: "unfoldFile" }
38
+ | { type: "toggleDiffView" }
39
+ | { type: "nextAnnotation" }
40
+ | { type: "prevAnnotation" }
41
+ | { type: "walkStart" }
42
+ | { type: "walkForward" }
43
+ | { type: "walkBack" }
44
+ | { type: "walkLeave" }
45
+ | { type: "removeAnnotation" }
46
+ | { type: "deselect" }
47
+ | { type: "closeOverlay" }
48
+ | { type: "saveCompose" }
49
+ | { type: "submitMessage" }
50
+ | { type: "cycleMessage"; direction: -1 | 1 }
51
+ | { type: "finishReview" }
52
+ | { type: "optInAutoClose" }
53
+ | { type: "dismissCompletion" }
54
+ | { type: "toggleTree" }
55
+ | { type: "treeMove"; direction: -1 | 1 }
56
+ | { type: "treeGo" }
57
+ | { type: "treeBranch" }
58
+ | { type: "treeLabel" }
59
+ | { type: "treeFork" }
60
+ | { type: "treeForkShare" };
35
61
 
36
62
  export interface KeyInput {
37
63
  name: string;
38
64
  shift: boolean;
65
+ /** Option/Alt arrives as `meta` in this terminal stack (never as a raw alt). */
66
+ meta?: boolean;
39
67
  }
40
68
 
41
69
  export interface KeyState {
42
70
  /** Loaded keymap (config.ts): action -> key combos. */
43
71
  keys: Record<string, string[]>;
44
72
  readOnly: boolean;
73
+ /**
74
+ * Owner-only primitives a share collaborator lacks (undefined = owner, allowed).
75
+ * A collaborator annotates but cannot edit the plan (cut / $EDITOR runs on
76
+ * the gateway) or submit an agent message (there is no agent on a share).
77
+ */
78
+ canEditPlan?: boolean;
79
+ canSubmitMessage?: boolean;
80
+ /** Owner-only: publish the plan as a share. A collaborator never re-shares. */
81
+ canShare?: boolean;
45
82
  /** Layer that owns keys before the grammar runs. */
46
- overlay: "none" | "compose" | "submit" | "completion-prompt" | "completion-counting";
83
+ overlay:
84
+ | "none"
85
+ | "walk"
86
+ | "compose"
87
+ | "submit"
88
+ | "confirm"
89
+ | "prompt"
90
+ | "spanActions"
91
+ | "completion-prompt"
92
+ | "completion-counting";
47
93
  view: "inbox" | "plan" | "diff";
48
94
  /** Plan-only span selection sub-mode. */
49
95
  spanMode: boolean;
96
+ /** The walk cursor sits on the end card - return offers the submit action. */
97
+ walkAtEnd: boolean;
50
98
  resolved: boolean;
51
99
  hasInboxItems: boolean;
52
100
  annotationCount: number;
@@ -55,106 +103,278 @@ export interface KeyState {
55
103
  cursorAnnotatable: boolean;
56
104
  }
57
105
 
58
- /** Verbs that write session state; an observer never reaches their handlers. */
59
- const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit"]);
106
+ /** Primitives that write session state; an observer never reaches their handlers. */
107
+ const MUTATING_ACTIONS = new Set([
108
+ "comment",
109
+ "cut",
110
+ "edit",
111
+ "delete_annotation",
112
+ "submit",
113
+ "walk",
114
+ "share",
115
+ "reject_hunk",
116
+ "restore_curation",
117
+ ]);
60
118
 
61
- const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
119
+ const SPAN_KEYS = new Set(["l", "h", "w", "b", "$", "0"]);
62
120
 
63
- function status(msg: string): Intent[] {
64
- return [{ t: "status", msg }];
121
+ function status(message: string): Intent[] {
122
+ return [{ type: "status", message }];
65
123
  }
66
124
 
67
- export function reduceKey(state: KeyState, key: KeyInput): Intent[] {
125
+ /**
126
+ * Reduce one key event to intents. `resolvedAction` is the binding-layer
127
+ * resolution (key-bindings.ts over @opentui/keymap) when the caller has one;
128
+ * without it the reducer falls back to the plain reverse lookup, so the
129
+ * grammar stays testable as a pure function.
130
+ */
131
+ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: string): Intent[] {
68
132
  const name = key.name;
69
- // compose/submit overlays own the keys via focused inputs; only escape,
70
- // return, and verdict arrows route through the grammar
71
- if (state.overlay === "compose" || state.overlay === "submit") {
72
- if (name === "escape") return [{ t: "closeOverlay" }];
73
- if (name === "return" || name === "enter") {
74
- return [state.overlay === "compose" ? { t: "saveCompose" } : { t: "submitVerdict" }];
75
- }
76
- if (state.overlay === "submit" && (name === "left" || name === "right")) {
77
- return [{ t: "cycleVerdict", dir: name === "left" ? -1 : 1 }];
78
- }
79
- return [];
80
- }
81
- if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
82
- if (name === "return" || name === "enter" || name === "q") return [{ t: "finishReview" }];
83
- if (name === "a" && state.overlay === "completion-prompt") return [{ t: "optInAutoClose" }];
84
- if (name === "escape") return [{ t: "dismissCompletion" }];
85
- return [];
86
- }
87
- const action = actionFor(state.keys, name, key.shift);
88
- if (action === "quit") return [{ t: "exit" }];
133
+ const overlayGrammar = overlayGrammars[state.overlay];
134
+
135
+ if (overlayGrammar) return overlayGrammar(state, key);
136
+ const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
137
+
138
+ if (action === "quit") return [{ type: "exit" }];
89
139
  // the ONE read-only rule: any mutating attempt answers instead of acting
90
- // (span-mode c/s are hardwired keys, so they gate by name as well)
91
- const mutating = MUTATING_ACTIONS.has(action ?? "") || (state.spanMode && (name === "c" || name === "s"));
140
+ // (span-mode c, x, and a are hardwired keys, so they gate by name as well)
141
+ const mutating =
142
+ MUTATING_ACTIONS.has(action ?? "") ||
143
+ (state.spanMode && (name === "c" || name === "x" || name === "a"));
144
+
92
145
  if (state.readOnly && mutating) return status("observer - read-only");
93
146
 
147
+ // share is a session-level primitive: it works from any view, owner only
148
+ if (action === "share") {
149
+ if (state.canShare === false) return status("only the plan owner can share");
150
+
151
+ return [{ type: "share" }];
152
+ }
153
+
94
154
  if (state.view === "inbox") return inboxGrammar(state, name);
155
+ // span mode owns its single-letter keys (b slides the span back)
156
+ if (state.spanMode) return spanGrammar(state, name);
95
157
  if (state.view === "diff") return diffGrammar(state, action);
96
- if (state.spanMode) return spanGrammar(name);
158
+
97
159
  return planGrammar(state, action, name);
98
160
  }
99
161
 
162
+ type OverlayGrammar = (state: KeyState, key: KeyInput) => Intent[];
163
+
164
+ const overlayGrammars: Partial<Record<KeyState["overlay"], OverlayGrammar>> = {
165
+ compose: composeOverlayGrammar,
166
+ submit: submitOverlayGrammar,
167
+ confirm: confirmOverlayGrammar,
168
+ prompt: promptOverlayGrammar,
169
+ spanActions: spanActionsOverlayGrammar,
170
+ "completion-prompt": completionOverlayGrammar,
171
+ "completion-counting": completionOverlayGrammar,
172
+ walk: walkOverlayGrammar,
173
+ };
174
+
175
+ function composeOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
176
+ const name = key.name;
177
+
178
+ if (name === "escape") return [{ type: "closeOverlay" }];
179
+ if (name === "return" || name === "enter") {
180
+ // In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
181
+ // focused textarea owns that; only a bare ⏎ saves. The submit overlay
182
+ // keeps its plain ⏎ submit.
183
+ if (key.shift || key.meta) return [];
184
+
185
+ return [{ type: "saveCompose" }];
186
+ }
187
+
188
+ return [];
189
+ }
190
+
191
+ function submitOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
192
+ const name = key.name;
193
+
194
+ if (name === "escape") return [{ type: "closeOverlay" }];
195
+ if (name === "left" || name === "right") {
196
+ return [{ type: "cycleMessage", direction: name === "left" ? -1 : 1 }];
197
+ }
198
+
199
+ return [];
200
+ }
201
+
202
+ function confirmOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
203
+ const name = key.name;
204
+
205
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
206
+ if (name === "escape") return [{ type: "closeOverlay" }];
207
+
208
+ return [];
209
+ }
210
+
211
+ function promptOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
212
+ const name = key.name;
213
+
214
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
215
+ if (name === "escape") return [{ type: "closeOverlay" }];
216
+
217
+ return [];
218
+ }
219
+
220
+ function spanActionsOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
221
+ const name = key.name;
222
+
223
+ if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
224
+ if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
225
+ if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
226
+ if (name === "escape") return [{ type: "closeSpanActions" }];
227
+
228
+ return [];
229
+ }
230
+
231
+ function completionOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
232
+ const name = key.name;
233
+
234
+ if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
235
+ if (name === "a") return [{ type: "optInAutoClose" }];
236
+ if (name === "escape") return [{ type: "dismissCompletion" }];
237
+
238
+ return [];
239
+ }
240
+
241
+ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
242
+ const name = key.name;
243
+
244
+ if (name === "]") return [{ type: "walkForward" }];
245
+ if (name === "[") return [{ type: "walkBack" }];
246
+ if (name === "escape") return [{ type: "walkLeave" }];
247
+ if ((name === "return" || name === "enter") && state.walkAtEnd) {
248
+ return [{ type: "walkLeave" }, { type: "openSubmit" }];
249
+ }
250
+ if (name === "q") return [{ type: "exit" }];
251
+
252
+ return [];
253
+ }
254
+
100
255
  function inboxGrammar(state: KeyState, name: string): Intent[] {
101
256
  if (!state.hasInboxItems) return [];
102
- if (name === "j" || name === "down") return [{ t: "inboxMove", to: "down" }];
103
- if (name === "k" || name === "up") return [{ t: "inboxMove", to: "up" }];
104
- if (name === "return" || name === "enter") return [{ t: "openSession" }];
257
+ if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
258
+ if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
259
+ if (name === "return" || name === "enter") return [{ type: "openSession" }];
260
+ if (name === "d") return [{ type: "requestDeleteSession" }];
261
+
105
262
  return [];
106
263
  }
107
264
 
108
265
  function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
109
- const nav = navIntent(action);
110
- if (nav) return nav;
266
+ const navigation = navigationIntent(action);
267
+
268
+ if (navigation) return navigation;
269
+ if (action === "walk") {
270
+ // marking viewed writes the session record, so a resolved review answers
271
+ if (state.resolved) return status("review submitted - read-only");
272
+
273
+ return [{ type: "walkStart" }];
274
+ }
111
275
  if (action === "comment") {
112
276
  if (state.resolved) return status("review submitted - read-only");
113
277
  if (!state.cursorAnnotatable) return status("move to a code line to comment");
114
- return [{ t: "openCompose", kind: "comment", from: "cursor" }];
278
+
279
+ return [{ type: "openCompose", kind: "comment", from: "cursor" }];
280
+ }
281
+ // cut rejects the change under the cursor, reject_hunk the whole hunk; both
282
+ // write the working copy, so they gate on owner like a plan edit.
283
+ if (action === "cut" || action === "reject_hunk") {
284
+ if (state.resolved) return status("review submitted - read-only");
285
+ if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
286
+
287
+ return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
288
+ }
289
+ const viewToggle = viewToggleIntent(action);
290
+
291
+ if (viewToggle) return viewToggle;
292
+ // restore un-does a curated-out rejection from the rail; same owner gate as reject
293
+ if (action === "restore_curation") {
294
+ if (state.resolved) return status("review submitted - read-only");
295
+ if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
296
+
297
+ return [{ type: "restoreCuration" }];
115
298
  }
116
299
  const shared = annotationCluster(state, action);
300
+
117
301
  if (shared) return shared;
118
- if (action === "span" || action === "cut" || action === "edit" || action === "suggest") {
119
- return status("plan-only verb - diff review uses c on a line");
302
+ if (action === "span" || action === "edit") {
303
+ return status("plan-only primitive - diff review uses c on a line");
120
304
  }
305
+
121
306
  return [];
122
307
  }
123
308
 
124
- function spanGrammar(name: string): Intent[] {
125
- if (name === "escape") return [{ t: "closeOverlay" }];
126
- if (SPAN_KEYS.includes(name)) return [{ t: "spanKey", name }];
127
- if (name === "c" || name === "s") {
128
- return [{ t: "openCompose", kind: name === "s" ? "suggestion" : "comment", from: "span" }];
309
+ function spanGrammar(state: KeyState, name: string): Intent[] {
310
+ if (name === "escape") return [{ type: "closeOverlay" }];
311
+ if (SPAN_KEYS.has(name)) return [{ type: "spanKey", name }];
312
+ // c comments, x cuts, a opens quick-actions - all mutate, so a resolved
313
+ // review is read-only, like the plan grammar
314
+ if (name === "c" || name === "x" || name === "a") {
315
+ if (state.resolved) return status("review submitted - read-only");
316
+ if (name === "c") return [{ type: "openCompose", kind: "comment", from: "span" }];
317
+ if (name === "a") return [{ type: "openSpanActions" }];
318
+ if (state.canEditPlan === false) return [];
319
+
320
+ return [{ type: "spanCut" }];
129
321
  }
322
+
130
323
  return [];
131
324
  }
132
325
 
133
326
  function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
134
- const nav = navIntent(action);
135
- if (nav) return nav;
136
- if (name === "escape") return [{ t: "deselect" }];
137
- if (action === "span") return state.cursorAnnotatable ? [{ t: "startSpan" }] : [];
138
- if (action === "comment" || action === "suggest") {
327
+ const navigation = navigationIntent(action);
328
+
329
+ if (navigation) return navigation;
330
+ if (action === "walk") return status("the guided walk is a diff-review mode");
331
+ if (name === "escape") return [{ type: "deselect" }];
332
+ if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
333
+ if (action === "comment") {
139
334
  if (state.resolved) return status("review submitted - read-only");
140
335
  if (!state.cursorAnnotatable) return status("text is cut - restore it first");
141
- return [{ t: "openCompose", kind: action === "suggest" ? "suggestion" : "comment", from: "cursor" }];
336
+
337
+ return [{ type: "openCompose", kind: "comment", from: "cursor" }];
338
+ }
339
+ // restore un-does a rail removal (a cut block); a plan edit, so owner-only
340
+ if (action === "restore_curation") {
341
+ if (state.resolved) return status("review submitted - read-only");
342
+ if (state.canEditPlan === false) return [];
343
+
344
+ return [{ type: "restoreCuration" }];
142
345
  }
143
346
  if (action === "cut" || action === "edit") {
144
347
  if (state.resolved) return status("review submitted - read-only");
145
348
  // the document selects, the rail edits: with a card selected, Cut deletes
146
349
  // the annotation and edit rewrites the card body in place
147
- if (state.hasFocusedAnnotation) return [action === "cut" ? { t: "removeAnnotation" } : { t: "editCard" }];
148
- return [{ t: action }];
350
+ if (state.hasFocusedAnnotation)
351
+ return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
352
+ // editing the plan itself is the owner's primitive; a share viewer only annotates,
353
+ // and has no edit affordance, so the key is silent rather than a nag
354
+ if (state.canEditPlan === false) return [];
355
+
356
+ return [{ type: action }];
149
357
  }
358
+
150
359
  return annotationCluster(state, action) ?? [];
151
360
  }
152
361
 
153
362
  /** Cursor movement is the same intent everywhere; views clamp their own bounds. */
154
- function navIntent(action: string | undefined): Intent[] | null {
363
+ function navigationIntent(action: string | undefined): Intent[] | null {
155
364
  if (action === "down" || action === "up" || action === "top" || action === "bottom") {
156
- return [{ t: "move", to: action }];
365
+ return [{ type: "move", to: action }];
157
366
  }
367
+
368
+ return null;
369
+ }
370
+
371
+ /** Ungated view toggles in the diff: right folds a file to its band, left unfolds it, s flips
372
+ * split/stacked (split lays out only when the Changes pane is wide/zoomed). */
373
+ function viewToggleIntent(action: string | undefined): Intent[] | null {
374
+ if (action === "collapse_file") return [{ type: "foldFile" }];
375
+ if (action === "expand_file") return [{ type: "unfoldFile" }];
376
+ if (action === "split_diff") return [{ type: "toggleDiffView" }];
377
+
158
378
  return null;
159
379
  }
160
380
 
@@ -162,12 +382,26 @@ function navIntent(action: string | undefined): Intent[] | null {
162
382
  function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
163
383
  if (action === "next_annotation" || action === "prev_annotation") {
164
384
  if (!state.annotationCount) return status("no annotations");
165
- return [{ t: action === "next_annotation" ? "nextAnn" : "prevAnn" }];
385
+
386
+ return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
166
387
  }
167
388
  if (action === "delete_annotation") {
168
389
  if (state.resolved || !state.hasFocusedAnnotation) return [];
169
- return [{ t: "removeAnnotation" }];
390
+
391
+ return [{ type: "removeAnnotation" }];
170
392
  }
171
- if (action === "submit") return state.resolved ? [] : [{ t: "openSubmit" }];
393
+ if (action === "rename") {
394
+ if (!state.hasFocusedAnnotation) return status("select a collaborator's note to rename them");
395
+
396
+ return [{ type: "openRename" }];
397
+ }
398
+ if (action === "submit") {
399
+ // a collaborator's notes union back as they go; there is no message to submit
400
+ if (state.canSubmitMessage === false)
401
+ return status("shared view - your notes save as you go; q to leave");
402
+
403
+ return state.resolved ? [] : [{ type: "openSubmit" }];
404
+ }
405
+
172
406
  return null;
173
407
  }
@@ -0,0 +1,66 @@
1
+ /** Kitty emission guards: the image sits beneath painted cells, and every put reuses one placement id so repeated frames REPLACE the placement instead of stacking a new one each frame (the prototype's per-frame lag/ghosting regression). Transmit medium is file locally and base64 over ssh. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { placeKittyImage, resolveTransmitMedium, transmitKittyImage } from "./kitty-image";
5
+
6
+ const REGION = { column: 2, row: 3, columns: 40, rows: 12 };
7
+
8
+ describe("kitty image placement", () => {
9
+ test("places the prototype below cells with painted backgrounds", () => {
10
+ // Arrange
11
+ const writes: string[] = [];
12
+
13
+ // Act
14
+ transmitKittyImage((chunk) => writes.push(chunk), new Uint8Array([1]), REGION, 811);
15
+ placeKittyImage((chunk) => writes.push(chunk), REGION, 811);
16
+
17
+ // Assert
18
+ expect(writes).toHaveLength(2);
19
+ expect(writes[0]).toContain("z=-1073741825");
20
+ expect(writes[1]).toContain("z=-1073741825");
21
+ });
22
+
23
+ test("every frame reuses one placement id, so repeated puts replace rather than stack", () => {
24
+ // Arrange
25
+ const writes: string[] = [];
26
+ const write = (chunk: string): void => void writes.push(chunk);
27
+
28
+ // Act - one transmit then many re-places, as the per-frame paint loop does
29
+ transmitKittyImage(write, new Uint8Array([1]), REGION, 811, "base64");
30
+ for (let frame = 0; frame < 5; frame++) placeKittyImage(write, REGION, 811);
31
+
32
+ // Assert - a stable placement id p=1 on the transmit and every place
33
+ expect(writes[0]).toContain("i=811,p=1");
34
+ for (const placement of writes.slice(1)) expect(placement).toContain("i=811,p=1");
35
+ });
36
+
37
+ test("base64 transmit chunks the payload; file transmit hands over a path once", () => {
38
+ // Arrange
39
+ const big = new Uint8Array(9000).fill(1);
40
+ const base64Writes: string[] = [];
41
+ const fileWrites: string[] = [];
42
+
43
+ // Act
44
+ transmitKittyImage((chunk) => base64Writes.push(chunk), big, REGION, 811, "base64");
45
+ transmitKittyImage((chunk) => fileWrites.push(chunk), big, REGION, 811, "file");
46
+
47
+ // Assert - base64 inlines the data (a=T without t=), file references a temp path (t=t)
48
+ expect(base64Writes[0]).toContain("a=T");
49
+ expect(base64Writes[0]).not.toContain("t=t");
50
+ expect(fileWrites[0]).toContain("t=t");
51
+ });
52
+ });
53
+
54
+ describe("resolveTransmitMedium", () => {
55
+ test("defaults to base64; file transfer is opt-in and never over ssh", () => {
56
+ // Assert
57
+ expect(resolveTransmitMedium({})).toBe("base64");
58
+ expect(resolveTransmitMedium({ CUELOOP_KITTY_FILE: "1" })).toBe("file");
59
+ expect(
60
+ resolveTransmitMedium({
61
+ CUELOOP_KITTY_FILE: "1",
62
+ SSH_CONNECTION: "1.2.3.4 5 6 7",
63
+ }),
64
+ ).toBe("base64");
65
+ });
66
+ });