@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.51

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 (152) 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 +36 -31
  8. package/src/App.autoclose.test.tsx +137 -0
  9. package/src/App.diff.test.tsx +158 -22
  10. package/src/App.export.test.tsx +44 -37
  11. package/src/App.plan-review.test.tsx +672 -0
  12. package/src/App.prototype.test.tsx +155 -0
  13. package/src/App.readonly.test.tsx +60 -36
  14. package/src/App.rendering.test.tsx +132 -0
  15. package/src/App.review-panel.test.tsx +136 -0
  16. package/src/App.submit-confirm.test.tsx +218 -0
  17. package/src/App.test.tsx +128 -69
  18. package/src/App.toast.test.tsx +75 -0
  19. package/src/App.tsx +0 -0
  20. package/src/App.walk.test.tsx +289 -0
  21. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  22. package/src/attribution.test.ts +31 -0
  23. package/src/attribution.ts +38 -0
  24. package/src/clipboard.test.ts +23 -0
  25. package/src/clipboard.ts +30 -0
  26. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  27. package/src/components/AnnotationCard.stories.tsx +94 -0
  28. package/src/components/AnnotationCard.tsx +241 -0
  29. package/src/components/Breadcrumb.stories.tsx +19 -0
  30. package/src/components/Breadcrumb.tsx +44 -0
  31. package/src/components/CodeBlock.stories.tsx +42 -0
  32. package/src/components/CodeBlock.tsx +83 -0
  33. package/src/components/CompletionOverlay.stories.tsx +23 -0
  34. package/src/components/CompletionOverlay.tsx +59 -0
  35. package/src/components/ConfirmCard.stories.tsx +35 -0
  36. package/src/components/ConfirmCard.tsx +127 -0
  37. package/src/components/ConfirmDialog.stories.tsx +21 -0
  38. package/src/components/ConfirmDialog.tsx +64 -0
  39. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  40. package/src/components/DiffSheet.stories.tsx +78 -0
  41. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  42. package/src/components/DiffSheet.tsx +355 -0
  43. package/src/components/InboxList.stories.tsx +31 -0
  44. package/src/components/InboxList.tsx +62 -0
  45. package/src/components/KeybindsDialog.stories.tsx +31 -0
  46. package/src/components/KeybindsDialog.tsx +58 -0
  47. package/src/components/MarkerPopover.stories.tsx +58 -0
  48. package/src/components/MarkerPopover.tsx +108 -0
  49. package/src/components/MenuBar.stories.tsx +37 -0
  50. package/src/components/MenuBar.tsx +74 -0
  51. package/src/components/PlanSheet.stories.tsx +113 -0
  52. package/src/components/PlanSheet.tsx +456 -0
  53. package/src/components/PromptDialog.stories.tsx +36 -0
  54. package/src/components/PromptDialog.tsx +78 -0
  55. package/src/components/PrototypeSheet.stories.tsx +20 -0
  56. package/src/components/PrototypeSheet.tsx +358 -0
  57. package/src/components/ReviewPanel.stories.tsx +86 -0
  58. package/src/components/ReviewPanel.tsx +101 -0
  59. package/src/components/ReviewRail.stories.tsx +218 -0
  60. package/src/components/ReviewRail.tsx +317 -0
  61. package/src/components/SettingsDialog.stories.tsx +111 -0
  62. package/src/components/SettingsDialog.tsx +141 -0
  63. package/src/components/SettingsRows.stories.tsx +18 -0
  64. package/src/components/SettingsRows.tsx +108 -0
  65. package/src/components/Toast.stories.tsx +12 -0
  66. package/src/components/Toast.tsx +44 -0
  67. package/src/components/WalkWizard.stories.tsx +100 -0
  68. package/src/components/WalkWizard.tsx +152 -0
  69. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  70. package/src/components/agent-launcher.stories.tsx +26 -0
  71. package/src/components/agent-launcher.test.tsx +49 -0
  72. package/src/components/agent-launcher.tsx +147 -0
  73. package/src/components/diff-sheet-layout.test.ts +127 -0
  74. package/src/components/diff-sheet-layout.ts +167 -0
  75. package/src/components/primitives/Button.stories.tsx +32 -0
  76. package/src/components/primitives/Button.tsx +51 -0
  77. package/src/components/primitives/Card.stories.tsx +25 -0
  78. package/src/components/primitives/Card.tsx +59 -0
  79. package/src/components/primitives/Dialog.stories.tsx +24 -0
  80. package/src/components/primitives/Dialog.tsx +64 -0
  81. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  82. package/src/components/primitives/StatusBar.tsx +22 -0
  83. package/src/components/primitives/Tabs.stories.tsx +29 -0
  84. package/src/components/primitives/Tabs.tsx +98 -0
  85. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  86. package/src/components/primitives/Toolbar.tsx +14 -0
  87. package/src/components/primitives/frame.test.ts +12 -0
  88. package/src/components/primitives/frame.ts +14 -0
  89. package/src/components/quick-actions-editor.stories.tsx +54 -0
  90. package/src/components/quick-actions-editor.tsx +136 -0
  91. package/src/components/stories-app.tsx +123 -0
  92. package/src/components/stories.test.tsx +62 -0
  93. package/src/components/story-fixtures.ts +98 -0
  94. package/src/components/story.ts +69 -0
  95. package/src/components/syntax-highlight.ts +92 -0
  96. package/src/components/terminal-pane.test.tsx +40 -0
  97. package/src/components/terminal-pane.ts +181 -0
  98. package/src/components/theme-context.stories.tsx +46 -0
  99. package/src/components/theme-context.tsx +36 -0
  100. package/src/components/truncate-text.ts +6 -0
  101. package/src/config.test.ts +430 -31
  102. package/src/config.ts +304 -26
  103. package/src/diff-hunk-curate.test.ts +315 -0
  104. package/src/diff-hunk-curate.ts +233 -0
  105. package/src/diff-intraline.test.ts +164 -0
  106. package/src/diff-intraline.ts +132 -0
  107. package/src/diff-syntax.test.ts +96 -0
  108. package/src/diff-syntax.ts +154 -0
  109. package/src/editor.test.ts +143 -0
  110. package/src/editor.ts +123 -9
  111. package/src/ghostty-terminal.test.ts +82 -0
  112. package/src/ghostty-terminal.ts +168 -0
  113. package/src/herdr.test.ts +41 -0
  114. package/src/herdr.ts +27 -0
  115. package/src/index.ts +10 -0
  116. package/src/integrations.test.ts +41 -0
  117. package/src/integrations.ts +30 -0
  118. package/src/intent-dispatch.test.ts +493 -0
  119. package/src/intent-dispatch.ts +428 -0
  120. package/src/key-bindings.test.ts +152 -0
  121. package/src/key-bindings.ts +430 -0
  122. package/src/keymap.test.ts +622 -0
  123. package/src/keymap.ts +339 -0
  124. package/src/kitty-image.test.ts +20 -0
  125. package/src/kitty-image.ts +75 -0
  126. package/src/prototype-browser.test.ts +55 -0
  127. package/src/prototype-browser.ts +243 -0
  128. package/src/pty.test.ts +78 -0
  129. package/src/pty.ts +222 -0
  130. package/src/review-panel.test.ts +113 -0
  131. package/src/review-panel.ts +83 -0
  132. package/src/run.ts +9 -5
  133. package/src/serve.test.ts +24 -9
  134. package/src/serve.ts +7 -7
  135. package/src/session-controller.curate.test.ts +253 -0
  136. package/src/session-controller.share.test.ts +309 -0
  137. package/src/session-controller.ts +914 -0
  138. package/src/share.ts +113 -0
  139. package/src/test-support.test.ts +55 -0
  140. package/src/test-support.ts +187 -0
  141. package/src/theme-presets.ts +168 -0
  142. package/src/theme.ts +38 -17
  143. package/src/version.ts +5 -0
  144. package/src/view-diff.test.ts +19 -8
  145. package/src/view-diff.ts +34 -23
  146. package/src/view-plan.regression.test.ts +60 -0
  147. package/src/view-plan.test.ts +399 -0
  148. package/src/view-plan.ts +495 -0
  149. package/src/walk.test.ts +121 -0
  150. package/src/walk.ts +70 -0
  151. package/src/view.test.ts +0 -157
  152. package/src/view.ts +0 -0
package/src/keymap.ts ADDED
@@ -0,0 +1,339 @@
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 verbs 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: "nextAnnotation" }
37
+ | { type: "prevAnnotation" }
38
+ | { type: "walkStart" }
39
+ | { type: "walkForward" }
40
+ | { type: "walkBack" }
41
+ | { type: "walkLeave" }
42
+ | { type: "removeAnnotation" }
43
+ | { type: "deselect" }
44
+ | { type: "closeOverlay" }
45
+ | { type: "saveCompose" }
46
+ | { type: "submitVerdict" }
47
+ | { type: "cycleVerdict"; direction: -1 | 1 }
48
+ | { type: "finishReview" }
49
+ | { type: "optInAutoClose" }
50
+ | { type: "dismissCompletion" }
51
+ | { type: "cycleReviewPanel" }
52
+ | { type: "resizeReviewPanel"; direction: -1 | 1 };
53
+
54
+ export interface KeyInput {
55
+ name: string;
56
+ shift: boolean;
57
+ /** Option/Alt arrives as `meta` in this terminal stack (never as a raw alt). */
58
+ meta?: boolean;
59
+ }
60
+
61
+ export interface KeyState {
62
+ /** Loaded keymap (config.ts): action -> key combos. */
63
+ keys: Record<string, string[]>;
64
+ readOnly: boolean;
65
+ /**
66
+ * Owner-only verbs a share collaborator lacks (undefined = owner, allowed).
67
+ * A collaborator annotates but cannot edit the plan (cut / $EDITOR runs on
68
+ * the gateway) or submit an agent verdict (there is no agent on a share).
69
+ */
70
+ canEditPlan?: boolean;
71
+ canSubmitVerdict?: boolean;
72
+ /** Owner-only: publish the plan as a share. A collaborator never re-shares. */
73
+ canShare?: boolean;
74
+ /** Layer that owns keys before the grammar runs. */
75
+ overlay:
76
+ | "none"
77
+ | "walk"
78
+ | "compose"
79
+ | "submit"
80
+ | "confirm"
81
+ | "prompt"
82
+ | "spanActions"
83
+ | "completion-prompt"
84
+ | "completion-counting";
85
+ view: "inbox" | "plan" | "diff";
86
+ /** Plan-only span selection sub-mode. */
87
+ spanMode: boolean;
88
+ /** The walk cursor sits on the end card - return offers the submit action. */
89
+ walkAtEnd: boolean;
90
+ resolved: boolean;
91
+ hasInboxItems: boolean;
92
+ annotationCount: number;
93
+ hasFocusedAnnotation: boolean;
94
+ /** Cursor sits on annotatable text: a work block (plan) or a code row (diff). */
95
+ cursorAnnotatable: boolean;
96
+ }
97
+
98
+ /** Verbs that write session state; an observer never reaches their handlers. */
99
+ const MUTATING_ACTIONS = new Set([
100
+ "comment",
101
+ "cut",
102
+ "edit",
103
+ "delete_annotation",
104
+ "submit",
105
+ "walk",
106
+ "share",
107
+ "reject_hunk",
108
+ "restore_curation",
109
+ ]);
110
+
111
+ const SPAN_KEYS = new Set(["l", "h", "w", "b", "$", "0"]);
112
+
113
+ function status(message: string): Intent[] {
114
+ return [{ type: "status", message }];
115
+ }
116
+
117
+ /**
118
+ * Reduce one key event to intents. `resolvedAction` is the binding-layer
119
+ * resolution (key-bindings.ts over @opentui/keymap) when the caller has one;
120
+ * without it the reducer falls back to the plain reverse lookup, so the
121
+ * grammar stays testable as a pure function.
122
+ */
123
+ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: string): Intent[] {
124
+ const name = key.name;
125
+ // compose/submit overlays own the keys via focused inputs; only escape,
126
+ // return, and verdict arrows route through the grammar
127
+ if (state.overlay === "compose" || state.overlay === "submit") {
128
+ if (name === "escape") return [{ type: "closeOverlay" }];
129
+ if (name === "return" || name === "enter") {
130
+ // In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
131
+ // focused textarea owns that; only a bare ⏎ saves. The submit overlay
132
+ // keeps its plain ⏎ submit.
133
+ if (state.overlay === "compose") {
134
+ if (key.shift || key.meta) return [];
135
+ return [{ type: "saveCompose" }];
136
+ }
137
+ return [{ type: "submitVerdict" }];
138
+ }
139
+ if (state.overlay === "submit" && (name === "left" || name === "right")) {
140
+ return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
141
+ }
142
+ return [];
143
+ }
144
+ // a modal confirm owns the keys: ⏎ commits the action, escape backs out
145
+ if (state.overlay === "confirm") {
146
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
147
+ if (name === "escape") return [{ type: "closeOverlay" }];
148
+ return [];
149
+ }
150
+ // a text prompt: the focused input owns typing; only ⏎ save and esc route here
151
+ if (state.overlay === "prompt") {
152
+ if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
153
+ if (name === "escape") return [{ type: "closeOverlay" }];
154
+ return [];
155
+ }
156
+ // the quick-actions list owns its keys: j/k move, ⏎ picks the highlighted
157
+ // action (inserting its preset comment), escape returns to the span toolbar
158
+ if (state.overlay === "spanActions") {
159
+ if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
160
+ if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
161
+ if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
162
+ if (name === "escape") return [{ type: "closeSpanActions" }];
163
+ return [];
164
+ }
165
+ if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
166
+ if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
167
+ if (name === "a") return [{ type: "optInAutoClose" }];
168
+ if (name === "escape") return [{ type: "dismissCompletion" }];
169
+ return [];
170
+ }
171
+ // the walk wizard owns its keys while active: ] advances (marking the
172
+ // current file viewed), [ steps back, escape leaves keeping progress, and
173
+ // return on the end card hands over to the submit confirm
174
+ if (state.overlay === "walk") {
175
+ if (name === "]") return [{ type: "walkForward" }];
176
+ if (name === "[") return [{ type: "walkBack" }];
177
+ if (name === "escape") return [{ type: "walkLeave" }];
178
+ if ((name === "return" || name === "enter") && state.walkAtEnd) {
179
+ return [{ type: "walkLeave" }, { type: "openSubmit" }];
180
+ }
181
+ if (name === "q") return [{ type: "exit" }];
182
+ return [];
183
+ }
184
+ const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
185
+ if (action === "quit") return [{ type: "exit" }];
186
+ // the ONE read-only rule: any mutating attempt answers instead of acting
187
+ // (span-mode c, x, and a are hardwired keys, so they gate by name as well)
188
+ const mutating =
189
+ MUTATING_ACTIONS.has(action ?? "") ||
190
+ (state.spanMode && (name === "c" || name === "x" || name === "a"));
191
+ if (state.readOnly && mutating) return status("observer - read-only");
192
+
193
+ // share is a session-level verb: it works from any view, owner only
194
+ if (action === "share") {
195
+ if (state.canShare === false) return status("only the plan owner can share");
196
+ return [{ type: "share" }];
197
+ }
198
+
199
+ if (state.view === "inbox") return inboxGrammar(state, name);
200
+ // span mode owns its single-letter keys (b slides the span back) before the
201
+ // review-panel controls claim them
202
+ if (state.spanMode) return spanGrammar(state, name);
203
+ // the review panel rides both plan and diff reviews; collapsing and resizing
204
+ // are view state, so the read-only gate above lets them through
205
+ const reviewPanel = reviewPanelGrammar(action);
206
+ if (reviewPanel) return reviewPanel;
207
+ if (state.view === "diff") return diffGrammar(state, action);
208
+ return planGrammar(state, action, name);
209
+ }
210
+
211
+ /** The review-panel controls: cycle the mode, widen and narrow the rail. */
212
+ function reviewPanelGrammar(action: string | undefined): Intent[] | null {
213
+ if (action === "review_cycle") return [{ type: "cycleReviewPanel" }];
214
+ if (action === "review_wider") return [{ type: "resizeReviewPanel", direction: 1 }];
215
+ if (action === "review_narrower") return [{ type: "resizeReviewPanel", direction: -1 }];
216
+ return null;
217
+ }
218
+
219
+ function inboxGrammar(state: KeyState, name: string): Intent[] {
220
+ if (!state.hasInboxItems) return [];
221
+ if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
222
+ if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
223
+ if (name === "return" || name === "enter") return [{ type: "openSession" }];
224
+ if (name === "d") return [{ type: "requestDeleteSession" }];
225
+ return [];
226
+ }
227
+
228
+ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
229
+ const navigation = navigationIntent(action);
230
+ if (navigation) return navigation;
231
+ if (action === "walk") {
232
+ // marking viewed writes the session record, so a resolved review answers
233
+ if (state.resolved) return status("review submitted - read-only");
234
+ return [{ type: "walkStart" }];
235
+ }
236
+ if (action === "comment") {
237
+ if (state.resolved) return status("review submitted - read-only");
238
+ if (!state.cursorAnnotatable) return status("move to a code line to comment");
239
+ return [{ type: "openCompose", kind: "comment", from: "cursor" }];
240
+ }
241
+ // cut rejects the change under the cursor, reject_hunk the whole hunk; both
242
+ // write the working copy, so they gate on owner like a plan edit.
243
+ if (action === "cut" || action === "reject_hunk") {
244
+ if (state.resolved) return status("review submitted - read-only");
245
+ if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
246
+ return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
247
+ }
248
+ // restore un-does a curated-out rejection from the rail; same owner gate as reject
249
+ if (action === "restore_curation") {
250
+ if (state.resolved) return status("review submitted - read-only");
251
+ if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
252
+ return [{ type: "restoreCuration" }];
253
+ }
254
+ const shared = annotationCluster(state, action);
255
+ if (shared) return shared;
256
+ if (action === "span" || action === "edit") {
257
+ return status("plan-only verb - diff review uses c on a line");
258
+ }
259
+ return [];
260
+ }
261
+
262
+ function spanGrammar(state: KeyState, name: string): Intent[] {
263
+ if (name === "escape") return [{ type: "closeOverlay" }];
264
+ if (SPAN_KEYS.has(name)) return [{ type: "spanKey", name }];
265
+ // c comments, x cuts, a opens quick-actions - all mutate, so a resolved
266
+ // review is read-only, like the plan grammar
267
+ if (name === "c" || name === "x" || name === "a") {
268
+ if (state.resolved) return status("review submitted - read-only");
269
+ if (name === "c") return [{ type: "openCompose", kind: "comment", from: "span" }];
270
+ if (name === "a") return [{ type: "openSpanActions" }];
271
+ // partial-span cut is not in the working-copy model, so cut removes the
272
+ // whole block the span sits in; owner-only, like plan cut
273
+ if (state.canEditPlan === false) return [];
274
+ return [{ type: "spanCut" }];
275
+ }
276
+ return [];
277
+ }
278
+
279
+ function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
280
+ const navigation = navigationIntent(action);
281
+ if (navigation) return navigation;
282
+ if (action === "walk") return status("the guided walk is a diff-review mode");
283
+ if (name === "escape") return [{ type: "deselect" }];
284
+ if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
285
+ if (action === "comment") {
286
+ if (state.resolved) return status("review submitted - read-only");
287
+ if (!state.cursorAnnotatable) return status("text is cut - restore it first");
288
+ return [{ type: "openCompose", kind: "comment", from: "cursor" }];
289
+ }
290
+ // restore un-does a rail removal (a cut block); a plan edit, so owner-only
291
+ if (action === "restore_curation") {
292
+ if (state.resolved) return status("review submitted - read-only");
293
+ if (state.canEditPlan === false) return [];
294
+ return [{ type: "restoreCuration" }];
295
+ }
296
+ if (action === "cut" || action === "edit") {
297
+ if (state.resolved) return status("review submitted - read-only");
298
+ // the document selects, the rail edits: with a card selected, Cut deletes
299
+ // the annotation and edit rewrites the card body in place
300
+ if (state.hasFocusedAnnotation)
301
+ return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
302
+ // editing the plan itself is the owner's verb; a share viewer only annotates,
303
+ // and has no edit affordance, so the key is silent rather than a nag
304
+ if (state.canEditPlan === false) return [];
305
+ return [{ type: action }];
306
+ }
307
+ return annotationCluster(state, action) ?? [];
308
+ }
309
+
310
+ /** Cursor movement is the same intent everywhere; views clamp their own bounds. */
311
+ function navigationIntent(action: string | undefined): Intent[] | null {
312
+ if (action === "down" || action === "up" || action === "top" || action === "bottom") {
313
+ return [{ type: "move", to: action }];
314
+ }
315
+ return null;
316
+ }
317
+
318
+ /** Annotation navigation, deletion, and submit: one path for plan and diff. */
319
+ function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
320
+ if (action === "next_annotation" || action === "prev_annotation") {
321
+ if (!state.annotationCount) return status("no annotations");
322
+ return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
323
+ }
324
+ if (action === "delete_annotation") {
325
+ if (state.resolved || !state.hasFocusedAnnotation) return [];
326
+ return [{ type: "removeAnnotation" }];
327
+ }
328
+ if (action === "rename") {
329
+ if (!state.hasFocusedAnnotation) return status("select a collaborator's note to rename them");
330
+ return [{ type: "openRename" }];
331
+ }
332
+ if (action === "submit") {
333
+ // a collaborator's notes union back as they go; there is no verdict to submit
334
+ if (state.canSubmitVerdict === false)
335
+ return status("shared view - your notes save as you go; q to leave");
336
+ return state.resolved ? [] : [{ type: "openSubmit" }];
337
+ }
338
+ return null;
339
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { placeKittyImage, transmitKittyImage } from "./kitty-image";
3
+
4
+ const REGION = { column: 2, row: 3, columns: 40, rows: 12 };
5
+
6
+ describe("kitty image stacking", () => {
7
+ test("places the prototype below cells with painted backgrounds", () => {
8
+ // Arrange
9
+ const writes: string[] = [];
10
+
11
+ // Act
12
+ transmitKittyImage((chunk) => writes.push(chunk), new Uint8Array([1]), REGION, 811);
13
+ placeKittyImage((chunk) => writes.push(chunk), REGION, 811);
14
+
15
+ // Assert
16
+ expect(writes).toHaveLength(2);
17
+ expect(writes[0]).toContain("z=-1073741825");
18
+ expect(writes[1]).toContain("z=-1073741825");
19
+ });
20
+ });
@@ -0,0 +1,75 @@
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, then re-place it after each
5
+ * frame over its reserved (never-repainted) cells, cell-addressed so it needs no
6
+ * pixel-resolution report.
7
+ */
8
+
9
+ export interface CellRegion {
10
+ /** 0-based screen cell of the region's top-left corner. */
11
+ column: number;
12
+ row: number;
13
+ columns: number;
14
+ rows: number;
15
+ }
16
+
17
+ /** Writes a raw sequence ordered against the renderer's own frame output. */
18
+ export type OrderedWrite = (chunk: string) => void;
19
+
20
+ const ESCAPE_STRING_TERMINATOR = "\x1b\\";
21
+ const CHUNK_BYTES = 4096;
22
+ // The protocol reserves values below INT32_MIN/2 for images that must sit
23
+ // beneath cells with painted backgrounds, such as the marker popover.
24
+ const IMAGE_Z_INDEX = -1_073_741_825;
25
+
26
+ /** Save the cursor, run the placement at the region's corner, restore it. */
27
+ function atRegion(region: CellRegion, body: string): string {
28
+ const moveToCorner = `\x1b[${region.row + 1};${region.column + 1}H`;
29
+ return `\x1b7${moveToCorner}${body}\x1b8`;
30
+ }
31
+
32
+ function graphicsCommand(keys: string, payload: string): string {
33
+ return `\x1b_G${keys};${payload}${ESCAPE_STRING_TERMINATOR}`;
34
+ }
35
+
36
+ /**
37
+ * Transmit a PNG and display it filling the region's cells (a=T). f=100 is PNG,
38
+ * C=1 keeps the cursor put so a full-region image cannot scroll the screen, and
39
+ * q=2 suppresses the terminal's replies. Data over 4096 bytes is chunked.
40
+ */
41
+ export function transmitKittyImage(
42
+ write: OrderedWrite,
43
+ png: Uint8Array,
44
+ region: CellRegion,
45
+ imageId: number,
46
+ ): void {
47
+ if (region.columns < 1 || region.rows < 1) return;
48
+ const base64 = Buffer.from(png).toString("base64");
49
+ const controlKeys = `a=T,f=100,i=${imageId},q=2,C=1,z=${IMAGE_Z_INDEX},c=${region.columns},r=${region.rows}`;
50
+ let sequence = "";
51
+ for (let offset = 0; offset < base64.length; offset += CHUNK_BYTES) {
52
+ const chunk = base64.slice(offset, offset + CHUNK_BYTES);
53
+ const isMore = offset + CHUNK_BYTES < base64.length ? 1 : 0;
54
+ sequence +=
55
+ offset === 0
56
+ ? graphicsCommand(`${controlKeys},m=${isMore}`, chunk)
57
+ : graphicsCommand(`m=${isMore}`, chunk);
58
+ }
59
+ write(atRegion(region, sequence));
60
+ }
61
+
62
+ /** Re-place an already-transmitted image over its region, cheaply (a=p, no data). */
63
+ export function placeKittyImage(write: OrderedWrite, region: CellRegion, imageId: number): void {
64
+ if (region.columns < 1 || region.rows < 1) return;
65
+ const command = graphicsCommand(
66
+ `a=p,i=${imageId},q=2,C=1,z=${IMAGE_Z_INDEX},c=${region.columns},r=${region.rows}`,
67
+ "",
68
+ );
69
+ write(atRegion(region, command));
70
+ }
71
+
72
+ /** Remove the image so it does not linger after the sheet closes (a=d). */
73
+ export function deleteKittyImage(write: OrderedWrite, imageId: number): void {
74
+ write(graphicsCommand(`a=d,d=i,i=${imageId},q=2`, ""));
75
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { cssBoxToCell, imageCellToCss } from "./prototype-browser";
3
+
4
+ const IMAGE = { x: 1, y: 1, width: 80, height: 40 };
5
+ const VIEWPORT = { width: 1280, height: 720 };
6
+
7
+ describe("imageCellToCss", () => {
8
+ test("maps a cell inside the image to the center of its viewport pixel band", () => {
9
+ // Act - the image's top-left cell (1,1)
10
+ const css = imageCellToCss({ x: 1, y: 1 }, IMAGE, VIEWPORT)!;
11
+
12
+ // Assert - half a cell into the first band
13
+ expect(css.x).toBeCloseTo((0.5 / 80) * 1280, 5);
14
+ expect(css.y).toBeCloseTo((0.5 / 40) * 720, 5);
15
+ });
16
+
17
+ test("a click below the image's origin lands deeper in the page", () => {
18
+ // Act
19
+ const top = imageCellToCss({ x: 40, y: 2 }, IMAGE, VIEWPORT)!;
20
+ const bottom = imageCellToCss({ x: 40, y: 38 }, IMAGE, VIEWPORT)!;
21
+
22
+ // Assert
23
+ expect(bottom.y).toBeGreaterThan(top.y);
24
+ });
25
+
26
+ test("a cell outside the image returns null", () => {
27
+ // Assert - the gutter left of and the row above the image
28
+ expect(imageCellToCss({ x: 0, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
29
+ expect(imageCellToCss({ x: 10, y: 0 }, IMAGE, VIEWPORT)).toBeNull();
30
+ expect(imageCellToCss({ x: 200, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
31
+ });
32
+ });
33
+
34
+ describe("cssBoxToCell", () => {
35
+ test("maps a viewport rect back to the image cells covering it", () => {
36
+ // Arrange - a rect covering the right half, lower half of the page
37
+ const box = { x: 640, y: 360, width: 320, height: 180 };
38
+
39
+ // Act
40
+ const cell = cssBoxToCell(box, IMAGE, VIEWPORT);
41
+
42
+ // Assert - x at half width -> 40 cells in from the image origin (1)
43
+ expect(cell.column).toBe(1 + Math.floor((640 / 1280) * 80));
44
+ expect(cell.row).toBe(1 + Math.floor((360 / 720) * 40));
45
+ expect(cell.columns).toBe(Math.round((320 / 1280) * 80));
46
+ expect(cell.rows).toBe(Math.round((180 / 720) * 40));
47
+ });
48
+
49
+ test("a zero-size rect still reserves at least one cell", () => {
50
+ // Assert
51
+ const cell = cssBoxToCell({ x: 0, y: 0, width: 0, height: 0 }, IMAGE, VIEWPORT);
52
+ expect(cell.columns).toBe(1);
53
+ expect(cell.rows).toBe(1);
54
+ });
55
+ });