@kabmo-labs/kabmo 0.7.0 → 0.9.0

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 (351) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +403 -384
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +10 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/header-controls/index.tsx +24 -1
  24. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  25. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  26. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  27. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  28. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  29. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  30. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  31. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  32. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  33. package/src/client/components/layout/desktop-layout/index.tsx +55 -69
  34. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  35. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  36. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  37. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  38. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  40. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  41. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  42. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  43. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  44. package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
  45. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  46. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  47. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  48. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  49. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  50. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  51. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  52. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  53. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  54. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  55. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  56. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  57. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  58. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  59. package/src/client/components/mobile/worktree-session.ts +51 -0
  60. package/src/client/components/settings/Modal.tsx +8 -0
  61. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  62. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  63. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  64. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  65. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
  66. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  67. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  68. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
  69. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  70. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  71. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  72. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  73. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  74. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  75. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  76. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  77. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  78. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  79. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  80. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  81. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  82. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  83. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
  84. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  85. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  86. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  87. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  88. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  89. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  90. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  91. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  92. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  93. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  94. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  95. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  96. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  97. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  98. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  99. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  100. package/src/client/components/workspace/editor/Body.tsx +4 -7
  101. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  102. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  103. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  104. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  105. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  106. package/src/client/components/workspace/editor/index.tsx +7 -9
  107. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  108. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  109. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  110. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  111. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  112. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  113. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  114. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  115. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  116. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  117. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  118. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  119. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  120. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  121. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  122. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  123. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  124. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  125. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  126. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  127. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  128. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  129. package/src/client/hooks/chat/timeline/index.ts +27 -28
  130. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  131. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  132. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  133. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  134. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  135. package/src/client/hooks/chat/timeline/send.ts +11 -1
  136. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  137. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  138. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  139. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  140. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  141. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  142. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  143. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  144. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  145. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  146. package/src/client/hooks/editor/find-state.ts +1 -1
  147. package/src/client/hooks/editor/theme.test.ts +206 -0
  148. package/src/client/hooks/editor/theme.ts +82 -0
  149. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  150. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  151. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  152. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  153. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  154. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  155. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  156. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  157. package/src/client/hooks/ui/long-press.test.ts +174 -0
  158. package/src/client/hooks/ui/long-press.ts +112 -0
  159. package/src/client/hooks/ui/settings-modal.ts +56 -0
  160. package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
  161. package/src/client/hooks/ui/updates/shell-download.ts +153 -28
  162. package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
  163. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  164. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  165. package/src/client/hooks/workspace/diff-file.ts +29 -10
  166. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  167. package/src/client/hooks/workspace/editor-view.ts +61 -0
  168. package/src/client/hooks/workspace/file-create.ts +41 -11
  169. package/src/client/hooks/workspace/file-listing.ts +56 -1
  170. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  171. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  172. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  173. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  174. package/src/client/hooks/workspace/worktree/index.ts +165 -0
  175. package/src/client/lib/desktop/bridge.ts +45 -0
  176. package/src/client/lib/search-reveal.ts +1 -1
  177. package/src/client/main.tsx +5 -1
  178. package/src/client/tailwind.css +56 -8
  179. package/src/server/index.ts +7 -2
  180. package/src/server/lib/auth/guard.test.ts +28 -0
  181. package/src/server/lib/auth/guard.ts +10 -0
  182. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  183. package/src/server/lib/db/schema.ts +4 -0
  184. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  185. package/src/server/lib/fs/canonical.ts +52 -0
  186. package/src/server/lib/fs/relative-path.ts +48 -0
  187. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  188. package/src/server/lib/fs/root.ts +39 -10
  189. package/src/server/lib/fs/worktree/carry.ts +134 -0
  190. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  191. package/src/server/lib/fs/worktree/create.ts +262 -0
  192. package/src/server/lib/fs/worktree/errors.ts +43 -0
  193. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  194. package/src/server/lib/fs/worktree/list.ts +142 -0
  195. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  196. package/src/server/lib/fs/worktree/location.ts +114 -0
  197. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  198. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  199. package/src/server/lib/fs/worktree/remove.ts +152 -0
  200. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  201. package/src/server/lib/fs/worktree/topology.ts +142 -0
  202. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  203. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  204. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  205. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  206. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  207. package/src/server/lib/lifecycle/probe.ts +121 -13
  208. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  209. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  210. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  211. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  212. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  213. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  214. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  215. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  216. package/src/server/lib/panels/local-edits.server.ts +33 -0
  217. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  218. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  219. package/src/server/lib/panels/sync.server.test.ts +125 -147
  220. package/src/server/lib/panels/sync.server.ts +153 -48
  221. package/src/server/lib/realtime/signals.server.ts +10 -0
  222. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  223. package/src/server/lib/realtime/topics.server.ts +16 -0
  224. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  225. package/src/server/lib/updates/shell-assets.ts +200 -0
  226. package/src/server/lib/updates/shell-cache.ts +105 -0
  227. package/src/server/lib/updates/shell-pages.test.ts +187 -0
  228. package/src/server/lib/updates/shell-pages.ts +157 -0
  229. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  230. package/src/server/lib/updates/shell-release.test.ts +59 -71
  231. package/src/server/lib/updates/shell-release.ts +178 -198
  232. package/src/server/routes/fs/action-create.test.ts +18 -13
  233. package/src/server/routes/fs/action-nested.test.ts +184 -0
  234. package/src/server/routes/fs/action.ts +47 -16
  235. package/src/server/routes/fs/git-merge.test.ts +11 -1
  236. package/src/server/routes/fs/read.test.ts +24 -7
  237. package/src/server/routes/fs/scope.test.ts +15 -10
  238. package/src/server/routes/fs/scope.ts +3 -14
  239. package/src/server/routes/index.ts +2 -0
  240. package/src/server/routes/updates/android.ts +6 -1
  241. package/src/server/routes/updates/desktop.ts +59 -0
  242. package/src/server/routes/updates/index.ts +2 -0
  243. package/src/server/routes/updates/ios.ts +5 -1
  244. package/src/server/routes/worktrees/create.ts +138 -0
  245. package/src/server/routes/worktrees/index.ts +25 -0
  246. package/src/server/routes/worktrees/list.ts +134 -0
  247. package/src/server/routes/worktrees/remove.ts +102 -0
  248. package/src/server/routes/worktrees/routes.test.ts +326 -0
  249. package/src/server/routes/worktrees/shared.ts +90 -0
  250. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  251. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  252. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  253. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  254. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  255. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  256. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  257. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  258. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  259. package/src/shared/lib/code/editor/keymap.ts +3 -3
  260. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  261. package/src/shared/lib/code/editor/lines.ts +8 -48
  262. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  263. package/src/shared/lib/code/editor/marks.ts +13 -25
  264. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  265. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  266. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  267. package/src/shared/lib/code/editor/themes.ts +165 -0
  268. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  269. package/src/shared/lib/code/highlight-memo.ts +149 -0
  270. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  271. package/src/shared/lib/code/highlighter.ts +3 -1
  272. package/src/shared/lib/code/idle.ts +37 -0
  273. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  274. package/src/shared/lib/code/outline/rows.ts +94 -0
  275. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  276. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  277. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  278. package/src/shared/lib/fs/file-kind.ts +47 -13
  279. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  280. package/src/shared/lib/fs/file-tree.ts +159 -0
  281. package/src/shared/lib/fs/file-url.test.ts +52 -0
  282. package/src/shared/lib/fs/file-url.ts +37 -0
  283. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  284. package/src/shared/lib/fs/patch-sides.ts +139 -0
  285. package/src/shared/lib/fs/paths.test.ts +24 -1
  286. package/src/shared/lib/fs/paths.ts +17 -0
  287. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  288. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  289. package/src/shared/lib/realtime/protocol.ts +16 -0
  290. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  291. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  292. package/src/shared/lib/updates/shell-arch.ts +54 -0
  293. package/src/shared/lib/updates/shell-ua.ts +24 -4
  294. package/src/shared/lib/workspace/active-project.ts +23 -0
  295. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  296. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  297. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  298. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  299. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  300. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  301. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  302. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  303. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  304. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  305. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  306. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  307. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  308. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  309. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  310. package/src/shared/types/fs.ts +10 -0
  311. package/src/shared/types/realtime.ts +29 -0
  312. package/src/shared/types/settings/state.ts +10 -0
  313. package/src/shared/types/workspace.ts +14 -0
  314. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  315. package/src/test-support/file-explorer.test-util.ts +71 -40
  316. package/src/test-support/loopback.ts +183 -0
  317. package/dist/client/static/js/index-nzyjf92m.css +0 -1
  318. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  319. package/src/client/components/common/code-editor/handler.ts +0 -175
  320. package/src/client/components/common/code-editor/index.tsx +0 -314
  321. package/src/client/components/common/code-surface/measure.ts +0 -148
  322. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  323. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  324. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  325. package/src/client/hooks/editor/scroll-container.ts +0 -26
  326. package/src/client/hooks/editor/use-code-window.ts +0 -100
  327. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  328. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  329. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  330. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  331. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  332. package/src/shared/lib/code/editor/commands.ts +0 -260
  333. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  334. package/src/shared/lib/code/editor/edits.ts +0 -122
  335. package/src/shared/lib/code/editor/history.test.ts +0 -118
  336. package/src/shared/lib/code/editor/history.ts +0 -101
  337. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  338. package/src/shared/lib/code/editor/layers.ts +0 -67
  339. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  340. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  341. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  342. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  343. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  344. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  345. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  346. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  347. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  348. package/src/shared/lib/code/lazy-window.ts +0 -156
  349. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  350. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  351. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -13,8 +13,7 @@
13
13
  import { useMemo } from 'preact/hooks';
14
14
  import type { HashlineSection, HashlineVerb } from '@/shared/lib/omp/session/hashline-patch';
15
15
  import { getLanguageFromPath } from '@/shared/lib/code/language';
16
- import { highlightLines } from '@/shared/lib/code/syntax-highlight';
17
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
16
+ import { useDeferredHighlightLines } from '@/client/hooks/code/deferred-highlight';
18
17
 
19
18
  /** A hashline `CUT` removes lines the model already saw, so it reads as the
20
19
  * destructive half of the patch; `PUT` carries the replacement body. */
@@ -22,16 +21,38 @@ function opTone(verb: HashlineVerb): string {
22
21
  return verb === 'CUT' ? 'bg-error/10 text-error' : 'bg-success/10 text-success';
23
22
  }
24
23
 
25
- export function HashlinePatch({ sections }: { sections: HashlineSection[] }) {
26
- const syntaxReady = useSyntaxReady();
27
-
28
- const htmlByOp = useMemo(() => {
29
- return sections.map((section) => {
30
- const lang = getLanguageFromPath(section.path);
31
- return section.ops.map((op) => highlightLines(op.body.join('\n'), lang));
32
- });
33
- }, [sections, syntaxReady]);
24
+ /**
25
+ * One op's added lines. A component rather than a call in the parent's map: the
26
+ * deferred highlight is a hook, and a hook cannot run inside a callback — the
27
+ * parent used to tokenize EVERY op of EVERY file in one synchronous pass, which
28
+ * is the whole cost this card was paying on expand.
29
+ */
30
+ function OpBody({ body, lang }: { body: string[]; lang: string }) {
31
+ const html = useDeferredHighlightLines(useMemo(() => body.join('\n'), [body]), lang);
32
+ return (
33
+ <>
34
+ {body.map((row, rowIndex) => {
35
+ const markup = html[rowIndex] || '';
36
+ return markup ? (
37
+ <div
38
+ key={rowIndex}
39
+ className="shiki whitespace-pre bg-success/[0.05] px-2.5 font-mono text-[11px] leading-relaxed"
40
+ dangerouslySetInnerHTML={{ __html: markup }}
41
+ />
42
+ ) : (
43
+ <div
44
+ key={rowIndex}
45
+ className="whitespace-pre bg-success/[0.05] px-2.5 font-mono text-[11px] leading-relaxed text-success/90"
46
+ >
47
+ {row}
48
+ </div>
49
+ );
50
+ })}
51
+ </>
52
+ );
53
+ }
34
54
 
55
+ export function HashlinePatch({ sections }: { sections: HashlineSection[] }) {
35
56
  return (
36
57
  <div className="space-y-1.5">
37
58
  <div className="flex items-center justify-between text-[9.5px] font-semibold uppercase tracking-[0.14em] text-ink/40">
@@ -43,7 +64,7 @@ export function HashlinePatch({ sections }: { sections: HashlineSection[] }) {
43
64
  )}
44
65
  </div>
45
66
  <div className="overflow-hidden rounded-lg border border-ink/8">
46
- {sections.map((section, secIdx) => (
67
+ {sections.map((section) => (
47
68
  <div key={section.path} className="border-b border-ink/6 last:border-b-0">
48
69
  <div className="flex min-w-0 items-center gap-2 bg-canvas/40 px-2.5 py-1.5">
49
70
  <span className="truncate font-mono text-[10.5px] font-medium text-ink">{section.path}</span>
@@ -65,23 +86,7 @@ export function HashlinePatch({ sections }: { sections: HashlineSection[] }) {
65
86
  </span>
66
87
  {op.spec && <span className="font-mono text-[10.5px] text-ink/70">{op.spec}</span>}
67
88
  </div>
68
- {op.body.map((row, rowIndex) => {
69
- const html = htmlByOp[secIdx]?.[index]?.[rowIndex] || '';
70
- return html ? (
71
- <div
72
- key={rowIndex}
73
- className="shiki whitespace-pre bg-success/[0.05] px-2.5 font-mono text-[11px] leading-relaxed"
74
- dangerouslySetInnerHTML={{ __html: html }}
75
- />
76
- ) : (
77
- <div
78
- key={rowIndex}
79
- className="whitespace-pre bg-success/[0.05] px-2.5 font-mono text-[11px] leading-relaxed text-success/90"
80
- >
81
- {row}
82
- </div>
83
- );
84
- })}
89
+ <OpBody body={op.body} lang={getLanguageFromPath(section.path)} />
85
90
  </div>
86
91
  ))}
87
92
  </div>
@@ -2,8 +2,7 @@ import { useMemo } from 'preact/hooks';
2
2
  import { ArrowRight, CheckCircle2, Clock, Wand2 } from 'lucide-preact';
3
3
  import type { ToolCallData } from '@/shared/types';
4
4
  import { getLanguageFromPath } from '@/shared/lib/code/language';
5
- import { highlightLines } from '@/shared/lib/code/syntax-highlight';
6
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
5
+ import { useDeferredHighlightLines } from '@/client/hooks/code/deferred-highlight';
7
6
 
8
7
  interface AstEditItem {
9
8
  file?: unknown;
@@ -51,14 +50,17 @@ export function AstEdit({ tool }: { tool: ToolCallData }) {
51
50
  ? details.changes
52
51
  : parseAstChanges(output);
53
52
 
54
- const syntaxReady = useSyntaxReady();
55
53
  const diffLang = getLanguageFromPath(paths[0]);
56
- const diffHtml = useMemo(() => {
57
- const contents = diffLines.map((line: string) =>
58
- line.startsWith('+') || line.startsWith('-') || line.startsWith(' ') ? line.slice(1) : line
59
- );
60
- return highlightLines(contents.join('\n'), diffLang);
61
- }, [diffLines, diffLang, syntaxReady]);
54
+ const diffCode = useMemo(
55
+ () =>
56
+ diffLines
57
+ .map((line: string) =>
58
+ line.startsWith('+') || line.startsWith('-') || line.startsWith(' ') ? line.slice(1) : line
59
+ )
60
+ .join('\n'),
61
+ [diffLines],
62
+ );
63
+ const diffHtml = useDeferredHighlightLines(diffCode, diffLang);
62
64
 
63
65
  // If Staged Proposal Mode (oh-my-pi xd://ast_edit)
64
66
  if (isStaged || ops.length > 0 || diffLines.length > 0) {
@@ -5,8 +5,9 @@ import { bashServiceOf, daemonTone, procUptime } from '@/shared/lib/omp/session/
5
5
  import { CopyButton } from '@/client/components/common/CopyButton';
6
6
  import { stripAnsiCodes } from '@/shared/lib/code/ansi';
7
7
  import { isCodeLike } from '@/shared/lib/code/language';
8
- import { highlightCode, highlightLines, tryParseJson } from '@/shared/lib/code/syntax-highlight';
9
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
8
+ import { tryParseJson } from '@/shared/lib/code/syntax-highlight';
9
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
10
+ import { HighlightedCode } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode';
10
11
  import { JsonCodeBlock } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock';
11
12
  import { OutputWindow } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/OutputWindow';
12
13
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
@@ -75,8 +76,9 @@ export function Bash({ tool }: { tool: ToolCallData }) {
75
76
  () => (jsonResult.isValid && jsonResult.pretty ? truncateTailLines(jsonResult.pretty, MAX_OUTPUT_LINES) : null),
76
77
  [jsonResult]
77
78
  );
78
- const syntaxReady = useSyntaxReady();
79
- const highlightedCommand = useMemo(() => (command ? highlightCode(command, 'bash') : ''), [command, syntaxReady]);
79
+ // Deferred: a command line is short, but the grammar's first compile is not,
80
+ // and this card opens while a run streams.
81
+ const highlightedCommand = useDeferredHighlight(command, 'bash');
80
82
 
81
83
  const service = bashServiceOf(tool);
82
84
  const serviceTone = daemonTone(service?.state);
@@ -181,7 +183,7 @@ export function Bash({ tool }: { tool: ToolCallData }) {
181
183
  <div className="flex items-start gap-2.5 px-3 py-2 font-mono text-[11.5px] leading-relaxed">
182
184
  <span className="select-none font-bold text-ink/40">$</span>
183
185
  <pre
184
- className="min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-ink/90 select-text"
186
+ className="shiki min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-ink/90 select-text"
185
187
  dangerouslySetInnerHTML={{ __html: highlightedCommand }}
186
188
  />
187
189
  </div>
@@ -242,9 +244,10 @@ export function Bash({ tool }: { tool: ToolCallData }) {
242
244
  className="max-h-72 overflow-y-auto overscroll-contain p-3 font-mono text-[11px] leading-relaxed select-text"
243
245
  render={(visible) =>
244
246
  outputIsCode ? (
245
- <pre
247
+ <HighlightedCode
248
+ code={visible.join('\n')}
249
+ language={outputLang}
246
250
  className="overflow-x-auto whitespace-pre text-ink/85 leading-relaxed"
247
- dangerouslySetInnerHTML={{ __html: highlightLines(visible.join('\n'), outputLang).join('\n') }}
248
251
  />
249
252
  ) : (
250
253
  visible.map((line, idx) => {
@@ -9,8 +9,7 @@ import { ExcerptCode } from '@/client/components/workspace/chat-timeline/tool-re
9
9
  import { ToolPanelHeader } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/ToolPanelHeader';
10
10
  import { parseEditOutput } from '@/shared/lib/chat/excerpt';
11
11
  import { getLanguageFromPath } from '@/shared/lib/code/language';
12
- import { highlightCode } from '@/shared/lib/code/syntax-highlight';
13
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
12
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
14
13
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
15
14
  import { outputMarkdown, readToolOutput } from '@/shared/lib/chat/tool-output';
16
15
  import { hashlinePatchFromArgs } from '@/shared/lib/omp/session/hashline-patch';
@@ -146,13 +145,31 @@ export function Edit({ tool }: { tool: ToolCallData }) {
146
145
 
147
146
  const truncatedOld = useMemo(() => (oldString ? truncateTailLines(oldString, MAX_OUTPUT_LINES) : null), [oldString]);
148
147
  const truncatedNew = useMemo(() => (newString ? truncateTailLines(newString, MAX_OUTPUT_LINES) : null), [newString]);
149
- const syntaxReady = useSyntaxReady();
150
- const highlightedOld = useMemo(() => (truncatedOld ? highlightCode(truncatedOld.text, lang) : ''), [truncatedOld, lang, syntaxReady]);
151
- const highlightedNew = useMemo(() => (truncatedNew ? highlightCode(truncatedNew.text, lang) : ''), [truncatedNew, lang, syntaxReady]);
152
148
  const previewLineCount = useMemo(() => (newContent ? newContent.split('\n').length : 0), [newContent]);
153
- const highlightedPreview = useMemo(
154
- () => (newContent ? highlightCode(newContent.split('\n').slice(0, 80).join('\n'), lang) : ''),
155
- [newContent, lang, syntaxReady]
149
+
150
+ /**
151
+ * Which of the three blocks this card actually DRAWS.
152
+ *
153
+ * They are mutually exclusive in the markup below — a card with a diff shows
154
+ * neither the replacement pair nor the write preview — and the highlights
155
+ * used to run for all three on every render regardless: three tokenizer passes
156
+ * to paint one block, measured as the largest single contributor to an `edit`
157
+ * card's expand. Gating the input on the branch that renders it makes the two
158
+ * unused passes `''`, which the hook treats as nothing to compute.
159
+ */
160
+ const showsReplacement = !diffText && Boolean(oldString || newString);
161
+ const showsPreview = !diffText && !oldString && !newString && Boolean(newContent);
162
+ const highlightedOld = useDeferredHighlight(
163
+ showsReplacement && truncatedOld ? truncatedOld.text : '',
164
+ lang,
165
+ );
166
+ const highlightedNew = useDeferredHighlight(
167
+ showsReplacement && truncatedNew ? truncatedNew.text : '',
168
+ lang,
169
+ );
170
+ const highlightedPreview = useDeferredHighlight(
171
+ showsPreview && newContent ? newContent.split('\n').slice(0, 80).join('\n') : '',
172
+ lang,
156
173
  );
157
174
 
158
175
  // omp answers an edit with the excerpt it touched, not with prose: numbered
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The `eval` card body.
8
+ *
9
+ * The alignment defect is pinned here, and it was reported from a live
10
+ * transcript rather than found by reading the code: **the gutter numbers
11
+ * drifted off the code.** The gutter row was `text-[10px]` while the code was
12
+ * `text-[11px]`, and both inherited `leading-relaxed` — a ratio, so the row box
13
+ * was 16.25px against the code's 17.875px and every number sat higher than its
14
+ * line by a growing amount (measured after a reload: line 12's number was a
15
+ * full line above its code). The two sides now share one explicit line box,
16
+ * which is what the alignment case asserts — by comparing the two elements' own
17
+ * leading class, so a future edit that changes one side is caught instead of
18
+ * copied.
19
+ *
20
+ * The result cases cover the OTHER half of the same report ("output does not
21
+ * render while streaming"): the panel does read `tool.output` as it arrives, so
22
+ * what made a live eval look empty was the card being folded — pinned in
23
+ * `hooks/chat/timeline/tool-open.test.ts`, where a running eval opens itself.
24
+ *
25
+ * Rendered with `h()` against happy-dom, following `panels/Read.test.ts`.
26
+ */
27
+
28
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
29
+ import { Window } from 'happy-dom';
30
+ import { h, render } from 'preact';
31
+ import { act } from 'preact/test-utils';
32
+ import { Eval } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/Eval';
33
+ import type { ToolCallData } from '@/shared/types';
34
+
35
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
36
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
37
+
38
+ let container: HTMLElement;
39
+
40
+ beforeAll(() => {
41
+ const win = new Window({ url: 'http://localhost' });
42
+ const target = globalThis as unknown as Record<string, unknown>;
43
+ for (const key of DOM_GLOBALS) {
44
+ nativeGlobals[key] = target[key];
45
+ target[key] = (win as unknown as Record<string, unknown>)[key];
46
+ }
47
+ });
48
+
49
+ afterAll(() => {
50
+ const target = globalThis as unknown as Record<string, unknown>;
51
+ for (const key of DOM_GLOBALS) {
52
+ if (nativeGlobals[key] === undefined) delete target[key];
53
+ else target[key] = nativeGlobals[key];
54
+ }
55
+ });
56
+
57
+ beforeEach(() => {
58
+ container = document.createElement('div');
59
+ document.body.appendChild(container);
60
+ });
61
+
62
+ function mount(tool: ToolCallData): HTMLElement {
63
+ act(() => {
64
+ render(h(Eval, { tool }), container);
65
+ });
66
+ return container;
67
+ }
68
+
69
+ const CODE = ['const a = 1;', 'const b = 2;', 'console.log(a + b);'].join('\n');
70
+
71
+ function evalTool(overrides: Partial<ToolCallData> = {}): ToolCallData {
72
+ return {
73
+ id: 'call_1',
74
+ type: 'eval',
75
+ name: 'eval',
76
+ title: 'Sum two numbers',
77
+ input: { language: 'js', code: CODE },
78
+ status: 'running',
79
+ ...overrides,
80
+ } as ToolCallData;
81
+ }
82
+
83
+ /** The `leading-[…]` token a node carries, or null. */
84
+ function leadingOf(el: Element): string | null {
85
+ return /(?:^|\s)(leading-\[\d+px\])/.exec(el.className)?.[1] ?? null;
86
+ }
87
+
88
+ /** The code card: the gutter/pre pair whose pre holds the SCRIPT. */
89
+ function codeCard(root: HTMLElement): { gutter: Element; pre: Element } {
90
+ const gutter = root.querySelectorAll('.sticky')[0];
91
+ const pre = gutter?.parentElement?.querySelector('pre');
92
+ if (!gutter || !pre) throw new Error('code card not rendered');
93
+ return { gutter, pre };
94
+ }
95
+
96
+ describe('Eval — result rendering', () => {
97
+ test('a running call renders the output it has so far', () => {
98
+ // omp's `tool_execution_update` carries the cell's output SO FAR, and the
99
+ // panel reads it from `tool.output` — which is what the fold writes from
100
+ // the frame's `content`. Verified against a real omp run: `content` and
101
+ // `details.cells[].output` are byte-identical on every update frame.
102
+ const root = mount(evalTool({ output: 'live 1\nlive 2' }));
103
+ const text = root.textContent ?? '';
104
+ expect(text).toContain('live 1');
105
+ expect(text).toContain('live 2');
106
+ });
107
+
108
+ test('renders only the script while a running call has produced nothing yet', () => {
109
+ const root = mount(evalTool({ output: '' }));
110
+ expect(root.querySelectorAll('pre')).toHaveLength(1);
111
+ expect(root.textContent).toContain('Script Evaluation');
112
+ });
113
+ });
114
+
115
+ describe('Eval — gutter aligns with the code', () => {
116
+ test('the gutter and the code share one line box', () => {
117
+ const { gutter, pre } = codeCard(mount(evalTool()));
118
+ const gutterLeading = leadingOf(gutter);
119
+ expect(gutterLeading).not.toBeNull();
120
+ // The bug: an inherited RATIO (`leading-relaxed`) resolved against two
121
+ // different font sizes, so the boxes differed by 1.625px per line.
122
+ expect(leadingOf(pre)).toBe(gutterLeading);
123
+ });
124
+
125
+ test('one gutter row per code line', () => {
126
+ const { gutter } = codeCard(mount(evalTool()));
127
+ expect(gutter.children).toHaveLength(CODE.split('\n').length);
128
+ });
129
+
130
+ test('the JSON result block aligns the same way', () => {
131
+ // The pretty-printed result draws its own gutter, and it had the same
132
+ // mismatch (plus a `py-2` against the code's `p-2.5`).
133
+ const root = mount(evalTool({ output: '{"a": 1, "b": [2, 3]}', status: 'success' }));
134
+ const gutters = root.querySelectorAll('.sticky');
135
+ const jsonGutter = gutters[1];
136
+ expect(jsonGutter).toBeDefined();
137
+ const pre = jsonGutter.parentElement?.querySelector('pre');
138
+ expect(pre).not.toBeNull();
139
+ // Explicit on BOTH, not merely equal: `null === null` would pass while both
140
+ // sides inherit a ratio against different font sizes, which is the bug.
141
+ expect(leadingOf(jsonGutter)).not.toBeNull();
142
+ expect(leadingOf(pre as Element)).toBe(leadingOf(jsonGutter));
143
+ // `{"a": 1, "b": [2, 3]}` pretty-prints to 7 lines.
144
+ expect(jsonGutter.children).toHaveLength(7);
145
+ });
146
+ });
@@ -2,9 +2,27 @@ import { useMemo } from 'preact/hooks';
2
2
  import { AlertCircle, Ban, Clock, Code2, Play } from 'lucide-preact';
3
3
  import type { ToolCallData } from '@/shared/types';
4
4
  import { CopyButton } from '@/client/components/common/CopyButton';
5
- import { highlightCode, highlightJson, tryParseJson } from '@/shared/lib/code/syntax-highlight';
5
+ import { tryParseJson } from '@/shared/lib/code/syntax-highlight';
6
6
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
7
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
7
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
8
+
9
+ /**
10
+ * The line box both sides of a code card share — gutter row and code line.
11
+ *
12
+ * A gutter row and the code line beside it are separate elements, so they agree
13
+ * only when their line boxes do. They did not: the gutter was `text-[10px]`
14
+ * while the code was `text-[11px]`, and both inherited `leading-relaxed` — a
15
+ * RATIO, so 10 × 1.625 = 16.25px against 11 × 1.625 = 17.875px. Each number
16
+ * then sat 1.6px higher than its line, and by the tenth line the number was a
17
+ * whole line above the code it labels (measured in the browser after a reload:
18
+ * `gutterRowH 16.25`, `preLineHeight 17.875`, `firstRowOffset 10`).
19
+ *
20
+ * The box is stated ONCE and applied to both containers, because a `<div>`
21
+ * holding one line takes its height from `line-height` — no per-row `h-[…]`
22
+ * that could drift from the constant. This is the same shape `Read.tsx` and
23
+ * `ExcerptCode` use.
24
+ */
25
+ const LINE_BOX = 'leading-[18px]';
8
26
 
9
27
  /** Panel khusus untuk tool `eval` — eksekusi kode script, browser eval, dsb. */
10
28
  export function Eval({ tool }: { tool: ToolCallData }) {
@@ -25,23 +43,20 @@ export function Eval({ tool }: { tool: ToolCallData }) {
25
43
  const isError = tool.status === 'error' || output.includes('ToolError:') || output.includes('Command exited with code');
26
44
  const isAborted = output.includes('Command aborted') || tool.status === 'aborted';
27
45
 
28
- const syntaxReady = useSyntaxReady();
29
-
30
46
  const jsonResult = useMemo(
31
47
  () => (!isError && !isAborted ? tryParseJson(output) : { isValid: false }),
32
48
  [output, isError, isAborted]
33
49
  );
34
50
 
35
51
  const truncatedCode = useMemo(() => truncateTailLines(code, MAX_OUTPUT_LINES), [code]);
36
- const highlightedCode = useMemo(
37
- () => highlightCode(truncatedCode.text, 'javascript'),
38
- [truncatedCode, syntaxReady]
39
- );
52
+ // A cell's script is short but the grammar compile is not, and an eval card
53
+ // opens while its own output streams.
54
+ const highlightedCode = useDeferredHighlight(truncatedCode.text, 'javascript');
40
55
  // The result is shown whole: an eval's output IS the answer, so hiding its
41
56
  // head behind a "… earlier lines hidden" line misreports what the cell returned.
42
- const highlightedJson = useMemo(
43
- () => (jsonResult.isValid && jsonResult.pretty ? highlightJson(jsonResult.pretty) : ''),
44
- [jsonResult, syntaxReady]
57
+ const highlightedJson = useDeferredHighlight(
58
+ jsonResult.isValid && jsonResult.pretty ? jsonResult.pretty : '',
59
+ 'json',
45
60
  );
46
61
 
47
62
  return (
@@ -78,14 +93,14 @@ export function Eval({ tool }: { tool: ToolCallData }) {
78
93
  … {truncatedCode.skipped} earlier lines hidden
79
94
  </div>
80
95
  )}
81
- <div className="flex max-h-56 items-start overflow-x-auto font-mono text-[11px] leading-relaxed select-text">
82
- <div className="sticky left-0 flex-shrink-0 select-none border-r border-ink/6 bg-canvas/50 py-2.5 pl-2.5 pr-2 text-right text-[10px] text-ink/25">
96
+ <div className={`flex max-h-56 items-start overflow-x-auto font-mono text-[11px] ${LINE_BOX} select-text`}>
97
+ <div className={`sticky left-0 flex-shrink-0 select-none border-r border-ink/6 bg-canvas/50 py-2.5 pl-2.5 pr-2 text-right text-[10px] ${LINE_BOX} tabular-nums text-ink/25`}>
83
98
  {truncatedCode.text.split('\n').map((_, idx) => (
84
99
  <div key={idx}>{idx + 1}</div>
85
100
  ))}
86
101
  </div>
87
102
  <pre
88
- className="flex-1 overflow-x-auto p-2.5 whitespace-pre text-ink/85 font-mono"
103
+ className={`flex-1 overflow-x-auto p-2.5 whitespace-pre ${LINE_BOX} text-ink/85 font-mono`}
89
104
  dangerouslySetInnerHTML={{ __html: highlightedCode }}
90
105
  />
91
106
  </div>
@@ -157,14 +172,14 @@ export function Eval({ tool }: { tool: ToolCallData }) {
157
172
  </div>
158
173
 
159
174
  {jsonResult.isValid && jsonResult.pretty ? (
160
- <div className="flex max-h-56 items-start overflow-x-auto font-mono text-[11px] leading-relaxed select-text">
161
- <div className="sticky left-0 flex-shrink-0 select-none border-r border-ink/6 bg-canvas/50 py-2 pl-2.5 pr-2 text-right text-[10px] text-ink/25">
175
+ <div className={`flex max-h-56 items-start overflow-x-auto font-mono text-[11px] ${LINE_BOX} select-text`}>
176
+ <div className={`sticky left-0 flex-shrink-0 select-none border-r border-ink/6 bg-canvas/50 py-2.5 pl-2.5 pr-2 text-right text-[10px] ${LINE_BOX} tabular-nums text-ink/25`}>
162
177
  {jsonResult.pretty.split('\n').map((_, idx) => (
163
178
  <div key={idx}>{idx + 1}</div>
164
179
  ))}
165
180
  </div>
166
181
  <pre
167
- className="flex-1 overflow-x-auto p-2.5 whitespace-pre text-ink/85 font-mono"
182
+ className={`flex-1 overflow-x-auto p-2.5 whitespace-pre ${LINE_BOX} text-ink/85 font-mono`}
168
183
  dangerouslySetInnerHTML={{ __html: highlightedJson }}
169
184
  />
170
185
  </div>
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One file's matches inside a `grep` card.
8
+ *
9
+ * The card used to mount every line of every file group inside a `max-h-80`
10
+ * scroller, and tokenize them all in one pass. Two properties are pinned here,
11
+ * because both were the measured cost of expanding the card:
12
+ *
13
+ * 1. **A file group mounts a bounded number of rows**, with the rest one click
14
+ * away — `MAX_OUTPUT_LINES` caps the whole output, so a single 900-line group
15
+ * used to draw 900 rows to show ~14.
16
+ * 2. **The rows that ARE mounted carry their line numbers and text from the
17
+ * first paint**, before the deferred highlight lands.
18
+ */
19
+
20
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
21
+ import { Window } from 'happy-dom';
22
+ import { h, render } from 'preact';
23
+ import { act } from 'preact/test-utils';
24
+
25
+ import { FileMatches, MAX_MATCH_LINES_PER_FILE } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches';
26
+ import { clearHighlightMemo } from '@/shared/lib/code/highlight-memo';
27
+
28
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'MouseEvent'] as const;
29
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
30
+ let container: HTMLElement;
31
+
32
+ beforeAll(() => {
33
+ const win = new Window({ url: 'http://localhost' });
34
+ const target = globalThis as unknown as Record<string, unknown>;
35
+ for (const key of DOM_GLOBALS) {
36
+ nativeGlobals[key] = target[key];
37
+ target[key] = (win as unknown as Record<string, unknown>)[key];
38
+ }
39
+ container = document.createElement('div');
40
+ document.body.appendChild(container);
41
+ });
42
+
43
+ afterAll(() => {
44
+ const target = globalThis as unknown as Record<string, unknown>;
45
+ for (const key of DOM_GLOBALS) {
46
+ if (nativeGlobals[key] === undefined) delete target[key];
47
+ else target[key] = nativeGlobals[key];
48
+ }
49
+ });
50
+
51
+ beforeEach(() => {
52
+ clearHighlightMemo();
53
+ // A fresh root per test: `render` keeps its vnode tree on the element it was
54
+ // given, so clearing the markup in place would leave preact diffing against a
55
+ // tree that is no longer there and skip the render entirely.
56
+ container = document.createElement('div');
57
+ document.body.appendChild(container);
58
+ });
59
+
60
+ /** Rows the card drew, counted by the gutter numbers it printed. */
61
+ function rowCount(el: HTMLElement): number {
62
+ return el.querySelectorAll('pre.shiki').length;
63
+ }
64
+
65
+ function matches(total: number) {
66
+ return Array.from({ length: total }, (_, index) => ({
67
+ lineNum: index + 1,
68
+ text: `const value${index} = ${index};`,
69
+ isMatch: index % 5 === 0,
70
+ }));
71
+ }
72
+
73
+ describe('FileMatches', () => {
74
+ test('mounts a bounded number of rows for a long file group', async () => {
75
+ await act(async () => {
76
+ render(h(FileMatches, { path: 'src/a.ts', lines: matches(300), matchCount: 60, isGlobList: false }), container);
77
+ });
78
+
79
+ expect(rowCount(container)).toBe(MAX_MATCH_LINES_PER_FILE);
80
+ expect(container.textContent).toContain('const value0 = 0;');
81
+ expect(container.textContent).not.toContain('const value299 = 299;');
82
+ });
83
+
84
+ test('offers the hidden rows, and reveals every one of them', async () => {
85
+ await act(async () => {
86
+ render(h(FileMatches, { path: 'src/a.ts', lines: matches(300), matchCount: 60, isGlobList: false }), container);
87
+ });
88
+ const reveal = Array.from(container.querySelectorAll('button')).find((b) => (b.textContent ?? '').includes('show all'));
89
+ expect(reveal).toBeDefined();
90
+ expect(reveal?.textContent).toContain('240');
91
+
92
+ await act(async () => {
93
+ reveal?.click();
94
+ });
95
+ expect(rowCount(container)).toBe(300);
96
+ });
97
+
98
+ test('a short group draws no reveal control at all', async () => {
99
+ await act(async () => {
100
+ render(h(FileMatches, { path: 'src/a.ts', lines: matches(4), matchCount: 1, isGlobList: false }), container);
101
+ });
102
+ expect(rowCount(container)).toBe(4);
103
+ expect(container.textContent).not.toContain('show all');
104
+ });
105
+
106
+ test('the first paint carries the text, before any highlight is computed', async () => {
107
+ await act(async () => {
108
+ render(h(FileMatches, { path: 'src/a.ts', lines: matches(3), matchCount: 1, isGlobList: false }), container);
109
+ });
110
+ // Nothing tokenized yet, and the rows still read correctly.
111
+ expect(container.textContent).toContain('const value2 = 2;');
112
+ expect(container.querySelectorAll('pre.shiki')).toHaveLength(3);
113
+ });
114
+ });