@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.100

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 (355) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +42 -34
  8. package/src/annotations/annotation-palette.ts +62 -0
  9. package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
  10. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  11. package/src/annotations/components/AnnotationCards.tsx +364 -0
  12. package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
  13. package/src/annotations/components/DiscussionMarkerRail.tsx +186 -0
  14. package/src/annotations/discussions.ts +77 -0
  15. package/src/annotations/thread-selection.test.ts +210 -0
  16. package/src/annotations/thread-selection.ts +242 -0
  17. package/src/annotations/use-annotation-surface.tsx +1238 -0
  18. package/src/app/App.appearance.test.tsx +53 -0
  19. package/src/{App.autoclose.test.tsx → app/App.autoclose.test.tsx} +77 -51
  20. package/src/app/App.deleted-scroll.test.tsx +187 -0
  21. package/src/app/App.diff.test.tsx +313 -0
  22. package/src/app/App.export.test.tsx +105 -0
  23. package/src/app/App.focus.test.ts +89 -0
  24. package/src/app/App.identity.test.tsx +84 -0
  25. package/src/app/App.plan-render.test.tsx +209 -0
  26. package/src/app/App.plan-review.test.tsx +428 -0
  27. package/src/app/App.prototype-markdown.test.tsx +128 -0
  28. package/src/app/App.prototype.test.tsx +176 -0
  29. package/src/app/App.readonly.test.tsx +210 -0
  30. package/src/app/App.regression.test.tsx +99 -0
  31. package/src/app/App.rendering.test.tsx +134 -0
  32. package/src/app/App.share-dialog.test.tsx +177 -0
  33. package/src/app/App.submit-confirm.test.tsx +267 -0
  34. package/src/app/App.test.tsx +496 -0
  35. package/src/app/App.thread-agent.test.ts +12 -0
  36. package/src/app/App.toast.test.tsx +124 -0
  37. package/src/app/App.tsx +1650 -0
  38. package/src/app/App.visual.test.tsx +59 -0
  39. package/src/app/App.walk.test.tsx +299 -0
  40. package/src/app/App.workbench.test.tsx +1149 -0
  41. package/src/app/app-key-state.ts +61 -0
  42. package/src/app/app-screens.tsx +606 -0
  43. package/src/app/app-view-model.ts +168 -0
  44. package/src/app/components/AppHeader.stories.tsx +50 -0
  45. package/src/app/components/AppHeader.tsx +81 -0
  46. package/src/app/components/AppShell.brand.test.tsx +58 -0
  47. package/src/app/components/AppShell.extensions.test.tsx +191 -0
  48. package/src/app/components/AppShell.stories.tsx +89 -0
  49. package/src/app/components/AppShell.tsx +358 -0
  50. package/src/app/components/AppShell.zoom.test.tsx +149 -0
  51. package/src/app/components/Breadcrumb.stories.tsx +19 -0
  52. package/src/app/components/Breadcrumb.tsx +45 -0
  53. package/src/app/components/MenuBar.stories.tsx +28 -0
  54. package/src/app/components/MenuBar.tsx +44 -0
  55. package/src/app/components/PaneGrid.stories.tsx +84 -0
  56. package/src/app/components/PaneGrid.tsx +128 -0
  57. package/src/app/components/PanelColumn.stories.tsx +25 -0
  58. package/src/app/components/PanelColumn.tsx +93 -0
  59. package/src/app/components/ShellHeader.stories.tsx +50 -0
  60. package/src/app/components/ShellHeader.tsx +137 -0
  61. package/src/app/components/WorkspacePanels.stories.tsx +36 -0
  62. package/src/app/components/WorkspacePanels.tsx +120 -0
  63. package/src/app/components/pages/Workspace.stories.tsx +181 -0
  64. package/src/app/intent-dispatch.test.ts +728 -0
  65. package/src/app/intent-dispatch.ts +687 -0
  66. package/src/app/launch-layout.test.ts +78 -0
  67. package/src/app/launch-layout.ts +57 -0
  68. package/src/app/ready-signal.test.ts +85 -0
  69. package/src/app/ready-signal.ts +52 -0
  70. package/src/app/run.ts +109 -0
  71. package/src/{serve.test.ts → app/serve.test.ts} +24 -12
  72. package/src/app/serve.ts +111 -0
  73. package/src/app/version.ts +5 -0
  74. package/src/appearance/components/theme-context.stories.tsx +46 -0
  75. package/src/appearance/components/theme-context.tsx +37 -0
  76. package/src/appearance/theme-presets.test.ts +51 -0
  77. package/src/appearance/theme-presets.ts +219 -0
  78. package/src/appearance/theme.ts +112 -0
  79. package/src/appearance/visual/contrast.test.ts +35 -0
  80. package/src/appearance/visual/contrast.ts +33 -0
  81. package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
  82. package/src/diff/components/AnnotatableFileView.test.tsx +157 -0
  83. package/src/diff/components/AnnotatableFileView.tsx +141 -0
  84. package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
  85. package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
  86. package/src/diff/components/DiffContentView.scroll.test.tsx +434 -0
  87. package/src/diff/components/DiffContentView.split.test.tsx +140 -0
  88. package/src/diff/components/DiffContentView.stories.tsx +80 -0
  89. package/src/diff/components/DiffContentView.syntax.test.tsx +82 -0
  90. package/src/diff/components/DiffContentView.tsx +1055 -0
  91. package/src/diff/components/WalkWizard.stories.tsx +100 -0
  92. package/src/diff/components/WalkWizard.tsx +146 -0
  93. package/src/diff/components/diff-content-view-layout.test.ts +198 -0
  94. package/src/diff/components/diff-content-view-layout.ts +84 -0
  95. package/src/diff/diff-fold.test.ts +78 -0
  96. package/src/diff/diff-fold.ts +126 -0
  97. package/src/diff/diff-hunk-curate.test.ts +315 -0
  98. package/src/diff/diff-hunk-curate.ts +152 -0
  99. package/src/diff/diff-intraline.test.ts +239 -0
  100. package/src/diff/diff-intraline.ts +238 -0
  101. package/src/diff/diff-syntax.test.ts +125 -0
  102. package/src/diff/diff-syntax.ts +186 -0
  103. package/src/diff/split-diff.test.ts +110 -0
  104. package/src/diff/split-diff.ts +144 -0
  105. package/src/diff/view-diff.test.ts +325 -0
  106. package/src/diff/view-diff.ts +522 -0
  107. package/src/diff/walk.test.ts +121 -0
  108. package/src/diff/walk.ts +73 -0
  109. package/src/index.ts +31 -2
  110. package/src/integrations/attribution.test.ts +31 -0
  111. package/src/integrations/attribution.ts +42 -0
  112. package/src/integrations/client-extension-registry.test.tsx +61 -0
  113. package/src/integrations/client-extension-registry.ts +159 -0
  114. package/src/integrations/clipboard.test.ts +23 -0
  115. package/src/integrations/clipboard.ts +33 -0
  116. package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
  117. package/src/integrations/components/ExtensionZone.tsx +145 -0
  118. package/src/integrations/components/ShareDialog.stories.tsx +121 -0
  119. package/src/integrations/components/ShareDialog.tsx +550 -0
  120. package/src/integrations/components/share-dialog-store.ts +55 -0
  121. package/src/integrations/editor.test.ts +149 -0
  122. package/src/integrations/editor.ts +160 -0
  123. package/src/integrations/github-identity.test.ts +58 -0
  124. package/src/integrations/github-identity.ts +41 -0
  125. package/src/integrations/herdr.test.ts +43 -0
  126. package/src/integrations/herdr.ts +38 -0
  127. package/src/integrations/integrations.test.ts +41 -0
  128. package/src/integrations/integrations.ts +32 -0
  129. package/src/integrations/pasted-image.test.ts +67 -0
  130. package/src/integrations/pasted-image.ts +60 -0
  131. package/src/integrations/share.ts +238 -0
  132. package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
  133. package/src/keyboard/components/CompletionOverlay.tsx +75 -0
  134. package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
  135. package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
  136. package/src/keyboard/components/HandleChipsInput.tsx +143 -0
  137. package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
  138. package/src/keyboard/components/KeybindsDialog.tsx +103 -0
  139. package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
  140. package/src/keyboard/components/NavModeHint.tsx +31 -0
  141. package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
  142. package/src/keyboard/components/SlashComposer.tsx +87 -0
  143. package/src/keyboard/key-bindings.test.ts +156 -0
  144. package/src/keyboard/key-bindings.ts +459 -0
  145. package/src/keyboard/keymap.test.ts +611 -0
  146. package/src/keyboard/keymap.ts +395 -0
  147. package/src/keyboard/skills.test.ts +74 -0
  148. package/src/keyboard/skills.ts +75 -0
  149. package/src/keyboard/slash-palette.test.ts +103 -0
  150. package/src/keyboard/slash-palette.ts +129 -0
  151. package/src/keyboard/thread-chords.test.ts +79 -0
  152. package/src/keyboard/thread-chords.ts +163 -0
  153. package/src/keyboard/use-shared-keyboard.ts +55 -0
  154. package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  155. package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
  156. package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
  157. package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
  158. package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
  159. package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
  160. package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
  161. package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
  162. package/src/markdown/components/ThreadView.stories.tsx +26 -0
  163. package/src/markdown/components/ThreadView.tsx +560 -0
  164. package/src/markdown/mark-runs.ts +121 -0
  165. package/src/markdown/markdown-editor-syntax-style.ts +50 -0
  166. package/src/markdown/markdown-grid.test.ts +101 -0
  167. package/src/markdown/markdown-grid.ts +203 -0
  168. package/src/markdown/markdown-highlight.test.ts +61 -0
  169. package/src/markdown/markdown-highlight.ts +124 -0
  170. package/src/markdown/thread-code-syntax.ts +45 -0
  171. package/src/markdown/use-thread-body-editing.tsx +54 -0
  172. package/src/markdown/view-plan.regression.test.ts +60 -0
  173. package/src/markdown/view-plan.test.ts +545 -0
  174. package/src/markdown/view-plan.ts +1009 -0
  175. package/src/performance/otlp-trace.test.ts +119 -0
  176. package/src/performance/otlp-trace.ts +134 -0
  177. package/src/performance/perf-report.ts +18 -0
  178. package/src/performance/perf-timings.test.ts +18 -0
  179. package/src/performance/perf-timings.ts +39 -0
  180. package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
  181. package/src/prototype/components/PrototypeContentView.tsx +442 -0
  182. package/src/prototype/prototype-browser.test.ts +81 -0
  183. package/src/prototype/prototype-browser.ts +321 -0
  184. package/src/prototype/prototype-pixels.tsx +35 -0
  185. package/src/settings/components/SettingsDialog.stories.tsx +117 -0
  186. package/src/settings/components/SettingsDialog.tsx +183 -0
  187. package/src/settings/components/SettingsRows.stories.tsx +18 -0
  188. package/src/settings/components/SettingsRows.tsx +112 -0
  189. package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
  190. package/src/settings/components/quick-actions-editor.test.tsx +120 -0
  191. package/src/settings/components/quick-actions-editor.tsx +222 -0
  192. package/src/settings/config-identity.test.ts +78 -0
  193. package/src/settings/config.test.ts +676 -0
  194. package/src/settings/config.ts +628 -0
  195. package/src/settings/use-settings-dialog.tsx +351 -0
  196. package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
  197. package/src/stories/stories-app.tsx +196 -0
  198. package/src/stories/stories.test.tsx +74 -0
  199. package/src/stories/story-fixtures.ts +102 -0
  200. package/src/stories/story-tree.test.ts +108 -0
  201. package/src/stories/story.ts +154 -0
  202. package/src/terminal/ansi-screen.test.ts +28 -0
  203. package/src/terminal/ansi-screen.ts +72 -0
  204. package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
  205. package/src/terminal/components/AnsiScreen.tsx +28 -0
  206. package/src/terminal/components/terminal-pane.test.tsx +40 -0
  207. package/src/terminal/components/terminal-pane.ts +196 -0
  208. package/src/terminal/ghostty-terminal.test.ts +85 -0
  209. package/src/terminal/ghostty-terminal.ts +197 -0
  210. package/src/terminal/kitty-image.test.ts +66 -0
  211. package/src/terminal/kitty-image.ts +137 -0
  212. package/src/terminal/pty.test.ts +81 -0
  213. package/src/terminal/pty.ts +246 -0
  214. package/src/testing/test-support.test.ts +264 -0
  215. package/src/testing/test-support.ts +367 -0
  216. package/src/thread/agent-continuation.test.ts +32 -0
  217. package/src/thread/agent-continuation.ts +13 -0
  218. package/src/thread/agent-transcript.test.ts +161 -0
  219. package/src/thread/agent-transcript.ts +336 -0
  220. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  221. package/src/thread/components/AgentConfigControls.test.tsx +112 -0
  222. package/src/thread/components/AgentConfigControls.tsx +114 -0
  223. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  224. package/src/thread/components/AgentThreadPane.test.tsx +829 -0
  225. package/src/thread/components/AgentThreadPane.tsx +367 -0
  226. package/src/thread/components/ThreadFooter.stories.tsx +30 -0
  227. package/src/thread/components/ThreadFooter.test.tsx +49 -0
  228. package/src/thread/components/ThreadFooter.tsx +77 -0
  229. package/src/thread/components/ThreadTree.stories.tsx +46 -0
  230. package/src/thread/components/ThreadTree.test.tsx +41 -0
  231. package/src/thread/components/ThreadTree.tsx +267 -0
  232. package/src/thread/components/thread-tree.test.ts +137 -0
  233. package/src/thread/components/thread-tree.ts +112 -0
  234. package/src/thread/thread-controller.curate.test.ts +502 -0
  235. package/src/thread/thread-controller.derivation.test.ts +62 -0
  236. package/src/thread/thread-controller.live-diff.test.ts +357 -0
  237. package/src/thread/thread-controller.optimistic.test.ts +69 -0
  238. package/src/thread/thread-controller.share.test.ts +573 -0
  239. package/src/thread/thread-controller.switch-cache.test.ts +174 -0
  240. package/src/thread/thread-controller.tree.test.ts +263 -0
  241. package/src/thread/thread-controller.ts +2018 -0
  242. package/src/thread/tree-view.test.ts +128 -0
  243. package/src/thread/tree-view.ts +146 -0
  244. package/src/thread/use-thread-agent.test.tsx +208 -0
  245. package/src/thread/use-thread-agent.ts +111 -0
  246. package/src/ui/components/CodeBlock.stories.tsx +42 -0
  247. package/src/ui/components/CodeBlock.tsx +80 -0
  248. package/src/ui/components/ConfirmCard.stories.tsx +37 -0
  249. package/src/ui/components/ConfirmCard.tsx +151 -0
  250. package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
  251. package/src/ui/components/ConfirmDialog.tsx +72 -0
  252. package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
  253. package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
  254. package/src/ui/components/OverlayScrollbar.tsx +129 -0
  255. package/src/ui/components/PromptDialog.stories.tsx +36 -0
  256. package/src/ui/components/PromptDialog.test.tsx +65 -0
  257. package/src/ui/components/PromptDialog.tsx +99 -0
  258. package/src/ui/components/RootOverlay.stories.tsx +45 -0
  259. package/src/ui/components/RootOverlay.tsx +49 -0
  260. package/src/ui/components/ScrollArea.stories.tsx +20 -0
  261. package/src/ui/components/ScrollArea.tsx +61 -0
  262. package/src/ui/components/Toast.stories.tsx +19 -0
  263. package/src/ui/components/Toast.tsx +54 -0
  264. package/src/ui/components/Tooltip.stories.tsx +26 -0
  265. package/src/ui/components/Tooltip.tsx +72 -0
  266. package/src/ui/components/menu-control.stories.tsx +35 -0
  267. package/src/ui/components/menu-control.tsx +43 -0
  268. package/src/ui/components/primitives/Button.stories.tsx +32 -0
  269. package/src/ui/components/primitives/Button.tsx +57 -0
  270. package/src/ui/components/primitives/Card.stories.tsx +25 -0
  271. package/src/ui/components/primitives/Card.tsx +62 -0
  272. package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
  273. package/src/ui/components/primitives/Dialog.test.tsx +38 -0
  274. package/src/ui/components/primitives/Dialog.tsx +69 -0
  275. package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
  276. package/src/ui/components/primitives/DialogActions.tsx +39 -0
  277. package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
  278. package/src/ui/components/primitives/IconButton.tsx +58 -0
  279. package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
  280. package/src/ui/components/primitives/StatusBar.tsx +23 -0
  281. package/src/ui/components/primitives/Switch.stories.tsx +17 -0
  282. package/src/ui/components/primitives/Switch.tsx +32 -0
  283. package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
  284. package/src/ui/components/primitives/Tabs.tsx +101 -0
  285. package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
  286. package/src/ui/components/primitives/Toolbar.tsx +14 -0
  287. package/src/ui/components/primitives/Tree.stories.tsx +124 -0
  288. package/src/ui/components/primitives/Tree.tsx +133 -0
  289. package/src/ui/components/primitives/frame.test.ts +12 -0
  290. package/src/ui/components/primitives/frame.ts +17 -0
  291. package/src/ui/components/primitives/icons.ts +61 -0
  292. package/src/ui/components/primitives/tree-model.test.ts +99 -0
  293. package/src/ui/components/primitives/tree-model.ts +137 -0
  294. package/src/ui/components/syntax-highlight.ts +133 -0
  295. package/src/ui/components/truncate-text.ts +7 -0
  296. package/src/ui/components/truncate-title.test.ts +31 -0
  297. package/src/ui/components/truncate-title.ts +15 -0
  298. package/src/ui/use-frame-measure.ts +74 -0
  299. package/src/ui/use-terminal-virtualizer.ts +259 -0
  300. package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
  301. package/src/workbench/components/ChangesColumn.tsx +203 -0
  302. package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
  303. package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
  304. package/src/workbench/components/EditorGrid.stories.tsx +86 -0
  305. package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
  306. package/src/workbench/components/EditorGrid.tsx +544 -0
  307. package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
  308. package/src/workbench/components/FileContentsView.stories.tsx +16 -0
  309. package/src/workbench/components/FileContentsView.test.tsx +29 -0
  310. package/src/workbench/components/FileContentsView.tsx +100 -0
  311. package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
  312. package/src/workbench/components/GridTabContent.stories.tsx +59 -0
  313. package/src/workbench/components/GridTabContent.tsx +193 -0
  314. package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
  315. package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
  316. package/src/workbench/components/ProjectTreeView.tsx +151 -0
  317. package/src/workbench/components/TreePane.stories.tsx +106 -0
  318. package/src/workbench/components/TreePane.tsx +155 -0
  319. package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
  320. package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
  321. package/src/workbench/components/WelcomePlayground.tsx +138 -0
  322. package/src/workbench/components/editor-grid.test.ts +184 -0
  323. package/src/workbench/components/editor-grid.ts +281 -0
  324. package/src/workbench/components/file-tree.test.ts +60 -0
  325. package/src/workbench/components/file-tree.ts +112 -0
  326. package/src/workbench/use-changes-workbench.ts +205 -0
  327. package/src/workbench/use-remember-layout.ts +31 -0
  328. package/src/workbench/use-repo-changes.ts +91 -0
  329. package/src/workbench/workbench-snapshot.test.ts +99 -0
  330. package/src/workbench/workbench-snapshot.ts +59 -0
  331. package/src/App.diff.test.tsx +0 -99
  332. package/src/App.export.test.tsx +0 -88
  333. package/src/App.plan-review.test.tsx +0 -259
  334. package/src/App.readonly.test.tsx +0 -148
  335. package/src/App.rendering.test.tsx +0 -108
  336. package/src/App.submit-confirm.test.tsx +0 -183
  337. package/src/App.test.tsx +0 -218
  338. package/src/App.tsx +0 -1249
  339. package/src/config.test.ts +0 -116
  340. package/src/config.ts +0 -150
  341. package/src/editor.ts +0 -34
  342. package/src/herdr.test.ts +0 -68
  343. package/src/herdr.ts +0 -34
  344. package/src/keymap.test.ts +0 -270
  345. package/src/keymap.ts +0 -173
  346. package/src/run.ts +0 -30
  347. package/src/serve.ts +0 -75
  348. package/src/session-controller.ts +0 -398
  349. package/src/syntax.test.ts +0 -41
  350. package/src/syntax.ts +0 -88
  351. package/src/theme.ts +0 -46
  352. package/src/view-diff.test.ts +0 -55
  353. package/src/view-diff.ts +0 -86
  354. package/src/view.test.ts +0 -157
  355. package/src/view.ts +0 -364
@@ -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,137 @@
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
+ column: number;
19
+ row: number;
20
+ columns: number;
21
+ rows: number;
22
+ }
23
+
24
+ /** Writes a raw sequence ordered against the renderer's own frame output. */
25
+ export type OrderedWrite = (chunk: string) => void;
26
+
27
+ export type TransmitMedium = "file" | "base64";
28
+
29
+ const ESCAPE_STRING_TERMINATOR = "\x1b\\";
30
+ const CHUNK_BYTES = 4096;
31
+ // The protocol reserves values below INT32_MIN/2 for images that must sit
32
+ // beneath cells with painted backgrounds, such as the marker popover.
33
+ const IMAGE_Z_INDEX = -1_073_741_825;
34
+ // One fixed placement per image: reusing it makes every put replace that
35
+ // placement instead of accumulating a new one each frame.
36
+ const PLACEMENT_ID = 1;
37
+ const SYNCHRONIZED_UPDATE_START = "\x1b[?2026h";
38
+ const SYNCHRONIZED_UPDATE_END = "\x1b[?2026l";
39
+
40
+ let tempFileCounter = 0;
41
+ let privateTempDir: string | null = null;
42
+
43
+ /**
44
+ * base64 is the safe default: it works over the pty everywhere, including the
45
+ * ssh sharing path. Out-of-band file transfer (t=t) is faster for the initial
46
+ * transmit but depends on the terminal trusting the temp-file path, so it is
47
+ * opt-in via `CUELOOP_KITTY_FILE=1` and never used over ssh (no shared disk).
48
+ */
49
+ export function resolveTransmitMedium(env: NodeJS.ProcessEnv = process.env): TransmitMedium {
50
+ if (env.CUELOOP_KITTY_FILE === "1" && !env.SSH_CONNECTION && !env.SSH_TTY) return "file";
51
+
52
+ return "base64";
53
+ }
54
+
55
+ /** Save the cursor, run the placement at the region's corner, restore it. */
56
+ function atRegion(region: CellRegion, body: string): string {
57
+ const moveToCorner = `\x1b[${region.row + 1};${region.column + 1}H`;
58
+
59
+ return `\x1b7${moveToCorner}${body}\x1b8`;
60
+ }
61
+
62
+ function graphicsCommand(keys: string, payload: string): string {
63
+ return `\x1b_G${keys};${payload}${ESCAPE_STRING_TERMINATOR}`;
64
+ }
65
+
66
+ function placementKeys(imageId: number, region: CellRegion): string {
67
+ return `i=${imageId},p=${PLACEMENT_ID},q=2,C=1,z=${IMAGE_Z_INDEX},c=${region.columns},r=${region.rows}`;
68
+ }
69
+
70
+ // A per-process 0700 directory with an unpredictable name, so a local attacker
71
+ // cannot pre-place a symlink at the screenshot path and redirect the write.
72
+ function writeTempPng(png: Uint8Array): string {
73
+ if (!privateTempDir) privateTempDir = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
74
+ const path = join(privateTempDir, `${tempFileCounter++}.png`);
75
+
76
+ writeFileSync(path, png, { flag: "wx" });
77
+
78
+ return path;
79
+ }
80
+
81
+ function base64Transmit(png: Uint8Array, region: CellRegion, imageId: number): string {
82
+ const base64 = Buffer.from(png).toString("base64");
83
+ const controlKeys = `a=T,f=100,${placementKeys(imageId, region)}`;
84
+ let sequence = "";
85
+
86
+ for (let offset = 0; offset < base64.length; offset += CHUNK_BYTES) {
87
+ const chunk = base64.slice(offset, offset + CHUNK_BYTES);
88
+ const isMore = offset + CHUNK_BYTES < base64.length ? 1 : 0;
89
+
90
+ sequence +=
91
+ offset === 0
92
+ ? graphicsCommand(`${controlKeys},m=${isMore}`, chunk)
93
+ : graphicsCommand(`m=${isMore}`, chunk);
94
+ }
95
+
96
+ return sequence;
97
+ }
98
+
99
+ function fileTransmit(png: Uint8Array, region: CellRegion, imageId: number): string {
100
+ const path = writeTempPng(png);
101
+ const payload = Buffer.from(path).toString("base64");
102
+
103
+ return graphicsCommand(`a=T,f=100,t=t,${placementKeys(imageId, region)}`, payload);
104
+ }
105
+
106
+ /**
107
+ * Transmit a PNG and display it filling the region's cells (a=T). f=100 is PNG,
108
+ * C=1 keeps the cursor put so a full-region image cannot scroll the screen, and
109
+ * q=2 suppresses the terminal's replies. The whole write is bracketed in
110
+ * synchronized-update mode so the terminal composites it atomically. `file`
111
+ * hands over a temp path (t=t: the terminal deletes it after reading); `base64`
112
+ * inlines the data, chunked at 4096 bytes.
113
+ */
114
+ export function transmitKittyImage(
115
+ write: OrderedWrite,
116
+ png: Uint8Array,
117
+ region: CellRegion,
118
+ imageId: number,
119
+ medium: TransmitMedium = "base64",
120
+ ): void {
121
+ if (region.columns < 1 || region.rows < 1) return;
122
+ const body =
123
+ medium === "file" ? fileTransmit(png, region, imageId) : base64Transmit(png, region, imageId);
124
+
125
+ write(SYNCHRONIZED_UPDATE_START + atRegion(region, body) + SYNCHRONIZED_UPDATE_END);
126
+ }
127
+
128
+ /** Re-place the already-transmitted image over its region, cheaply (a=p, no data). */
129
+ export function placeKittyImage(write: OrderedWrite, region: CellRegion, imageId: number): void {
130
+ if (region.columns < 1 || region.rows < 1) return;
131
+ write(atRegion(region, graphicsCommand(`a=p,${placementKeys(imageId, region)}`, "")));
132
+ }
133
+
134
+ /** Remove the image so it does not linger after the sheet closes (a=d). */
135
+ export function deleteKittyImage(write: OrderedWrite, imageId: number): void {
136
+ write(graphicsCommand(`a=d,d=i,i=${imageId},q=2`, ""));
137
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ptyAvailable, spawn } from "./pty";
3
+
4
+ // The forkpty shim is a per-platform prebuilt; skip where none ships (e.g. CI
5
+ // without the built dylib), exactly as the caller degrades to a herdr split.
6
+ const ptyTest = ptyAvailable() ? test : test.skip;
7
+
8
+ /** Run a child on a PTY, collect its output, and resolve with the exit code. */
9
+ function runOnPty(file: string, args: string[]): Promise<{ output: string; exitCode: number }> {
10
+ return new Promise((resolve, reject) => {
11
+ const pty = spawn(file, args, { name: "xterm-256color", cols: 80, rows: 24 });
12
+ let output = "";
13
+ const timeout = setTimeout(() => reject(new Error("pty child never exited")), 5000);
14
+
15
+ pty.onData((chunk) => {
16
+ output += chunk;
17
+ });
18
+ pty.onExit(({ exitCode }) => {
19
+ clearTimeout(timeout);
20
+ resolve({ output, exitCode });
21
+ });
22
+ });
23
+ }
24
+
25
+ describe("pty", () => {
26
+ ptyTest("streams a child's output and reports its exit code", async () => {
27
+ // Act
28
+ const { output, exitCode } = await runOnPty("sh", ["-c", "printf 'PTY-OK'; exit 7"]);
29
+
30
+ // Assert
31
+ expect(output).toContain("PTY-OK");
32
+ expect(exitCode).toBe(7);
33
+ });
34
+
35
+ ptyTest("forwards written input to the child", async () => {
36
+ // Arrange - `cat` echoes stdin back through the tty until it closes
37
+ const pty = spawn("cat", [], { name: "xterm-256color", cols: 80, rows: 24 });
38
+ let output = "";
39
+
40
+ pty.onData((chunk) => {
41
+ output += chunk;
42
+ });
43
+
44
+ // Act
45
+ pty.write("ping\n");
46
+ await new Promise((resolve) => setTimeout(resolve, 200));
47
+ pty.kill();
48
+
49
+ // Assert - the tty echoes the written line back
50
+ expect(output).toContain("ping");
51
+ });
52
+
53
+ ptyTest("passes env through to the child", async () => {
54
+ // Act
55
+ const { output } = await runOnPty("sh", ["-c", "printf '%s' \"$CUELOOP_PTY_MARKER\""]);
56
+
57
+ // Assert - a child with no inherited marker prints nothing for it
58
+ expect(output).not.toContain("marker-value");
59
+
60
+ // Act - now inject the marker via env
61
+ const withEnv = await new Promise<string>((resolve, reject) => {
62
+ const pty = spawn("sh", ["-c", "printf '%s' \"$CUELOOP_PTY_MARKER\""], {
63
+ name: "xterm-256color",
64
+ env: { ...process.env, CUELOOP_PTY_MARKER: "marker-value" },
65
+ });
66
+ let collected = "";
67
+ const timeout = setTimeout(() => reject(new Error("no exit")), 5000);
68
+
69
+ pty.onData((chunk) => {
70
+ collected += chunk;
71
+ });
72
+ pty.onExit(() => {
73
+ clearTimeout(timeout);
74
+ resolve(collected);
75
+ });
76
+ });
77
+
78
+ // Assert
79
+ expect(withEnv).toContain("marker-value");
80
+ });
81
+ });
@@ -0,0 +1,246 @@
1
+ /**
2
+ * A pseudo-terminal for the embedded Agent-tab terminal, over cueloop's own
3
+ * forkpty(3) FFI shim (native/src/pty.zig). `spawn` returns a `Pty` with
4
+ * `onData`/`onExit` events and `write`/`resize`/`kill`; a poll loop drains the
5
+ * child's output and decodes it as streaming UTF-8. `ptyAvailable()` returns
6
+ * false when no prebuilt shim ships for this platform, so callers fall back to a
7
+ * herdr split, exactly like the Ghostty VT loader.
8
+ */
9
+
10
+ import { dlopen, FFIType, ptr } from "bun:ffi";
11
+ import { existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ const DEFAULT_COLS = 80;
15
+ const DEFAULT_ROWS = 24;
16
+ /** Shortest read-loop sleep, used while the child is actively streaming - keeps output snappy. */
17
+ const READ_IDLE_MS = 8;
18
+ /** Longest read-loop sleep, reached after the child sits quiet - stops the loop burning a core at idle. */
19
+ const READ_IDLE_MAX_MS = 100;
20
+ const READ_BUFFER_BYTES = 4096;
21
+ /** cueloop_pty_read's sentinel: the child has exited and its output is drained. */
22
+ const CHILD_EXITED = -2;
23
+
24
+ export interface PtyForkOptions {
25
+ name?: string;
26
+ cols?: number;
27
+ rows?: number;
28
+ cwd?: string;
29
+ env?: Record<string, string>;
30
+ }
31
+
32
+ export interface ExitEvent {
33
+ exitCode: number;
34
+ }
35
+
36
+ export interface Disposable {
37
+ dispose(): void;
38
+ }
39
+
40
+ /** The pseudo-terminal surface the embedded terminal consumes. */
41
+ export interface IPty {
42
+ readonly pid: number;
43
+ readonly cols: number;
44
+ readonly rows: number;
45
+ readonly onData: (listener: (data: string) => void) => Disposable;
46
+ readonly onExit: (listener: (event: ExitEvent) => void) => Disposable;
47
+ write(data: string): void;
48
+ resize(cols: number, rows: number): void;
49
+ kill(signal?: string): void;
50
+ }
51
+
52
+ const PTY_SYMBOLS = {
53
+ cueloop_pty_spawn: {
54
+ args: [FFIType.ptr, FFIType.cstring, FFIType.ptr, FFIType.i32, FFIType.i32],
55
+ returns: FFIType.i32,
56
+ },
57
+ cueloop_pty_write: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
58
+ cueloop_pty_read: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
59
+ cueloop_pty_resize: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
60
+ cueloop_pty_kill: { args: [FFIType.i32], returns: FFIType.i32 },
61
+ cueloop_pty_get_pid: { args: [FFIType.i32], returns: FFIType.i32 },
62
+ cueloop_pty_get_exit_code: { args: [FFIType.i32], returns: FFIType.i32 },
63
+ cueloop_pty_close: { args: [FFIType.i32], returns: FFIType.void },
64
+ } as const;
65
+
66
+ type PtyLib = ReturnType<typeof dlopen<typeof PTY_SYMBOLS>>["symbols"];
67
+
68
+ /** The prebuilt pty shim for this platform, or null when none ships for it. */
69
+ function nativeLibraryPath(): string | null {
70
+ const suffix = process.platform === "darwin" ? "dylib" : "so";
71
+ const dir = `${process.platform}-${process.arch}`;
72
+ const path = join(import.meta.dir, "..", "..", "native", dir, `libcuelooppty.${suffix}`);
73
+
74
+ return existsSync(path) ? path : null;
75
+ }
76
+
77
+ /** One shared load of the pty shim; null when no dylib ships for the platform. */
78
+ let ptyLib: PtyLib | null | undefined;
79
+
80
+ function library(): PtyLib | null {
81
+ if (ptyLib !== undefined) return ptyLib;
82
+ const path = nativeLibraryPath();
83
+
84
+ if (!path) return (ptyLib = null);
85
+ try {
86
+ ptyLib = dlopen(path, PTY_SYMBOLS).symbols;
87
+ } catch (error) {
88
+ console.error(`cueloop: failed to load ${path}:`, error);
89
+ ptyLib = null;
90
+ }
91
+
92
+ return ptyLib;
93
+ }
94
+
95
+ /** True when this platform ships a prebuilt pty shim (embedding is possible). */
96
+ export function ptyAvailable(): boolean {
97
+ return library() !== null;
98
+ }
99
+
100
+ class EventEmitter<T> {
101
+ private listeners: ((value: T) => void)[] = [];
102
+ readonly event = (listener: (value: T) => void): Disposable => {
103
+ this.listeners.push(listener);
104
+
105
+ return {
106
+ dispose: () => {
107
+ const index = this.listeners.indexOf(listener);
108
+
109
+ if (index !== -1) this.listeners.splice(index, 1);
110
+ },
111
+ };
112
+ };
113
+ fire(value: T): void {
114
+ for (const listener of this.listeners) listener(value);
115
+ }
116
+ }
117
+
118
+ /** Pack tokens into the C shim's "tok\0tok\0...\0\0" (double-NUL-terminated) form. */
119
+ function packTokens(tokens: string[]): Buffer {
120
+ return Buffer.from(tokens.map((token) => `${token}\0`).join("") + "\0", "utf8");
121
+ }
122
+
123
+ class Pty implements IPty {
124
+ private handle: number;
125
+ private processId: number;
126
+ private columns: number;
127
+ private rowCount: number;
128
+ private closing = false;
129
+ private reading = false;
130
+ private readonly decoder = new TextDecoder("utf-8");
131
+ private readonly dataEvent = new EventEmitter<string>();
132
+ private readonly exitEvent = new EventEmitter<ExitEvent>();
133
+
134
+ constructor(
135
+ private readonly lib: PtyLib,
136
+ file: string,
137
+ args: string[],
138
+ options: PtyForkOptions,
139
+ ) {
140
+ this.columns = options.cols ?? DEFAULT_COLS;
141
+ this.rowCount = options.rows ?? DEFAULT_ROWS;
142
+ const cwd = options.cwd ?? process.cwd();
143
+ const env = options.env
144
+ ? Object.entries(options.env).map(([key, value]) => `${key}=${value}`)
145
+ : [];
146
+
147
+ const argvPacked = packTokens([file, ...args]);
148
+ const envPacked = packTokens(env);
149
+
150
+ this.handle = lib.cueloop_pty_spawn(
151
+ ptr(argvPacked),
152
+ Buffer.from(`${cwd}\0`, "utf8"),
153
+ ptr(envPacked),
154
+ this.columns,
155
+ this.rowCount,
156
+ );
157
+ if (this.handle < 0) throw new Error("PTY spawn failed");
158
+ this.processId = lib.cueloop_pty_get_pid(this.handle);
159
+ // Let the caller attach onData/onExit before the first bytes arrive.
160
+ queueMicrotask(() => void this.readLoop());
161
+ }
162
+
163
+ get pid(): number {
164
+ return this.processId;
165
+ }
166
+ get cols(): number {
167
+ return this.columns;
168
+ }
169
+ get rows(): number {
170
+ return this.rowCount;
171
+ }
172
+ get onData() {
173
+ return this.dataEvent.event;
174
+ }
175
+ get onExit() {
176
+ return this.exitEvent.event;
177
+ }
178
+
179
+ write(data: string): void {
180
+ if (this.closing) return;
181
+ const buffer = Buffer.from(data, "utf8");
182
+
183
+ this.lib.cueloop_pty_write(this.handle, ptr(buffer), buffer.length);
184
+ }
185
+
186
+ resize(cols: number, rows: number): void {
187
+ if (this.closing) return;
188
+ this.columns = cols;
189
+ this.rowCount = rows;
190
+ this.lib.cueloop_pty_resize(this.handle, cols, rows);
191
+ }
192
+
193
+ kill(): void {
194
+ if (this.closing) return;
195
+ this.closing = true;
196
+ this.lib.cueloop_pty_kill(this.handle);
197
+ this.lib.cueloop_pty_close(this.handle);
198
+ this.exitEvent.fire({ exitCode: 0 });
199
+ }
200
+
201
+ private async readLoop(): Promise<void> {
202
+ if (this.reading) return;
203
+ this.reading = true;
204
+ const buffer = Buffer.allocUnsafe(READ_BUFFER_BYTES);
205
+ // consecutive empty reads; the idle sleep backs off exponentially so a quiet agent prompt
206
+ // ramps from an 8ms hot poll up to a 100ms idle poll instead of spinning a core forever
207
+ let idlePolls = 0;
208
+
209
+ while (!this.closing) {
210
+ const count = this.lib.cueloop_pty_read(this.handle, ptr(buffer), buffer.length);
211
+
212
+ if (count > 0) {
213
+ idlePolls = 0;
214
+ // Stream mode buffers a multibyte char split across reads (box-drawing etc.).
215
+ const text = this.decoder.decode(buffer.subarray(0, count), { stream: true });
216
+
217
+ if (text) this.dataEvent.fire(text);
218
+ } else if (count === CHILD_EXITED || count < 0) {
219
+ // Both the drained-exit sentinel and a hard read error end the session:
220
+ // flush the decoder, reap for the code, close, and fire the exit once.
221
+ const tail = this.decoder.decode();
222
+
223
+ if (tail) this.dataEvent.fire(tail);
224
+ const exitCode = this.lib.cueloop_pty_get_exit_code(this.handle);
225
+
226
+ this.lib.cueloop_pty_close(this.handle);
227
+ this.closing = true;
228
+ this.exitEvent.fire({ exitCode });
229
+ } else {
230
+ const delay = Math.min(READ_IDLE_MAX_MS, READ_IDLE_MS * 2 ** Math.min(idlePolls, 5));
231
+
232
+ idlePolls += 1;
233
+ await new Promise((resolve) => setTimeout(resolve, delay));
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ /** Spawn `file` with `args` on a fresh PTY. Throws when no shim ships (gate on ptyAvailable). */
240
+ export function spawn(file: string, args: string[], options: PtyForkOptions = {}): IPty {
241
+ const lib = library();
242
+
243
+ if (!lib) throw new Error("cueloop: no pty shim for this platform");
244
+
245
+ return new Pty(lib, file, args, options);
246
+ }