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

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 (356) 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 +369 -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 +1243 -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 +20 -0
  36. package/src/app/App.toast.test.tsx +124 -0
  37. package/src/app/App.tsx +1667 -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.agent.test.tsx +50 -0
  119. package/src/integrations/components/ShareDialog.stories.tsx +121 -0
  120. package/src/integrations/components/ShareDialog.tsx +574 -0
  121. package/src/integrations/components/share-dialog-store.ts +59 -0
  122. package/src/integrations/editor.test.ts +149 -0
  123. package/src/integrations/editor.ts +160 -0
  124. package/src/integrations/github-identity.test.ts +58 -0
  125. package/src/integrations/github-identity.ts +41 -0
  126. package/src/integrations/herdr.test.ts +43 -0
  127. package/src/integrations/herdr.ts +38 -0
  128. package/src/integrations/integrations.test.ts +41 -0
  129. package/src/integrations/integrations.ts +32 -0
  130. package/src/integrations/pasted-image.test.ts +67 -0
  131. package/src/integrations/pasted-image.ts +60 -0
  132. package/src/integrations/share.ts +238 -0
  133. package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
  134. package/src/keyboard/components/CompletionOverlay.tsx +75 -0
  135. package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
  136. package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
  137. package/src/keyboard/components/HandleChipsInput.tsx +143 -0
  138. package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
  139. package/src/keyboard/components/KeybindsDialog.tsx +103 -0
  140. package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
  141. package/src/keyboard/components/NavModeHint.tsx +31 -0
  142. package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
  143. package/src/keyboard/components/SlashComposer.tsx +87 -0
  144. package/src/keyboard/key-bindings.test.ts +156 -0
  145. package/src/keyboard/key-bindings.ts +459 -0
  146. package/src/keyboard/keymap.test.ts +611 -0
  147. package/src/keyboard/keymap.ts +395 -0
  148. package/src/keyboard/skills.test.ts +74 -0
  149. package/src/keyboard/skills.ts +75 -0
  150. package/src/keyboard/slash-palette.test.ts +103 -0
  151. package/src/keyboard/slash-palette.ts +129 -0
  152. package/src/keyboard/thread-chords.test.ts +79 -0
  153. package/src/keyboard/thread-chords.ts +163 -0
  154. package/src/keyboard/use-shared-keyboard.ts +55 -0
  155. package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  156. package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
  157. package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
  158. package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
  159. package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
  160. package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
  161. package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
  162. package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
  163. package/src/markdown/components/ThreadView.stories.tsx +26 -0
  164. package/src/markdown/components/ThreadView.tsx +560 -0
  165. package/src/markdown/mark-runs.ts +121 -0
  166. package/src/markdown/markdown-editor-syntax-style.ts +50 -0
  167. package/src/markdown/markdown-grid.test.ts +101 -0
  168. package/src/markdown/markdown-grid.ts +203 -0
  169. package/src/markdown/markdown-highlight.test.ts +61 -0
  170. package/src/markdown/markdown-highlight.ts +124 -0
  171. package/src/markdown/thread-code-syntax.ts +45 -0
  172. package/src/markdown/use-thread-body-editing.tsx +54 -0
  173. package/src/markdown/view-plan.regression.test.ts +60 -0
  174. package/src/markdown/view-plan.test.ts +545 -0
  175. package/src/markdown/view-plan.ts +1009 -0
  176. package/src/performance/otlp-trace.test.ts +119 -0
  177. package/src/performance/otlp-trace.ts +134 -0
  178. package/src/performance/perf-report.ts +18 -0
  179. package/src/performance/perf-timings.test.ts +18 -0
  180. package/src/performance/perf-timings.ts +39 -0
  181. package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
  182. package/src/prototype/components/PrototypeContentView.tsx +442 -0
  183. package/src/prototype/prototype-browser.test.ts +81 -0
  184. package/src/prototype/prototype-browser.ts +321 -0
  185. package/src/prototype/prototype-pixels.tsx +35 -0
  186. package/src/settings/components/SettingsDialog.stories.tsx +117 -0
  187. package/src/settings/components/SettingsDialog.tsx +183 -0
  188. package/src/settings/components/SettingsRows.stories.tsx +18 -0
  189. package/src/settings/components/SettingsRows.tsx +112 -0
  190. package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
  191. package/src/settings/components/quick-actions-editor.test.tsx +120 -0
  192. package/src/settings/components/quick-actions-editor.tsx +222 -0
  193. package/src/settings/config-identity.test.ts +78 -0
  194. package/src/settings/config.test.ts +676 -0
  195. package/src/settings/config.ts +628 -0
  196. package/src/settings/use-settings-dialog.tsx +351 -0
  197. package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
  198. package/src/stories/stories-app.tsx +196 -0
  199. package/src/stories/stories.test.tsx +74 -0
  200. package/src/stories/story-fixtures.ts +102 -0
  201. package/src/stories/story-tree.test.ts +108 -0
  202. package/src/stories/story.ts +154 -0
  203. package/src/terminal/ansi-screen.test.ts +28 -0
  204. package/src/terminal/ansi-screen.ts +72 -0
  205. package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
  206. package/src/terminal/components/AnsiScreen.tsx +28 -0
  207. package/src/terminal/components/terminal-pane.test.tsx +40 -0
  208. package/src/terminal/components/terminal-pane.ts +196 -0
  209. package/src/terminal/ghostty-terminal.test.ts +85 -0
  210. package/src/terminal/ghostty-terminal.ts +197 -0
  211. package/src/terminal/kitty-image.test.ts +66 -0
  212. package/src/terminal/kitty-image.ts +137 -0
  213. package/src/terminal/pty.test.ts +81 -0
  214. package/src/terminal/pty.ts +246 -0
  215. package/src/testing/test-support.test.ts +264 -0
  216. package/src/testing/test-support.ts +367 -0
  217. package/src/thread/agent-continuation.test.ts +32 -0
  218. package/src/thread/agent-continuation.ts +13 -0
  219. package/src/thread/agent-transcript.test.ts +161 -0
  220. package/src/thread/agent-transcript.ts +336 -0
  221. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  222. package/src/thread/components/AgentConfigControls.test.tsx +112 -0
  223. package/src/thread/components/AgentConfigControls.tsx +114 -0
  224. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  225. package/src/thread/components/AgentThreadPane.test.tsx +935 -0
  226. package/src/thread/components/AgentThreadPane.tsx +377 -0
  227. package/src/thread/components/ThreadFooter.stories.tsx +30 -0
  228. package/src/thread/components/ThreadFooter.test.tsx +49 -0
  229. package/src/thread/components/ThreadFooter.tsx +77 -0
  230. package/src/thread/components/ThreadTree.stories.tsx +46 -0
  231. package/src/thread/components/ThreadTree.test.tsx +41 -0
  232. package/src/thread/components/ThreadTree.tsx +267 -0
  233. package/src/thread/components/thread-tree.test.ts +137 -0
  234. package/src/thread/components/thread-tree.ts +112 -0
  235. package/src/thread/thread-controller.curate.test.ts +502 -0
  236. package/src/thread/thread-controller.derivation.test.ts +62 -0
  237. package/src/thread/thread-controller.live-diff.test.ts +357 -0
  238. package/src/thread/thread-controller.optimistic.test.ts +69 -0
  239. package/src/thread/thread-controller.share.test.ts +573 -0
  240. package/src/thread/thread-controller.switch-cache.test.ts +174 -0
  241. package/src/thread/thread-controller.tree.test.ts +263 -0
  242. package/src/thread/thread-controller.ts +2026 -0
  243. package/src/thread/tree-view.test.ts +128 -0
  244. package/src/thread/tree-view.ts +146 -0
  245. package/src/thread/use-thread-agent.test.tsx +208 -0
  246. package/src/thread/use-thread-agent.ts +117 -0
  247. package/src/ui/components/CodeBlock.stories.tsx +42 -0
  248. package/src/ui/components/CodeBlock.tsx +80 -0
  249. package/src/ui/components/ConfirmCard.stories.tsx +37 -0
  250. package/src/ui/components/ConfirmCard.tsx +151 -0
  251. package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
  252. package/src/ui/components/ConfirmDialog.tsx +72 -0
  253. package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
  254. package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
  255. package/src/ui/components/OverlayScrollbar.tsx +129 -0
  256. package/src/ui/components/PromptDialog.stories.tsx +36 -0
  257. package/src/ui/components/PromptDialog.test.tsx +65 -0
  258. package/src/ui/components/PromptDialog.tsx +99 -0
  259. package/src/ui/components/RootOverlay.stories.tsx +45 -0
  260. package/src/ui/components/RootOverlay.tsx +49 -0
  261. package/src/ui/components/ScrollArea.stories.tsx +20 -0
  262. package/src/ui/components/ScrollArea.tsx +61 -0
  263. package/src/ui/components/Toast.stories.tsx +19 -0
  264. package/src/ui/components/Toast.tsx +54 -0
  265. package/src/ui/components/Tooltip.stories.tsx +26 -0
  266. package/src/ui/components/Tooltip.tsx +72 -0
  267. package/src/ui/components/menu-control.stories.tsx +35 -0
  268. package/src/ui/components/menu-control.tsx +43 -0
  269. package/src/ui/components/primitives/Button.stories.tsx +32 -0
  270. package/src/ui/components/primitives/Button.tsx +57 -0
  271. package/src/ui/components/primitives/Card.stories.tsx +25 -0
  272. package/src/ui/components/primitives/Card.tsx +62 -0
  273. package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
  274. package/src/ui/components/primitives/Dialog.test.tsx +38 -0
  275. package/src/ui/components/primitives/Dialog.tsx +69 -0
  276. package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
  277. package/src/ui/components/primitives/DialogActions.tsx +39 -0
  278. package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
  279. package/src/ui/components/primitives/IconButton.tsx +58 -0
  280. package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
  281. package/src/ui/components/primitives/StatusBar.tsx +23 -0
  282. package/src/ui/components/primitives/Switch.stories.tsx +17 -0
  283. package/src/ui/components/primitives/Switch.tsx +32 -0
  284. package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
  285. package/src/ui/components/primitives/Tabs.tsx +101 -0
  286. package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
  287. package/src/ui/components/primitives/Toolbar.tsx +14 -0
  288. package/src/ui/components/primitives/Tree.stories.tsx +124 -0
  289. package/src/ui/components/primitives/Tree.tsx +133 -0
  290. package/src/ui/components/primitives/frame.test.ts +12 -0
  291. package/src/ui/components/primitives/frame.ts +17 -0
  292. package/src/ui/components/primitives/icons.ts +61 -0
  293. package/src/ui/components/primitives/tree-model.test.ts +99 -0
  294. package/src/ui/components/primitives/tree-model.ts +137 -0
  295. package/src/ui/components/syntax-highlight.ts +133 -0
  296. package/src/ui/components/truncate-text.ts +7 -0
  297. package/src/ui/components/truncate-title.test.ts +31 -0
  298. package/src/ui/components/truncate-title.ts +15 -0
  299. package/src/ui/use-frame-measure.ts +74 -0
  300. package/src/ui/use-terminal-virtualizer.ts +259 -0
  301. package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
  302. package/src/workbench/components/ChangesColumn.tsx +203 -0
  303. package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
  304. package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
  305. package/src/workbench/components/EditorGrid.stories.tsx +86 -0
  306. package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
  307. package/src/workbench/components/EditorGrid.tsx +544 -0
  308. package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
  309. package/src/workbench/components/FileContentsView.stories.tsx +16 -0
  310. package/src/workbench/components/FileContentsView.test.tsx +29 -0
  311. package/src/workbench/components/FileContentsView.tsx +100 -0
  312. package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
  313. package/src/workbench/components/GridTabContent.stories.tsx +59 -0
  314. package/src/workbench/components/GridTabContent.tsx +193 -0
  315. package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
  316. package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
  317. package/src/workbench/components/ProjectTreeView.tsx +151 -0
  318. package/src/workbench/components/TreePane.stories.tsx +106 -0
  319. package/src/workbench/components/TreePane.tsx +155 -0
  320. package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
  321. package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
  322. package/src/workbench/components/WelcomePlayground.tsx +138 -0
  323. package/src/workbench/components/editor-grid.test.ts +184 -0
  324. package/src/workbench/components/editor-grid.ts +281 -0
  325. package/src/workbench/components/file-tree.test.ts +60 -0
  326. package/src/workbench/components/file-tree.ts +112 -0
  327. package/src/workbench/use-changes-workbench.ts +205 -0
  328. package/src/workbench/use-remember-layout.ts +31 -0
  329. package/src/workbench/use-repo-changes.ts +91 -0
  330. package/src/workbench/workbench-snapshot.test.ts +99 -0
  331. package/src/workbench/workbench-snapshot.ts +59 -0
  332. package/src/App.diff.test.tsx +0 -99
  333. package/src/App.export.test.tsx +0 -88
  334. package/src/App.plan-review.test.tsx +0 -259
  335. package/src/App.readonly.test.tsx +0 -148
  336. package/src/App.rendering.test.tsx +0 -108
  337. package/src/App.submit-confirm.test.tsx +0 -183
  338. package/src/App.test.tsx +0 -218
  339. package/src/App.tsx +0 -1249
  340. package/src/config.test.ts +0 -116
  341. package/src/config.ts +0 -150
  342. package/src/editor.ts +0 -34
  343. package/src/herdr.test.ts +0 -68
  344. package/src/herdr.ts +0 -34
  345. package/src/keymap.test.ts +0 -270
  346. package/src/keymap.ts +0 -173
  347. package/src/run.ts +0 -30
  348. package/src/serve.ts +0 -75
  349. package/src/session-controller.ts +0 -398
  350. package/src/syntax.test.ts +0 -41
  351. package/src/syntax.ts +0 -88
  352. package/src/theme.ts +0 -46
  353. package/src/view-diff.test.ts +0 -55
  354. package/src/view-diff.ts +0 -86
  355. package/src/view.test.ts +0 -157
  356. package/src/view.ts +0 -364
@@ -0,0 +1,1149 @@
1
+ /** The four-pane workbench in the virtual terminal, mirroring the browser prototype's interactions:
2
+ * a diff session lays out Threads | Thread | Changes editor | Project sidebar; the diff (±) and tree
3
+ * toggles are mutually exclusive and switching to tree keeps the Changes editor open; a project-tree
4
+ * file opens as a read-only contents tab; the split control offers four directions and the arrow keys
5
+ * split; dismissing the Changes tab collapses the Changes pane but keeps the Project sidebar;
6
+ * the right-sidebar toggle collapses the region to a thin rail and reopens it; zoom hides the Thread pane
7
+ * while the sidebars stay. */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import React from "react";
14
+ import { testRender as renderTestComponent } from "@opentui/react/test-utils";
15
+ import { DaemonServer } from "@cueloop/daemon";
16
+ import type { Thread } from "@cueloop/schema";
17
+ import { App } from "./App";
18
+ import { loadConfig } from "../settings/config";
19
+ import { pairLayout, planLayout, reviewLayout } from "./launch-layout";
20
+ import {
21
+ dragText,
22
+ isolateUserConfig,
23
+ locateText,
24
+ press,
25
+ pressKey,
26
+ typeText,
27
+ waitForState,
28
+ waitForText,
29
+ waitForTextGone,
30
+ renderReadyApp as renderReadyTestApp,
31
+ } from "../testing/test-support";
32
+ import { NERD } from "../ui/components/primitives/icons";
33
+
34
+ const PATCH = `diff --git a/src/store.ts b/src/store.ts
35
+ index 111..222 100644
36
+ --- a/src/store.ts
37
+ +++ b/src/store.ts
38
+ @@ -1,3 +1,3 @@
39
+ export class Store {
40
+ - private items = [];
41
+ + private items = new Map();
42
+ }
43
+ `;
44
+
45
+ let home: string;
46
+ let repo: string;
47
+ let restoreUserConfig: () => void;
48
+ let server: DaemonServer;
49
+ let session: Thread;
50
+ const renderers = new Set<Awaited<ReturnType<typeof renderTestComponent>>["renderer"]>();
51
+
52
+ async function testRender(...args: Parameters<typeof renderTestComponent>) {
53
+ const setup = await renderTestComponent(...args);
54
+
55
+ renderers.add(setup.renderer);
56
+
57
+ return setup;
58
+ }
59
+
60
+ async function renderReadyApp(...args: Parameters<typeof renderReadyTestApp>) {
61
+ const setup = await renderReadyTestApp(...args);
62
+
63
+ renderers.add(setup.renderer);
64
+
65
+ return setup;
66
+ }
67
+
68
+ /** A throwaway git repo so the Project tree (git ls-files) and file contents resolve without a real checkout. */
69
+ function makeRepo(): string {
70
+ const root = mkdtempSync(join(tmpdir(), "cueloop-workbench-repo-"));
71
+
72
+ mkdirSync(join(root, "src"), { recursive: true });
73
+ writeFileSync(join(root, "README.md"), "# Workbench Fixture\n\nA tiny tracked repo.\n");
74
+ writeFileSync(join(root, "src", "store.ts"), "export class Store {}\n");
75
+ writeFileSync(join(root, "src", "util.ts"), "export const noop = () => {};\n");
76
+ const git = (args: string[]) => Bun.spawnSync(["git", "-C", root, ...args]);
77
+
78
+ git(["init", "-q"]);
79
+ git(["config", "user.email", "fixture@example.com"]);
80
+ git(["config", "user.name", "Fixture"]);
81
+ git(["add", "-A"]);
82
+ git(["commit", "-q", "-m", "seed"]);
83
+
84
+ return root;
85
+ }
86
+
87
+ beforeEach(() => {
88
+ home = mkdtempSync(join(tmpdir(), "cueloop-workbench-"));
89
+ repo = makeRepo();
90
+ restoreUserConfig = isolateUserConfig(home);
91
+ server = new DaemonServer({ home, idleExitMs: 0 });
92
+ server.start();
93
+ session = server.core.sessionCreate({
94
+ workspace: { repoRoot: repo, branch: "main" },
95
+ artifact: { type: "diff", content: PATCH, meta: { title: "working tree" } },
96
+ });
97
+ });
98
+ afterEach(() => {
99
+ for (const renderer of renderers) {
100
+ if (!renderer.isDestroyed) renderer.destroy();
101
+ }
102
+ renderers.clear();
103
+ restoreUserConfig();
104
+ server.stop();
105
+ rmSync(home, { recursive: true, force: true });
106
+ rmSync(repo, { recursive: true, force: true });
107
+ });
108
+
109
+ async function renderApp() {
110
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
111
+ width: 160,
112
+ height: 20,
113
+ });
114
+
115
+ await waitForText(setup, "store.ts");
116
+
117
+ return setup;
118
+ }
119
+
120
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
121
+
122
+ /** The header controls sit on the top content row of the compact header. */
123
+ const HEADER_ROW = 0;
124
+ const BARE_NAV_HINT = "c comment · z fold · type to leave";
125
+
126
+ /** Column of a Project-header toggle, located by its own glyph so spacing changes never break the click. */
127
+ function toggleColumn(setup: Setup, glyph: string): number {
128
+ return setup.captureCharFrame().split("\n")[HEADER_ROW]!.lastIndexOf(glyph);
129
+ }
130
+ const diffToggleColumn = (setup: Setup): number => toggleColumn(setup, "changes");
131
+ const treeToggleColumn = (setup: Setup): number => toggleColumn(setup, "project");
132
+ const rightToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sidebarRight);
133
+ // the collapsed rail shows the outline (off) variant, mirroring the left toggle's filled/outline states
134
+ const railToggleColumn = (setup: Setup): number => toggleColumn(setup, NERD.sidebarRightOff);
135
+
136
+ function changedFileVisibleInRightTree(frame: string, file: string): boolean {
137
+ return frame.split("\n").some((line) => {
138
+ const divider = line.lastIndexOf("│");
139
+
140
+ return divider >= 0 && line.slice(divider + 1).includes(file);
141
+ });
142
+ }
143
+
144
+ describe("the four-pane workbench", () => {
145
+ test("a bootstrapped workbench paints its changed-file list in the first usable frame", async () => {
146
+ const workbench = server.core.sessionCreate({
147
+ workspace: { repoRoot: repo, branch: "main" },
148
+ artifact: { type: "diff", content: "", meta: { title: "Workbench", workbench: true } },
149
+ });
150
+ const initialReview = {
151
+ session: workbench,
152
+ diff: {
153
+ patch: PATCH,
154
+ files: [
155
+ {
156
+ path: "src/store.ts",
157
+ oldContents: "export class Store { private items = []; }\n",
158
+ newContents: "export class Store { private items = new Map(); }\n",
159
+ status: "modified" as const,
160
+ },
161
+ ],
162
+ },
163
+ };
164
+ const setup = await renderReadyApp(
165
+ <App
166
+ home={home}
167
+ sessionId={workbench.id}
168
+ initialReview={initialReview}
169
+ layout={reviewLayout()}
170
+ />,
171
+ { width: 160, height: 20 },
172
+ );
173
+
174
+ expect(changedFileVisibleInRightTree(setup.captureCharFrame(), "store.ts")).toBe(true);
175
+ setup.renderer.destroy();
176
+ });
177
+
178
+ test("reopening Changes does not restore files from the launch snapshot", async () => {
179
+ const workbench = server.core.sessionCreate({
180
+ workspace: { repoRoot: repo, branch: "main" },
181
+ artifact: { type: "diff", content: "", meta: { title: "Workbench", workbench: true } },
182
+ });
183
+ const initialReview = {
184
+ session: workbench,
185
+ diff: {
186
+ patch: PATCH,
187
+ files: [
188
+ {
189
+ path: "src/store.ts",
190
+ oldContents: "before\n",
191
+ newContents: "after\n",
192
+ status: "modified" as const,
193
+ },
194
+ ],
195
+ },
196
+ };
197
+ const setup = await renderReadyApp(
198
+ <App
199
+ home={home}
200
+ sessionId={workbench.id}
201
+ initialReview={initialReview}
202
+ layout={reviewLayout()}
203
+ />,
204
+ { width: 160, height: 20 },
205
+ );
206
+
207
+ await waitForState(
208
+ setup,
209
+ () => !changedFileVisibleInRightTree(setup.captureCharFrame(), "store.ts"),
210
+ );
211
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
212
+ await waitForState(setup, () => diffToggleColumn(setup) === -1);
213
+ await setup.mockMouse.click(railToggleColumn(setup), HEADER_ROW);
214
+
215
+ expect(changedFileVisibleInRightTree(setup.captureCharFrame(), "store.ts")).toBe(false);
216
+ setup.renderer.destroy();
217
+ });
218
+
219
+ test("rapid wheel scrolling a large Changes diff keeps the app responsive", async () => {
220
+ const filePatches = Array.from({ length: 40 }, (_, fileIndex) => [
221
+ `diff --git a/file-${fileIndex}.ts b/file-${fileIndex}.ts`,
222
+ `--- a/file-${fileIndex}.ts`,
223
+ `+++ b/file-${fileIndex}.ts`,
224
+ "@@ -0,0 +1,10 @@",
225
+ ...Array.from(
226
+ { length: 10 },
227
+ (_, lineIndex) => `+line ${lineIndex} ${"dense source text ".repeat(40)}`,
228
+ ),
229
+ ]).flat();
230
+ const large = server.core.sessionCreate({
231
+ workspace: { repoRoot: repo, branch: "main" },
232
+ artifact: {
233
+ type: "diff",
234
+ content: [...filePatches, ""].join("\n"),
235
+ meta: { title: "large changes" },
236
+ },
237
+ });
238
+ const setup = await renderReadyApp(<App home={home} sessionId={large.id} />, {
239
+ width: 160,
240
+ height: 20,
241
+ });
242
+
243
+ await waitForText(setup, "file-0.ts");
244
+ for (const [index, direction] of Array.from({ length: 500 }, () => "down" as const).entries()) {
245
+ // eslint-disable-next-line no-await-in-loop
246
+ await setup.mockMouse.scroll(75, 10, direction);
247
+ if (index % 25 === 0) {
248
+ // eslint-disable-next-line no-await-in-loop
249
+ await setup.waitForVisualIdle();
250
+ }
251
+ }
252
+ await setup.waitForVisualIdle();
253
+ expect(setup.captureCharFrame()).toContain("changes");
254
+ setup.renderer.destroy();
255
+ }, 60000);
256
+
257
+ test("pair starts focused on the Project tree beside the zoomed Changes editor", async () => {
258
+ const setup = await renderReadyApp(
259
+ <App home={home} sessionId={session.id} layout={pairLayout()} />,
260
+ { width: 160, height: 20 },
261
+ );
262
+
263
+ await waitForText(setup, "README.md");
264
+ expect(setup.captureCharFrame()).toContain("Changes");
265
+ await pressKey(setup, "RETURN");
266
+ await waitForText(setup, "Workbench Fixture");
267
+ setup.renderer.destroy();
268
+ });
269
+
270
+ test("a live thread's Changes tree follows edits and a clean working tree", async () => {
271
+ const liveSession = server.core.sessionCreate({
272
+ workspace: { repoRoot: repo, branch: "main" },
273
+ artifact: { type: "diff", content: "", meta: { title: "live changes", workbench: true } },
274
+ });
275
+
276
+ writeFileSync(join(repo, "README.md"), "# Edited\n");
277
+ const setup = await renderReadyApp(
278
+ <App home={home} sessionId={liveSession.id} layout={reviewLayout()} />,
279
+ {
280
+ width: 160,
281
+ height: 20,
282
+ },
283
+ );
284
+
285
+ await waitForText(setup, "README.md");
286
+ writeFileSync(join(repo, "README.md"), "# Workbench Fixture\n\nA tiny tracked repo.\n");
287
+ await waitForState(setup, () => !setup.captureCharFrame().includes("README.md"));
288
+ writeFileSync(join(repo, "src", "util.ts"), "export const noop = () => 1;\n");
289
+ await waitForText(setup, "util.ts");
290
+ }, 12_000);
291
+
292
+ test("a frozen diff's Changes tree follows a new captured revision", async () => {
293
+ const frozen = server.core.sessionCreate({
294
+ workspace: { repoRoot: repo, branch: "main" },
295
+ artifact: {
296
+ type: "diff",
297
+ content: "diff --git a/README.md b/README.md\n",
298
+ files: [
299
+ {
300
+ path: "README.md",
301
+ status: "modified",
302
+ oldContents: "before\n",
303
+ newContents: "after\n",
304
+ },
305
+ ],
306
+ meta: { title: "captured diff" },
307
+ },
308
+ });
309
+ const setup = await renderReadyApp(
310
+ <App home={home} sessionId={frozen.id} layout={reviewLayout()} />,
311
+ { width: 160, height: 20 },
312
+ );
313
+
314
+ await waitForText(setup, "README.md");
315
+ server.core.sessionSubmitRevision(
316
+ frozen.id,
317
+ "diff --git a/src/util.ts b/src/util.ts\n",
318
+ [],
319
+ [
320
+ {
321
+ path: "src/util.ts",
322
+ status: "modified",
323
+ oldContents: "before\n",
324
+ newContents: "after\n",
325
+ },
326
+ ],
327
+ );
328
+ await waitForState(setup, () => {
329
+ const frame = setup.captureCharFrame();
330
+
331
+ return frame.includes("util.ts") && !frame.includes("README.md");
332
+ });
333
+ setup.renderer.destroy();
334
+ });
335
+
336
+ test("a diff session lays out the four panes with the diff toggle active", async () => {
337
+ const setup = await renderApp();
338
+
339
+ const frame = setup.captureCharFrame();
340
+
341
+ expect(frame).toContain("cueloop");
342
+ expect(frame).toContain("Changes");
343
+ expect(frame).toContain("store.ts");
344
+ expect(frame).toContain("src/store.ts");
345
+ expect(frame).toContain(NERD.copy);
346
+ // the diff (±) toggle exists in the Project header
347
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
348
+ });
349
+
350
+ test("switching to the project tree keeps the Changes editor open", async () => {
351
+ const setup = await renderApp();
352
+
353
+ // the diff and tree toggles are mutually exclusive; switch to the full project tree
354
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
355
+ await waitForText(setup, "README.md");
356
+
357
+ const frame = setup.captureCharFrame();
358
+
359
+ // the full tree is showing...
360
+ expect(frame).toContain("README.md");
361
+ expect(frame).toContain("src");
362
+ // ...and the Changes editor stayed open through the mode switch
363
+ expect(frame).toContain("Changes");
364
+ });
365
+
366
+ test("opening a project-tree file adds a read-only contents tab", async () => {
367
+ const setup = await renderApp();
368
+
369
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
370
+ await waitForText(setup, "README.md");
371
+
372
+ const readme = locateText(setup, "README.md");
373
+
374
+ await setup.mockMouse.click(readme.column, readme.row);
375
+ await waitForText(setup, "Workbench Fixture");
376
+
377
+ const frame = setup.captureCharFrame();
378
+
379
+ // the contents tab carries the file's text with no diff markers
380
+ expect(frame).toContain("A tiny tracked repo.");
381
+ expect(frame.split("\n")[HEADER_ROW]!).toContain("README.md");
382
+ });
383
+
384
+ test("project clicks reuse one preview and a double-click keeps the file open once", async () => {
385
+ const setup = await renderApp();
386
+
387
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
388
+ await waitForText(setup, "README.md");
389
+ const readme = locateText(setup, "README.md");
390
+
391
+ await setup.mockMouse.click(readme.column, readme.row);
392
+ await waitForState(setup, () =>
393
+ setup.captureCharFrame().split("\n")[HEADER_ROW]!.includes("README.md"),
394
+ );
395
+
396
+ const folder = locateText(setup, "src");
397
+
398
+ await setup.mockMouse.click(folder.column, folder.row);
399
+ await waitForText(setup, "util.ts");
400
+ const util = locateText(setup, "util.ts");
401
+
402
+ await setup.mockMouse.click(util.column, util.row);
403
+ await waitForState(setup, () => {
404
+ const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
405
+
406
+ return header.includes("util.ts") && !header.includes("README.md");
407
+ });
408
+
409
+ await setup.mockMouse.doubleClick(util.column, util.row);
410
+ await setup.mockMouse.click(readme.column, readme.row);
411
+ await waitForState(setup, () => {
412
+ const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
413
+
414
+ return header.includes("util.ts") && header.includes("README.md");
415
+ });
416
+ await setup.mockMouse.click(util.column, util.row);
417
+ const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
418
+
419
+ expect(header.match(/util\.ts/g)).toHaveLength(1);
420
+ expect(header.match(/README\.md/g)).toHaveLength(1);
421
+ setup.renderer.destroy();
422
+ });
423
+
424
+ test("the split control offers four directions and arrow-right makes two groups", async () => {
425
+ const setup = await renderApp();
426
+
427
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
428
+ await waitForText(setup, "README.md");
429
+ const readme = locateText(setup, "README.md");
430
+
431
+ await setup.mockMouse.click(readme.column, readme.row);
432
+ await waitForText(setup, "Workbench Fixture");
433
+
434
+ // the split control appears only on a file tab
435
+ const split = locateText(setup, "split");
436
+
437
+ await setup.mockMouse.click(split.column, split.row);
438
+ await waitForText(setup, "right");
439
+
440
+ const menu = setup.captureCharFrame();
441
+
442
+ expect(menu).toContain("left");
443
+ expect(menu).toContain("right");
444
+ expect(menu).toContain("up");
445
+ expect(menu).toContain("down");
446
+
447
+ // the arrow keys drive the popover: right splits the group into two
448
+ await pressKey(setup, "ARROW_RIGHT");
449
+ await waitForState(setup, () =>
450
+ /│ Changes\s+│.*│ README\.md/.test(setup.captureCharFrame().split("\n")[HEADER_ROW]!),
451
+ );
452
+ expect(
453
+ setup
454
+ .captureCharFrame()
455
+ .split("\n")
456
+ [HEADER_ROW]!.match(/README\.md/g),
457
+ ).toHaveLength(1);
458
+ });
459
+
460
+ test("dismissing the Changes tab collapses the Changes pane but keeps the Project sidebar", async () => {
461
+ const setup = await renderApp();
462
+
463
+ const changes = locateText(setup, "Changes");
464
+
465
+ // the tab's close box sits just past the label; the ✕ is a hover affordance, the box always clicks
466
+ await setup.mockMouse.click(changes.column + 8, changes.row);
467
+ await waitForState(setup, () => !setup.captureCharFrame().includes("store.ts"));
468
+
469
+ const frame = setup.captureCharFrame();
470
+
471
+ // the Changes editor is gone...
472
+ expect(frame).not.toContain("store.ts");
473
+ // ...but the Project sidebar and its toggles remain
474
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
475
+ });
476
+
477
+ test("the right-sidebar toggle closes the region and its header toggle reopens it", async () => {
478
+ const setup = await renderApp();
479
+
480
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
481
+ await waitForState(setup, () => !setup.captureCharFrame().includes("store.ts"));
482
+
483
+ // the right region is closed: no Changes editor, no Project tree toggles, no gutter
484
+ expect(setup.captureCharFrame()).not.toContain("store.ts");
485
+ expect(diffToggleColumn(setup)).toBe(-1);
486
+
487
+ // the reopen toggle (outline/off variant) rides the thread header; clicking it reopens the region
488
+ await setup.mockMouse.click(railToggleColumn(setup), HEADER_ROW);
489
+ await waitForText(setup, "store.ts");
490
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
491
+ });
492
+
493
+ test("the Threads toggle opens and closes the sidebar, relocating the brand", async () => {
494
+ const setup = await renderApp();
495
+
496
+ // deep-linked into a session, the Threads sidebar starts collapsed: the brand rides in the
497
+ // Thread header and the inbox groups are hidden
498
+ const collapsed = setup.captureCharFrame();
499
+
500
+ expect(collapsed).toContain("cueloop");
501
+ expect(collapsed).not.toContain("Threads");
502
+
503
+ // the sidebar toggle sits just left of the brand (one glyph plus its margin)
504
+ const brand = locateText(setup, "cueloop");
505
+
506
+ await setup.mockMouse.click(brand.column - 3, brand.row);
507
+ await waitForText(setup, "Threads");
508
+
509
+ // the Threads pane is open with its inbox groups, and the brand moved into its header
510
+ expect(setup.captureCharFrame()).toContain("Threads");
511
+
512
+ // toggling again collapses it back
513
+ const reopened = locateText(setup, "cueloop");
514
+
515
+ await setup.mockMouse.click(reopened.column - 3, reopened.row);
516
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Threads"));
517
+ expect(setup.captureCharFrame()).not.toContain("Threads");
518
+ });
519
+
520
+ test("zoom hides the Thread pane while the sidebars stay", async () => {
521
+ const setup = await renderApp();
522
+
523
+ const zoom = locateText(setup, NERD.zoom);
524
+
525
+ await setup.mockMouse.click(zoom.column, zoom.row);
526
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Select a thread"));
527
+
528
+ const frame = setup.captureCharFrame();
529
+
530
+ // the Thread pane's prompt is gone...
531
+ expect(frame).not.toContain("Select a thread");
532
+ // ...while the Changes editor and Project sidebar stay
533
+ expect(frame).toContain("store.ts");
534
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
535
+ });
536
+
537
+ test("a create-command session remembers its zoom toggle for the next bare launch", async () => {
538
+ // a create-command opens the review zoomed; toggling zoom off must persist to the remembered layout
539
+ const setup = await renderReadyApp(
540
+ <App home={home} sessionId={session.id} layout={reviewLayout()} />,
541
+ {
542
+ width: 160,
543
+ height: 20,
544
+ },
545
+ );
546
+
547
+ await waitForText(setup, "store.ts");
548
+
549
+ const zoom = locateText(setup, NERD.zoom);
550
+
551
+ await setup.mockMouse.click(zoom.column, zoom.row);
552
+ await waitForState(
553
+ setup,
554
+ () =>
555
+ loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout?.zoomChanges === false,
556
+ );
557
+ expect(loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout?.zoomChanges).toBe(
558
+ false,
559
+ );
560
+ });
561
+
562
+ test("selecting a non-diff thread while zoomed exits zoom and restores the Thread pane", async () => {
563
+ // a second, non-diff thread to navigate to; its sync has no Changes editor to fill a zoom
564
+ server.core.sessionCreate({
565
+ workspace: { repoRoot: repo, branch: "main" },
566
+ artifact: {
567
+ type: "plan",
568
+ content: "# Zoomed Plan\n\nReview this plan.\n",
569
+ meta: { title: "Zoomed Plan" },
570
+ },
571
+ });
572
+ const setup = await renderApp();
573
+
574
+ // open the collapsed Threads sidebar first (its toggle sits three cells left of the brand); the
575
+ // brand rides the Thread header, which zoom then hides, so it must be reached before zooming
576
+ const brand = locateText(setup, "cueloop");
577
+
578
+ await setup.mockMouse.click(brand.column - 3, brand.row);
579
+ await waitForText(setup, "Zoomed Plan");
580
+
581
+ // zoom the diff - the Thread pane hides and the footer rides the Changes pane
582
+ const zoom = locateText(setup, NERD.zoom);
583
+
584
+ await setup.mockMouse.click(zoom.column, zoom.row);
585
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Select a thread"));
586
+
587
+ // switch to the plan thread: it has no Changes editor, so a stranded zoom would blank the middle
588
+ const planRow = locateText(setup, "Zoomed Plan");
589
+
590
+ await setup.mockMouse.click(planRow.column, planRow.row);
591
+ await waitForText(setup, "Review this plan.");
592
+
593
+ const frame = setup.captureCharFrame();
594
+
595
+ // the Thread pane is back with the plan, and the diff's Changes editor is gone - never a blank middle
596
+ expect(frame).toContain("Review this plan.");
597
+ expect(frame).not.toContain("store.ts");
598
+ });
599
+
600
+ test("toggling the right sidebar while zoomed exits zoom and restores the Thread pane", async () => {
601
+ const setup = await renderApp();
602
+
603
+ // zoom the Changes editor - the Thread pane hides
604
+ const zoom = locateText(setup, NERD.zoom);
605
+
606
+ await setup.mockMouse.click(zoom.column, zoom.row);
607
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Select a thread"));
608
+
609
+ // toggling the right sidebar off must not strand a blank screen: zoom exits, the Thread pane returns
610
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
611
+ await waitForText(setup, "Select a thread");
612
+ const frame = setup.captureCharFrame();
613
+
614
+ expect(frame).toContain("Select a thread");
615
+ // the right region is collapsed and the Changes editor is gone
616
+ expect(frame).not.toContain("store.ts");
617
+ expect(diffToggleColumn(setup)).toBe(-1);
618
+ });
619
+
620
+ test("hovering the right-sidebar icon surfaces its tooltip label at the screen root", async () => {
621
+ const setup = await renderApp();
622
+
623
+ // the tip is not painted until the pointer is over the control
624
+ expect(setup.captureCharFrame()).not.toContain("Toggle Sidebar");
625
+
626
+ await setup.mockMouse.moveTo(rightToggleColumn(setup), HEADER_ROW);
627
+ // the label surfaces at the root, escaping the header cell that would otherwise clip it
628
+ await waitForText(setup, "Toggle Sidebar");
629
+ });
630
+ });
631
+
632
+ /** The bare-launch (no thread) shell is the same four panes as a thread: the Thread pane waits in its
633
+ * empty state and a disposable Welcome tab rides in the Changes editor. The right-region toggles behave
634
+ * like the thread shell, dismissing Welcome collapses the editor without stranding an empty screen, and
635
+ * a closed right region moves its reopen toggle into the header rather than leaving a gutter. */
636
+ describe("the bare-launch welcome shell", () => {
637
+ let welcomeHome: string;
638
+ let welcomeRepo: string;
639
+ let restoreWelcome: () => void;
640
+ let welcomeServer: DaemonServer;
641
+
642
+ beforeEach(() => {
643
+ welcomeHome = mkdtempSync(join(tmpdir(), "cueloop-welcome-"));
644
+ // a small, controlled launch repo so the welcome tree/changes resolve fast and deterministically
645
+ welcomeRepo = makeRepo();
646
+ writeFileSync(join(welcomeRepo, "README.md"), "# Fixture\n\nedited in the working tree\n");
647
+ restoreWelcome = isolateUserConfig(welcomeHome);
648
+ welcomeServer = new DaemonServer({ home: welcomeHome, idleExitMs: 0 });
649
+ welcomeServer.start();
650
+ });
651
+ afterEach(() => {
652
+ restoreWelcome();
653
+ welcomeServer.stop();
654
+ rmSync(welcomeHome, { recursive: true, force: true });
655
+ rmSync(welcomeRepo, { recursive: true, force: true });
656
+ });
657
+
658
+ async function renderWelcome() {
659
+ const setup = await testRender(
660
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
661
+ { width: 180, height: 14 },
662
+ );
663
+
664
+ await waitForText(setup, "cueloop");
665
+
666
+ return setup;
667
+ }
668
+
669
+ test("the project tree shows the launch repo's files even with no thread open", async () => {
670
+ const setup = await renderWelcome();
671
+
672
+ // the tree toggle loads the git repo the client launched in
673
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
674
+ await waitForText(setup, "README.md");
675
+ expect(setup.captureCharFrame()).toContain("README.md");
676
+ });
677
+
678
+ test("Review shows only the skill selected in TOML", async () => {
679
+ writeFileSync(process.env.CUELOOP_CONFIG!, '[review]\nskill = "custom-review"\n');
680
+ const setup = await renderWelcome();
681
+
682
+ await setup.mockMouse.click(1, HEADER_ROW);
683
+ await waitForText(setup, "settings");
684
+ const settings = locateText(setup, "settings");
685
+
686
+ await setup.mockMouse.click(settings.column, settings.row);
687
+ await waitForText(setup, "General");
688
+ const review = locateText(setup, "Review");
689
+
690
+ await setup.mockMouse.click(review.column, review.row);
691
+ await waitForText(setup, "custom-review");
692
+ expect(setup.captureCharFrame()).not.toContain("research");
693
+ const skill = locateText(setup, "custom-review");
694
+
695
+ await setup.mockMouse.click(skill.column, skill.row);
696
+ expect(setup.captureCharFrame()).toContain("custom-review");
697
+ setup.renderer.destroy();
698
+ });
699
+
700
+ test("the first comment in the bare shell creates and persists a per-repo workbench thread", async () => {
701
+ const setup = await renderWelcome();
702
+
703
+ // open a project file in the bare shell (no thread yet - nothing on disk)
704
+ expect(welcomeServer.core.sessionList().length).toBe(0);
705
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
706
+ await waitForText(setup, "README.md");
707
+ const readme = locateText(setup, "README.md");
708
+
709
+ await setup.mockMouse.click(readme.column, readme.row);
710
+ await waitForText(setup, "edited in the working tree");
711
+
712
+ // comment on a line - the first note find-or-creates the workbench thread
713
+ const line = "edited in the working tree";
714
+
715
+ await dragText(setup, line, line, line.length);
716
+ await typeText(setup, "workbench note");
717
+ await pressKey(setup, "RETURN", { meta: true });
718
+
719
+ // the daemon persisted one per-repo workbench thread carrying the note
720
+ await waitForState(setup, () =>
721
+ welcomeServer.core.sessionList().some((thread) => thread.artifact.meta.workbench === true),
722
+ );
723
+ const workbench = welcomeServer.core
724
+ .sessionList()
725
+ .find((thread) => thread.artifact.meta.workbench === true)!;
726
+
727
+ expect(workbench.workspace.rootCommit).toBeTruthy();
728
+ await waitForState(setup, () =>
729
+ welcomeServer.core
730
+ .sessionGet(workbench.id)
731
+ .annotations.some((note) => note.body.includes("workbench note")),
732
+ );
733
+ });
734
+
735
+ test("the changes tree lists the launch repo's working-tree changes", async () => {
736
+ const setup = await renderWelcome();
737
+
738
+ // changes mode is the default; the edited README shows with its status
739
+ await waitForText(setup, "README.md");
740
+ expect(setup.captureCharFrame()).toContain("README.md");
741
+ });
742
+
743
+ test("a changed file tab keeps copy and expand without a collapse action", async () => {
744
+ const setup = await renderWelcome();
745
+
746
+ await waitForText(setup, "README.md");
747
+ const readme = locateText(setup, "README.md");
748
+
749
+ await setup.mockMouse.click(readme.column, readme.row);
750
+ await waitForText(setup, "edited in the working tree");
751
+ const frame = setup.captureCharFrame();
752
+
753
+ expect(frame).toContain(NERD.copy);
754
+ expect(frame).not.toContain("v README.md");
755
+ expect(frame).toContain("expand");
756
+ setup.renderer.destroy();
757
+ });
758
+
759
+ test("a bare changed-file tab uses Split only when zoomed in", async () => {
760
+ const setup = await renderWelcome();
761
+
762
+ await waitForText(setup, "README.md");
763
+ const readme = locateText(setup, "README.md");
764
+
765
+ await setup.mockMouse.click(readme.column, readme.row);
766
+ await waitForText(setup, "edited in the working tree");
767
+ const pairedLine = (line: string): boolean =>
768
+ line.includes("Workbench Fixture") && line.includes("+ # Fixture");
769
+
770
+ expect(setup.captureCharFrame().split("\n").some(pairedLine)).toBe(false);
771
+ const zoom = locateText(setup, NERD.zoom);
772
+
773
+ await setup.mockMouse.click(zoom.column, zoom.row);
774
+ await waitForState(setup, () => setup.captureCharFrame().split("\n").some(pairedLine));
775
+ expect(setup.captureCharFrame().split("\n").some(pairedLine)).toBe(true);
776
+ setup.renderer.destroy();
777
+ });
778
+
779
+ test("reopening Changes after dismissing Welcome keeps file actions", async () => {
780
+ const setup = await renderWelcome();
781
+ const welcome = locateText(setup, "Welcome");
782
+
783
+ await setup.mockMouse.click(welcome.column + 8, welcome.row);
784
+ await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
785
+ await waitForText(setup, "edited in the working tree");
786
+ const frame = setup.captureCharFrame();
787
+
788
+ expect(frame).toContain("README.md");
789
+ expect(frame).toContain(NERD.copy);
790
+ const title = locateText(setup, "README.md");
791
+
792
+ await setup.mockMouse.click(title.column, title.row);
793
+ await waitForTextGone(setup, "edited in the working tree");
794
+ expect(setup.captureCharFrame()).toContain("README.md");
795
+ await setup.mockMouse.click(title.column, title.row);
796
+ await waitForText(setup, "edited in the working tree");
797
+ setup.renderer.destroy();
798
+ });
799
+
800
+ test("the changes tree clears after the launch repo becomes clean", async () => {
801
+ const setup = await renderWelcome();
802
+
803
+ await waitForText(setup, "README.md");
804
+ writeFileSync(join(welcomeRepo, "README.md"), "# Workbench Fixture\n\nA tiny tracked repo.\n");
805
+ await waitForState(setup, () => !setup.captureCharFrame().includes("README.md"));
806
+ });
807
+
808
+ test("a review layout opens the Changes panel, listing the working-tree changes", async () => {
809
+ const setup = await testRender(
810
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} layout={reviewLayout()} />,
811
+ { width: 180, height: 14 },
812
+ );
813
+
814
+ await waitForText(setup, "README.md");
815
+ expect(setup.captureCharFrame()).toContain("README.md");
816
+ });
817
+
818
+ test("a plan layout opens with the right region closed, so no Changes panel shows", async () => {
819
+ const setup = await testRender(
820
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} layout={planLayout()} />,
821
+ { width: 180, height: 14 },
822
+ );
823
+
824
+ await waitForText(setup, "cueloop");
825
+ // rightSidebar "off": neither the Changes list nor the launch repo's files paint
826
+ expect(setup.captureCharFrame()).not.toContain("README.md");
827
+ });
828
+
829
+ test("a hand-edited off+zoom layout drops the zoom so the center is not stranded blank", async () => {
830
+ // zoom fills the middle with the Changes editor; with the region off there is none, so honoring zoom
831
+ // would hide the Thread pane over a blank center. The Thread placeholder must still render.
832
+ const setup = await testRender(
833
+ <App
834
+ home={welcomeHome}
835
+ sessionId={undefined}
836
+ cwd={welcomeRepo}
837
+ layout={{ threads: true, rightSidebar: "off", zoomChanges: true }}
838
+ />,
839
+ { width: 180, height: 14 },
840
+ );
841
+
842
+ await waitForText(setup, "Select a thread");
843
+ expect(setup.captureCharFrame()).toContain("Select a thread");
844
+ });
845
+
846
+ test("closing the right region on the vanilla shell remembers it for the next launch", async () => {
847
+ const setup = await testRender(
848
+ <App
849
+ home={welcomeHome}
850
+ sessionId={undefined}
851
+ cwd={welcomeRepo}
852
+ layout={{ threads: true, rightSidebar: "changes", zoomChanges: false }}
853
+ />,
854
+ { width: 180, height: 14 },
855
+ );
856
+
857
+ await waitForText(setup, "README.md");
858
+
859
+ // close the Changes/Project region; the remembered layout follows it to "off"
860
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
861
+ await waitForState(
862
+ setup,
863
+ () =>
864
+ loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout?.rightSidebar ===
865
+ "off",
866
+ );
867
+ expect(loadConfig({ userConfigPath: process.env.CUELOOP_CONFIG }).ui.layout).toEqual({
868
+ threads: true,
869
+ rightSidebar: "off",
870
+ zoomChanges: false,
871
+ });
872
+ });
873
+
874
+ test("clicking a changed file in the bare shell opens its diff, not its contents", async () => {
875
+ const setup = await renderWelcome();
876
+
877
+ await waitForText(setup, "README.md");
878
+ const readme = locateText(setup, "README.md");
879
+
880
+ await setup.mockMouse.click(readme.column, readme.row);
881
+
882
+ // a diff shows both the removed old line and the added new line; contents would show only the new
883
+ await waitForText(setup, "A tiny tracked repo.");
884
+ const frame = setup.captureCharFrame();
885
+
886
+ expect(frame).toContain("A tiny tracked repo.");
887
+ expect(frame).toContain("edited in the working tree");
888
+ });
889
+
890
+ test("nav mode on the Welcome tab offers only comment and fold; a diff letter types", async () => {
891
+ const setup = await renderReadyApp(
892
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
893
+ { width: 160, height: 28 },
894
+ );
895
+
896
+ await waitForText(setup, "quick brown fox");
897
+ const line = locateText(setup, "quick brown fox");
898
+
899
+ await setup.mockMouse.click(line.column, line.row);
900
+
901
+ // esc enters nav; the footer names what this surface can do, not the diff commands
902
+ await press(setup, "escape");
903
+ await waitForText(setup, BARE_NAV_HINT);
904
+ expect(setup.captureCharFrame()).not.toContain("x reject");
905
+
906
+ // a diff letter has no command here, so it leaves nav and types, as the footer says
907
+ await pressKey(setup, "d");
908
+ await waitForText(setup, "● d");
909
+ await press(setup, "escape");
910
+ await waitForTextGone(setup, "● d");
911
+
912
+ // c opens a comment on the caret word; z folds the saved card
913
+ await press(setup, "escape");
914
+ await waitForText(setup, BARE_NAV_HINT);
915
+ await pressKey(setup, "c");
916
+ await typeText(setup, "note");
917
+ await waitForText(setup, "● note");
918
+ await pressKey(setup, "RETURN", { meta: true });
919
+ await waitForText(setup, "note");
920
+ await press(setup, "escape");
921
+ await waitForText(setup, BARE_NAV_HINT);
922
+ await pressKey(setup, "z");
923
+ await waitForText(setup, "○ 1 comment ›");
924
+
925
+ setup.renderer.destroy();
926
+ });
927
+
928
+ test("nav mode on a bare changed-file diff has no reject or walk; x types instead", async () => {
929
+ const setup = await renderReadyApp(
930
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
931
+ { width: 160, height: 28 },
932
+ );
933
+
934
+ await waitForText(setup, "README.md");
935
+ const readme = locateText(setup, "README.md");
936
+
937
+ await setup.mockMouse.click(readme.column, readme.row);
938
+ await waitForText(setup, "edited in the working tree");
939
+ const line = locateText(setup, "edited in the working tree");
940
+
941
+ await setup.mockMouse.click(line.column, line.row);
942
+
943
+ await press(setup, "escape");
944
+ await waitForText(setup, BARE_NAV_HINT);
945
+ expect(setup.captureCharFrame()).not.toContain("x reject");
946
+ await pressKey(setup, "x");
947
+ await waitForText(setup, "● x");
948
+ const frame = setup.captureCharFrame();
949
+
950
+ expect(frame).toContain("edited in the working tree");
951
+ expect(frame).not.toContain("change rejected");
952
+
953
+ setup.renderer.destroy();
954
+ });
955
+
956
+ test("nav mode on a project file offers only comment and fold", async () => {
957
+ const setup = await renderReadyApp(
958
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
959
+ { width: 160, height: 28 },
960
+ );
961
+
962
+ await waitForText(setup, "README.md");
963
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
964
+ await waitForText(setup, "README.md");
965
+ const readme = locateText(setup, "README.md");
966
+
967
+ await setup.mockMouse.click(readme.column, readme.row);
968
+ await waitForText(setup, "edited in the working tree");
969
+ const line = locateText(setup, "edited in the working tree");
970
+
971
+ await setup.mockMouse.click(line.column, line.row);
972
+
973
+ await press(setup, "escape");
974
+ await waitForText(setup, BARE_NAV_HINT);
975
+ expect(setup.captureCharFrame()).not.toContain("x reject");
976
+ await pressKey(setup, "d");
977
+ await waitForText(setup, "● d");
978
+
979
+ setup.renderer.destroy();
980
+ });
981
+
982
+ test("a bare launch rides a Welcome tab in the editor while the Thread pane waits empty", async () => {
983
+ const setup = await renderWelcome();
984
+
985
+ // the Welcome playground measures its width before it paints, so wait for its copy
986
+ await waitForText(setup, "Getting started");
987
+ const frame = setup.captureCharFrame();
988
+
989
+ // the getting-started surface is an editor tab, not the thread pane
990
+ expect(frame.split("\n")[HEADER_ROW]!).toContain("Welcome");
991
+ expect(frame).toContain("Getting started");
992
+ // the Thread pane shows its empty state until a thread is opened
993
+ expect(frame).toContain("Select a thread");
994
+ });
995
+
996
+ test("the Threads sidebar owns the keyboard until a pane is clicked, so nav never touches the editor", async () => {
997
+ const setup = await renderReadyApp(
998
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
999
+ { width: 160, height: 24 },
1000
+ );
1001
+
1002
+ await waitForText(setup, "Getting started");
1003
+
1004
+ // focus starts on the Threads sidebar: a letter drives the inbox, it never drafts in the editor
1005
+ await typeText(setup, "zzz");
1006
+ expect(setup.captureCharFrame()).not.toContain("zzz");
1007
+
1008
+ // clicking a line in the Welcome editor focuses that pane; now typing composes there
1009
+ const line = locateText(setup, "quick brown fox");
1010
+
1011
+ await setup.mockMouse.click(line.column, line.row);
1012
+ await typeText(setup, "note");
1013
+ await waitForText(setup, "● note");
1014
+
1015
+ setup.renderer.destroy();
1016
+ });
1017
+
1018
+ test("typing in the welcome composer never reaches the inbox keys", async () => {
1019
+ // a pending thread in the sidebar so the inbox keys (delete, open) have a target to act on
1020
+ welcomeServer.core.sessionCreate({
1021
+ workspace: { repoRoot: welcomeRepo, branch: "main" },
1022
+ artifact: { type: "plan", content: "# Seeded\n", meta: { title: "Seeded Plan" } },
1023
+ });
1024
+ const setup = await testRender(
1025
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
1026
+ { width: 160, height: 28 },
1027
+ );
1028
+
1029
+ await waitForText(setup, "quick brown fox");
1030
+
1031
+ // open the playground composer, then type letters that are also inbox keys (j, k, d), one at a
1032
+ // time as a person would - each keystroke settles a frame, the way real input arrives
1033
+ await dragText(setup, "quick brown fox", "quick brown fox", "quick brown fox".length);
1034
+ for (const character of "note: jkd") await typeText(setup, character);
1035
+ await waitForText(setup, "note: jkd");
1036
+
1037
+ // the letters landed in the note, not the inbox: no delete confirmation opened
1038
+ expect(setup.captureCharFrame()).not.toContain("This removes the plan");
1039
+
1040
+ // Cmd+Enter saves the note; it must not dispatch openSession and replace the playground
1041
+ await pressKey(setup, "RETURN", { meta: true });
1042
+ expect(setup.captureCharFrame()).toContain("Getting started");
1043
+
1044
+ setup.renderer.destroy();
1045
+ });
1046
+
1047
+ test("dismissing the Welcome tab collapses the editor without stranding an empty screen", async () => {
1048
+ const setup = await renderWelcome();
1049
+
1050
+ const welcome = locateText(setup, "Welcome");
1051
+
1052
+ // the close box sits just past the label
1053
+ await setup.mockMouse.click(welcome.column + 8, welcome.row);
1054
+ await waitForState(
1055
+ setup,
1056
+ () => !setup.captureCharFrame().includes("comment on agent-authored work"),
1057
+ );
1058
+
1059
+ // the Threads sidebar and the Thread empty state remain - never a blank shell
1060
+ const frame = setup.captureCharFrame();
1061
+
1062
+ expect(frame).toContain("cueloop");
1063
+ expect(frame).toContain("Select a thread");
1064
+ });
1065
+
1066
+ test("the changed-files and tree toggles switch navigator mode without collapsing", async () => {
1067
+ const setup = await renderWelcome();
1068
+
1069
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
1070
+
1071
+ // the tree toggle switches to the project view (the launch repo's tree) without collapsing
1072
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
1073
+ await waitForText(setup, "src");
1074
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
1075
+
1076
+ // the changed-files toggle switches back, still open
1077
+ await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
1078
+ await waitForState(setup, () => !setup.captureCharFrame().includes("src"));
1079
+ expect(diffToggleColumn(setup)).toBeGreaterThan(0);
1080
+
1081
+ // the sidebar toggle closes the region
1082
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
1083
+ await waitForState(setup, () => diffToggleColumn(setup) === -1);
1084
+ });
1085
+
1086
+ test("the changes sidebar label opens one Changes tab beside Welcome and reactivates it", async () => {
1087
+ const setup = await renderWelcome();
1088
+
1089
+ await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
1090
+ await waitForText(setup, "edited in the working tree");
1091
+ const firstHeader = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
1092
+
1093
+ expect(firstHeader).toContain("Welcome");
1094
+ expect(firstHeader).toContain("Changes");
1095
+
1096
+ const welcome = locateText(setup, "Welcome");
1097
+
1098
+ await setup.mockMouse.click(welcome.column, welcome.row);
1099
+ await waitForText(setup, "Getting started");
1100
+ await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
1101
+ await waitForText(setup, "edited in the working tree");
1102
+ expect(
1103
+ setup
1104
+ .captureCharFrame()
1105
+ .split("\n")
1106
+ [HEADER_ROW]!.match(/Changes/g),
1107
+ ).toHaveLength(1);
1108
+ setup.renderer.destroy();
1109
+ });
1110
+
1111
+ test("the changes sidebar label activates Changes beside an open file tab", async () => {
1112
+ const setup = await renderWelcome();
1113
+
1114
+ await waitForText(setup, "README.md");
1115
+ const file = locateText(setup, "README.md");
1116
+
1117
+ await setup.mockMouse.click(file.column, file.row);
1118
+ await waitForText(setup, "README.md");
1119
+ await setup.mockMouse.click(diffToggleColumn(setup), HEADER_ROW);
1120
+ await waitForText(setup, "edited in the working tree");
1121
+ const header = setup.captureCharFrame().split("\n")[HEADER_ROW]!;
1122
+
1123
+ expect(header).toContain("Welcome");
1124
+ expect(header).toContain("README.md");
1125
+ expect(header).toContain("Changes");
1126
+ setup.renderer.destroy();
1127
+ });
1128
+
1129
+ test("a closed region draws its reopen divider in the header only, not full-height", async () => {
1130
+ const setup = await renderWelcome();
1131
+
1132
+ // close the right region so only the header toggle remains
1133
+ await setup.mockMouse.click(rightToggleColumn(setup), HEADER_ROW);
1134
+ await waitForState(setup, () => diffToggleColumn(setup) === -1);
1135
+
1136
+ const lines = setup.captureCharFrame().split("\n");
1137
+ // the reopen toggle's divider sits at the far right; find its column on the header row
1138
+ const headerDivider = lines[HEADER_ROW]!.lastIndexOf("│");
1139
+
1140
+ expect(headerDivider).toBeGreaterThan(0);
1141
+ // that column stays clear below the two-row header - no full-height column rule
1142
+ const bodyRows = lines.slice(3).filter((line) => line.length > headerDivider);
1143
+
1144
+ expect(bodyRows.length).toBeGreaterThan(0);
1145
+ for (const line of bodyRows) {
1146
+ expect(line[headerDivider]).not.toBe("│");
1147
+ }
1148
+ });
1149
+ });