@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,203 @@
1
+ // The right-hand Changes column: the diff's changed files as a directory tree.
2
+ // A click on a file scrolls the diff to that file. Renders nothing when collapsed
3
+ // so the diff reclaims the width. Mirrors the left Threads column on the far side.
4
+ // Co-located here: the state hook that opens it by context and the header toggle.
5
+
6
+ import { ScrollArea } from "../../ui/components/ScrollArea";
7
+ import React, { useMemo, useState } from "react";
8
+ import { useKeyboard } from "@opentui/react";
9
+ import type { DiffFileContents, Thread } from "@cueloop/schema";
10
+ import type { Theme } from "../../appearance/theme";
11
+ import type { DiffRow } from "../../diff/view-diff";
12
+ import { Tree } from "../../ui/components/primitives/Tree";
13
+ import { allFolderIds, flattenTree } from "../../ui/components/primitives/tree-model";
14
+ import { IconButton } from "../../ui/components/primitives/IconButton";
15
+ import { NERD } from "../../ui/components/primitives/icons";
16
+ import { buildFileTree } from "./file-tree";
17
+
18
+ export interface DiffColumnsState {
19
+ changesOpen: boolean;
20
+ toggleChanges: () => void;
21
+ currentFilePath?: string;
22
+ scrollToFile: (path: string) => void;
23
+ }
24
+
25
+ /**
26
+ * Owns the right Changes column for a diff: context opens it when you enter a
27
+ * diff thread, and a file click scrolls the sheet to that file's first row.
28
+ * Resets on session change via the guarded render-time pattern, no effect.
29
+ */
30
+ export function useDiffColumns(params: {
31
+ session: Thread | null;
32
+ rows: DiffRow[];
33
+ cursor: number;
34
+ setCursor: (index: number) => void;
35
+ }): DiffColumnsState {
36
+ const { session, rows, cursor, setCursor } = params;
37
+ const contextDefault = session?.artifact.type === "diff";
38
+ const [changesOpen, setChangesOpen] = useState(contextDefault);
39
+ const [seenSessionId, setSeenSessionId] = useState(session?.id);
40
+
41
+ // entering another thread lets context decide again; within a thread the manual toggle stays
42
+ if (session?.id !== seenSessionId) {
43
+ setSeenSessionId(session?.id);
44
+ setChangesOpen(contextDefault);
45
+ }
46
+
47
+ return {
48
+ changesOpen,
49
+ toggleChanges: () => setChangesOpen((open) => !open),
50
+ currentFilePath: rows[cursor]?.file,
51
+ scrollToFile: (path: string) => {
52
+ const fileRowIndex = rows.findIndex((row) => row.kind === "file" && row.file === path);
53
+
54
+ if (fileRowIndex >= 0) setCursor(fileRowIndex);
55
+ },
56
+ };
57
+ }
58
+
59
+ /** The header toggle for the Changes column; shown only for a diff, where files exist to browse. */
60
+ export function DiffChangesToggle({
61
+ isDiff,
62
+ open,
63
+ onToggle,
64
+ theme,
65
+ }: {
66
+ isDiff: boolean;
67
+ open: boolean;
68
+ onToggle: () => void;
69
+ theme?: Theme;
70
+ }): React.ReactNode {
71
+ if (!isDiff) return null;
72
+
73
+ return (
74
+ <IconButton
75
+ glyph={open ? NERD.sidebarRight : NERD.sidebarRightOff}
76
+ onPress={onToggle}
77
+ marginLeft={2}
78
+ theme={theme}
79
+ />
80
+ );
81
+ }
82
+
83
+ export interface ChangesColumnProps {
84
+ open: boolean;
85
+ files?: readonly DiffFileContents[];
86
+ selectedPath?: string;
87
+ onSelectFile: (path: string, persistent?: boolean) => void;
88
+ commentCounts?: ReadonlyMap<string, number>;
89
+ focused?: boolean;
90
+ width?: number;
91
+ theme?: Theme;
92
+ }
93
+
94
+ /**
95
+ * The changed-files tree on its own, with no surrounding column frame: the body
96
+ * a workbench Project pane renders. A file click scrolls the diff to that file.
97
+ */
98
+ export function ChangesFileTree({
99
+ files = [],
100
+ selectedPath,
101
+ onSelectFile,
102
+ commentCounts,
103
+ focused = false,
104
+ theme,
105
+ }: Omit<ChangesColumnProps, "open" | "width">): React.ReactNode {
106
+ const nodes = useMemo(() => buildFileTree(files, commentCounts), [files, commentCounts]);
107
+ // every folder opens by default so each changed file is reachable; the user
108
+ // only ever names the folders they fold shut, so a new file set stays open
109
+ const [collapsedIds, setCollapsedIds] = useState<ReadonlySet<string>>(new Set());
110
+ const [cursor, setCursor] = useState(0);
111
+ const expandedIds = useMemo(() => {
112
+ const expanded = allFolderIds(nodes);
113
+
114
+ for (const id of collapsedIds) expanded.delete(id);
115
+
116
+ return expanded;
117
+ }, [nodes, collapsedIds]);
118
+ const rows = useMemo(
119
+ () => flattenTree(nodes, { expandedIds, flattenEmptyDirectories: true }),
120
+ [nodes, expandedIds],
121
+ );
122
+ const cursorIndex = Math.min(cursor, Math.max(0, rows.length - 1));
123
+
124
+ const toggle = (id: string): void =>
125
+ setCollapsedIds((current) => {
126
+ const next = new Set(current);
127
+
128
+ if (next.has(id)) next.delete(id);
129
+ else next.add(id);
130
+
131
+ return next;
132
+ });
133
+
134
+ useKeyboard((key) => {
135
+ if (!focused || rows.length === 0) return;
136
+ if (key.name === "j" || key.name === "down")
137
+ return setCursor(Math.min(cursorIndex + 1, rows.length - 1));
138
+ if (key.name === "k" || key.name === "up") return setCursor(Math.max(cursorIndex - 1, 0));
139
+ const row = rows[cursorIndex];
140
+
141
+ if (!row) return;
142
+ if (key.name === "return" || key.name === "enter")
143
+ return row.isFolder ? toggle(row.id) : onSelectFile(row.id, true);
144
+ if (key.name === "l" && row.isFolder && collapsedIds.has(row.id)) return toggle(row.id);
145
+ if (key.name === "h" && row.isFolder && !collapsedIds.has(row.id)) return toggle(row.id);
146
+ });
147
+
148
+ return (
149
+ <ScrollArea
150
+ id="tree-scroll"
151
+ revealId={focused ? rows[cursorIndex]?.id : undefined}
152
+ gestureWheel
153
+ >
154
+ <Tree
155
+ nodes={nodes}
156
+ expandedIds={expandedIds}
157
+ selectedId={focused ? rows[cursorIndex]?.id : selectedPath}
158
+ singleLine
159
+ flattenEmptyDirectories
160
+ showStatus
161
+ onSelect={(id) => {
162
+ const index = rows.findIndex((row) => row.id === id);
163
+
164
+ if (index >= 0) setCursor(index);
165
+ onSelectFile(id);
166
+ }}
167
+ onDoubleSelect={(id) => onSelectFile(id, true)}
168
+ onToggle={toggle}
169
+ theme={theme}
170
+ />
171
+ </ScrollArea>
172
+ );
173
+ }
174
+
175
+ export function ChangesColumn({
176
+ open,
177
+ files = [],
178
+ selectedPath,
179
+ onSelectFile,
180
+ width = 32,
181
+ theme,
182
+ }: ChangesColumnProps): React.ReactNode {
183
+ if (!open) return null;
184
+
185
+ return (
186
+ <box
187
+ style={{
188
+ width,
189
+ flexDirection: "column",
190
+ borderStyle: "single",
191
+ border: ["left"],
192
+ borderColor: theme?.border,
193
+ }}
194
+ >
195
+ <ChangesFileTree
196
+ files={files}
197
+ selectedPath={selectedPath}
198
+ onSelectFile={onSelectFile}
199
+ theme={theme}
200
+ />
201
+ </box>
202
+ );
203
+ }
@@ -0,0 +1,181 @@
1
+ /** The changed-files tree navigates by keyboard when focused: j/k move the cursor, Enter folds a folder
2
+ * or opens a file. */
3
+
4
+ import { test, expect } from "bun:test";
5
+ import React from "react";
6
+ import { testRender } from "@opentui/react/test-utils";
7
+ import { ScrollBoxRenderable, TextRenderable, type Renderable } from "@opentui/core";
8
+ import type { DiffFileContents } from "@cueloop/schema";
9
+ import { ChangesFileTree } from "./ChangesColumn";
10
+ import { settle, waitForText } from "../../testing/test-support";
11
+ import { DARK } from "../../appearance/theme";
12
+
13
+ function findById(node: Renderable, id: string): Renderable | undefined {
14
+ if (node.id === id) return node;
15
+ for (const child of node.getChildren()) {
16
+ const found = findById(child, id);
17
+
18
+ if (found) return found;
19
+ }
20
+
21
+ return undefined;
22
+ }
23
+
24
+ const file = (path: string): DiffFileContents => ({
25
+ path,
26
+ oldContents: "",
27
+ newContents: "x",
28
+ status: "modified",
29
+ });
30
+
31
+ const files = [file("src/a.ts"), file("src/b.ts")];
32
+
33
+ test("j moves onto a file and Enter opens it", async () => {
34
+ const opened: string[] = [];
35
+ const setup = await testRender(
36
+ <ChangesFileTree
37
+ files={files}
38
+ onSelectFile={(path) => opened.push(path)}
39
+ focused
40
+ theme={DARK}
41
+ />,
42
+ { width: 40, height: 12 },
43
+ );
44
+
45
+ await settle(setup);
46
+ await waitForText(setup, "a.ts");
47
+
48
+ // folders open by default; cursor starts on the "src" folder, j steps onto a.ts
49
+ setup.mockInput.pressKey("j");
50
+ await settle(setup);
51
+ setup.mockInput.pressKey("RETURN");
52
+ await settle(setup);
53
+
54
+ expect(opened).toEqual(["src/a.ts"]);
55
+
56
+ setup.renderer.destroy();
57
+ });
58
+
59
+ test("Tab leaves the focused tree without opening a file", async () => {
60
+ const opened: string[] = [];
61
+ const setup = await testRender(
62
+ <ChangesFileTree
63
+ files={files}
64
+ onSelectFile={(path) => opened.push(path)}
65
+ focused
66
+ theme={DARK}
67
+ />,
68
+ { width: 40, height: 12 },
69
+ );
70
+
71
+ await waitForText(setup, "a.ts");
72
+ setup.mockInput.pressKey("j");
73
+ setup.mockInput.pressKey("TAB");
74
+ await settle(setup);
75
+ expect(opened).toEqual([]);
76
+ setup.renderer.destroy();
77
+ });
78
+
79
+ test("keyboard navigation reveals the selected changed file", async () => {
80
+ const manyFiles = Array.from({ length: 35 }, (_, index) =>
81
+ file(`file-${String(index).padStart(2, "0")}.ts`),
82
+ );
83
+ const setup = await testRender(
84
+ <ChangesFileTree files={manyFiles} onSelectFile={() => {}} focused theme={DARK} />,
85
+ { width: 28, height: 8 },
86
+ );
87
+
88
+ await waitForText(setup, "file-00.ts");
89
+ const scrollbox = findById(setup.renderer.root, "tree-scroll");
90
+
91
+ if (!(scrollbox instanceof ScrollBoxRenderable)) throw new Error("tree scrollbox missing");
92
+ const scrollSteps: number[] = [];
93
+
94
+ for (const [index, key] of Array.from({ length: 15 }, () => "j").entries()) {
95
+ const before = scrollbox.scrollTop;
96
+
97
+ setup.mockInput.pressKey(key);
98
+ // eslint-disable-next-line no-await-in-loop
99
+ await settle(setup);
100
+ scrollSteps.push(scrollbox.scrollTop - before);
101
+ const row = scrollbox.content.findDescendantById(
102
+ `tree-row-file-${String(index + 1).padStart(2, "0")}.ts`,
103
+ );
104
+
105
+ expect(row?.y).toBeGreaterThanOrEqual(scrollbox.viewport.y);
106
+ expect(row?.y).toBeLessThan(scrollbox.viewport.y + scrollbox.viewport.height);
107
+ }
108
+
109
+ expect(scrollbox.scrollTop).toBeGreaterThan(0);
110
+ expect(Math.max(...scrollSteps)).toBe(1);
111
+ expect(setup.captureCharFrame()).toContain("file-15.ts");
112
+ setup.renderer.destroy();
113
+ });
114
+
115
+ test("without focus the keyboard opens nothing", async () => {
116
+ const opened: string[] = [];
117
+ const setup = await testRender(
118
+ <ChangesFileTree files={files} onSelectFile={(path) => opened.push(path)} theme={DARK} />,
119
+ { width: 40, height: 12 },
120
+ );
121
+
122
+ await settle(setup);
123
+ await waitForText(setup, "a.ts");
124
+
125
+ setup.mockInput.pressKey("j");
126
+ setup.mockInput.pressKey("RETURN");
127
+ await settle(setup);
128
+ expect(opened).toEqual([]);
129
+
130
+ setup.renderer.destroy();
131
+ });
132
+
133
+ test("a selected long filename has no empty continuation row", async () => {
134
+ for (const width of [33, 32, 31, 30, 29, 28, 27, 26, 25, 24]) {
135
+ const setup = await testRender(
136
+ <ChangesFileTree
137
+ files={[
138
+ file("packages/client/src/App.workbench.test.tsx"),
139
+ file("packages/client/src/next.ts"),
140
+ ]}
141
+ onSelectFile={() => {}}
142
+ focused
143
+ theme={DARK}
144
+ />,
145
+ { width, height: 12 },
146
+ );
147
+
148
+ await settle(setup);
149
+ setup.mockInput.pressKey("j");
150
+ await settle(setup);
151
+ const frame = setup.captureCharFrame().split("\n");
152
+ const selectedLine = frame.findIndex((line) => line.includes("App.wor"));
153
+ const row = findById(
154
+ setup.renderer.root,
155
+ "tree-row-packages/client/src/App.workbench.test.tsx",
156
+ );
157
+
158
+ expect(selectedLine).toBeGreaterThanOrEqual(0);
159
+ expect(frame[selectedLine + 1]).toContain("next.ts");
160
+ expect(row?.height).toBe(1);
161
+ setup.renderer.destroy();
162
+ }
163
+ });
164
+
165
+ test("horizontal name scrolling reveals the final extension beside the status", async () => {
166
+ const path = "src/sidebar-scroll.test.tsx";
167
+ const setup = await testRender(
168
+ <ChangesFileTree files={[file(path)]} onSelectFile={() => {}} focused theme={DARK} />,
169
+ { width: 24, height: 8 },
170
+ );
171
+
172
+ await waitForText(setup, "src");
173
+ const label = findById(setup.renderer.root, `tree-label-${path}`);
174
+
175
+ if (!(label instanceof TextRenderable)) throw new Error("file label missing");
176
+ expect(label.maxScrollX).toBeGreaterThan(0);
177
+ label.scrollX = label.maxScrollX;
178
+ await settle(setup);
179
+ expect(setup.captureCharFrame()).toContain(".tsx");
180
+ setup.renderer.destroy();
181
+ });
@@ -0,0 +1,118 @@
1
+ import { expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import type { Renderable } from "@opentui/core";
5
+ import { allowEventLoopUpdates } from "../../testing/test-support";
6
+ import { EditorGrid } from "./EditorGrid";
7
+ import { makeGroup, splitGroup, welcomeTab, type EditorNode } from "./editor-grid";
8
+ import { WelcomePlayground } from "./WelcomePlayground";
9
+
10
+ function findById(node: Renderable, id: string): Renderable | undefined {
11
+ if (node.id === id) return node;
12
+
13
+ for (const child of node.getChildren()) {
14
+ const found = findById(child, id);
15
+
16
+ if (found) return found;
17
+ }
18
+
19
+ return undefined;
20
+ }
21
+
22
+ function groupIds(node: EditorNode): string[] {
23
+ if (node.type === "group") return [node.id];
24
+
25
+ return node.children.flatMap(groupIds);
26
+ }
27
+
28
+ test("mixed splits keep each Welcome page inside its own tile", async () => {
29
+ const first = makeGroup([welcomeTab()]);
30
+ let tree: EditorNode = first;
31
+ let focusGroupId = first.id;
32
+
33
+ for (const direction of ["right", "up", "left", "down"] as const) {
34
+ const next = splitGroup(tree, focusGroupId, direction);
35
+
36
+ tree = next.tree;
37
+ focusGroupId = next.focusGroupId;
38
+ }
39
+
40
+ const setup = await testRender(
41
+ <EditorGrid
42
+ tree={tree}
43
+ focusedGroupId={focusGroupId}
44
+ onFocusGroup={() => {}}
45
+ onActivateTab={() => {}}
46
+ onCloseTab={() => {}}
47
+ onSplit={() => {}}
48
+ onZoom={() => {}}
49
+ zoomed={false}
50
+ renderTab={(_, focused) => <WelcomePlayground quickActions={[]} suspended={!focused} />}
51
+ />,
52
+ { width: 120, height: 48 },
53
+ );
54
+
55
+ allowEventLoopUpdates();
56
+ await setup.waitForVisualIdle();
57
+ expect(setup.renderer.listenerCount("frame")).toBeLessThan(10);
58
+ expect(setup.renderer.listenerCount("resize")).toBeLessThan(10);
59
+ expect(setup.renderer.keyInput.listenerCount("keypress")).toBeLessThan(10);
60
+
61
+ for (const id of groupIds(tree)) {
62
+ const group = findById(setup.renderer.root, `editor-group:${id}`);
63
+ const scroll = group && findById(group, "diff-scroll");
64
+
65
+ expect(group).toBeDefined();
66
+ expect(scroll).toBeDefined();
67
+ expect(scroll!.y).toBeGreaterThanOrEqual(group!.y + 2);
68
+ expect(scroll!.y + scroll!.height).toBeLessThanOrEqual(group!.y + group!.height);
69
+ }
70
+
71
+ setup.renderer.destroy();
72
+ });
73
+
74
+ test("Split is disabled at eight tiles and when a tile is too small", async () => {
75
+ const first = makeGroup([welcomeTab()]);
76
+ let tree: EditorNode = first;
77
+ let focusGroupId = first.id;
78
+
79
+ for (const direction of ["right", "up", "left", "down", "right", "up", "left"] as const) {
80
+ const next = splitGroup(tree, focusGroupId, direction);
81
+
82
+ tree = next.tree;
83
+ focusGroupId = next.focusGroupId;
84
+ }
85
+
86
+ for (const [candidate, width, height] of [
87
+ [tree, 240, 100],
88
+ [first, 40, 12],
89
+ ] as const) {
90
+ let splitCalls = 0;
91
+ const setup = await testRender(
92
+ <EditorGrid
93
+ tree={candidate}
94
+ focusedGroupId={first.id}
95
+ onFocusGroup={() => {}}
96
+ onActivateTab={() => {}}
97
+ onCloseTab={() => {}}
98
+ onSplit={() => {
99
+ splitCalls += 1;
100
+ }}
101
+ onZoom={() => {}}
102
+ zoomed={false}
103
+ renderTab={() => <text>body</text>}
104
+ />,
105
+ { width, height },
106
+ );
107
+
108
+ allowEventLoopUpdates();
109
+ await setup.waitForVisualIdle();
110
+ const button = findById(setup.renderer.root, `split-button:${first.id}`);
111
+
112
+ expect(button).toBeDefined();
113
+ await setup.mockMouse.click(button!.x, button!.y);
114
+ expect(setup.captureCharFrame()).not.toContain("right →");
115
+ expect(splitCalls).toBe(0);
116
+ setup.renderer.destroy();
117
+ }
118
+ });
@@ -0,0 +1,86 @@
1
+ import React from "react";
2
+ import { DARK } from "../../appearance/theme";
3
+ import type { Story, StoryMeta } from "../../stories/story";
4
+ import { EditorGrid } from "./EditorGrid";
5
+ import { changesTab, fileTab, makeGroup, splitGroup, type EditorTab } from "./editor-grid";
6
+
7
+ export const meta: StoryMeta = { title: "Surfaces/EditorGrid" };
8
+
9
+ const noop = (): void => {};
10
+
11
+ function renderTab(tab: EditorTab): React.ReactNode {
12
+ return (
13
+ <box style={{ flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
14
+ <text fg={DARK.textMuted}>{tab.kind === "changes" ? "the whole diff" : tab.path}</text>
15
+ </box>
16
+ );
17
+ }
18
+
19
+ const oneGroup = makeGroup([changesTab(), fileTab("App.tsx", "src/App.tsx", "diff")]);
20
+ const sourceGroup = makeGroup([fileTab("plugin.json", ".claude-plugin/plugin.json", "contents")]);
21
+ const splitTree = splitGroup(sourceGroup, sourceGroup.id, "right").tree;
22
+
23
+ export const OneGroup: Story = {
24
+ render: () => (
25
+ <EditorGrid
26
+ tree={oneGroup}
27
+ focusedGroupId={oneGroup.id}
28
+ onFocusGroup={noop}
29
+ onActivateTab={noop}
30
+ onCloseTab={noop}
31
+ onSplit={noop}
32
+ onZoom={noop}
33
+ zoomed={false}
34
+ renderTab={renderTab}
35
+ theme={DARK}
36
+ />
37
+ ),
38
+ expectedColors: [DARK.textMuted, DARK.border],
39
+ size: { width: 80, height: 16 },
40
+ };
41
+
42
+ export const SplitGroups: Story = {
43
+ render: () => (
44
+ <EditorGrid
45
+ tree={splitTree}
46
+ focusedGroupId={null}
47
+ onFocusGroup={noop}
48
+ onActivateTab={noop}
49
+ onCloseTab={noop}
50
+ onSplit={noop}
51
+ onZoom={noop}
52
+ zoomed={false}
53
+ renderTab={renderTab}
54
+ theme={DARK}
55
+ />
56
+ ),
57
+ expectedColors: [DARK.textMuted, DARK.border],
58
+ size: { width: 100, height: 16 },
59
+ };
60
+
61
+ /** The rightmost group takes the remainder after whole-cell leading groups and divider cells. */
62
+ export const WholeCellSizing: Story = {
63
+ render: () => (
64
+ <box style={{ flexDirection: "column", width: 100, height: 16 }}>
65
+ <text fg={DARK.text}>Whole-cell split sizing</text>
66
+ <text fg={DARK.textMuted}>100 cells = 49 left + 1 divider + 50 right</text>
67
+ <text fg={DARK.textDim}>Fix the leading width; let the last group take the remainder.</text>
68
+ <box style={{ flexGrow: 1, minHeight: 0 }}>
69
+ <EditorGrid
70
+ tree={splitTree}
71
+ focusedGroupId={null}
72
+ onFocusGroup={noop}
73
+ onActivateTab={noop}
74
+ onCloseTab={noop}
75
+ onSplit={noop}
76
+ onZoom={noop}
77
+ zoomed={false}
78
+ renderTab={renderTab}
79
+ theme={DARK}
80
+ />
81
+ </box>
82
+ </box>
83
+ ),
84
+ expectedColors: [DARK.textMuted, DARK.border],
85
+ size: { width: 100, height: 16 },
86
+ };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Regression: file tabs keep the width of their name and the strip scrolls horizontally
3
+ * behind the header controls when the row overflows, revealing the active tab - instead of
4
+ * squeezing every tab and truncating its label.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import React from "react";
10
+ import { EditorGrid, visibleTabWindow } from "./EditorGrid";
11
+ import { changesTab, fileTab, makeGroup, type EditorTab } from "./editor-grid";
12
+ import { NERD } from "../../ui/components/primitives/icons";
13
+ import { allowEventLoopUpdates } from "../../testing/test-support";
14
+
15
+ describe("visibleTabWindow", () => {
16
+ const tabs: EditorTab[] = ["aaaa", "bbbb", "cccc", "dddd", "eeee"].map((name) =>
17
+ fileTab(name, name, "diff"),
18
+ );
19
+
20
+ test("an unmeasured strip shows every tab", () => {
21
+ // Arrange / Act / Assert - width 0 means the header clips until it is measured
22
+ expect(visibleTabWindow(tabs, 0, 0, 0)).toEqual({ first: 0, end: tabs.length });
23
+ });
24
+
25
+ test("only the run that fits renders, and the active tab pulls the window to it", () => {
26
+ // Arrange - each tab is 9 cells (4 label + 5 chrome); a 30-cell strip fits about three
27
+ // Act - the last tab is active
28
+ const window = visibleTabWindow(tabs, 0, tabs.length - 1, 30);
29
+
30
+ // Assert - the window ends on the active tab and dropped earlier tabs
31
+ expect(window.end).toBe(tabs.length);
32
+ expect(window.first).toBeGreaterThan(0);
33
+ });
34
+
35
+ test("a tab wider than the strip still shows on its own", () => {
36
+ // Arrange
37
+ const wide = [fileTab("a-very-long-file-name.tsx", "x", "diff")];
38
+
39
+ // Act / Assert
40
+ expect(visibleTabWindow(wide, 0, 0, 10)).toEqual({ first: 0, end: 1 });
41
+ });
42
+
43
+ test("the comment badge counts toward a tab's width, so a commented run does not overflow", () => {
44
+ // Arrange - three 9-cell tabs fit a 27-cell strip exactly with no badge
45
+ const three = ["aaaa", "bbbb", "cccc"].map((name) => fileTab(name, name, "diff"));
46
+
47
+ // Act - without badges all three fit; a ` ● 2` badge on each widens them past the strip
48
+ const bare = visibleTabWindow(three, 0, 0, 27);
49
+ const badged = visibleTabWindow(three, 0, 0, 27, () => 2);
50
+
51
+ // Assert - the badge width narrows the window rather than clipping the last tab
52
+ expect(bare.end).toBe(3);
53
+ expect(badged.end).toBeLessThan(3);
54
+ });
55
+ });
56
+
57
+ const NAMES = [
58
+ "AppShell.tsx",
59
+ "DiffContentView.tsx",
60
+ "IconButton.tsx",
61
+ "EditorGrid.zoom.test.tsx",
62
+ "ThreadView.tsx",
63
+ ];
64
+
65
+ async function headerRow(activeIndex: number): Promise<string> {
66
+ const group = makeGroup([
67
+ changesTab(),
68
+ ...NAMES.map((name) => fileTab(name, `src/${name}`, "diff")),
69
+ ]);
70
+ const tree = { ...group, activeTabId: group.tabs[activeIndex]!.id };
71
+ const setup = await testRender(
72
+ <EditorGrid
73
+ tree={tree}
74
+ focusedGroupId={tree.id}
75
+ onFocusGroup={() => {}}
76
+ onActivateTab={() => {}}
77
+ onCloseTab={() => {}}
78
+ onSplit={() => {}}
79
+ onZoom={() => {}}
80
+ zoomed={false}
81
+ renderTab={() => <text>body</text>}
82
+ />,
83
+ { width: 70, height: 4 },
84
+ );
85
+
86
+ allowEventLoopUpdates();
87
+ await setup.waitForVisualIdle();
88
+ // the strip reveals the active tab on the frame after layout
89
+ await setup.waitForVisualIdle();
90
+ const row = setup.captureCharFrame().split("\n")[0]!;
91
+
92
+ setup.renderer.destroy();
93
+
94
+ return row;
95
+ }
96
+
97
+ describe("editor tab strip", () => {
98
+ test("tabs keep their full names and the controls stay pinned when the row overflows", async () => {
99
+ // Act
100
+ const row = await headerRow(0);
101
+
102
+ // Assert - no squeezed labels: every visible tab reads in full up to the clip edge
103
+ expect(row).toContain("Changes ");
104
+ expect(row).toContain("AppShell.tsx ");
105
+ expect(row).toContain("DiffContentView.tsx ");
106
+ // and the header controls sit at the right edge, past the clipped strip (search is out for now)
107
+ expect(row.trimEnd().endsWith(NERD.zoom)).toBe(true);
108
+ });
109
+
110
+ test("the strip scrolls to reveal the active tab", async () => {
111
+ // Act
112
+ const row = await headerRow(NAMES.length);
113
+
114
+ // Assert - the last tab is on screen, the first has scrolled out behind the left edge
115
+ expect(row).toContain("ThreadView.tsx ");
116
+ expect(row).not.toContain("Changes ");
117
+ });
118
+ });