@cueloop/client 0.1.0-alpha.8 → 0.1.0-alpha.81

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 (307) 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 +40 -32
  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 +176 -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 +428 -0
  16. package/src/App.prototype-markdown.test.tsx +92 -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 +222 -0
  23. package/src/App.test.tsx +333 -131
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1338 -608
  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 +701 -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 +574 -0
  35. package/src/app-view-model.ts +162 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/clipboard.test.ts +23 -0
  39. package/src/clipboard.ts +33 -0
  40. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  41. package/src/components/AnnotatableFileView.test.tsx +102 -0
  42. package/src/components/AnnotatableFileView.tsx +134 -0
  43. package/src/components/AnnotationCards.stories.tsx +86 -0
  44. package/src/components/AnnotationCards.tsx +341 -0
  45. package/src/components/AnsiScreen.stories.tsx +12 -0
  46. package/src/components/AnsiScreen.tsx +28 -0
  47. package/src/components/AppHeader.stories.tsx +50 -0
  48. package/src/components/AppHeader.tsx +83 -0
  49. package/src/components/AppShell.brand.test.tsx +58 -0
  50. package/src/components/AppShell.stories.tsx +89 -0
  51. package/src/components/AppShell.tsx +329 -0
  52. package/src/components/AppShell.zoom.test.tsx +135 -0
  53. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  54. package/src/components/BareWorkbenchFileView.tsx +64 -0
  55. package/src/components/Breadcrumb.stories.tsx +19 -0
  56. package/src/components/Breadcrumb.tsx +45 -0
  57. package/src/components/ChangesColumn.stories.tsx +44 -0
  58. package/src/components/ChangesColumn.tsx +199 -0
  59. package/src/components/ChangesFileTree.test.tsx +61 -0
  60. package/src/components/CodeBlock.stories.tsx +42 -0
  61. package/src/components/CodeBlock.tsx +84 -0
  62. package/src/components/CompletionOverlay.stories.tsx +37 -0
  63. package/src/components/CompletionOverlay.tsx +77 -0
  64. package/src/components/ConfirmCard.stories.tsx +37 -0
  65. package/src/components/ConfirmCard.tsx +153 -0
  66. package/src/components/ConfirmDialog.stories.tsx +21 -0
  67. package/src/components/ConfirmDialog.tsx +72 -0
  68. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  69. package/src/components/DiffContentView.split.test.tsx +140 -0
  70. package/src/components/DiffContentView.stories.tsx +80 -0
  71. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  72. package/src/components/DiffContentView.tsx +876 -0
  73. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  74. package/src/components/DiscussionMarkerRail.tsx +186 -0
  75. package/src/components/EditorGrid.stories.tsx +59 -0
  76. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  77. package/src/components/EditorGrid.tsx +426 -0
  78. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  79. package/src/components/FileContentsView.stories.tsx +16 -0
  80. package/src/components/FileContentsView.test.tsx +29 -0
  81. package/src/components/FileContentsView.tsx +98 -0
  82. package/src/components/GridTabContent.stories.tsx +59 -0
  83. package/src/components/GridTabContent.tsx +182 -0
  84. package/src/components/HandleChipsInput.stories.tsx +24 -0
  85. package/src/components/HandleChipsInput.test.tsx +35 -0
  86. package/src/components/HandleChipsInput.tsx +145 -0
  87. package/src/components/KeybindsDialog.stories.tsx +31 -0
  88. package/src/components/KeybindsDialog.tsx +103 -0
  89. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  90. package/src/components/MarkdownGridBlock.tsx +72 -0
  91. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  92. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  93. package/src/components/MarkdownThreadEditor.tsx +162 -0
  94. package/src/components/MenuBar.stories.tsx +28 -0
  95. package/src/components/MenuBar.tsx +44 -0
  96. package/src/components/NavModeHint.stories.tsx +40 -0
  97. package/src/components/NavModeHint.tsx +31 -0
  98. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  99. package/src/components/OverlayScrollbar.test.tsx +139 -0
  100. package/src/components/OverlayScrollbar.tsx +129 -0
  101. package/src/components/PaneGrid.stories.tsx +84 -0
  102. package/src/components/PaneGrid.tsx +128 -0
  103. package/src/components/PanelColumn.stories.tsx +25 -0
  104. package/src/components/PanelColumn.tsx +98 -0
  105. package/src/components/ProjectTreeView.stories.tsx +25 -0
  106. package/src/components/ProjectTreeView.test.tsx +59 -0
  107. package/src/components/ProjectTreeView.tsx +111 -0
  108. package/src/components/PromptDialog.stories.tsx +36 -0
  109. package/src/components/PromptDialog.test.tsx +61 -0
  110. package/src/components/PromptDialog.tsx +99 -0
  111. package/src/components/PrototypeContentView.stories.tsx +19 -0
  112. package/src/components/PrototypeContentView.tsx +446 -0
  113. package/src/components/RootOverlay.stories.tsx +45 -0
  114. package/src/components/RootOverlay.tsx +48 -0
  115. package/src/components/ScrollArea.stories.tsx +20 -0
  116. package/src/components/ScrollArea.tsx +44 -0
  117. package/src/components/SettingsDialog.stories.tsx +117 -0
  118. package/src/components/SettingsDialog.tsx +186 -0
  119. package/src/components/SettingsRows.stories.tsx +18 -0
  120. package/src/components/SettingsRows.tsx +112 -0
  121. package/src/components/ShareDialog.stories.tsx +121 -0
  122. package/src/components/ShareDialog.tsx +552 -0
  123. package/src/components/ShellHeader.stories.tsx +50 -0
  124. package/src/components/ShellHeader.tsx +137 -0
  125. package/src/components/SlashComposer.stories.tsx +37 -0
  126. package/src/components/SlashComposer.tsx +87 -0
  127. package/src/components/ThreadFooter.stories.tsx +30 -0
  128. package/src/components/ThreadFooter.test.tsx +47 -0
  129. package/src/components/ThreadFooter.tsx +76 -0
  130. package/src/components/ThreadTree.stories.tsx +46 -0
  131. package/src/components/ThreadTree.tsx +255 -0
  132. package/src/components/ThreadView.selection.test.tsx +899 -0
  133. package/src/components/ThreadView.slash.test.tsx +146 -0
  134. package/src/components/ThreadView.stories.tsx +26 -0
  135. package/src/components/ThreadView.tsx +472 -0
  136. package/src/components/Toast.stories.tsx +19 -0
  137. package/src/components/Toast.tsx +52 -0
  138. package/src/components/Tooltip.stories.tsx +26 -0
  139. package/src/components/Tooltip.tsx +72 -0
  140. package/src/components/TreePane.stories.tsx +106 -0
  141. package/src/components/TreePane.tsx +157 -0
  142. package/src/components/WalkWizard.stories.tsx +100 -0
  143. package/src/components/WalkWizard.tsx +150 -0
  144. package/src/components/WelcomePlayground.stories.tsx +12 -0
  145. package/src/components/WelcomePlayground.test.tsx +47 -0
  146. package/src/components/WelcomePlayground.tsx +142 -0
  147. package/src/components/__snapshots__/stories.test.tsx.snap +2238 -0
  148. package/src/components/diff-content-view-layout.test.ts +198 -0
  149. package/src/components/diff-content-view-layout.ts +84 -0
  150. package/src/components/editor-grid.test.ts +86 -0
  151. package/src/components/editor-grid.ts +138 -0
  152. package/src/components/file-tree.test.ts +60 -0
  153. package/src/components/file-tree.ts +112 -0
  154. package/src/components/menu-control.stories.tsx +35 -0
  155. package/src/components/menu-control.tsx +43 -0
  156. package/src/components/pages/Workspace.stories.tsx +181 -0
  157. package/src/components/primitives/Button.stories.tsx +32 -0
  158. package/src/components/primitives/Button.tsx +56 -0
  159. package/src/components/primitives/Card.stories.tsx +25 -0
  160. package/src/components/primitives/Card.tsx +63 -0
  161. package/src/components/primitives/Dialog.stories.tsx +24 -0
  162. package/src/components/primitives/Dialog.test.tsx +37 -0
  163. package/src/components/primitives/Dialog.tsx +71 -0
  164. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  165. package/src/components/primitives/DialogActions.tsx +39 -0
  166. package/src/components/primitives/IconButton.stories.tsx +36 -0
  167. package/src/components/primitives/IconButton.tsx +62 -0
  168. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  169. package/src/components/primitives/StatusBar.tsx +23 -0
  170. package/src/components/primitives/Switch.stories.tsx +17 -0
  171. package/src/components/primitives/Switch.tsx +32 -0
  172. package/src/components/primitives/Tabs.stories.tsx +29 -0
  173. package/src/components/primitives/Tabs.tsx +102 -0
  174. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  175. package/src/components/primitives/Toolbar.tsx +14 -0
  176. package/src/components/primitives/Tree.stories.tsx +124 -0
  177. package/src/components/primitives/Tree.tsx +100 -0
  178. package/src/components/primitives/frame.test.ts +12 -0
  179. package/src/components/primitives/frame.ts +17 -0
  180. package/src/components/primitives/icons.ts +61 -0
  181. package/src/components/primitives/tree-model.test.ts +99 -0
  182. package/src/components/primitives/tree-model.ts +137 -0
  183. package/src/components/quick-actions-editor.stories.tsx +58 -0
  184. package/src/components/quick-actions-editor.test.tsx +117 -0
  185. package/src/components/quick-actions-editor.tsx +226 -0
  186. package/src/components/share-dialog-store.ts +58 -0
  187. package/src/components/stories-app.tsx +193 -0
  188. package/src/components/stories.test.tsx +74 -0
  189. package/src/components/story-fixtures.ts +102 -0
  190. package/src/components/story-tree.test.ts +108 -0
  191. package/src/components/story.ts +159 -0
  192. package/src/components/syntax-highlight.ts +133 -0
  193. package/src/components/terminal-pane.test.tsx +40 -0
  194. package/src/components/terminal-pane.ts +199 -0
  195. package/src/components/theme-context.stories.tsx +46 -0
  196. package/src/components/theme-context.tsx +37 -0
  197. package/src/components/thread-tree.test.ts +134 -0
  198. package/src/components/thread-tree.ts +109 -0
  199. package/src/components/truncate-text.ts +7 -0
  200. package/src/components/truncate-title.test.ts +31 -0
  201. package/src/components/truncate-title.ts +15 -0
  202. package/src/config-identity.test.ts +78 -0
  203. package/src/config.test.ts +517 -33
  204. package/src/config.ts +467 -57
  205. package/src/diff-fold.test.ts +78 -0
  206. package/src/diff-fold.ts +126 -0
  207. package/src/diff-hunk-curate.test.ts +315 -0
  208. package/src/diff-hunk-curate.ts +153 -0
  209. package/src/diff-intraline.test.ts +238 -0
  210. package/src/diff-intraline.ts +236 -0
  211. package/src/diff-syntax.test.ts +112 -0
  212. package/src/diff-syntax.ts +179 -0
  213. package/src/discussions.ts +79 -0
  214. package/src/editor.test.ts +149 -0
  215. package/src/editor.ts +139 -9
  216. package/src/ghostty-terminal.test.ts +85 -0
  217. package/src/ghostty-terminal.ts +199 -0
  218. package/src/github-identity.test.ts +58 -0
  219. package/src/github-identity.ts +41 -0
  220. package/src/herdr.test.ts +9 -34
  221. package/src/herdr.ts +25 -21
  222. package/src/index.ts +13 -0
  223. package/src/integrations.test.ts +41 -0
  224. package/src/integrations.ts +32 -0
  225. package/src/intent-dispatch.test.ts +723 -0
  226. package/src/intent-dispatch.ts +692 -0
  227. package/src/key-bindings.test.ts +156 -0
  228. package/src/key-bindings.ts +459 -0
  229. package/src/keymap.test.ts +461 -113
  230. package/src/keymap.ts +321 -79
  231. package/src/kitty-image.test.ts +66 -0
  232. package/src/kitty-image.ts +138 -0
  233. package/src/launch-layout.test.ts +52 -0
  234. package/src/launch-layout.ts +42 -0
  235. package/src/mark-runs.ts +121 -0
  236. package/src/markdown-editor-syntax-style.ts +50 -0
  237. package/src/markdown-grid.test.ts +101 -0
  238. package/src/markdown-grid.ts +203 -0
  239. package/src/markdown-highlight.test.ts +61 -0
  240. package/src/markdown-highlight.ts +122 -0
  241. package/src/pasted-image.test.ts +67 -0
  242. package/src/pasted-image.ts +60 -0
  243. package/src/perf/otlp-trace.test.ts +116 -0
  244. package/src/perf/otlp-trace.ts +130 -0
  245. package/src/perf/perf-report.ts +17 -0
  246. package/src/perf/perf-timings.test.ts +18 -0
  247. package/src/perf/perf-timings.ts +38 -0
  248. package/src/prototype-browser.test.ts +64 -0
  249. package/src/prototype-browser.ts +329 -0
  250. package/src/prototype-pixels.tsx +35 -0
  251. package/src/pty.test.ts +81 -0
  252. package/src/pty.ts +245 -0
  253. package/src/ready-signal.test.ts +85 -0
  254. package/src/ready-signal.ts +52 -0
  255. package/src/run.ts +78 -13
  256. package/src/serve.test.ts +24 -12
  257. package/src/serve.ts +52 -10
  258. package/src/share.ts +237 -0
  259. package/src/skills.test.ts +74 -0
  260. package/src/skills.ts +75 -0
  261. package/src/slash-palette.test.ts +103 -0
  262. package/src/slash-palette.ts +129 -0
  263. package/src/split-diff.test.ts +110 -0
  264. package/src/split-diff.ts +149 -0
  265. package/src/test-support.test.ts +264 -0
  266. package/src/test-support.ts +369 -0
  267. package/src/theme-presets.test.ts +51 -0
  268. package/src/theme-presets.ts +214 -0
  269. package/src/theme.ts +84 -19
  270. package/src/thread-chords.test.ts +79 -0
  271. package/src/thread-chords.ts +163 -0
  272. package/src/thread-controller.curate.test.ts +450 -0
  273. package/src/thread-controller.derivation.test.ts +62 -0
  274. package/src/thread-controller.live-diff.test.ts +322 -0
  275. package/src/thread-controller.optimistic.test.ts +69 -0
  276. package/src/thread-controller.share.test.ts +561 -0
  277. package/src/thread-controller.switch-cache.test.ts +176 -0
  278. package/src/thread-controller.tree.test.ts +261 -0
  279. package/src/thread-controller.ts +1893 -0
  280. package/src/thread-selection.test.ts +210 -0
  281. package/src/thread-selection.ts +243 -0
  282. package/src/tree-view.test.ts +128 -0
  283. package/src/tree-view.ts +148 -0
  284. package/src/use-annotation-surface.tsx +915 -0
  285. package/src/use-changes-workbench.ts +190 -0
  286. package/src/use-frame-measure.ts +41 -0
  287. package/src/use-remember-layout.ts +31 -0
  288. package/src/use-settings-dialog.tsx +320 -0
  289. package/src/use-terminal-virtualizer.ts +233 -0
  290. package/src/use-thread-body-editing.tsx +58 -0
  291. package/src/version.ts +5 -0
  292. package/src/view-diff.test.ts +137 -9
  293. package/src/view-diff.ts +269 -23
  294. package/src/view-plan.regression.test.ts +60 -0
  295. package/src/view-plan.test.ts +456 -0
  296. package/src/view-plan.ts +719 -0
  297. package/src/visual/contrast.test.ts +35 -0
  298. package/src/visual/contrast.ts +33 -0
  299. package/src/walk.test.ts +121 -0
  300. package/src/walk.ts +74 -0
  301. package/src/workbench-snapshot.test.ts +81 -0
  302. package/src/workbench-snapshot.ts +29 -0
  303. package/src/session-controller.ts +0 -345
  304. package/src/syntax.test.ts +0 -41
  305. package/src/syntax.ts +0 -88
  306. package/src/view.test.ts +0 -157
  307. package/src/view.ts +0 -312
@@ -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
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defaultLayout, layoutFromPanes, planLayout, reviewLayout } from "./launch-layout";
3
+
4
+ describe("launch layout factories", () => {
5
+ test("review opens the diff zoomed with the Changes panel", () => {
6
+ // Assert
7
+ expect(reviewLayout()).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
8
+ });
9
+
10
+ test("plan fills the middle with the thread pane and no right region", () => {
11
+ // Assert
12
+ expect(planLayout()).toEqual({ threads: true, rightSidebar: "off", zoomChanges: false });
13
+ });
14
+
15
+ test("the first-run default lands on the inbox with the diff zoomed", () => {
16
+ // Assert
17
+ expect(defaultLayout()).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
18
+ });
19
+ });
20
+
21
+ describe("layoutFromPanes", () => {
22
+ test("the Changes editor wins the right region when open", () => {
23
+ // Assert
24
+ expect(
25
+ layoutFromPanes({ threads: true, changesOpen: true, projectOpen: true, zoomChanges: true }),
26
+ ).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
27
+ });
28
+
29
+ test("the Project tree holds the right region when the Changes editor is closed", () => {
30
+ // Assert
31
+ expect(
32
+ layoutFromPanes({
33
+ threads: false,
34
+ changesOpen: false,
35
+ projectOpen: true,
36
+ zoomChanges: false,
37
+ }),
38
+ ).toEqual({ threads: false, rightSidebar: "project", zoomChanges: false });
39
+ });
40
+
41
+ test("the right region is off when neither pane is open", () => {
42
+ // Assert
43
+ expect(
44
+ layoutFromPanes({
45
+ threads: true,
46
+ changesOpen: false,
47
+ projectOpen: false,
48
+ zoomChanges: false,
49
+ }),
50
+ ).toEqual({ threads: true, rightSidebar: "off", zoomChanges: false });
51
+ });
52
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The pane composition a launch opens with: whether the Threads sidebar is open,
3
+ * what the right region shows, and whether the Changes editor is zoomed. Each
4
+ * command picks a layout through a factory; a bare launch restores the last one
5
+ * the user left (persisted in config), falling back to the default.
6
+ */
7
+
8
+ export interface LaunchLayout {
9
+ /** The Threads sidebar is open. */
10
+ threads: boolean;
11
+ /** The right region: the Changes editor, the Project tree, or closed. */
12
+ rightSidebar: "changes" | "project" | "off";
13
+ /** The Changes editor is zoomed - the Thread pane hides so the diff fills the middle. */
14
+ zoomChanges: boolean;
15
+ }
16
+
17
+ /** `cueloop diff` / `cueloop review`: the diff front and center. */
18
+ export function reviewLayout(): LaunchLayout {
19
+ return { threads: true, rightSidebar: "changes", zoomChanges: true };
20
+ }
21
+
22
+ /** `cueloop plan` / `cueloop reply`: the thread pane fills the middle; no Changes panel. */
23
+ export function planLayout(): LaunchLayout {
24
+ return { threads: true, rightSidebar: "off", zoomChanges: false };
25
+ }
26
+
27
+ /** The first-run default, before any layout has been remembered: the inbox with the diff zoomed. */
28
+ export function defaultLayout(): LaunchLayout {
29
+ return { threads: true, rightSidebar: "changes", zoomChanges: true };
30
+ }
31
+
32
+ /** Read back the layout the panes currently sit in, to remember it for the next bare launch. */
33
+ export function layoutFromPanes(panes: {
34
+ threads: boolean;
35
+ changesOpen: boolean;
36
+ projectOpen: boolean;
37
+ zoomChanges: boolean;
38
+ }): LaunchLayout {
39
+ const rightSidebar = panes.changesOpen ? "changes" : panes.projectOpen ? "project" : "off";
40
+
41
+ return { threads: panes.threads, rightSidebar, zoomChanges: panes.zoomChanges };
42
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Mark geometry for an annotated line of text: which stretches are marked
3
+ * (a discussion span, a held selection) or carry the caret cell, the greedy
4
+ * word wrap into visual lines, and the printable-key test that starts a
5
+ * comment. Renderer-free, shared by the plan thread view and the diff sheet.
6
+ */
7
+
8
+ import type { KeyEvent } from "@opentui/core";
9
+ import { wordRanges } from "./thread-selection";
10
+
11
+ export interface MarkRange {
12
+ start: number;
13
+ end: number;
14
+ /** The bare caret's single cell - a cursor, not yet a mark. */
15
+ caretOnly?: boolean;
16
+ }
17
+
18
+ export interface Run {
19
+ text: string;
20
+ marked: boolean;
21
+ caretOnly: boolean;
22
+ }
23
+
24
+ /** Split a block's text into plain/marked runs from the union of ranges. */
25
+ export function runsFor(text: string, ranges: MarkRange[]): Run[] {
26
+ const cuts = new Set<number>([0, text.length]);
27
+
28
+ for (const range of ranges) {
29
+ cuts.add(Math.max(0, range.start));
30
+ cuts.add(Math.min(text.length, range.end));
31
+ }
32
+ const edges = [...cuts].toSorted((left, right) => left - right);
33
+ const runs: Run[] = [];
34
+
35
+ for (let index = 0; index < edges.length - 1; index++) {
36
+ const start = edges[index]!;
37
+ const end = edges[index + 1]!;
38
+
39
+ if (end <= start) continue;
40
+ const covering = ranges.filter((range) => range.start <= start && end <= range.end);
41
+ // the caret cell shows through a mark, so the head of a selection is visible
42
+ runs.push({
43
+ text: text.slice(start, end),
44
+ marked: covering.some((range) => range.caretOnly !== true),
45
+ caretOnly: covering.some((range) => range.caretOnly === true),
46
+ });
47
+ }
48
+
49
+ return runs.length > 0 ? runs : [{ text, marked: false, caretOnly: false }];
50
+ }
51
+
52
+ /** A visual line of a block: the char range of the block's text it shows. */
53
+ export interface VisualLine {
54
+ start: number;
55
+ end: number;
56
+ }
57
+
58
+ /**
59
+ * Greedy word wrap into visual-line char ranges, so a comment card can slot
60
+ * in directly below the line its mark ends on (mid-paragraph when needed).
61
+ */
62
+ export function wrapLines(text: string, width: number): VisualLine[] {
63
+ const lines: VisualLine[] = [];
64
+ let segmentStart = 0;
65
+
66
+ // hard breaks first: every visual line must be exactly one terminal row,
67
+ // or hit-testing and the mark geometry drift from what is painted
68
+ for (const segment of text.split("\n")) {
69
+ const segmentEnd = segmentStart + segment.length;
70
+
71
+ if (width <= 0 || segment.length <= width) {
72
+ lines.push({ start: segmentStart, end: segmentEnd });
73
+ } else {
74
+ // greedy word wrap inside the segment; the first line keeps its
75
+ // leading indentation, continuation lines start at a word
76
+ let lineStart = segmentStart;
77
+ let lineEnd = segmentStart;
78
+
79
+ for (const word of wordRanges(segment)) {
80
+ const wordStart = segmentStart + word.start;
81
+ const wordEnd = segmentStart + word.end;
82
+
83
+ if (wordEnd - lineStart > width && lineEnd > lineStart) {
84
+ lines.push({ start: lineStart, end: lineEnd });
85
+ lineStart = wordStart;
86
+ }
87
+ // a token wider than the line (a long identifier, a URL) breaks at the cell edge, so
88
+ // every visual line stays one terminal row and nothing is clipped away
89
+ while (wordEnd - lineStart > width) {
90
+ lines.push({ start: lineStart, end: lineStart + width });
91
+ lineStart += width;
92
+ }
93
+ lineEnd = wordEnd;
94
+ }
95
+ lines.push({ start: lineStart, end: Math.max(lineEnd, lineStart) });
96
+ }
97
+ segmentStart = segmentEnd + 1;
98
+ }
99
+
100
+ return lines;
101
+ }
102
+
103
+ /** The block's mark ranges clipped to one visual line, in that line's coordinates. */
104
+ export function lineMarkRanges(ranges: MarkRange[], line: VisualLine): MarkRange[] {
105
+ return ranges
106
+ .map((range) => ({
107
+ start: Math.max(range.start, line.start) - line.start,
108
+ end: Math.min(range.end, line.end) - line.start,
109
+ caretOnly: range.caretOnly,
110
+ }))
111
+ .filter((range) => range.end > range.start);
112
+ }
113
+
114
+ /** Printable input that should reach a composer: one key, or a whole pasted run in one event. */
115
+ export function printableSequence(key: KeyEvent): string | null {
116
+ if (!key.sequence || key.ctrl || key.meta) return null;
117
+ // reject any run carrying a control byte, so escape sequences and \r\t\b never seed a draft
118
+ for (const character of key.sequence) if (character < " ") return null;
119
+
120
+ return key.sequence.length > 0 ? key.sequence : null;
121
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The native SyntaxStyle for the inline markdown editor: maps each markdown
3
+ * token group to a theme color and attribute set, and resolves the styleId the
4
+ * edit buffer needs for `addHighlightByCharRange`. Cached per theme, mirroring
5
+ * syntaxStyleFor. Syntax punctuation (the "marker" group) is dimmed rather than
6
+ * hidden, since the editor shows raw source.
7
+ */
8
+
9
+ import { SyntaxStyle, type StyleDefinitionInput } from "@opentui/core";
10
+ import { type Theme } from "./theme";
11
+ import { MARKDOWN_HIGHLIGHT_GROUPS, type MarkdownHighlightGroup } from "./markdown-highlight";
12
+
13
+ /** A markdown SyntaxStyle plus the lookup from token group to its native styleId. */
14
+ export interface MarkdownEditorStyle {
15
+ style: SyntaxStyle;
16
+ styleIdFor: (group: MarkdownHighlightGroup) => number;
17
+ }
18
+
19
+ function markdownGroupStyles(theme: Theme): Record<MarkdownHighlightGroup, StyleDefinitionInput> {
20
+ return {
21
+ heading: { bold: true },
22
+ marker: { fg: theme.textDim },
23
+ link: { fg: theme.blue, underline: true },
24
+ code: { fg: theme.textDim },
25
+ };
26
+ }
27
+
28
+ const markdownEditorStyleCache = new WeakMap<Theme, MarkdownEditorStyle>();
29
+
30
+ /** Build (or reuse) the markdown editor's SyntaxStyle for a theme, with a group-to-styleId lookup. */
31
+ export function markdownEditorStyle(theme: Theme): MarkdownEditorStyle {
32
+ const cached = markdownEditorStyleCache.get(theme);
33
+
34
+ if (cached) return cached;
35
+ const groupStyles = markdownGroupStyles(theme);
36
+ const style = SyntaxStyle.fromStyles(groupStyles);
37
+ const styleIds = new Map<MarkdownHighlightGroup, number>();
38
+
39
+ for (const group of MARKDOWN_HIGHLIGHT_GROUPS) {
40
+ styleIds.set(group, style.getStyleId(group) ?? 0);
41
+ }
42
+ const entry: MarkdownEditorStyle = {
43
+ style,
44
+ styleIdFor: (group) => styleIds.get(group) ?? 0,
45
+ };
46
+
47
+ markdownEditorStyleCache.set(theme, entry);
48
+
49
+ return entry;
50
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ layoutFrontmatterGrid,
4
+ layoutMarkdownTable,
5
+ parseFrontmatterRows,
6
+ parseMarkdownTable,
7
+ } from "./markdown-grid";
8
+
9
+ /** The plain text of a grid line, segments concatenated. */
10
+ function lineText(line: { text: string }[]): string {
11
+ return line.map((segment) => segment.text).join("");
12
+ }
13
+
14
+ describe("parseMarkdownTable", () => {
15
+ test("reads header cells, alignments, and body rows", () => {
16
+ const table = parseMarkdownTable("| Name | Size |\n| :--- | ---: |\n| a | 1 |\n| bb | 22 |");
17
+
18
+ expect(table.header).toEqual(["Name", "Size"]);
19
+ expect(table.aligns).toEqual(["left", "right"]);
20
+ expect(table.rows).toEqual([
21
+ ["a", "1"],
22
+ ["bb", "22"],
23
+ ]);
24
+ });
25
+
26
+ test("center alignment reads from a :---: delimiter", () => {
27
+ expect(parseMarkdownTable("| a |\n| :---: |\n| x |").aligns).toEqual(["center"]);
28
+ });
29
+
30
+ test("an escaped pipe stays inside its cell instead of splitting the column", () => {
31
+ const table = parseMarkdownTable("| Name | Note |\n| --- | --- |\n| a | b \\| c |");
32
+
33
+ expect(table.rows).toEqual([["a", "b | c"]]);
34
+ });
35
+
36
+ test("a blank cell between border pipes survives the border strip", () => {
37
+ expect(parseMarkdownTable("| | b |\n| --- | --- |\n| x | y |").header).toEqual(["", "b"]);
38
+ });
39
+ });
40
+
41
+ describe("layoutMarkdownTable", () => {
42
+ test("bolds the header, rules under it, and right-aligns a numeric column", () => {
43
+ const lines = layoutMarkdownTable(
44
+ parseMarkdownTable("| Name | Size |\n| --- | ---: |\n| a | 1 |"),
45
+ );
46
+
47
+ expect(lines[0]![0]!.bold).toBe(true);
48
+ expect(lineText(lines[0]!)).toBe("Name Size");
49
+ expect(lines[1]![0]!.dim).toBe(true);
50
+ expect(lineText(lines[1]!)).toBe("─".repeat("Name Size".length));
51
+ // "a" padded to the "Name" column, "1" right-aligned under "Size"
52
+ expect(lineText(lines[2]!)).toBe("a" + " ".repeat(8) + "1");
53
+ });
54
+ });
55
+
56
+ describe("parseFrontmatterRows", () => {
57
+ test("splits top-level key: value pairs", () => {
58
+ expect(parseFrontmatterRows("title: Plan\nowner: me")).toEqual([
59
+ { key: "title", value: "Plan" },
60
+ { key: "owner", value: "me" },
61
+ ]);
62
+ });
63
+
64
+ test("folds an indented continuation into the row above", () => {
65
+ expect(parseFrontmatterRows("tags:\n - one\n - two")).toEqual([
66
+ { key: "tags", value: "- one\n- two" },
67
+ ]);
68
+ });
69
+ });
70
+
71
+ describe("layoutFrontmatterGrid", () => {
72
+ test("draws a full border with a bold key and a rule between rows", () => {
73
+ const lines = layoutFrontmatterGrid(
74
+ [
75
+ { key: "title", value: "Plan" },
76
+ { key: "owner", value: "me" },
77
+ ],
78
+ 40,
79
+ );
80
+
81
+ expect(lineText(lines[0]!).startsWith("┌")).toBe(true);
82
+ expect(lineText(lines.at(-1)!).startsWith("└")).toBe(true);
83
+ // the key cell is bold, the border pieces are dim
84
+ const firstRow = lines[1]!;
85
+
86
+ expect(firstRow.find((segment) => segment.text.includes("title"))!.bold).toBe(true);
87
+ expect(firstRow[0]!.dim).toBe(true);
88
+ // a divider separates the two rows
89
+ expect(lines.some((line) => lineText(line).startsWith("├"))).toBe(true);
90
+ });
91
+
92
+ test("wraps a long value across cell lines, keeping the key on the first", () => {
93
+ const lines = layoutFrontmatterGrid([{ key: "desc", value: "one two three four five" }], 24);
94
+ const bodyLines = lines.filter((line) => lineText(line).startsWith("│"));
95
+
96
+ expect(bodyLines.length).toBeGreaterThan(1);
97
+ expect(bodyLines[0]!.some((segment) => segment.text.includes("desc"))).toBe(true);
98
+ // the continuation line keeps the key column blank
99
+ expect(bodyLines[1]!.some((segment) => segment.text.includes("desc"))).toBe(false);
100
+ });
101
+ });