@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
package/src/App.tsx DELETED
@@ -1,1249 +0,0 @@
1
- /**
2
- * The cueloop TUI (#22, #71): header, plan sheet (sheet header + inline
3
- * compose + selectable lines) or diff pane, right review rail, footer hint
4
- * bar. Thin renderer over the review-session controller: daemon IO and the
5
- * mutation verbs live in session-controller.ts, the keyboard grammar in
6
- * keymap.ts; local state is view state only (cursor, selection, overlays).
7
- *
8
- * The plan review grammar: selection is the entry primitive (mouse drag or
9
- * keyboard span on one native renderer selection), compose happens inline
10
- * under the anchor, annotation text lives in the rail while the document
11
- * keeps only the kind-colored highlight, and the rail edits what the
12
- * document selects - one selected id drives both sides.
13
- */
14
-
15
- import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
16
- import { useKeyboard, useRenderer } from "@opentui/react";
17
- import type { ScrollBoxRenderable, TextRenderable } from "@opentui/core";
18
- import { type Annotation, type ReviewSession, type VerdictKind } from "@cueloop/schema";
19
- import {
20
- blockRuns,
21
- composeBoxHeight,
22
- displayText,
23
- marksByDisplay,
24
- overlayMarks,
25
- revisionDelta,
26
- spanKey,
27
- startSpan,
28
- wrapRuns,
29
- type DisplayBlock,
30
- type Mark,
31
- type SpanState,
32
- type StyleRun,
33
- } from "./view";
34
- import { type DiffRow } from "./view-diff";
35
- import { DARK as T } from "./theme";
36
- import { highlightCode, type CodeToken } from "./syntax";
37
- import { DEFAULT_KEYS, loadConfig } from "./config";
38
- import { returnPaneFor } from "./herdr";
39
- import { createReviewController } from "./session-controller";
40
- import { reduceKey, type Intent, type KeyState } from "./keymap";
41
-
42
- export interface AppProps {
43
- home?: string;
44
- sessionId?: string;
45
- /**
46
- * Observer mode (SSH-served connections): every mutating verb is ignored and
47
- * answers "observer - read-only" in the status line; navigation still works.
48
- */
49
- readOnly?: boolean;
50
- onExit?: (code: number) => void;
51
- }
52
-
53
- type Mode =
54
- | { m: "normal" }
55
- | { m: "span"; span: SpanState }
56
- | { m: "compose"; kind: "comment" | "suggestion"; dispIdx: number; start: number; end: number; text: string }
57
- | { m: "railEdit"; id: string; text: string }
58
- | { m: "submit"; verdict: VerdictKind; summary: string };
59
-
60
- type RailTab = "review" | "agent";
61
-
62
- const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
63
- /** Selector words in the rail confirm card - one word per verdict. */
64
- const VERDICT_LABEL: Record<VerdictKind, string> = {
65
- comment: "Comment",
66
- approve: "Approve",
67
- request_changes: "Changes",
68
- };
69
-
70
- /** One rendered plan line, registered for the native selection primitive. */
71
- interface PlanLineRef {
72
- renderable: TextRenderable;
73
- dispIdx: number;
74
- lineIndex: number;
75
- /** Block-text offset of the line's first positioned run; null for pure-del lines. */
76
- lineStart: number | null;
77
- /** Screen columns before the run text (cursor glyph + list marker). */
78
- prefixColumns: number;
79
- /** Length of the positioned run text in this line. */
80
- textLength: number;
81
- }
82
-
83
- /** Rail card height (3 text rows + 1 margin), for reveal-scroll math. */
84
- const RAIL_CARD_HEIGHT = 4;
85
-
86
- export function App({ home, sessionId, readOnly = false, onExit }: AppProps): React.ReactNode {
87
- const controller = useMemo(
88
- () => createReviewController({ home, sessionId, readOnly, onExit }),
89
- // eslint-disable-next-line react-hooks/exhaustive-deps
90
- [home, sessionId],
91
- );
92
- useEffect(() => {
93
- controller.connect();
94
- return () => controller.close();
95
- }, [controller]);
96
- const { session, inbox, status, error, completion, editOrphanCount } = useSyncExternalStore(
97
- controller.subscribe,
98
- controller.getSnapshot,
99
- controller.getSnapshot,
100
- );
101
- const renderer = useRenderer();
102
-
103
- // ── view state ──────────────────────────────
104
- const [cursor, setCursor] = useState(0);
105
- const [inboxCursor, setInboxCursor] = useState(0);
106
- const [mode, setMode] = useState<Mode>({ m: "normal" });
107
- const [focusedAnn, setFocusedAnn] = useState<string | undefined>(undefined);
108
- const [railTab, setRailTab] = useState<RailTab>("review");
109
- // ~2s focus pulse on the document highlight when a rail card is activated
110
- const [pulseAnn, setPulseAnn] = useState<string | null>(null);
111
- const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
112
- // live mirror of overlay input text: refs commit synchronously, so the
113
- // RETURN handler never reads a stale value mid-typing
114
- const liveInput = useRef("");
115
- // rendered plan lines, for driving and reading the native selection
116
- const lineRefs = useRef(new Map<string, PlanLineRef>());
117
- const docScrollRef = useRef<ScrollBoxRenderable | null>(null);
118
- const railScrollRef = useRef<ScrollBoxRenderable | null>(null);
119
- // keymap from layered config; theme overrides land on the shared tokens
120
- const keysRef = useRef(DEFAULT_KEYS);
121
- useEffect(() => {
122
- const cfg = loadConfig({ repoRoot: session?.workspace.repoRoot });
123
- keysRef.current = cfg.keys;
124
- Object.assign(T, cfg.theme);
125
- controller.applyConfig(cfg);
126
- }, [session?.workspace.repoRoot, controller]);
127
- useEffect(
128
- () => () => {
129
- if (pulseTimer.current) clearTimeout(pulseTimer.current);
130
- },
131
- [],
132
- );
133
-
134
- // ── derived view model ──────────────────────
135
- const display = controller.display();
136
- const rows = controller.rows();
137
- const marks = useMemo(
138
- () => (session ? marksByDisplay(session.annotations, display, pulseAnn ?? undefined) : new Map<number, Mark[]>()),
139
- [session, display, pulseAnn],
140
- );
141
- /** Annotation ids whose anchor resolved against the working copy. */
142
- const resolvedIds = useMemo(() => {
143
- const ids = new Set<string>();
144
- for (const blockMarks of marks.values()) {
145
- for (const mark of blockMarks) if (mark.annotationId) ids.add(mark.annotationId);
146
- }
147
- return ids;
148
- }, [marks]);
149
- const resolved = session?.status === "resolved";
150
- const isDiff = session?.artifact.type === "diff";
151
-
152
- // ── the native selection: keyboard driver ───
153
- const lineRefFor = (dispIdx: number, offset: number): PlanLineRef | null => {
154
- for (const meta of lineRefs.current.values()) {
155
- if (meta.dispIdx !== dispIdx || meta.lineStart === null) continue;
156
- if (offset >= meta.lineStart && offset < meta.lineStart + meta.textLength) return meta;
157
- }
158
- return null;
159
- };
160
-
161
- /** Anchor/extend the renderer's native selection from keyboard span offsets. */
162
- const driveNativeSelection = (span: SpanState): void => {
163
- if (!renderer) return;
164
- const startMeta = lineRefFor(span.dispIdx, span.start);
165
- const endMeta = lineRefFor(span.dispIdx, span.end - 1);
166
- if (!startMeta || !endMeta) return;
167
- const startColumn = startMeta.prefixColumns + (span.start - startMeta.lineStart!);
168
- const endColumn = endMeta.prefixColumns + (span.end - 1 - endMeta.lineStart!);
169
- renderer.startSelection(startMeta.renderable, startMeta.renderable.x + startColumn, startMeta.renderable.y);
170
- renderer.updateSelection(endMeta.renderable, endMeta.renderable.x + endColumn + 1, endMeta.renderable.y);
171
- };
172
- // driving needs committed layout, so it runs after render; any transition
173
- // out of span mode clears the renderer selection (compose paints its own
174
- // mark, and a mouse drag never changes the mode, so it survives)
175
- useEffect(() => {
176
- if (mode.m === "span") driveNativeSelection(mode.span);
177
- else renderer?.clearSelection();
178
- // eslint-disable-next-line react-hooks/exhaustive-deps
179
- }, [mode]);
180
-
181
- /**
182
- * Read the native selection (mouse drag) back as a block range. The quote
183
- * anchors in the FIRST selected block - quote anchors resolve within one
184
- * block. Columns map 1:1 onto text offsets except inside word-diffed mod
185
- * blocks, where inline del runs shift the screen columns.
186
- */
187
- const readNativeSelection = (): { dispIdx: number; start: number; end: number } | null => {
188
- if (!renderer?.hasSelection) return null;
189
- const ordered = [...lineRefs.current.values()].sort(
190
- (a, b) => a.dispIdx - b.dispIdx || a.lineIndex - b.lineIndex,
191
- );
192
- let found: { dispIdx: number; start: number; end: number } | null = null;
193
- for (const meta of ordered) {
194
- if (meta.lineStart === null) continue;
195
- const selection = meta.renderable.getSelection();
196
- if (!selection || selection.end <= selection.start) continue;
197
- const startInText = Math.min(meta.textLength, Math.max(0, selection.start - meta.prefixColumns));
198
- const endInText = Math.min(meta.textLength, Math.max(0, selection.end - meta.prefixColumns));
199
- if (endInText <= startInText) continue;
200
- const blockStart = meta.lineStart + startInText;
201
- const blockEnd = meta.lineStart + endInText;
202
- if (!found) found = { dispIdx: meta.dispIdx, start: blockStart, end: blockEnd };
203
- else if (found.dispIdx === meta.dispIdx) found.end = Math.max(found.end, blockEnd);
204
- }
205
- return found;
206
- };
207
-
208
- // ── selection symmetry: one selected id, both sides ──
209
- const pulse = (id: string): void => {
210
- setPulseAnn(id);
211
- if (pulseTimer.current) clearTimeout(pulseTimer.current);
212
- pulseTimer.current = setTimeout(() => setPulseAnn(null), 2000);
213
- };
214
-
215
- const revealRailCard = (annotationId: string): void => {
216
- const index = session?.annotations.findIndex((annotation) => annotation.id === annotationId) ?? -1;
217
- if (index >= 0) railScrollRef.current?.scrollTo(Math.max(0, index * RAIL_CARD_HEIGHT - 1));
218
- };
219
-
220
- /** Card activation scrolls the document to the anchor and pulses it. */
221
- const revealAnchor = (annotationId: string): void => {
222
- for (const [dispIdx, blockMarks] of marks) {
223
- if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
224
- setCursor(dispIdx);
225
- try {
226
- docScrollRef.current?.scrollChildIntoView(`plan-block-${dispIdx}`);
227
- } catch {
228
- // reveal is best-effort; selection state is already correct
229
- }
230
- return;
231
- }
232
- };
233
-
234
- const selectCardFromDocument = (annotationId: string): void => {
235
- setFocusedAnn(annotationId);
236
- revealRailCard(annotationId);
237
- };
238
-
239
- const selectCardFromRail = (annotationId: string): void => {
240
- setFocusedAnn(annotationId);
241
- pulse(annotationId);
242
- revealAnchor(annotationId);
243
- };
244
-
245
- const openCardEdit = (annotationId: string): void => {
246
- if (readOnly) return controller.setStatus("observer - read-only");
247
- if (resolved) return controller.setStatus("review submitted - read-only");
248
- const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
249
- if (!annotation) return;
250
- liveInput.current = annotation.body;
251
- setMode({ m: "railEdit", id: annotation.id, text: annotation.body });
252
- };
253
-
254
- // ── keyboard grammar: build state, reduce, dispatch ──
255
- const dispatch = (intent: Intent): void => {
256
- switch (intent.t) {
257
- case "exit":
258
- return void onExit?.(0);
259
- case "status":
260
- return controller.setStatus(intent.msg);
261
- case "move": {
262
- const len = isDiff ? rows.length : display.length;
263
- if (intent.to === "down") setCursor((c) => Math.min(len - 1, c + 1));
264
- else if (intent.to === "up") setCursor((c) => Math.max(0, c - 1));
265
- else if (intent.to === "top") setCursor(0);
266
- else setCursor(len - 1);
267
- return;
268
- }
269
- case "inboxMove": {
270
- const len = inbox?.length ?? 0;
271
- setInboxCursor((c) => (intent.to === "down" ? Math.min(len - 1, c + 1) : Math.max(0, c - 1)));
272
- return;
273
- }
274
- case "openSession": {
275
- const s = inbox?.[inboxCursor];
276
- if (s) controller.open(s.id);
277
- return;
278
- }
279
- case "startSpan": {
280
- const d = display[cursor];
281
- if (!d?.work) return;
282
- const span = startSpan(cursor, displayText(d));
283
- if (span) setMode({ m: "span", span });
284
- return;
285
- }
286
- case "spanKey":
287
- if (mode.m === "span") {
288
- const span = spanKey(mode.span, intent.name, displayText(display[mode.span.dispIdx]!));
289
- setMode({ m: "span", span });
290
- driveNativeSelection(span);
291
- }
292
- return;
293
- case "openCompose": {
294
- liveInput.current = "";
295
- if (intent.from === "span" && mode.m === "span") {
296
- setMode({
297
- m: "compose",
298
- kind: intent.kind,
299
- dispIdx: mode.span.dispIdx,
300
- start: mode.span.start,
301
- end: mode.span.end,
302
- text: "",
303
- });
304
- } else if (isDiff) {
305
- const row = rows[cursor];
306
- if (row) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: row.text.length, text: "" });
307
- } else {
308
- // a mouse drag leaves a native selection; it wins over the cursor block
309
- const native = readNativeSelection();
310
- if (native) {
311
- setMode({ m: "compose", kind: intent.kind, ...native, text: "" });
312
- } else {
313
- const d = display[cursor];
314
- if (d) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: displayText(d).length, text: "" });
315
- }
316
- }
317
- return;
318
- }
319
- case "openSubmit":
320
- if (!session) return;
321
- liveInput.current = "";
322
- // the confirm card lives in the review tab; opening submit reveals it
323
- setRailTab("review");
324
- return void setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
325
- case "cut":
326
- return controller.cut(cursor);
327
- case "edit":
328
- return controller.edit();
329
- case "editCard":
330
- if (focusedAnn) openCardEdit(focusedAnn);
331
- return;
332
- case "nextAnn":
333
- case "prevAnn": {
334
- const anns = session?.annotations ?? [];
335
- if (!anns.length) return;
336
- const idx = anns.findIndex((a) => a.id === focusedAnn);
337
- const next = idx === -1 ? 0 : (idx + (intent.t === "nextAnn" ? 1 : -1) + anns.length) % anns.length;
338
- return void selectCardFromDocument(anns[next]!.id);
339
- }
340
- case "removeAnnotation":
341
- if (focusedAnn) {
342
- controller.removeAnnotation(focusedAnn);
343
- setFocusedAnn(undefined);
344
- }
345
- return;
346
- case "deselect":
347
- renderer?.clearSelection();
348
- setFocusedAnn(undefined);
349
- setPulseAnn(null);
350
- return;
351
- case "closeOverlay":
352
- return void setMode({ m: "normal" });
353
- case "saveCompose": {
354
- const body = liveInput.current.trim();
355
- if (mode.m === "railEdit") {
356
- if (session && body) controller.updateAnnotation(mode.id, body);
357
- return void setMode({ m: "normal" });
358
- }
359
- if (mode.m !== "compose") return;
360
- if (session && body) {
361
- const annotationId = controller.annotate(mode.kind, mode.dispIdx, mode.start, mode.end, body);
362
- if (annotationId) setFocusedAnn(annotationId);
363
- }
364
- return void setMode({ m: "normal" });
365
- }
366
- case "submitVerdict":
367
- if (mode.m === "submit") controller.submit(mode.verdict, liveInput.current);
368
- return void setMode({ m: "normal" });
369
- case "cycleVerdict": {
370
- if (mode.m !== "submit") return;
371
- const idx = (VERDICTS.indexOf(mode.verdict) + intent.dir + VERDICTS.length) % VERDICTS.length;
372
- return void setMode({ ...mode, verdict: VERDICTS[idx]! });
373
- }
374
- case "finishReview":
375
- return controller.finishReview();
376
- case "optInAutoClose":
377
- return controller.optInAutoClose();
378
- case "dismissCompletion":
379
- return controller.dismissCompletion();
380
- }
381
- };
382
-
383
- useKeyboard((key) => {
384
- const state: KeyState = {
385
- keys: keysRef.current,
386
- readOnly,
387
- overlay:
388
- mode.m === "compose" || mode.m === "railEdit"
389
- ? "compose"
390
- : mode.m === "submit"
391
- ? "submit"
392
- : completion.phase === "prompt"
393
- ? "completion-prompt"
394
- : completion.phase === "counting"
395
- ? "completion-counting"
396
- : "none",
397
- view: !session ? "inbox" : isDiff ? "diff" : "plan",
398
- spanMode: mode.m === "span",
399
- resolved: !!resolved,
400
- hasInboxItems: !!inbox?.length,
401
- annotationCount: session?.annotations.length ?? 0,
402
- hasFocusedAnnotation: focusedAnn !== undefined,
403
- cursorAnnotatable: isDiff
404
- ? rows[cursor] !== undefined && rows[cursor]!.t !== "file" && rows[cursor]!.t !== "hunk"
405
- : !!display[cursor]?.work,
406
- };
407
- for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift })) dispatch(intent);
408
- });
409
-
410
- // ── render ──────────────────────────────────
411
- if (error) return <text fg={T.red}>cueloop: {error}</text>;
412
- if (!session && !inbox) return <text fg={T.textDim}>connecting to the daemon…</text>;
413
- if (!session && inbox) return <Inbox inbox={inbox} cursor={inboxCursor} />;
414
-
415
- const s = session!;
416
- const pendingCount = s.annotations.length + (s.workingCopy !== undefined ? 1 : 0);
417
-
418
- if ((completion.phase === "prompt" || completion.phase === "counting") && s.verdict) {
419
- return (
420
- <CompletionOverlay
421
- verdict={s.verdict.kind}
422
- completion={completion}
423
- status={status}
424
- returnsTo={returnPaneFor(s.artifact.meta.herdrPane) ? (s.artifact.meta.agent ?? "the agent") : undefined}
425
- />
426
- );
427
- }
428
-
429
- const composeState =
430
- mode.m === "compose" && !isDiff
431
- ? {
432
- kind: mode.kind,
433
- dispIdx: mode.dispIdx,
434
- quote: displayText(display[mode.dispIdx]!).slice(mode.start, mode.end),
435
- text: mode.text,
436
- onInput: (text: string) => {
437
- liveInput.current = text;
438
- setMode({ ...mode, text });
439
- },
440
- onSave: () => dispatch({ t: "saveCompose" }),
441
- onCancel: () => dispatch({ t: "closeOverlay" }),
442
- }
443
- : null;
444
-
445
- const registerLine = (key: string, renderable: TextRenderable | null, meta: Omit<PlanLineRef, "renderable">): void => {
446
- if (renderable) lineRefs.current.set(key, { renderable, ...meta });
447
- else lineRefs.current.delete(key);
448
- };
449
-
450
- const onLineActivate = (dispIdx: number): void => {
451
- // releasing a drag-selection lands here too; a live selection is not a click
452
- if (renderer?.hasSelection) return;
453
- setCursor(dispIdx);
454
- const annotationId = marks.get(dispIdx)?.[0]?.annotationId;
455
- if (annotationId) selectCardFromDocument(annotationId);
456
- };
457
-
458
- const onEditRequest = (): void => {
459
- if (readOnly) return controller.setStatus("observer - read-only");
460
- if (resolved) return controller.setStatus("review submitted - read-only");
461
- controller.edit();
462
- };
463
-
464
- // clicking the rail Submit button: same read-only answer as the submit key
465
- const onSubmitRequest = (): void => {
466
- if (readOnly) return controller.setStatus("observer - read-only");
467
- if (resolved) return;
468
- dispatch({ t: "openSubmit" });
469
- };
470
-
471
- const submitConfirmState: SubmitConfirmState | null =
472
- mode.m === "submit"
473
- ? {
474
- verdict: mode.verdict,
475
- summary: mode.summary,
476
- annotationCount: s.annotations.length,
477
- blockingCount: s.annotations.filter(annotationBlocking).length,
478
- onInput: (summary: string) => {
479
- liveInput.current = summary;
480
- setMode({ ...mode, summary });
481
- },
482
- onSelectVerdict: (verdict: VerdictKind) => setMode({ ...mode, verdict }),
483
- onSubmit: () => dispatch({ t: "submitVerdict" }),
484
- onCancel: () => dispatch({ t: "closeOverlay" }),
485
- }
486
- : null;
487
-
488
- return (
489
- <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg }}>
490
- <box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1, flexDirection: "row" }}>
491
- <text fg={T.text}>
492
- <span fg={T.accent}>cueloop</span>
493
- <span fg={T.textDim}> · {s.artifact.meta.title ?? s.artifact.meta.planPath ?? s.id} · rev {s.revisions.length}</span>
494
- {resolved ? <span fg={T.green}> · resolved: {s.verdict!.kind.replace("_", " ")}</span> : null}
495
- {readOnly ? <span fg={T.textDim}> · observer</span> : null}
496
- {status ? <span fg={T.accent}> · {status}</span> : null}
497
- </text>
498
- </box>
499
- <box style={{ flexGrow: 1, flexDirection: "row" }}>
500
- {isDiff ? (
501
- <DiffPane rows={rows} cursor={cursor} annotations={s.annotations} focusedAnn={focusedAnn} />
502
- ) : (
503
- <PlanPane
504
- display={display}
505
- marks={marks}
506
- cursor={cursor}
507
- mode={mode}
508
- session={s}
509
- editOrphanCount={editOrphanCount}
510
- compose={composeState}
511
- registerLine={registerLine}
512
- onLineActivate={onLineActivate}
513
- onEditRequest={onEditRequest}
514
- docScrollRef={docScrollRef}
515
- />
516
- )}
517
- <Rail
518
- session={s}
519
- selectedId={focusedAnn}
520
- resolvedIds={isDiff ? null : resolvedIds}
521
- railTab={railTab}
522
- pendingCount={pendingCount}
523
- cardEdit={
524
- mode.m === "railEdit"
525
- ? {
526
- id: mode.id,
527
- text: mode.text,
528
- onInput: (text: string) => {
529
- liveInput.current = text;
530
- setMode({ m: "railEdit", id: mode.id, text });
531
- },
532
- onSave: () => dispatch({ t: "saveCompose" }),
533
- onCancel: () => dispatch({ t: "closeOverlay" }),
534
- }
535
- : null
536
- }
537
- submitConfirm={submitConfirmState}
538
- onTab={setRailTab}
539
- onSelectCard={selectCardFromRail}
540
- onActivateCard={openCardEdit}
541
- onSubmitRequest={onSubmitRequest}
542
- railScrollRef={railScrollRef}
543
- />
544
- </box>
545
- {mode.m === "compose" && isDiff ? (
546
- <ComposeBar mode={mode} quote={rows[mode.dispIdx]?.text ?? ""} onChange={(text) => { liveInput.current = text; setMode({ ...mode, text }); }} />
547
- ) : (
548
- <box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1 }}>
549
- <text fg={T.textDim}>
550
- {readOnly
551
- ? "observer - read-only · j/k move · n/p annotations · q quit"
552
- : mode.m === "submit"
553
- ? "verdict ←/→ · ⏎ submit · esc cancel"
554
- : mode.m === "span"
555
- ? "span · l/h grow/shrink · w/b slide · $ end · c comment · s suggest · esc"
556
- : mode.m === "compose" || mode.m === "railEdit"
557
- ? "typing · ⏎ save · esc cancel"
558
- : focusedAnn !== undefined
559
- ? "card · e edit · x Cut · n/p cards · esc deselect · ⏎ submit"
560
- : "j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit"}
561
- </text>
562
- </box>
563
- )}
564
- </box>
565
- );
566
- }
567
-
568
- // ── panes ─────────────────────────────────────
569
-
570
- interface ComposeState {
571
- kind: "comment" | "suggestion";
572
- dispIdx: number;
573
- quote: string;
574
- text: string;
575
- onInput: (text: string) => void;
576
- onSave: () => void;
577
- onCancel: () => void;
578
- }
579
-
580
- function PlanPane({
581
- display,
582
- marks,
583
- cursor,
584
- mode,
585
- session,
586
- editOrphanCount,
587
- compose,
588
- registerLine,
589
- onLineActivate,
590
- onEditRequest,
591
- docScrollRef,
592
- }: {
593
- display: DisplayBlock[];
594
- marks: Map<number, Mark[]>;
595
- cursor: number;
596
- mode: Mode;
597
- session: ReviewSession;
598
- editOrphanCount: number;
599
- compose: ComposeState | null;
600
- registerLine: (key: string, renderable: TextRenderable | null, meta: Omit<PlanLineRef, "renderable">) => void;
601
- onLineActivate: (dispIdx: number) => void;
602
- onEditRequest: () => void;
603
- docScrollRef: React.RefObject<ScrollBoxRenderable | null>;
604
- }): React.ReactNode {
605
- const width = 76;
606
- const children: React.ReactNode[] = [];
607
- for (let i = 0; i < display.length; i++) {
608
- const d = display[i]!;
609
- const isCursor = i === cursor;
610
- const gap = topGap(display[i - 1], d);
611
- if (d.kind === "code") {
612
- children.push(
613
- <CodeBlock key={i} id={`plan-block-${i}`} block={d} isCursor={isCursor} gap={gap} annotated={(marks.get(i) ?? []).length > 0} />,
614
- );
615
- } else {
616
- const blockMarks = [...(marks.get(i) ?? [])];
617
- if (mode.m === "span" && mode.span.dispIdx === i) {
618
- blockMarks.push({ start: mode.span.start, end: mode.span.end, role: "kspan" });
619
- }
620
- // the compose anchor stays painted selection-style while the box is open
621
- if (mode.m === "compose" && mode.dispIdx === i) {
622
- blockMarks.push({ start: mode.start, end: mode.end, role: "kspan" });
623
- }
624
- const runs = overlayMarks(blockRuns(d, true), blockMarks);
625
- const lines = wrapRuns(runs, width - marker(d).length);
626
- const prefixColumns = 2 + marker(d).length;
627
- children.push(
628
- <box key={i} id={`plan-block-${i}`} style={{ flexDirection: "column", marginTop: gap }}>
629
- {lines.map((line, li) => {
630
- const positioned = line.filter((run) => run.start !== null);
631
- const lineStart = positioned.length ? positioned[0]!.start : null;
632
- const lastPositioned = positioned[positioned.length - 1];
633
- const textLength =
634
- lineStart !== null && lastPositioned ? lastPositioned.start! + lastPositioned.text.length - lineStart : 0;
635
- return (
636
- <text
637
- key={li}
638
- bg={isCursor ? T.cursorBg : undefined}
639
- selectable
640
- selectionBg={T.accent}
641
- selectionFg={T.accentInk}
642
- ref={(renderable: TextRenderable | null) =>
643
- registerLine(`${i}:${li}`, renderable, { dispIdx: i, lineIndex: li, lineStart, prefixColumns, textLength })
644
- }
645
- onMouseUp={() => onLineActivate(i)}
646
- >
647
- <span fg={isCursor ? T.accent : T.textDim}>{li === 0 ? cursorGlyph(isCursor) : " "}</span>
648
- <span fg={T.textDim}>{li === 0 ? marker(d) : " ".repeat(marker(d).length)}</span>
649
- {line.map((r, ri) => (
650
- <span key={ri} {...runStyle(r, d)}>
651
- {r.text}
652
- </span>
653
- ))}
654
- {li === 0 && d.type !== "same" ? <span fg={tagColor(d)}> [{tagLabel(d)}]</span> : null}
655
- </text>
656
- );
657
- })}
658
- </box>,
659
- );
660
- }
661
- if (compose && compose.dispIdx === i) {
662
- children.push(
663
- <AnnotationCard
664
- key={`compose-${i}`}
665
- kind={compose.kind}
666
- quote={compose.quote}
667
- draft={{ text: compose.text, onInput: compose.onInput, onSave: compose.onSave, onCancel: compose.onCancel }}
668
- />,
669
- );
670
- }
671
- }
672
- return (
673
- <box style={{ flexGrow: 1, flexDirection: "column" }}>
674
- <SheetHeader session={session} onEditRequest={onEditRequest} />
675
- {editOrphanCount > 0 ? (
676
- <box style={{ height: 1, backgroundColor: T.markCommentBg, paddingLeft: 2 }}>
677
- <text fg={T.red}>
678
- {editOrphanCount} annotation{editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.
679
- </text>
680
- </box>
681
- ) : null}
682
- <scrollbox ref={docScrollRef} style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
683
- {children}
684
- </scrollbox>
685
- </box>
686
- );
687
- }
688
-
689
- /** Sheet chrome: submitted-by + revision delta left, the Edit word-button right. */
690
- function SheetHeader({ session, onEditRequest }: { session: ReviewSession; onEditRequest: () => void }): React.ReactNode {
691
- const revisionCount = session.revisions.length;
692
- const previous = revisionCount > 1 ? session.revisions[revisionCount - 2] : undefined;
693
- const delta = previous ? revisionDelta(previous.content, session.artifact.content) : null;
694
- return (
695
- <box style={{ height: 1, flexDirection: "row", paddingLeft: 2, paddingRight: 1 }}>
696
- <text fg={T.textDim}>
697
- submitted by <span fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</span> · revision {revisionCount}
698
- {delta ? (
699
- <span fg={T.green}>
700
- {" "}· v{revisionCount - 1}→v{revisionCount} +{delta.added} -{delta.removed}
701
- </span>
702
- ) : null}
703
- </text>
704
- <box style={{ flexGrow: 1 }} />
705
- <box onMouseUp={onEditRequest}>
706
- <text fg={T.textDim}> Edit </text>
707
- </box>
708
- </box>
709
- );
710
- }
711
-
712
- function CodeBlock({
713
- id,
714
- block,
715
- isCursor,
716
- gap,
717
- annotated,
718
- }: {
719
- id: string;
720
- block: DisplayBlock;
721
- isCursor: boolean;
722
- gap: number;
723
- annotated: boolean;
724
- }): React.ReactNode {
725
- const lang = (block.work ?? block.base)?.lang;
726
- const content = displayText(block);
727
- // Shiki tokens arrive async; plain verbatim lines render until then
728
- const [tokens, setTokens] = useState<CodeToken[][] | null>(null);
729
- useEffect(() => {
730
- let live = true;
731
- void highlightCode(content, lang).then((t) => {
732
- if (live && t) setTokens(t);
733
- });
734
- return () => {
735
- live = false;
736
- };
737
- }, [content, lang]);
738
- const lines: CodeToken[][] = tokens ?? content.split("\n").map((l) => [{ content: l }]);
739
- return (
740
- <box id={id} style={{ flexDirection: "column", marginTop: gap }}>
741
- <text>
742
- <span fg={isCursor ? T.accent : T.textDim}>{cursorGlyph(isCursor)}</span>
743
- <span fg={T.textDim}>{lang ?? "code"}</span>
744
- {annotated ? <span fg={T.accent}> ◆</span> : null}
745
- {block.type !== "same" ? <span fg={tagColor(block)}> [{tagLabel(block)}]</span> : null}
746
- </text>
747
- <box
748
- style={{
749
- flexDirection: "column",
750
- backgroundColor: T.elevated,
751
- paddingLeft: 2,
752
- paddingRight: 2,
753
- paddingTop: 1,
754
- paddingBottom: 1,
755
- marginLeft: 2,
756
- }}
757
- >
758
- {lines.map((line, li) => (
759
- <text key={li}>
760
- {line.length === 0 || (line.length === 1 && line[0]!.content === "") ? (
761
- " "
762
- ) : (
763
- line.map((tok, ti) => (
764
- <span key={ti} fg={tok.color ?? T.textMuted}>
765
- {tok.content}
766
- </span>
767
- ))
768
- )}
769
- </text>
770
- ))}
771
- </box>
772
- </box>
773
- );
774
- }
775
-
776
- /** Vertical rhythm: gaps live ABOVE blocks so boundaries never collapse. */
777
- function topGap(prev: DisplayBlock | undefined, cur: DisplayBlock): number {
778
- if (!prev) return 0;
779
- const tightPair =
780
- (cur.kind === "li" && prev.kind === "li") || (cur.kind === "oli" && prev.kind === "oli");
781
- return tightPair ? 0 : 1;
782
- }
783
-
784
- // ── the rail ──────────────────────────────────
785
-
786
- interface CardEditState {
787
- id: string;
788
- text: string;
789
- onInput: (text: string) => void;
790
- onSave: () => void;
791
- onCancel: () => void;
792
- }
793
-
794
- /** The open confirm card: verdict, summary draft, counts, and its actions. */
795
- interface SubmitConfirmState {
796
- verdict: VerdictKind;
797
- summary: string;
798
- annotationCount: number;
799
- blockingCount: number;
800
- onInput: (summary: string) => void;
801
- onSelectVerdict: (verdict: VerdictKind) => void;
802
- onSubmit: () => void;
803
- onCancel: () => void;
804
- }
805
-
806
- function Rail({
807
- session,
808
- selectedId,
809
- resolvedIds,
810
- railTab,
811
- pendingCount,
812
- cardEdit,
813
- submitConfirm,
814
- onTab,
815
- onSelectCard,
816
- onActivateCard,
817
- onSubmitRequest,
818
- railScrollRef,
819
- }: {
820
- session: ReviewSession;
821
- selectedId?: string;
822
- /** Ids whose anchor resolved; null = orphan display off (diff view). */
823
- resolvedIds: Set<string> | null;
824
- railTab: RailTab;
825
- pendingCount: number;
826
- cardEdit: CardEditState | null;
827
- submitConfirm: SubmitConfirmState | null;
828
- onTab: (tab: RailTab) => void;
829
- onSelectCard: (id: string) => void;
830
- onActivateCard: (id: string) => void;
831
- onSubmitRequest: () => void;
832
- railScrollRef: React.RefObject<ScrollBoxRenderable | null>;
833
- }): React.ReactNode {
834
- return (
835
- <box style={{ width: 34, backgroundColor: T.panel, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
836
- <box style={{ height: 1, flexDirection: "row" }}>
837
- <box onMouseUp={() => onTab("review")}>
838
- <text fg={railTab === "review" ? T.accent : T.textDim}>Review ({pendingCount})</text>
839
- </box>
840
- <text fg={T.textDim}>{" "}</text>
841
- <box onMouseUp={() => onTab("agent")}>
842
- <text fg={railTab === "agent" ? T.accent : T.textDim}>Agent</text>
843
- </box>
844
- </box>
845
- <text> </text>
846
- {railTab === "agent" ? (
847
- <AgentTab session={session} />
848
- ) : (
849
- <>
850
- {session.workingCopy !== undefined ? <text fg={T.textDim}>± plan edits → one diff</text> : null}
851
- {session.annotations.length === 0 ? (
852
- <text fg={T.textDim}>no annotations yet</text>
853
- ) : (
854
- <scrollbox ref={railScrollRef} style={{ flexGrow: 1 }} focused={false}>
855
- {session.annotations.map((annotation) => (
856
- <AnnotationCard
857
- key={annotation.id}
858
- kind={annotation.kind}
859
- quote={annotation.anchor.quote}
860
- saved={{
861
- body: annotation.body,
862
- selected: annotation.id === selectedId,
863
- orphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
864
- blocking: annotationBlocking(annotation),
865
- editing:
866
- cardEdit && cardEdit.id === annotation.id
867
- ? { text: cardEdit.text, onInput: cardEdit.onInput, onSave: cardEdit.onSave, onCancel: cardEdit.onCancel }
868
- : null,
869
- onMouseUp: () =>
870
- annotation.id === selectedId ? onActivateCard(annotation.id) : onSelectCard(annotation.id),
871
- }}
872
- />
873
- ))}
874
- </scrollbox>
875
- )}
876
- <box style={{ flexGrow: 1 }} />
877
- {/* the confirm card sits OUTSIDE the scrollbox: the annotation stack
878
- above scrolls while the card stays pinned to the rail bottom */}
879
- {session.status === "resolved" ? (
880
- <text fg={T.green}>resolved: {session.verdict!.kind.replace("_", " ")}</text>
881
- ) : submitConfirm ? (
882
- <SubmitConfirmCard confirm={submitConfirm} />
883
- ) : (
884
- <box onMouseUp={onSubmitRequest}>
885
- <text fg={T.accent}>{`Submit review (${pendingCount}) ⏎`}</text>
886
- </box>
887
- )}
888
- </>
889
- )}
890
- </box>
891
- );
892
- }
893
-
894
- /**
895
- * Confirm card content rows: counts, spacer, verdict selector, spacer,
896
- * summary input, spacer, Submit/Cancel buttons. The bordered box height is
897
- * this count plus the two border rows - a pure function of the content, so
898
- * layout and render never drift.
899
- */
900
- const SUBMIT_CONFIRM_CONTENT_ROWS = 7;
901
-
902
- function submitConfirmHeight(): number {
903
- return SUBMIT_CONFIRM_CONTENT_ROWS + 2;
904
- }
905
-
906
- /**
907
- * The Submit button expanded into a bordered confirm card at the rail bottom:
908
- * honest counts, the verdict selector (arrow keys or click), the optional
909
- * summary, and plain word-buttons - key hints live in the status line only.
910
- */
911
- function SubmitConfirmCard({ confirm }: { confirm: SubmitConfirmState }): React.ReactNode {
912
- return (
913
- <box
914
- style={{
915
- height: submitConfirmHeight(),
916
- marginRight: 1,
917
- border: true,
918
- borderStyle: "rounded",
919
- borderColor: T.accent,
920
- backgroundColor: T.elevated,
921
- flexDirection: "column",
922
- paddingLeft: 1,
923
- }}
924
- title=" submit review "
925
- >
926
- <text fg={T.textDim}>{`${confirm.annotationCount} annotations · ${confirm.blockingCount} blocking`}</text>
927
- <box style={{ height: 1 }} />
928
- <box style={{ flexDirection: "row", height: 1 }}>
929
- {VERDICTS.map((candidate) => (
930
- <box key={candidate} style={{ paddingRight: 1 }} onMouseUp={() => confirm.onSelectVerdict(candidate)}>
931
- <text fg={candidate === confirm.verdict ? verdictColor(candidate) : T.textDim}>
932
- {candidate === confirm.verdict ? `[${VERDICT_LABEL[candidate]}]` : ` ${VERDICT_LABEL[candidate]} `}
933
- </text>
934
- </box>
935
- ))}
936
- </box>
937
- <box style={{ height: 1 }} />
938
- <input focused value={confirm.summary} onInput={confirm.onInput} placeholder="summary for the agent (optional)" />
939
- <box style={{ height: 1 }} />
940
- <box style={{ flexDirection: "row", height: 1 }}>
941
- <box style={{ backgroundColor: T.accent, marginRight: 2 }} onMouseUp={confirm.onSubmit}>
942
- <text fg={T.accentInk}> Submit </text>
943
- </box>
944
- <box onMouseUp={confirm.onCancel}>
945
- <text fg={T.textDim}> Cancel </text>
946
- </box>
947
- </box>
948
- </box>
949
- );
950
- }
951
-
952
- /** Agent tab: who submitted, where the session stands, which revision. */
953
- function AgentTab({ session }: { session: ReviewSession }): React.ReactNode {
954
- return (
955
- <box style={{ flexDirection: "column", flexGrow: 1 }}>
956
- <text fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</text>
957
- <text fg={T.textDim}>status: {session.status}</text>
958
- <text fg={T.textDim}>revision {session.revisions.length}</text>
959
- </box>
960
- );
961
- }
962
-
963
- /**
964
- * The composer and the rail card are one component in two modes: draft props
965
- * attached (bordered box inline in the document flow) or saved props attached
966
- * (rail card, optionally editing its body in place). Compose, saved, and
967
- * re-edit all share this rendering path.
968
- */
969
- function AnnotationCard({
970
- kind,
971
- quote,
972
- draft,
973
- saved,
974
- }: {
975
- kind: string;
976
- quote: string;
977
- draft?: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void };
978
- saved?: {
979
- body: string;
980
- selected: boolean;
981
- orphan: boolean;
982
- blocking: boolean;
983
- editing: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void } | null;
984
- onMouseUp: () => void;
985
- };
986
- }): React.ReactNode {
987
- const color = kind === "suggestion" ? T.green : T.accent;
988
- if (draft) {
989
- const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
990
- return (
991
- <box
992
- style={{
993
- height: composeBoxHeight(),
994
- marginLeft: 2,
995
- marginRight: 2,
996
- border: true,
997
- borderStyle: "rounded",
998
- borderColor: color,
999
- backgroundColor: T.elevated,
1000
- flexDirection: "column",
1001
- paddingLeft: 1,
1002
- }}
1003
- title={` ${verb} "${truncate(quote, 40)}" `}
1004
- >
1005
- <input focused value={draft.text} onInput={draft.onInput} placeholder="write a note..." />
1006
- <SaveCancelRow onSave={draft.onSave} onCancel={draft.onCancel} />
1007
- </box>
1008
- );
1009
- }
1010
- const card = saved!;
1011
- return (
1012
- <box
1013
- style={{ flexDirection: "column", marginBottom: 1, backgroundColor: card.selected ? T.elevated : undefined }}
1014
- onMouseUp={card.onMouseUp}
1015
- >
1016
- <text fg={card.selected ? T.text : color}>
1017
- {card.selected ? "▸ " : " "}
1018
- {kind.toUpperCase()}
1019
- {card.blocking ? <span fg={T.red}> · BLOCKING</span> : null}
1020
- <span fg={T.textDim}>{card.orphan ? " · ORPHANED" : " · pending"}</span>
1021
- </text>
1022
- <text fg={T.textDim}> "{truncate(quote, 26)}"</text>
1023
- {card.editing ? (
1024
- <>
1025
- <input focused value={card.editing.text} onInput={card.editing.onInput} />
1026
- <SaveCancelRow onSave={card.editing.onSave} onCancel={card.editing.onCancel} />
1027
- </>
1028
- ) : (
1029
- <text fg={card.orphan ? T.textDim : T.textMuted}> {truncate(card.body, 28)}</text>
1030
- )}
1031
- </box>
1032
- );
1033
- }
1034
-
1035
- function SaveCancelRow({ onSave, onCancel }: { onSave: () => void; onCancel: () => void }): React.ReactNode {
1036
- return (
1037
- <box style={{ flexDirection: "row", height: 1 }}>
1038
- <box style={{ backgroundColor: T.accent, marginRight: 2 }} onMouseUp={onSave}>
1039
- <text fg={T.accentInk}> Save ⏎ </text>
1040
- </box>
1041
- <box onMouseUp={onCancel}>
1042
- <text fg={T.textDim}> Cancel esc </text>
1043
- </box>
1044
- </box>
1045
- );
1046
- }
1047
-
1048
- /** Forward-compatible: open annotation kinds may carry a blocking flag. */
1049
- function annotationBlocking(annotation: Annotation): boolean {
1050
- return (annotation as Annotation & { blocking?: boolean }).blocking === true;
1051
- }
1052
-
1053
- function Inbox({ inbox, cursor }: { inbox: ReviewSession[]; cursor: number }): React.ReactNode {
1054
- return (
1055
- <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg, padding: 1 }}>
1056
- <text fg={T.accent}>cueloop · inbox ({inbox.length} pending)</text>
1057
- <text> </text>
1058
- {inbox.length === 0 ? (
1059
- <text fg={T.textDim}>nothing waiting for review</text>
1060
- ) : (
1061
- inbox.map((s, i) => (
1062
- <text key={s.id} fg={i === cursor ? T.text : T.textMuted} bg={i === cursor ? T.cursorBg : undefined}>
1063
- {i === cursor ? "▸ " : " "}
1064
- {s.artifact.meta.title ?? s.id} · {s.workspace.branch} · {s.artifact.type}
1065
- </text>
1066
- ))
1067
- )}
1068
- <box style={{ flexGrow: 1 }} />
1069
- <text fg={T.textDim}>j/k move · ⏎ open · q quit</text>
1070
- </box>
1071
- );
1072
- }
1073
-
1074
- /** Bottom compose bar - the diff view still composes here. */
1075
- function ComposeBar({
1076
- mode,
1077
- quote,
1078
- onChange,
1079
- }: {
1080
- mode: Extract<Mode, { m: "compose" }>;
1081
- quote: string;
1082
- onChange: (text: string) => void;
1083
- }): React.ReactNode {
1084
- return (
1085
- <box style={{ height: 2, backgroundColor: T.elevated, flexDirection: "column", paddingLeft: 1 }}>
1086
- <text fg={mode.kind === "suggestion" ? T.green : T.accent}>
1087
- {mode.kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncate(quote, 60)}” · ⏎ save · esc cancel
1088
- </text>
1089
- <input focused value={mode.text} onInput={onChange} />
1090
- </box>
1091
- );
1092
- }
1093
-
1094
- function DiffPane({
1095
- rows,
1096
- cursor,
1097
- annotations,
1098
- focusedAnn,
1099
- }: {
1100
- rows: DiffRow[];
1101
- cursor: number;
1102
- annotations: Annotation[];
1103
- focusedAnn?: string;
1104
- }): React.ReactNode {
1105
- // simple windowing: keep the cursor in view without a scroll dependency
1106
- const windowStart = Math.max(0, cursor - 12);
1107
- const visible = rows.slice(windowStart, windowStart + 200);
1108
- const annotated = new Map<number, Annotation>();
1109
- for (const a of annotations) {
1110
- const idx = rows.findIndex((r) => r.text === a.anchor.quote && (r.t === "ctx" || r.t === "add" || r.t === "del"));
1111
- if (idx !== -1) annotated.set(idx, a);
1112
- }
1113
- return (
1114
- <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
1115
- {visible.map((row, vi) => {
1116
- const i = windowStart + vi;
1117
- const isCursor = i === cursor;
1118
- const ann = annotated.get(i);
1119
- const gutter = row.t === "del" ? (row.oldLine?.toString() ?? "") : (row.newLine?.toString() ?? "");
1120
- const sign = row.t === "add" ? "+" : row.t === "del" ? "-" : " ";
1121
- if (row.t === "file") {
1122
- return (
1123
- <text key={i} fg={T.text} bg={isCursor ? T.cursorBg : T.panel}>
1124
- {isCursor ? "▎" : " "}■ {row.text}
1125
- </text>
1126
- );
1127
- }
1128
- if (row.t === "hunk") {
1129
- return (
1130
- <text key={i} fg={T.blue} bg={isCursor ? T.cursorBg : undefined}>
1131
- {isCursor ? "▎" : " "}{row.text}
1132
- </text>
1133
- );
1134
- }
1135
- const fg = row.t === "add" ? T.insFg : row.t === "del" ? T.delFg : T.textMuted;
1136
- return (
1137
- <box key={i} style={{ flexDirection: "column" }}>
1138
- <text bg={isCursor ? T.cursorBg : ann ? T.markCommentBg : undefined}>
1139
- <span fg={T.textDim}>{isCursor ? "▎" : " "}{gutter.padStart(4)} </span>
1140
- <span fg={fg}>{sign}{row.text}</span>
1141
- </text>
1142
- {ann ? (
1143
- <text>
1144
- <span fg={T.textDim}>{" "}</span>
1145
- <span fg={ann.id === focusedAnn ? T.text : T.accent}>◆ {truncate(ann.body, 70)}</span>
1146
- </text>
1147
- ) : null}
1148
- </box>
1149
- );
1150
- })}
1151
- </box>
1152
- );
1153
- }
1154
-
1155
- function CompletionOverlay({
1156
- verdict,
1157
- completion,
1158
- status,
1159
- returnsTo,
1160
- }: {
1161
- verdict: VerdictKind;
1162
- completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
1163
- /** Latest status line (e.g. the vault-export path) stays visible here. */
1164
- status: string;
1165
- /** Where focus goes on close (the agent's herdr pane), when known. */
1166
- returnsTo?: string;
1167
- }): React.ReactNode {
1168
- const approved = verdict === "approve";
1169
- return (
1170
- <box
1171
- style={{
1172
- width: "100%",
1173
- height: "100%",
1174
- backgroundColor: T.bg,
1175
- flexDirection: "column",
1176
- justifyContent: "center",
1177
- alignItems: "center",
1178
- }}
1179
- >
1180
- <text fg={approved ? T.green : T.accent}>{approved ? "✓ review approved" : "✎ feedback sent"}</text>
1181
- <text> </text>
1182
- <text fg={T.text}>The agent has your {approved ? "approval" : "feedback"} and is unblocked.</text>
1183
- {status ? <text fg={T.textDim}>{status}</text> : null}
1184
- <text> </text>
1185
- {completion.phase === "counting" ? (
1186
- <text fg={T.textDim}>
1187
- closing in {completion.remaining}s{returnsTo ? ` - returning to ${returnsTo}` : ""} · ⏎ now · esc stay
1188
- </text>
1189
- ) : (
1190
- <text fg={T.textDim}>⏎ close · a always close after submit (3s) · esc stay</text>
1191
- )}
1192
- </box>
1193
- );
1194
- }
1195
-
1196
- // ── small helpers ─────────────────────────────
1197
-
1198
- function marker(d: DisplayBlock): string {
1199
- if (d.kind === "li") return "- ";
1200
- if (d.kind === "oli") return `${d.oliNum ?? 1}. `;
1201
- if (d.kind === "quote") return "▏ ";
1202
- return "";
1203
- }
1204
-
1205
- function cursorGlyph(isCursor: boolean): string {
1206
- return isCursor ? "▎ " : " ";
1207
- }
1208
-
1209
- function tagLabel(d: DisplayBlock): string {
1210
- return d.type === "del" ? "cut" : d.type === "add" ? "new" : "edited";
1211
- }
1212
-
1213
- function tagColor(d: DisplayBlock): string {
1214
- return d.type === "del" ? T.red : d.type === "add" ? T.green : T.accent;
1215
- }
1216
-
1217
- function verdictColor(v: VerdictKind): string {
1218
- return v === "approve" ? T.green : v === "request_changes" ? T.red : T.blue;
1219
- }
1220
-
1221
- function defaultVerdict(s: ReviewSession): VerdictKind {
1222
- return s.annotations.length || s.workingCopy !== undefined ? "request_changes" : "approve";
1223
- }
1224
-
1225
- function runStyle(r: StyleRun, d: DisplayBlock): { fg?: string; bg?: string; attributes?: number } {
1226
- const headingFg = d.kind === "h1" ? T.text : d.kind === "h2" || d.kind === "h3" ? T.accent : undefined;
1227
- const struck = d.type === "del";
1228
- switch (r.role) {
1229
- case "ins":
1230
- return { fg: T.insFg };
1231
- case "del":
1232
- return { fg: T.delFg };
1233
- case "mark-comment":
1234
- return { fg: T.text, bg: T.markCommentBg };
1235
- case "mark-suggestion":
1236
- return { fg: T.text, bg: T.markSuggestionBg };
1237
- case "mark-focus":
1238
- return { fg: T.accentInk, bg: T.accent };
1239
- case "kspan":
1240
- return { fg: T.accentInk, bg: T.accent };
1241
- default:
1242
- return { fg: struck ? T.red : (headingFg ?? (d.kind === "code" ? T.textMuted : T.text)) };
1243
- }
1244
- }
1245
-
1246
- function truncate(s: string, n: number): string {
1247
- const one = s.replace(/\n/g, " ");
1248
- return one.length > n ? one.slice(0, n - 1) + "…" : one;
1249
- }