@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
package/src/keymap.ts ADDED
@@ -0,0 +1,410 @@
1
+ /**
2
+ * The keyboard grammar as a pure reducer: view state in, intents out.
3
+ * App builds a KeyState per key event, calls reduceKey, and dispatches the
4
+ * intents to controller primitives and view-state setters - no branch bodies live
5
+ * in the key handler. Diff and plan reviews share one path for annotation
6
+ * navigation, deletion, and submit; the read-only rule is one gate here.
7
+ */
8
+
9
+ import { actionFor } from "./config";
10
+
11
+ export type Intent =
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: "submitVerdict" }
50
+ | { type: "cycleVerdict"; 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" };
61
+
62
+ export interface KeyInput {
63
+ name: string;
64
+ shift: boolean;
65
+ /** Option/Alt arrives as `meta` in this terminal stack (never as a raw alt). */
66
+ meta?: boolean;
67
+ }
68
+
69
+ export interface KeyState {
70
+ /** Loaded keymap (config.ts): action -> key combos. */
71
+ keys: Record<string, string[]>;
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 verdict (there is no agent on a share).
77
+ */
78
+ canEditPlan?: boolean;
79
+ canSubmitVerdict?: boolean;
80
+ /** Owner-only: publish the plan as a share. A collaborator never re-shares. */
81
+ canShare?: boolean;
82
+ /** Layer that owns keys before the grammar runs. */
83
+ overlay:
84
+ | "none"
85
+ | "walk"
86
+ | "compose"
87
+ | "submit"
88
+ | "confirm"
89
+ | "prompt"
90
+ | "spanActions"
91
+ | "completion-prompt"
92
+ | "completion-counting";
93
+ view: "inbox" | "plan" | "diff";
94
+ /** Plan-only span selection sub-mode. */
95
+ spanMode: boolean;
96
+ /** The walk cursor sits on the end card - return offers the submit action. */
97
+ walkAtEnd: boolean;
98
+ resolved: boolean;
99
+ hasInboxItems: boolean;
100
+ annotationCount: number;
101
+ hasFocusedAnnotation: boolean;
102
+ /** Cursor sits on annotatable text: a work block (plan) or a code row (diff). */
103
+ cursorAnnotatable: boolean;
104
+ }
105
+
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
+ ]);
118
+
119
+ const SPAN_KEYS = new Set(["l", "h", "w", "b", "$", "0"]);
120
+
121
+ function status(message: string): Intent[] {
122
+ return [{ type: "status", message }];
123
+ }
124
+
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[] {
132
+ const name = key.name;
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" }];
139
+ // the ONE read-only rule: any mutating attempt answers instead of acting
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
+
145
+ if (state.readOnly && mutating) return status("observer - read-only");
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
+
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);
157
+ if (state.view === "diff") return diffGrammar(state, action);
158
+
159
+ return planGrammar(state, action, name);
160
+ }
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 === "return" || name === "enter") return [{ type: "submitVerdict" }];
196
+ if (name === "left" || name === "right") {
197
+ return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
198
+ }
199
+
200
+ return [];
201
+ }
202
+
203
+ function confirmOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
204
+ const name = key.name;
205
+
206
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
207
+ if (name === "escape") return [{ type: "closeOverlay" }];
208
+
209
+ return [];
210
+ }
211
+
212
+ function promptOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
213
+ const name = key.name;
214
+
215
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
216
+ if (name === "escape") return [{ type: "closeOverlay" }];
217
+
218
+ return [];
219
+ }
220
+
221
+ function spanActionsOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
222
+ const name = key.name;
223
+
224
+ if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
225
+ if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
226
+ if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
227
+ if (name === "escape") return [{ type: "closeSpanActions" }];
228
+
229
+ return [];
230
+ }
231
+
232
+ function completionOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
233
+ const name = key.name;
234
+
235
+ if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
236
+ if (name === "a") return [{ type: "optInAutoClose" }];
237
+ if (name === "escape") return [{ type: "dismissCompletion" }];
238
+
239
+ return [];
240
+ }
241
+
242
+ function walkOverlayGrammar(state: KeyState, key: KeyInput): Intent[] {
243
+ const name = key.name;
244
+
245
+ if (name === "]") return [{ type: "walkForward" }];
246
+ if (name === "[") return [{ type: "walkBack" }];
247
+ if (name === "escape") return [{ type: "walkLeave" }];
248
+ if ((name === "return" || name === "enter") && state.walkAtEnd) {
249
+ return [{ type: "walkLeave" }, { type: "openSubmit" }];
250
+ }
251
+ if (name === "q") return [{ type: "exit" }];
252
+
253
+ return [];
254
+ }
255
+
256
+ function inboxGrammar(state: KeyState, name: string): Intent[] {
257
+ if (!state.hasInboxItems) return [];
258
+ if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
259
+ if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
260
+ if (name === "return" || name === "enter") return [{ type: "openSession" }];
261
+ if (name === "d") return [{ type: "requestDeleteSession" }];
262
+
263
+ return [];
264
+ }
265
+
266
+ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
267
+ const navigation = navigationIntent(action);
268
+
269
+ if (navigation) return navigation;
270
+ if (action === "walk") {
271
+ // marking viewed writes the session record, so a resolved review answers
272
+ if (state.resolved) return status("review submitted - read-only");
273
+
274
+ return [{ type: "walkStart" }];
275
+ }
276
+ if (action === "comment") {
277
+ if (state.resolved) return status("review submitted - read-only");
278
+ if (!state.cursorAnnotatable) return status("move to a code line to comment");
279
+
280
+ return [{ type: "openCompose", kind: "comment", from: "cursor" }];
281
+ }
282
+ // cut rejects the change under the cursor, reject_hunk the whole hunk; both
283
+ // write the working copy, so they gate on owner like a plan edit.
284
+ if (action === "cut" || action === "reject_hunk") {
285
+ if (state.resolved) return status("review submitted - read-only");
286
+ if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
287
+
288
+ return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
289
+ }
290
+ const viewToggle = viewToggleIntent(action);
291
+
292
+ if (viewToggle) return viewToggle;
293
+ // restore un-does a curated-out rejection from the rail; same owner gate as reject
294
+ if (action === "restore_curation") {
295
+ if (state.resolved) return status("review submitted - read-only");
296
+ if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
297
+
298
+ return [{ type: "restoreCuration" }];
299
+ }
300
+ const shared = annotationCluster(state, action);
301
+
302
+ if (shared) return shared;
303
+ if (action === "span" || action === "edit") {
304
+ return status("plan-only primitive - diff review uses c on a line");
305
+ }
306
+
307
+ return [];
308
+ }
309
+
310
+ function spanGrammar(state: KeyState, name: string): Intent[] {
311
+ if (name === "escape") return [{ type: "closeOverlay" }];
312
+ if (SPAN_KEYS.has(name)) return [{ type: "spanKey", name }];
313
+ // c comments, x cuts, a opens quick-actions - all mutate, so a resolved
314
+ // review is read-only, like the plan grammar
315
+ if (name === "c" || name === "x" || name === "a") {
316
+ if (state.resolved) return status("review submitted - read-only");
317
+ if (name === "c") return [{ type: "openCompose", kind: "comment", from: "span" }];
318
+ if (name === "a") return [{ type: "openSpanActions" }];
319
+ // partial-span cut is not in the working-copy model, so cut removes the
320
+ // whole block the span sits in; owner-only, like plan cut
321
+ if (state.canEditPlan === false) return [];
322
+
323
+ return [{ type: "spanCut" }];
324
+ }
325
+
326
+ return [];
327
+ }
328
+
329
+ function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
330
+ const navigation = navigationIntent(action);
331
+
332
+ if (navigation) return navigation;
333
+ if (action === "walk") return status("the guided walk is a diff-review mode");
334
+ if (name === "escape") return [{ type: "deselect" }];
335
+ if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
336
+ if (action === "comment") {
337
+ if (state.resolved) return status("review submitted - read-only");
338
+ if (!state.cursorAnnotatable) return status("text is cut - restore it first");
339
+
340
+ return [{ type: "openCompose", kind: "comment", from: "cursor" }];
341
+ }
342
+ // restore un-does a rail removal (a cut block); a plan edit, so owner-only
343
+ if (action === "restore_curation") {
344
+ if (state.resolved) return status("review submitted - read-only");
345
+ if (state.canEditPlan === false) return [];
346
+
347
+ return [{ type: "restoreCuration" }];
348
+ }
349
+ if (action === "cut" || action === "edit") {
350
+ if (state.resolved) return status("review submitted - read-only");
351
+ // the document selects, the rail edits: with a card selected, Cut deletes
352
+ // the annotation and edit rewrites the card body in place
353
+ if (state.hasFocusedAnnotation)
354
+ return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
355
+ // editing the plan itself is the owner's primitive; a share viewer only annotates,
356
+ // and has no edit affordance, so the key is silent rather than a nag
357
+ if (state.canEditPlan === false) return [];
358
+
359
+ return [{ type: action }];
360
+ }
361
+
362
+ return annotationCluster(state, action) ?? [];
363
+ }
364
+
365
+ /** Cursor movement is the same intent everywhere; views clamp their own bounds. */
366
+ function navigationIntent(action: string | undefined): Intent[] | null {
367
+ if (action === "down" || action === "up" || action === "top" || action === "bottom") {
368
+ return [{ type: "move", to: action }];
369
+ }
370
+
371
+ return null;
372
+ }
373
+
374
+ /** Ungated view toggles in the diff: right folds a file to its band, left unfolds it, s flips
375
+ * split/stacked (split lays out only when the Changes pane is wide/zoomed). */
376
+ function viewToggleIntent(action: string | undefined): Intent[] | null {
377
+ if (action === "collapse_file") return [{ type: "foldFile" }];
378
+ if (action === "expand_file") return [{ type: "unfoldFile" }];
379
+ if (action === "split_diff") return [{ type: "toggleDiffView" }];
380
+
381
+ return null;
382
+ }
383
+
384
+ /** Annotation navigation, deletion, and submit: one path for plan and diff. */
385
+ function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
386
+ if (action === "next_annotation" || action === "prev_annotation") {
387
+ if (!state.annotationCount) return status("no annotations");
388
+
389
+ return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
390
+ }
391
+ if (action === "delete_annotation") {
392
+ if (state.resolved || !state.hasFocusedAnnotation) return [];
393
+
394
+ return [{ type: "removeAnnotation" }];
395
+ }
396
+ if (action === "rename") {
397
+ if (!state.hasFocusedAnnotation) return status("select a collaborator's note to rename them");
398
+
399
+ return [{ type: "openRename" }];
400
+ }
401
+ if (action === "submit") {
402
+ // a collaborator's notes union back as they go; there is no verdict to submit
403
+ if (state.canSubmitVerdict === false)
404
+ return status("shared view - your notes save as you go; q to leave");
405
+
406
+ return state.resolved ? [] : [{ type: "openSubmit" }];
407
+ }
408
+
409
+ return null;
410
+ }
@@ -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
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Direct kitty graphics protocol emission for a cell rectangle. OpenTUI's
3
+ * ImageRenderable does not display in every terminal, so prototype review paints
4
+ * the screenshot itself: transmit the PNG once under a fixed image AND placement
5
+ * id, then re-place that same placement after each frame over its reserved
6
+ * (never-repainted) cells. Reusing one placement id means each frame REPLACES
7
+ * the placement rather than stacking a new one, which is the protocol's
8
+ * flicker-free move/resize path. Locally the PNG travels out of band via a
9
+ * temporary file so its bytes never cross the pty; a base64 fallback covers the
10
+ * ssh sharing path where no shared filesystem exists.
11
+ */
12
+
13
+ import { mkdtempSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ export interface CellRegion {
18
+ /** 0-based screen cell of the region's top-left corner. */
19
+ column: number;
20
+ row: number;
21
+ columns: number;
22
+ rows: number;
23
+ }
24
+
25
+ /** Writes a raw sequence ordered against the renderer's own frame output. */
26
+ export type OrderedWrite = (chunk: string) => void;
27
+
28
+ export type TransmitMedium = "file" | "base64";
29
+
30
+ const ESCAPE_STRING_TERMINATOR = "\x1b\\";
31
+ const CHUNK_BYTES = 4096;
32
+ // The protocol reserves values below INT32_MIN/2 for images that must sit
33
+ // beneath cells with painted backgrounds, such as the marker popover.
34
+ const IMAGE_Z_INDEX = -1_073_741_825;
35
+ // One fixed placement per image: reusing it makes every put replace that
36
+ // placement instead of accumulating a new one each frame.
37
+ const PLACEMENT_ID = 1;
38
+ const SYNCHRONIZED_UPDATE_START = "\x1b[?2026h";
39
+ const SYNCHRONIZED_UPDATE_END = "\x1b[?2026l";
40
+
41
+ let tempFileCounter = 0;
42
+ let privateTempDir: string | null = null;
43
+
44
+ /**
45
+ * base64 is the safe default: it works over the pty everywhere, including the
46
+ * ssh sharing path. Out-of-band file transfer (t=t) is faster for the initial
47
+ * transmit but depends on the terminal trusting the temp-file path, so it is
48
+ * opt-in via `CUELOOP_KITTY_FILE=1` and never used over ssh (no shared disk).
49
+ */
50
+ export function resolveTransmitMedium(env: NodeJS.ProcessEnv = process.env): TransmitMedium {
51
+ if (env.CUELOOP_KITTY_FILE === "1" && !env.SSH_CONNECTION && !env.SSH_TTY) return "file";
52
+
53
+ return "base64";
54
+ }
55
+
56
+ /** Save the cursor, run the placement at the region's corner, restore it. */
57
+ function atRegion(region: CellRegion, body: string): string {
58
+ const moveToCorner = `\x1b[${region.row + 1};${region.column + 1}H`;
59
+
60
+ return `\x1b7${moveToCorner}${body}\x1b8`;
61
+ }
62
+
63
+ function graphicsCommand(keys: string, payload: string): string {
64
+ return `\x1b_G${keys};${payload}${ESCAPE_STRING_TERMINATOR}`;
65
+ }
66
+
67
+ function placementKeys(imageId: number, region: CellRegion): string {
68
+ return `i=${imageId},p=${PLACEMENT_ID},q=2,C=1,z=${IMAGE_Z_INDEX},c=${region.columns},r=${region.rows}`;
69
+ }
70
+
71
+ // A per-process 0700 directory with an unpredictable name, so a local attacker
72
+ // cannot pre-place a symlink at the screenshot path and redirect the write.
73
+ function writeTempPng(png: Uint8Array): string {
74
+ if (!privateTempDir) privateTempDir = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
75
+ const path = join(privateTempDir, `${tempFileCounter++}.png`);
76
+
77
+ writeFileSync(path, png, { flag: "wx" });
78
+
79
+ return path;
80
+ }
81
+
82
+ function base64Transmit(png: Uint8Array, region: CellRegion, imageId: number): string {
83
+ const base64 = Buffer.from(png).toString("base64");
84
+ const controlKeys = `a=T,f=100,${placementKeys(imageId, region)}`;
85
+ let sequence = "";
86
+
87
+ for (let offset = 0; offset < base64.length; offset += CHUNK_BYTES) {
88
+ const chunk = base64.slice(offset, offset + CHUNK_BYTES);
89
+ const isMore = offset + CHUNK_BYTES < base64.length ? 1 : 0;
90
+
91
+ sequence +=
92
+ offset === 0
93
+ ? graphicsCommand(`${controlKeys},m=${isMore}`, chunk)
94
+ : graphicsCommand(`m=${isMore}`, chunk);
95
+ }
96
+
97
+ return sequence;
98
+ }
99
+
100
+ function fileTransmit(png: Uint8Array, region: CellRegion, imageId: number): string {
101
+ const path = writeTempPng(png);
102
+ const payload = Buffer.from(path).toString("base64");
103
+
104
+ return graphicsCommand(`a=T,f=100,t=t,${placementKeys(imageId, region)}`, payload);
105
+ }
106
+
107
+ /**
108
+ * Transmit a PNG and display it filling the region's cells (a=T). f=100 is PNG,
109
+ * C=1 keeps the cursor put so a full-region image cannot scroll the screen, and
110
+ * q=2 suppresses the terminal's replies. The whole write is bracketed in
111
+ * synchronized-update mode so the terminal composites it atomically. `file`
112
+ * hands over a temp path (t=t: the terminal deletes it after reading); `base64`
113
+ * inlines the data, chunked at 4096 bytes.
114
+ */
115
+ export function transmitKittyImage(
116
+ write: OrderedWrite,
117
+ png: Uint8Array,
118
+ region: CellRegion,
119
+ imageId: number,
120
+ medium: TransmitMedium = "base64",
121
+ ): void {
122
+ if (region.columns < 1 || region.rows < 1) return;
123
+ const body =
124
+ medium === "file" ? fileTransmit(png, region, imageId) : base64Transmit(png, region, imageId);
125
+
126
+ write(SYNCHRONIZED_UPDATE_START + atRegion(region, body) + SYNCHRONIZED_UPDATE_END);
127
+ }
128
+
129
+ /** Re-place the already-transmitted image over its region, cheaply (a=p, no data). */
130
+ export function placeKittyImage(write: OrderedWrite, region: CellRegion, imageId: number): void {
131
+ if (region.columns < 1 || region.rows < 1) return;
132
+ write(atRegion(region, graphicsCommand(`a=p,${placementKeys(imageId, region)}`, "")));
133
+ }
134
+
135
+ /** Remove the image so it does not linger after the sheet closes (a=d). */
136
+ export function deleteKittyImage(write: OrderedWrite, imageId: number): void {
137
+ write(graphicsCommand(`a=d,d=i,i=${imageId},q=2`, ""));
138
+ }