@robota-sdk/agent-ui-terminal 3.0.0-beta.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 (348) hide show
  1. package/CHANGELOG.md +1204 -0
  2. package/LICENSE +661 -0
  3. package/README.md +50 -0
  4. package/dist/node/index.d.ts +1224 -0
  5. package/dist/node/index.d.ts.map +1 -0
  6. package/dist/node/index.js +33 -0
  7. package/dist/node/index.js.map +1 -0
  8. package/package.json +88 -0
  9. package/src/App.tsx +157 -0
  10. package/src/AppPresentation.tsx +190 -0
  11. package/src/AppView.tsx +26 -0
  12. package/src/BackgroundTaskPanel.tsx +73 -0
  13. package/src/CjkTextInput.tsx +336 -0
  14. package/src/ConfirmPrompt.tsx +140 -0
  15. package/src/ContextWarningBanner.tsx +45 -0
  16. package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
  17. package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
  18. package/src/HistorySearchOverlay.tsx +150 -0
  19. package/src/InputArea.tsx +338 -0
  20. package/src/ListPicker.tsx +158 -0
  21. package/src/MenuSelect.tsx +159 -0
  22. package/src/MessageList.tsx +308 -0
  23. package/src/MultiSelectList.tsx +294 -0
  24. package/src/PendingActionPrompt.tsx +121 -0
  25. package/src/PermissionPrompt.tsx +236 -0
  26. package/src/PluginTUI.tsx +258 -0
  27. package/src/RoleLabel.tsx +72 -0
  28. package/src/SafeText.tsx +66 -0
  29. package/src/SessionEventNotices.tsx +27 -0
  30. package/src/SessionPicker.tsx +92 -0
  31. package/src/SessionStatusBar.tsx +80 -0
  32. package/src/SlashAutocomplete.tsx +154 -0
  33. package/src/StatusBar.tsx +308 -0
  34. package/src/StreamingIndicator.tsx +145 -0
  35. package/src/SupervisedSessionView.tsx +872 -0
  36. package/src/TextPrompt.tsx +112 -0
  37. package/src/ThemePicker.tsx +366 -0
  38. package/src/ToolCommandOutput.tsx +48 -0
  39. package/src/ToolDiffBlock.tsx +44 -0
  40. package/src/TransportTUI.tsx +166 -0
  41. package/src/TuiInteractionChannel.ts +530 -0
  42. package/src/UpdateNotice.tsx +18 -0
  43. package/src/UsageSummaryEntry.tsx +49 -0
  44. package/src/WaveText.tsx +70 -0
  45. package/src/__tests__/ListPicker.test.tsx +188 -0
  46. package/src/__tests__/MenuSelect.test.tsx +127 -0
  47. package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
  48. package/src/__tests__/PluginTUI.test.tsx +167 -0
  49. package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
  50. package/src/__tests__/SupervisedSessionView-render.test.tsx +52 -0
  51. package/src/__tests__/SupervisedSessionView.test.tsx +1112 -0
  52. package/src/__tests__/TextPrompt.test.tsx +112 -0
  53. package/src/__tests__/TransportTUI.test.tsx +124 -0
  54. package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
  55. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +294 -0
  56. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +650 -0
  57. package/src/__tests__/UpdateNotice.test.tsx +15 -0
  58. package/src/__tests__/__fixtures__/korean-text.json +17 -0
  59. package/src/__tests__/abort-after-permission.test.tsx +160 -0
  60. package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
  61. package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
  62. package/src/__tests__/attention-bell-and-marks.test.ts +144 -0
  63. package/src/__tests__/autocomplete-editing-keys.test.tsx +127 -0
  64. package/src/__tests__/background-task-panel-countdown.test.tsx +75 -0
  65. package/src/__tests__/background-task-panel.test.tsx +98 -0
  66. package/src/__tests__/background-task-row-format.test.ts +152 -0
  67. package/src/__tests__/channel-factory-integration.test.ts +150 -0
  68. package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
  69. package/src/__tests__/cjk-defer-submit.test.tsx +68 -0
  70. package/src/__tests__/cjk-fallback-render.test.tsx +89 -0
  71. package/src/__tests__/cjk-text-input-flow.test.ts +187 -0
  72. package/src/__tests__/cjk-text-input.test.ts +196 -0
  73. package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
  74. package/src/__tests__/command-output-summary.test.ts +95 -0
  75. package/src/__tests__/command-result-handler.test.ts +171 -0
  76. package/src/__tests__/compact-event-bridge.test.ts +20 -0
  77. package/src/__tests__/confirm-permission-flow.test.ts +149 -0
  78. package/src/__tests__/confirm-prompt.test.tsx +103 -0
  79. package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
  80. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +47 -0
  81. package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
  82. package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
  83. package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
  84. package/src/__tests__/external-prompt-notice.test.tsx +100 -0
  85. package/src/__tests__/fixtures/command-handoff-driver.tsx +114 -0
  86. package/src/__tests__/fixtures/fake-editor.sh +7 -0
  87. package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
  88. package/src/__tests__/fork-attach.test.tsx +235 -0
  89. package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
  90. package/src/__tests__/helpers/terminal-profiles.ts +152 -0
  91. package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
  92. package/src/__tests__/history-clear-broadcast.test.ts +66 -0
  93. package/src/__tests__/history-search-overlay.test.tsx +329 -0
  94. package/src/__tests__/humanize-tool-name.test.ts +30 -0
  95. package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
  96. package/src/__tests__/input-area-flow.test.ts +199 -0
  97. package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
  98. package/src/__tests__/interaction-channel-charter.test.ts +13 -0
  99. package/src/__tests__/key-hint-consistency.test.tsx +249 -0
  100. package/src/__tests__/key-hint-footer.test.tsx +69 -0
  101. package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
  102. package/src/__tests__/message-list-rendering.test.tsx +474 -0
  103. package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
  104. package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
  105. package/src/__tests__/output-style-reaches-session.test.ts +35 -0
  106. package/src/__tests__/palette-consistency.test.ts +329 -0
  107. package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
  108. package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
  109. package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
  110. package/src/__tests__/permission-prompt-requester.test.tsx +34 -0
  111. package/src/__tests__/product-display-name.test.tsx +79 -0
  112. package/src/__tests__/prompt-queue.test.tsx +260 -0
  113. package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
  114. package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
  115. package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
  116. package/src/__tests__/pty/behavior-2437-git.ptytest.ts +350 -0
  117. package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
  118. package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
  119. package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
  120. package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
  121. package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
  122. package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
  123. package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
  124. package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +320 -0
  125. package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +211 -0
  126. package/src/__tests__/pty/ime-cursor.ptytest.ts +187 -0
  127. package/src/__tests__/pty/isolated-home.ts +97 -0
  128. package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
  129. package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
  130. package/src/__tests__/pty/pty-driver.ts +155 -0
  131. package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
  132. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +135 -0
  133. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +140 -0
  134. package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
  135. package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
  136. package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
  137. package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
  138. package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
  139. package/src/__tests__/pty/session-attach.ptytest.ts +182 -0
  140. package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
  141. package/src/__tests__/pty/spawn-pty.test.ts +120 -0
  142. package/src/__tests__/pty/spawn-pty.ts +229 -0
  143. package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
  144. package/src/__tests__/pty/trust-prompt.ptytest.ts +134 -0
  145. package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
  146. package/src/__tests__/pty-driver-dispose.test.ts +43 -0
  147. package/src/__tests__/real-cursor-positioning.test.tsx +264 -0
  148. package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
  149. package/src/__tests__/render-channel-options.test.ts +201 -0
  150. package/src/__tests__/render-lifecycle.test.ts +36 -0
  151. package/src/__tests__/render-markdown.test.ts +164 -0
  152. package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
  153. package/src/__tests__/safe-text-boundary.test.tsx +75 -0
  154. package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
  155. package/src/__tests__/screen-reader-input.test.tsx +106 -0
  156. package/src/__tests__/screen-reader-labels.test.ts +91 -0
  157. package/src/__tests__/screen-reader-menus.test.tsx +200 -0
  158. package/src/__tests__/screen-reader-pacing.test.ts +230 -0
  159. package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
  160. package/src/__tests__/screen-reader-render-options.test.ts +262 -0
  161. package/src/__tests__/screen-reader-stdout.test.ts +322 -0
  162. package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
  163. package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
  164. package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
  165. package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
  166. package/src/__tests__/selection-flow.test.ts +61 -0
  167. package/src/__tests__/session-event-notices.test.tsx +31 -0
  168. package/src/__tests__/session-init-poller.test.ts +102 -0
  169. package/src/__tests__/session-picker.test.tsx +93 -0
  170. package/src/__tests__/session-switch-channel.test.tsx +470 -0
  171. package/src/__tests__/shutdown-signal.test.ts +34 -0
  172. package/src/__tests__/status-activity.test.ts +71 -0
  173. package/src/__tests__/status-bar.test.tsx +290 -0
  174. package/src/__tests__/status-glyph.test.ts +30 -0
  175. package/src/__tests__/streaming-indicator.test.tsx +178 -0
  176. package/src/__tests__/terminal-capabilities.test.ts +153 -0
  177. package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
  178. package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
  179. package/src/__tests__/text-prompt-flow.test.ts +77 -0
  180. package/src/__tests__/theme-picker.test.tsx +345 -0
  181. package/src/__tests__/transport-boundary.test.ts +11 -0
  182. package/src/__tests__/tui-app-channel-port.test.ts +31 -0
  183. package/src/__tests__/tui-channel-init-failure.test.ts +70 -0
  184. package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
  185. package/src/__tests__/tui-interaction-queues.test.ts +149 -0
  186. package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
  187. package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
  188. package/src/__tests__/tui-state-manager.test.ts +482 -0
  189. package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
  190. package/src/__tests__/wave-text.test.tsx +133 -0
  191. package/src/__tests__/wire-tui-channel.test.ts +1457 -0
  192. package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
  193. package/src/app-banner.tsx +42 -0
  194. package/src/app-static-items.ts +35 -0
  195. package/src/app-view-model.ts +140 -0
  196. package/src/attached-session-connection.ts +14 -0
  197. package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
  198. package/src/attention/__tests__/attention-tracker.test.ts +103 -0
  199. package/src/attention/__tests__/countdown.test.ts +15 -0
  200. package/src/attention/__tests__/focus-input-filter.test.ts +93 -0
  201. package/src/attention/__tests__/interval-recap.test.ts +49 -0
  202. package/src/attention/attention-coordinator.ts +71 -0
  203. package/src/attention/attention-tracker.ts +124 -0
  204. package/src/attention/countdown.ts +24 -0
  205. package/src/attention/focus-input-filter.ts +131 -0
  206. package/src/attention/interval-recap.ts +148 -0
  207. package/src/attention/time-units.ts +6 -0
  208. package/src/attention-bell.ts +79 -0
  209. package/src/attributed-user-echo.ts +30 -0
  210. package/src/background-task-row-format.ts +99 -0
  211. package/src/command-interaction.ts +8 -0
  212. package/src/command-output-summary.ts +129 -0
  213. package/src/create-default-tui-cli-adapter.ts +45 -0
  214. package/src/execution-workspace-switcher-selection.ts +118 -0
  215. package/src/execution-workspace-view-model.ts +139 -0
  216. package/src/external-prompt-notice.tsx +38 -0
  217. package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
  218. package/src/flows/__tests__/defer-submit.test.ts +88 -0
  219. package/src/flows/__tests__/real-cursor-flow.test.ts +139 -0
  220. package/src/flows/background-focus-flow.ts +44 -0
  221. package/src/flows/cjk-cursor-motion.ts +75 -0
  222. package/src/flows/cjk-text-deletion.ts +53 -0
  223. package/src/flows/cjk-text-input-flow.ts +248 -0
  224. package/src/flows/cjk-text-input-types.ts +24 -0
  225. package/src/flows/confirm-prompt-flow.ts +90 -0
  226. package/src/flows/defer-submit.ts +67 -0
  227. package/src/flows/fork-attach-flow.ts +62 -0
  228. package/src/flows/input-area-flow.ts +188 -0
  229. package/src/flows/permission-prompt-flow.ts +108 -0
  230. package/src/flows/real-cursor-flow.ts +83 -0
  231. package/src/flows/selection-flow.ts +205 -0
  232. package/src/flows/session-init-poller.ts +76 -0
  233. package/src/flows/text-prompt-flow.ts +93 -0
  234. package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
  235. package/src/history-search/history-search-flow.ts +94 -0
  236. package/src/history-search/useHistorySearch.ts +336 -0
  237. package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
  238. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +87 -0
  239. package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
  240. package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
  241. package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
  242. package/src/hooks/command-result-handler.ts +44 -0
  243. package/src/hooks/side-effects-types.ts +45 -0
  244. package/src/hooks/useAppController.ts +271 -0
  245. package/src/hooks/useAppInputBindings.ts +163 -0
  246. package/src/hooks/useAppInteractionState.ts +84 -0
  247. package/src/hooks/useAppLifecycleState.ts +81 -0
  248. package/src/hooks/useAppOverlays.ts +90 -0
  249. package/src/hooks/useAppScreenState.ts +81 -0
  250. package/src/hooks/useAppSubmissionState.ts +45 -0
  251. package/src/hooks/useAppThemeState.ts +157 -0
  252. package/src/hooks/useAppWorkspaceState.ts +149 -0
  253. package/src/hooks/useAppearanceSettings.ts +30 -0
  254. package/src/hooks/useAutocomplete.ts +101 -0
  255. package/src/hooks/useBackgroundPanel.ts +58 -0
  256. package/src/hooks/useCountdownTick.ts +24 -0
  257. package/src/hooks/useExecutionDetailPage.ts +69 -0
  258. package/src/hooks/useForkAttach.ts +60 -0
  259. package/src/hooks/useInputAreaHistorySearch.ts +72 -0
  260. package/src/hooks/useInputAreaKeys.ts +114 -0
  261. package/src/hooks/useNumberedSelection.ts +93 -0
  262. package/src/hooks/usePluginCallbacks.ts +38 -0
  263. package/src/hooks/usePluginScreenData.ts +85 -0
  264. package/src/hooks/useRealCursorPosition.ts +125 -0
  265. package/src/hooks/useRenderMarkdown.ts +38 -0
  266. package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
  267. package/src/hooks/useSideEffects.ts +144 -0
  268. package/src/hooks/useStatusLineSettings.ts +50 -0
  269. package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
  270. package/src/hooks/useTuiChannel.ts +141 -0
  271. package/src/humanize-tool-name.ts +51 -0
  272. package/src/index.ts +72 -0
  273. package/src/input-area-rules.tsx +71 -0
  274. package/src/json-value.ts +18 -0
  275. package/src/key-hint-footer.tsx +54 -0
  276. package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
  277. package/src/keybindings/__tests__/node-keybindings-source.test.ts +118 -0
  278. package/src/keybindings/keybinding-catalogue.ts +171 -0
  279. package/src/keybindings/keybinding-registry.ts +303 -0
  280. package/src/keybindings/keybinding-syntax.ts +155 -0
  281. package/src/keybindings/keybindings-context.tsx +124 -0
  282. package/src/keybindings/node-keybindings-source.ts +189 -0
  283. package/src/numbered-list.tsx +113 -0
  284. package/src/plugin-tui-handlers.ts +163 -0
  285. package/src/product-display-name-context.tsx +30 -0
  286. package/src/render-markdown.ts +209 -0
  287. package/src/render.tsx +746 -0
  288. package/src/sanitize-terminal-text.ts +171 -0
  289. package/src/screen-reader-announcement.ts +52 -0
  290. package/src/screen-reader-context.tsx +32 -0
  291. package/src/screen-reader-labels.ts +64 -0
  292. package/src/screen-reader-pacing-context.tsx +47 -0
  293. package/src/screen-reader-pacing.ts +178 -0
  294. package/src/screen-reader-stdout.ts +328 -0
  295. package/src/short-session-id.ts +9 -0
  296. package/src/shutdown-signal.ts +34 -0
  297. package/src/slash-command-input.ts +11 -0
  298. package/src/status-activity.ts +70 -0
  299. package/src/status-glyph.ts +78 -0
  300. package/src/terminal-capabilities-context.tsx +27 -0
  301. package/src/terminal-capabilities.ts +77 -0
  302. package/src/terminal-focus-reporting.ts +50 -0
  303. package/src/terminal-handoff-controller.ts +98 -0
  304. package/src/terminal-marks.ts +63 -0
  305. package/src/theme/__tests__/built-in-themes.test.ts +117 -0
  306. package/src/theme/__tests__/color-vision.test.ts +82 -0
  307. package/src/theme/__tests__/theme-context.test.tsx +146 -0
  308. package/src/theme/__tests__/theme-document.test.ts +222 -0
  309. package/src/theme/__tests__/theme-registry.test.ts +138 -0
  310. package/src/theme/__tests__/theme-styles.test.ts +233 -0
  311. package/src/theme/built-in-themes.ts +342 -0
  312. package/src/theme/color-vision.ts +171 -0
  313. package/src/theme/index.ts +45 -0
  314. package/src/theme/theme-context.tsx +81 -0
  315. package/src/theme/theme-contracts.ts +139 -0
  316. package/src/theme/theme-document.ts +278 -0
  317. package/src/theme/theme-registry.ts +127 -0
  318. package/src/theme/theme-styles.ts +177 -0
  319. package/src/tool-summary-status.ts +59 -0
  320. package/src/tui-app-channel-port.ts +111 -0
  321. package/src/tui-channel-lifecycle-coordinator.ts +149 -0
  322. package/src/tui-channel-options.ts +184 -0
  323. package/src/tui-cli-adapter-context.tsx +13 -0
  324. package/src/tui-cli-adapter.ts +24 -0
  325. package/src/tui-interaction-queues.ts +171 -0
  326. package/src/tui-session-binding.ts +52 -0
  327. package/src/tui-session-event-projector.ts +149 -0
  328. package/src/tui-session-events.ts +89 -0
  329. package/src/tui-session-notice-store.ts +43 -0
  330. package/src/tui-session-options.ts +134 -0
  331. package/src/tui-state-manager.ts +347 -0
  332. package/src/types.ts +21 -0
  333. package/src/use-terminal-title.ts +29 -0
  334. package/src/utils/__tests__/edit-diff.test.ts +426 -0
  335. package/src/utils/__tests__/paste-detection.test.ts +116 -0
  336. package/src/utils/__tests__/paste-labels.test.ts +46 -0
  337. package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
  338. package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
  339. package/src/utils/edit-diff.ts +151 -0
  340. package/src/utils/input-top-border.ts +19 -0
  341. package/src/utils/paste-labels.ts +9 -0
  342. package/src/utils/tool-call-extractor.ts +92 -0
  343. package/src/utils/tool-diff-summary.ts +75 -0
  344. package/src/waiting-loop-stop-notice.ts +23 -0
  345. package/src/wire-history-sync.ts +159 -0
  346. package/src/wire-tui-channel.ts +963 -0
  347. package/src/wire-tui-client-commands.ts +142 -0
  348. package/src/wire-tui-projection.ts +136 -0
@@ -0,0 +1,872 @@
1
+ import { Box, render, useApp, useInput, useStdout } from 'ink';
2
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
3
+ import stringWidth from 'string-width';
4
+
5
+ import { useNumberedSelection } from './hooks/useNumberedSelection.js';
6
+ import { formatNumberedSelectionPrompt, numberedRowPrefix } from './numbered-list.js';
7
+ import { Text } from './SafeText.js';
8
+ import {
9
+ writeScreenReaderAnnouncement,
10
+ type TScreenReaderChannel,
11
+ } from './screen-reader-announcement.js';
12
+ import { ScreenReaderProvider, useScreenReader } from './screen-reader-context.js';
13
+
14
+ export interface ISupervisedViewRow {
15
+ readonly id: string;
16
+ readonly liveness: 'alive' | 'dead' | 'unknown';
17
+ readonly control: 'available' | 'unavailable';
18
+ readonly activity: 'working' | 'needs-input' | 'idle' | 'unknown';
19
+ readonly nextLoopAt?: string;
20
+ readonly name?: string;
21
+ readonly cwd?: string;
22
+ readonly pr?: {
23
+ readonly url: string;
24
+ readonly host: string;
25
+ readonly number: number;
26
+ readonly kind: 'pull' | 'merge-request';
27
+ };
28
+ readonly problem?: 'invalid-registration';
29
+ /** The verified registration generation; a row without one never offers an action. Never rendered. */
30
+ readonly generation?: string;
31
+ }
32
+
33
+ /** An attach the user confirmed in the view, bound to the process start the row showed. */
34
+ export interface ISupervisedAttachRequest {
35
+ readonly id: string;
36
+ readonly generation: string;
37
+ readonly mode: 'drive' | 'observe';
38
+ /** How the view was grouped, so it can reopen the same way after the attach. */
39
+ readonly groupByDirectory: boolean;
40
+ }
41
+
42
+ /** How the view ended: closed, or handed to the host to attach and then come back. */
43
+ export type TSupervisedViewExit =
44
+ | { readonly kind: 'closed' }
45
+ | ({ readonly kind: 'attach' } & ISupervisedAttachRequest);
46
+
47
+ const ROLE: Readonly<Record<ISupervisedAttachRequest['mode'], string>> = {
48
+ drive: 'drive (send prompts, answer its questions)',
49
+ observe: 'observe (read only)',
50
+ };
51
+
52
+ /**
53
+ * How a new session starts in a folder that is not trusted: trusted first (`trust` records the grant),
54
+ * or `restricted`, without the project's own configuration. A person chose it in the view.
55
+ */
56
+ export type TSupervisedStartTrustChoice = 'trust' | 'restricted';
57
+
58
+ /** What the person is asked about: the folder a new session would start in, and what trust loads. */
59
+ export interface ISupervisedStartTrustQuestion {
60
+ readonly folder: string;
61
+ /** What trusting it would load, one line each. */
62
+ readonly loads: readonly string[];
63
+ }
64
+
65
+ export interface ISupervisedSessionViewProps {
66
+ readonly loadRows: (signal: AbortSignal) => Promise<readonly ISupervisedViewRow[]>;
67
+ /** Called once the user confirmed; the view then closes so the host can attach this terminal. */
68
+ readonly onAttach?: (request: ISupervisedAttachRequest) => void;
69
+ /** Receives the generation the row displayed, so a session restarted under the same id is refused. */
70
+ readonly onStop?: (id: string, generation: string) => Promise<void>;
71
+ readonly onStart?: (choice?: TSupervisedStartTrustChoice) => Promise<string>;
72
+ /** The question to ask when the folder a new session starts in is not trusted yet; none otherwise. */
73
+ readonly startTrustQuestion?: () => Promise<ISupervisedStartTrustQuestion | undefined>;
74
+ /** Product-owned command shown when a long trust question is shortened. */
75
+ readonly trustStatusCommand?: string;
76
+ readonly onOpenPr?: (id: string, url: string, generation: string) => Promise<void>;
77
+ readonly filteredByCwd?: boolean;
78
+ readonly filteredByName?: boolean;
79
+ readonly filteredByPr?: boolean;
80
+ readonly stateFilter?: TGroup;
81
+ readonly refreshMs?: number;
82
+ /** The row to select first, when it is still listed — the one an attach left from. */
83
+ readonly initialSelectedId?: string;
84
+ readonly initialGroupByDirectory?: boolean;
85
+ }
86
+
87
+ const GROUP_ORDER = ['needs-input', 'working', 'idle', 'unknown', 'unverified', 'dead'] as const;
88
+
89
+ const TRUST_CHOICES = 'y Trust and start / r Start Restricted / n Cancel';
90
+ const TRUST_SUMMARY =
91
+ "Trusting it loads the project's own settings, hooks, plugins, skills and MCP servers:";
92
+
93
+ /** `lines` in at most `room` lines, the last one saying how many more there are. */
94
+ function fitLines(lines: readonly string[], room: number, trustStatusCommand?: string): readonly string[] {
95
+ if (lines.length <= room) return lines;
96
+ const shown = Math.max(1, room - 1);
97
+ const hint = trustStatusCommand === undefined
98
+ ? 'check workspace trust in the terminal'
99
+ : `see ${trustStatusCommand}`;
100
+ return [...lines.slice(0, shown), `… ${lines.length - shown} more — ${hint}`];
101
+ }
102
+ type TGroup = (typeof GROUP_ORDER)[number];
103
+ type TDisplayLine =
104
+ | { readonly kind: 'group'; readonly label: string }
105
+ | { readonly kind: 'row'; readonly row: ISupervisedViewRow; readonly index: number };
106
+
107
+ function isControllable(
108
+ row: ISupervisedViewRow | undefined,
109
+ ): row is ISupervisedViewRow & { readonly generation: string } {
110
+ return (
111
+ row?.liveness === 'alive' && row.control === 'available' && row.generation !== undefined
112
+ );
113
+ }
114
+
115
+ function groupOf(row: ISupervisedViewRow): TGroup {
116
+ if (row.liveness === 'dead') return 'dead';
117
+ if (!isControllable(row)) return 'unverified';
118
+ return row.activity;
119
+ }
120
+
121
+ function sortedRows(rows: readonly ISupervisedViewRow[]): readonly ISupervisedViewRow[] {
122
+ return [...rows].sort(
123
+ (a, b) =>
124
+ GROUP_ORDER.indexOf(groupOf(a)) - GROUP_ORDER.indexOf(groupOf(b)) || a.id.localeCompare(b.id),
125
+ );
126
+ }
127
+
128
+ function sortedDirectoryRows(rows: readonly ISupervisedViewRow[]): readonly ISupervisedViewRow[] {
129
+ return [...rows].sort(
130
+ (a, b) =>
131
+ (a.cwd === undefined ? 1 : 0) - (b.cwd === undefined ? 1 : 0) ||
132
+ (a.cwd ?? '').localeCompare(b.cwd ?? '') ||
133
+ GROUP_ORDER.indexOf(groupOf(a)) - GROUP_ORDER.indexOf(groupOf(b)) ||
134
+ a.id.localeCompare(b.id),
135
+ );
136
+ }
137
+
138
+ function uniqueDirectorySuffix(
139
+ cwd: string,
140
+ directories: readonly string[],
141
+ budget: number,
142
+ ): string {
143
+ if (cwd === '/') return cwd;
144
+ let longestSharedSuffix = 0;
145
+ for (const other of directories) {
146
+ if (other === cwd) continue;
147
+ let shared = 0;
148
+ while (
149
+ shared < cwd.length &&
150
+ shared < other.length &&
151
+ cwd[cwd.length - shared - 1] === other[other.length - shared - 1]
152
+ )
153
+ shared++;
154
+ longestSharedSuffix = Math.max(longestSharedSuffix, shared);
155
+ }
156
+ const basenameLength = cwd.split(/[\\/]/u).filter(Boolean).at(-1)?.length ?? cwd.length;
157
+ const uniqueLength = Math.min(cwd.length, Math.max(basenameLength, longestSharedSuffix + 3));
158
+ const suffixStart = cwd.length - uniqueLength;
159
+ const boundary = Math.max(
160
+ cwd.lastIndexOf('/', suffixStart - 1),
161
+ cwd.lastIndexOf('\\', suffixStart - 1),
162
+ );
163
+ const componentSuffix = cwd.slice(boundary + 1);
164
+ return stringWidth(displayPath(componentSuffix)) <= budget
165
+ ? componentSuffix
166
+ : cwd.slice(suffixStart);
167
+ }
168
+
169
+ function pathTokens(path: string): readonly string[] {
170
+ return Array.from(path, (character) =>
171
+ /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(character)
172
+ ? `\\u{${character.codePointAt(0)!.toString(16)}}`
173
+ : character,
174
+ );
175
+ }
176
+
177
+ function displayPath(path: string): string {
178
+ return pathTokens(path).join('');
179
+ }
180
+
181
+ function compactPath(path: string, budget: number): string {
182
+ const safe = pathTokens(path);
183
+ if (stringWidth(safe.join('')) <= budget) return safe.join('');
184
+ const headBudget = Math.ceil((budget - 1) / 2);
185
+ const tailBudget = budget - headBudget - 1;
186
+ let head = '';
187
+ for (const token of safe) {
188
+ if (stringWidth(head) + stringWidth(token) > headBudget) break;
189
+ head += token;
190
+ }
191
+ let tail = '';
192
+ for (const token of [...safe].reverse()) {
193
+ if (stringWidth(tail) + stringWidth(token) > tailBudget) break;
194
+ tail = token + tail;
195
+ }
196
+ return `${head}…${tail}`;
197
+ }
198
+
199
+ function sameRows(a: readonly ISupervisedViewRow[], b: readonly ISupervisedViewRow[]): boolean {
200
+ return (
201
+ a.length === b.length &&
202
+ a.every((row, index) => {
203
+ const other = b[index];
204
+ return (
205
+ other !== undefined &&
206
+ row.id === other.id &&
207
+ row.liveness === other.liveness &&
208
+ row.control === other.control &&
209
+ row.activity === other.activity &&
210
+ row.problem === other.problem &&
211
+ row.nextLoopAt === other.nextLoopAt &&
212
+ row.name === other.name &&
213
+ row.cwd === other.cwd &&
214
+ row.pr?.url === other.pr?.url &&
215
+ row.generation === other.generation
216
+ );
217
+ })
218
+ );
219
+ }
220
+
221
+ function loopWaitLabel(nextLoopAt: string, observedAtMs: number): string {
222
+ const remaining = Date.parse(nextLoopAt) - observedAtMs;
223
+ if (!Number.isFinite(remaining)) return '';
224
+ if (remaining <= 0) return 'loop eligible now';
225
+ return remaining < 60_000
226
+ ? 'loop eligible in <1m'
227
+ : `loop eligible in ${Math.ceil(remaining / 60_000)}m`;
228
+ }
229
+
230
+ export default function SupervisedSessionView({
231
+ loadRows,
232
+ onStop,
233
+ onStart,
234
+ startTrustQuestion,
235
+ trustStatusCommand,
236
+ onOpenPr,
237
+ onAttach,
238
+ filteredByCwd = false,
239
+ filteredByName = false,
240
+ filteredByPr = false,
241
+ stateFilter,
242
+ refreshMs = 2_000,
243
+ initialSelectedId,
244
+ initialGroupByDirectory = false,
245
+ }: ISupervisedSessionViewProps): React.ReactElement {
246
+ const { exit } = useApp();
247
+ const { stdout } = useStdout();
248
+ const columns = Math.max(12, stdout.columns ?? 80);
249
+ const screenReader = useScreenReader();
250
+ const [rows, setRows] = useState<readonly ISupervisedViewRow[]>([]);
251
+ const [observedAtMs, setObservedAtMs] = useState(Date.now);
252
+ const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable'>('loading');
253
+ const [chosenId, setSelectedId] = useState<string | undefined>(initialSelectedId);
254
+ const [showHelp, setShowHelp] = useState(false);
255
+ const [groupByDirectory, setGroupByDirectory] = useState(initialGroupByDirectory);
256
+ // One confirmation at a time, for the one registration the row showed when the key was pressed.
257
+ const [confirmStop, setConfirmStop] = useState<
258
+ | {
259
+ readonly id: string;
260
+ readonly generation: string;
261
+ readonly action: 'stop' | 'attach';
262
+ readonly mode?: ISupervisedAttachRequest['mode'];
263
+ }
264
+ | undefined
265
+ >();
266
+ const confirmStopId = confirmStop?.id;
267
+ const [attachStatus, setAttachStatus] = useState<'idle' | 'unavailable'>('idle');
268
+ const [stopStatus, setStopStatus] = useState<
269
+ 'idle' | 'unavailable' | 'stopping' | 'stopped' | 'failed'
270
+ >('idle');
271
+ const [lastStoppedId, setLastStoppedId] = useState<string | undefined>();
272
+ const stoppingRef = useRef(false);
273
+ const [startStatus, setStartStatus] = useState<'idle' | 'starting' | 'started' | 'failed'>(
274
+ 'idle',
275
+ );
276
+ const [lastStartedId, setLastStartedId] = useState<string | undefined>();
277
+ const [trustQuestion, setTrustQuestion] = useState<ISupervisedStartTrustQuestion | undefined>();
278
+ const startingRef = useRef(false);
279
+ const [prOpenStatus, setPrOpenStatus] = useState<
280
+ 'idle' | 'opening' | 'opened' | 'failed' | 'unavailable'
281
+ >('idle');
282
+ const openingPrRef = useRef(false);
283
+ const mountedRef = useRef(true);
284
+ const runStart = (choice?: TSupervisedStartTrustChoice): void => {
285
+ if (onStart === undefined) return;
286
+ startingRef.current = true;
287
+ setStartStatus('starting');
288
+ void Promise.resolve()
289
+ .then(() => onStart(choice))
290
+ .then((id) => {
291
+ if (!mountedRef.current) return;
292
+ setLastStartedId(id);
293
+ setStartStatus('started');
294
+ })
295
+ .catch(() => {
296
+ // A launch error can carry private paths, so the view shows none of it.
297
+ if (mountedRef.current) setStartStatus('failed');
298
+ })
299
+ .finally(() => {
300
+ startingRef.current = false;
301
+ });
302
+ };
303
+ const ordered = useMemo(() => {
304
+ const filtered = rows.filter(
305
+ (row) => stateFilter === undefined || groupOf(row) === stateFilter,
306
+ );
307
+ return groupByDirectory ? sortedDirectoryRows(filtered) : sortedRows(filtered);
308
+ }, [rows, stateFilter, groupByDirectory]);
309
+ const displayLines = useMemo((): readonly TDisplayLine[] => {
310
+ const lines: TDisplayLine[] = [];
311
+ const directories = [
312
+ ...new Set(ordered.map((row) => row.cwd).filter((cwd): cwd is string => cwd !== undefined)),
313
+ ];
314
+ let previousGroup: string | undefined;
315
+ let directoryNumber = 0;
316
+ ordered.forEach((row, index) => {
317
+ const group = groupByDirectory ? (row.cwd ?? 'unverified') : groupOf(row);
318
+ if (group !== previousGroup) {
319
+ const label = groupByDirectory
320
+ ? row.cwd === undefined
321
+ ? 'Directory: unverified'
322
+ : (() => {
323
+ const prefix = `Dir ${++directoryNumber}: `;
324
+ const budget = Math.max(4, columns - prefix.length);
325
+ const suffix = compactPath(
326
+ uniqueDirectorySuffix(row.cwd, directories, budget),
327
+ budget,
328
+ );
329
+ const full = `${prefix}${suffix} — ${displayPath(row.cwd)}`;
330
+ return screenReader || stringWidth(full) <= columns ? full : `${prefix}${suffix}`;
331
+ })()
332
+ : `${group}:`;
333
+ lines.push({ kind: 'group', label });
334
+ }
335
+ lines.push({ kind: 'row', row, index });
336
+ previousGroup = group;
337
+ });
338
+ return lines;
339
+ }, [ordered, groupByDirectory, columns, screenReader]);
340
+
341
+ useEffect(() => {
342
+ const controller = new AbortController();
343
+ let mounted = true;
344
+ let timer: ReturnType<typeof setTimeout> | undefined;
345
+ const poll = async (): Promise<void> => {
346
+ try {
347
+ const next = sortedRows(await loadRows(controller.signal));
348
+ if (!mounted || controller.signal.aborted) return;
349
+ const nowMs = Date.now();
350
+ setRows((previous) => (sameRows(previous, next) ? previous : next));
351
+ setObservedAtMs((previous) =>
352
+ next.some(
353
+ (row) =>
354
+ row.nextLoopAt !== undefined &&
355
+ loopWaitLabel(row.nextLoopAt, previous) !== loopWaitLabel(row.nextLoopAt, nowMs),
356
+ )
357
+ ? nowMs
358
+ : previous,
359
+ );
360
+ setStatus('ready');
361
+ } catch {
362
+ if (mounted && !controller.signal.aborted) setStatus('unavailable');
363
+ } finally {
364
+ if (mounted && !controller.signal.aborted)
365
+ timer = setTimeout(() => {
366
+ void poll();
367
+ }, refreshMs);
368
+ }
369
+ };
370
+ void poll();
371
+ return () => {
372
+ mounted = false;
373
+ if (timer !== undefined) clearTimeout(timer);
374
+ controller.abort();
375
+ };
376
+ }, [loadRows, refreshMs]);
377
+
378
+ useEffect(() => {
379
+ mountedRef.current = true;
380
+ return () => {
381
+ mountedRef.current = false;
382
+ };
383
+ }, []);
384
+
385
+ // Derived during render, so no frame ever shows loaded rows without a selection. The effect then
386
+ // records the fallback with a functional update, so it never overwrites a choice made meanwhile,
387
+ // and the selected row stays selected when regrouping reorders the list.
388
+ const selectedId =
389
+ chosenId !== undefined && ordered.some((row) => row.id === chosenId)
390
+ ? chosenId
391
+ : ordered[0]?.id;
392
+ useEffect(() => {
393
+ // Before the first rows arrive there is nothing to fall back to; the choice waits for them.
394
+ setSelectedId((current) =>
395
+ ordered.length === 0 ||
396
+ (current !== undefined && ordered.some((row) => row.id === current))
397
+ ? current
398
+ : ordered[0]?.id,
399
+ );
400
+ }, [ordered]);
401
+
402
+ useInput((input, key) => {
403
+ if (stoppingRef.current) return;
404
+ if (
405
+ openingPrRef.current &&
406
+ input !== 'q' &&
407
+ !(!screenReader && key.escape) &&
408
+ !(key.ctrl && input === 'c')
409
+ )
410
+ return;
411
+ if (
412
+ startingRef.current &&
413
+ input !== 'q' &&
414
+ !(!screenReader && key.escape) &&
415
+ !(key.ctrl && input === 'c')
416
+ )
417
+ return;
418
+ if (trustQuestion !== undefined) {
419
+ if (input === 'y' || input === 'r') {
420
+ setTrustQuestion(undefined);
421
+ runStart(input === 'y' ? 'trust' : 'restricted');
422
+ } else if (input === 'n' || key.escape) {
423
+ setTrustQuestion(undefined);
424
+ }
425
+ return;
426
+ }
427
+ if (confirmStopId !== undefined) {
428
+ if (input === 'n' || key.escape) {
429
+ setConfirmStop(undefined);
430
+ return;
431
+ }
432
+ if (input === 'y' && confirmStop?.action === 'attach') {
433
+ const target = confirmStop;
434
+ const row = ordered.find((candidate) => candidate.id === target.id);
435
+ setConfirmStop(undefined);
436
+ if (
437
+ status !== 'ready' ||
438
+ !isControllable(row) ||
439
+ row.generation !== target.generation ||
440
+ target.mode === undefined ||
441
+ onAttach === undefined
442
+ ) {
443
+ setAttachStatus('unavailable');
444
+ return;
445
+ }
446
+ onAttach({ id: target.id, generation: target.generation, mode: target.mode, groupByDirectory });
447
+ exit();
448
+ return;
449
+ }
450
+ if (input === 'y') {
451
+ const target = confirmStop;
452
+ const row = ordered.find((candidate) => candidate.id === target?.id);
453
+ setConfirmStop(undefined);
454
+ // The confirmation names one registration; a restart under the same id is a different session.
455
+ if (
456
+ status !== 'ready' ||
457
+ target === undefined ||
458
+ !isControllable(row) ||
459
+ row.generation !== target.generation ||
460
+ onStop === undefined
461
+ ) {
462
+ setStopStatus('unavailable');
463
+ return;
464
+ }
465
+ stoppingRef.current = true;
466
+ setStopStatus('stopping');
467
+ void onStop(target.id, target.generation)
468
+ .then(() => {
469
+ if (!mountedRef.current) return;
470
+ setLastStoppedId(target.id);
471
+ setStopStatus('stopped');
472
+ })
473
+ .catch(() => {
474
+ if (mountedRef.current) setStopStatus('failed');
475
+ })
476
+ .finally(() => {
477
+ stoppingRef.current = false;
478
+ });
479
+ }
480
+ return;
481
+ }
482
+ if (input === 'q' || (!screenReader && key.escape) || (key.ctrl && input === 'c')) {
483
+ exit();
484
+ return;
485
+ }
486
+ if (input === '?') {
487
+ setShowHelp((value) => !value);
488
+ return;
489
+ }
490
+ if (input === 'g') {
491
+ numbered.clear();
492
+ setGroupByDirectory((value) => !value);
493
+ return;
494
+ }
495
+ if (input === 'n' && onStart !== undefined) {
496
+ if (startTrustQuestion === undefined) {
497
+ runStart();
498
+ return;
499
+ }
500
+ // A folder not trusted yet is asked about first: the person chooses trust or Restricted.
501
+ startingRef.current = true;
502
+ void startTrustQuestion()
503
+ .then((question) => {
504
+ startingRef.current = false;
505
+ if (!mountedRef.current) return;
506
+ if (question !== undefined) setTrustQuestion(question);
507
+ else runStart();
508
+ })
509
+ .catch(() => {
510
+ startingRef.current = false;
511
+ if (mountedRef.current) setStartStatus('failed');
512
+ });
513
+ return;
514
+ }
515
+ if ((input === 'a' || input === 'p') && onAttach !== undefined) {
516
+ const row = ordered.find((candidate) => candidate.id === selectedId);
517
+ if (status !== 'ready' || !isControllable(row)) {
518
+ setAttachStatus('unavailable');
519
+ } else {
520
+ setAttachStatus('idle');
521
+ setStopStatus('idle');
522
+ setConfirmStop({
523
+ id: row.id,
524
+ generation: row.generation,
525
+ action: 'attach',
526
+ mode: input === 'a' ? 'drive' : 'observe',
527
+ });
528
+ }
529
+ return;
530
+ }
531
+ if (input === 'o' && onOpenPr !== undefined) {
532
+ const row = ordered.find((candidate) => candidate.id === selectedId);
533
+ if (status !== 'ready' || !isControllable(row) || !row.pr) {
534
+ setPrOpenStatus('unavailable');
535
+ return;
536
+ }
537
+ openingPrRef.current = true;
538
+ setPrOpenStatus('opening');
539
+ void onOpenPr(row.id, row.pr.url, row.generation)
540
+ .then(() => {
541
+ if (mountedRef.current) setPrOpenStatus('opened');
542
+ })
543
+ .catch(() => {
544
+ if (mountedRef.current) setPrOpenStatus('failed');
545
+ })
546
+ .finally(() => {
547
+ openingPrRef.current = false;
548
+ });
549
+ return;
550
+ }
551
+ if (input === 's' && onStop !== undefined) {
552
+ const row = ordered.find((candidate) => candidate.id === selectedId);
553
+ if (status !== 'ready' || !isControllable(row)) {
554
+ setStopStatus('unavailable');
555
+ } else {
556
+ setStopStatus('idle');
557
+ setAttachStatus('idle');
558
+ setConfirmStop({ id: row.id, generation: row.generation, action: 'stop' });
559
+ }
560
+ return;
561
+ }
562
+ if (screenReader || ordered.length === 0) return;
563
+ if (key.upArrow || key.downArrow) {
564
+ const index = Math.max(
565
+ 0,
566
+ ordered.findIndex((row) => row.id === selectedId),
567
+ );
568
+ const next = Math.max(0, Math.min(ordered.length - 1, index + (key.downArrow ? 1 : -1)));
569
+ setSelectedId(ordered[next]?.id);
570
+ }
571
+ });
572
+
573
+ const numbered = useNumberedSelection({
574
+ enabled:
575
+ screenReader &&
576
+ confirmStopId === undefined &&
577
+ trustQuestion === undefined &&
578
+ stopStatus !== 'stopping',
579
+ itemCount: ordered.length,
580
+ cancellable: true,
581
+ repeatable: true,
582
+ onSelect: (index) => {
583
+ setSelectedId(ordered[index]?.id);
584
+ setStopStatus('idle');
585
+ },
586
+ onCancel: () => {
587
+ if (!stoppingRef.current) exit();
588
+ },
589
+ });
590
+
591
+ const selectedRow = ordered.find((row) => row.id === selectedId);
592
+ const selectedName =
593
+ status === 'ready' && isControllable(selectedRow) ? selectedRow.name : undefined;
594
+ const selectedLoopStatus =
595
+ status === 'ready' &&
596
+ isControllable(selectedRow) &&
597
+ selectedRow.activity === 'idle' &&
598
+ selectedRow.nextLoopAt
599
+ ? loopWaitLabel(selectedRow.nextLoopAt, observedAtMs)
600
+ : '';
601
+ const selectedPr = status === 'ready' && isControllable(selectedRow) ? selectedRow.pr : undefined;
602
+
603
+ const height = Math.max(8, stdout.rows ?? 24);
604
+ const helpLines = [
605
+ 'Keys:',
606
+ screenReader ? 'Number+Enter Select' : '↑/↓ Select',
607
+ 's Request stop',
608
+ 'g Group state/dir',
609
+ ...(onStart === undefined ? [] : ['n New session']),
610
+ ...(onAttach === undefined ? [] : ['a Attach (drive)', 'p Peek (read only)']),
611
+ ...(onOpenPr === undefined ? [] : ['o Open linked PR']),
612
+ 'y Confirm',
613
+ 'n/Esc Cancel',
614
+ 'q/Esc/Ctrl+C Close',
615
+ '? Toggle help',
616
+ 'Activity ≠ liveness',
617
+ 'Idle ≠ attach-ready',
618
+ 'Close keeps sessions',
619
+ ];
620
+ // Reserve all fixed chrome plus both possible overflow indicators before choosing row lines.
621
+ const helpVisible = showHelp && confirmStopId === undefined && stopStatus !== 'stopping';
622
+ const fixedLines =
623
+ 2 +
624
+ (stateFilter === undefined ? 0 : 1) +
625
+ (status === 'loading' ||
626
+ status === 'unavailable' ||
627
+ (status === 'ready' && ordered.length === 0)
628
+ ? 1
629
+ : 0) +
630
+ (selectedId === undefined ? 0 : 1) +
631
+ (selectedName === undefined ? 0 : 1) +
632
+ (selectedPr === undefined ? 0 : 2) +
633
+ (selectedLoopStatus ? 1 : 0) +
634
+ (prOpenStatus === 'idle' ? 0 : 1) +
635
+ (startStatus === 'idle' ? 0 : 1) +
636
+ (trustQuestion === undefined ? 0 : 1) +
637
+ (confirmStopId !== undefined ? (screenReader ? 1 : 2) : stopStatus !== 'idle' ? 1 : 0) +
638
+ (confirmStopId === undefined && attachStatus !== 'idle' ? 1 : 0) +
639
+ 1 +
640
+ (helpVisible ? helpLines.length : 0) +
641
+ 2;
642
+ const viewport = Math.max(1, height - fixedLines);
643
+ const selectedLine = Math.max(
644
+ 0,
645
+ displayLines.findIndex((line) => line.kind === 'row' && line.row.id === selectedId),
646
+ );
647
+ const start = Math.min(
648
+ Math.max(0, selectedLine - Math.floor(viewport / 2)),
649
+ Math.max(0, displayLines.length - viewport),
650
+ );
651
+ const visible = screenReader ? displayLines : displayLines.slice(start, start + viewport);
652
+ // While the trust question is open it takes the rows' place, fitted to the rows' height, so the
653
+ // folder it names and the choices always show whole.
654
+ const questionLines =
655
+ trustQuestion === undefined
656
+ ? []
657
+ : fitLines(
658
+ [`Not trusted: ${trustQuestion.folder}`, TRUST_SUMMARY, ...trustQuestion.loads],
659
+ screenReader ? Number.POSITIVE_INFINITY : viewport,
660
+ trustStatusCommand,
661
+ );
662
+ const chromeWrap = screenReader ? {} : { wrap: 'truncate-end' as const };
663
+ // Attach and peek are offered only for a row this terminal could actually attach to.
664
+ const attachable = onAttach !== undefined && status === 'ready' && isControllable(selectedRow);
665
+ const footer =
666
+ stopStatus === 'stopping'
667
+ ? 'Stop in progress; wait for result.'
668
+ : trustQuestion !== undefined
669
+ ? 'Answer the trust question or cancel before closing.'
670
+ : confirmStopId !== undefined
671
+ ? `Confirm ${confirmStop?.action === 'attach' ? 'attach' : 'stop'} or cancel before closing.`
672
+ : screenReader
673
+ ? `Type a number and Enter to select; s Stop;${attachable ? ' a Attach; p Peek;' : ''}${onStart ? ' n New;' : ''}${onOpenPr ? ' o Open PR;' : ''} g Group; Escape to close; ? Help.`
674
+ : `↑↓ Navigate s Stop${attachable ? ' a Attach p Peek' : ''}${onStart ? ' n New' : ''}${onOpenPr ? ' o Open PR' : ''} g Group ? Help q/Esc Close`;
675
+ const confirmQuestion = (id: string): string =>
676
+ confirmStop?.action === 'attach' && confirmStop.mode !== undefined
677
+ ? `${confirmStop.mode === 'drive' ? 'Attach to' : 'Peek at'} ${id} to ${ROLE[confirmStop.mode]}?`
678
+ : `Stop ${id}?`;
679
+ return (
680
+ <Box flexDirection="column" {...(screenReader ? {} : { height })}>
681
+ <Text {...chromeWrap}>
682
+ {filteredByCwd
683
+ ? 'Background sessions in selected directory'
684
+ : 'Background sessions across projects'}
685
+ {filteredByName ? ' · name filter active' : ''}
686
+ {filteredByPr ? ' · PR filter active' : ''}
687
+ </Text>
688
+ <Text {...chromeWrap}>
689
+ {ordered.length} supervised session(s). Foreground peers and saved records are separate.
690
+ </Text>
691
+ {stateFilter !== undefined && <Text {...chromeWrap}>State: {stateFilter}</Text>}
692
+ {status === 'loading' && <Text {...chromeWrap}>Loading supervised sessions...</Text>}
693
+ {status === 'unavailable' && (
694
+ <Text {...chromeWrap}>
695
+ Supervised session discovery unavailable; last verified rows remain below.
696
+ </Text>
697
+ )}
698
+ {status === 'ready' && ordered.length === 0 && (
699
+ <Text {...chromeWrap}>No supervised sessions.</Text>
700
+ )}
701
+ {questionLines.map((line, index) => (
702
+ <Text key={`trust-${index}`} {...chromeWrap}>
703
+ {line}
704
+ </Text>
705
+ ))}
706
+ {trustQuestion === undefined && start > 0 && !screenReader && (
707
+ <Text>{start} more above</Text>
708
+ )}
709
+ {(trustQuestion === undefined ? visible : []).map((line) =>
710
+ line.kind === 'group' ? (
711
+ <Text key={`group-${line.label}`} {...chromeWrap}>
712
+ {line.label}
713
+ </Text>
714
+ ) : (
715
+ <Text
716
+ key={`row-${line.row.id}`}
717
+ {...(screenReader ? {} : { wrap: 'truncate-end' as const })}
718
+ >
719
+ {screenReader
720
+ ? numberedRowPrefix(line.index)
721
+ : line.row.id === selectedId
722
+ ? '> '
723
+ : ' '}
724
+ {line.row.name && isControllable(line.row)
725
+ ? screenReader
726
+ ? `${line.row.name} (${line.row.id})`
727
+ : `${Array.from(line.row.name).slice(0, 24).join('')}${Array.from(line.row.name).length > 24 ? '…' : ''} [${line.row.id.slice(-8)}]`
728
+ : line.row.id}{' '}
729
+ activity {line.row.activity} liveness {line.row.liveness} control {line.row.control}
730
+ {status === 'ready' &&
731
+ isControllable(line.row) &&
732
+ line.row.activity === 'idle' &&
733
+ line.row.nextLoopAt
734
+ ? ` ${loopWaitLabel(line.row.nextLoopAt, observedAtMs)}`
735
+ : ''}
736
+ {line.row.problem ? ` ${line.row.problem}` : ''}
737
+ {status === 'ready' && isControllable(line.row) && line.row.pr
738
+ ? ` ${line.row.pr.host} ${line.row.pr.kind === 'pull' ? '#' : '!'}${line.row.pr.number}`
739
+ : ''}
740
+ </Text>
741
+ ),
742
+ )}
743
+ {trustQuestion === undefined &&
744
+ !screenReader &&
745
+ start + visible.length < displayLines.length && (
746
+ <Text>{displayLines.length - start - visible.length} more below</Text>
747
+ )}
748
+ {selectedId !== undefined && <Text {...chromeWrap}>Selected {selectedId}</Text>}
749
+ {selectedName !== undefined && <Text {...chromeWrap}>Name: {selectedName}</Text>}
750
+ {selectedPr !== undefined && (
751
+ <Text {...chromeWrap}>
752
+ PR: {selectedPr.host} {selectedPr.kind === 'pull' ? '#' : '!'}
753
+ {selectedPr.number}
754
+ </Text>
755
+ )}
756
+ {selectedPr !== undefined && <Text {...chromeWrap}>URL: {selectedPr.url}</Text>}
757
+ {selectedLoopStatus && <Text {...chromeWrap}>{selectedLoopStatus}</Text>}
758
+ {prOpenStatus === 'opening' && <Text {...chromeWrap}>Opening linked PR...</Text>}
759
+ {prOpenStatus === 'opened' && <Text {...chromeWrap}>Opened linked PR.</Text>}
760
+ {prOpenStatus === 'failed' && (
761
+ <Text {...chromeWrap}>PR link changed or could not be opened.</Text>
762
+ )}
763
+ {prOpenStatus === 'unavailable' && (
764
+ <Text {...chromeWrap}>No verified PR link is available.</Text>
765
+ )}
766
+ {startStatus === 'starting' && <Text {...chromeWrap}>Starting a background session...</Text>}
767
+ {startStatus === 'started' && (
768
+ <Text {...chromeWrap}>
769
+ Started {lastStartedId}
770
+ {filteredByName || stateFilter !== undefined ? ' (may be hidden by filter)' : ''}
771
+ </Text>
772
+ )}
773
+ {startStatus === 'failed' && (
774
+ <Text {...chromeWrap}>
775
+ Start failed; check workspace trust or run session start --background for details.
776
+ </Text>
777
+ )}
778
+ {trustQuestion !== undefined && <Text {...chromeWrap}>{TRUST_CHOICES}</Text>}
779
+ {confirmStopId !== undefined &&
780
+ (screenReader ? (
781
+ <Text>{confirmQuestion(confirmStopId)} y Yes / n No</Text>
782
+ ) : (
783
+ <>
784
+ <Text {...chromeWrap}>{confirmQuestion(`…${confirmStopId.slice(-8)}`)}</Text>
785
+ <Text {...chromeWrap}>y Yes / n No</Text>
786
+ </>
787
+ ))}
788
+ {confirmStopId === undefined && stopStatus === 'unavailable' && (
789
+ <Text {...chromeWrap}>This session cannot be stopped from the view.</Text>
790
+ )}
791
+ {confirmStopId === undefined && attachStatus === 'unavailable' && (
792
+ <Text {...chromeWrap}>This session cannot be attached to from the view.</Text>
793
+ )}
794
+ {stopStatus === 'stopping' && (
795
+ <Text {...chromeWrap}>Stopping selected session; wait for confirmation.</Text>
796
+ )}
797
+ {stopStatus === 'stopped' && <Text {...chromeWrap}>Stopped {lastStoppedId}</Text>}
798
+ {stopStatus === 'failed' && (
799
+ <Text {...chromeWrap}>Stop failed; session remains listed until verified otherwise.</Text>
800
+ )}
801
+ {screenReader &&
802
+ ordered.length > 0 &&
803
+ confirmStopId === undefined &&
804
+ stopStatus !== 'stopping' && (
805
+ <Text>
806
+ {formatNumberedSelectionPrompt(ordered.length, true)}
807
+ {numbered.buffer ? ` ${numbered.buffer}` : ''}
808
+ </Text>
809
+ )}
810
+ {screenReader && numbered.invalid && <Text>Selection out of range.</Text>}
811
+ <Text {...chromeWrap}>{footer}</Text>
812
+ {helpVisible &&
813
+ helpLines.map((line) => (
814
+ <Text key={line} {...chromeWrap}>
815
+ {line}
816
+ </Text>
817
+ ))}
818
+ </Box>
819
+ );
820
+ }
821
+
822
+ export async function renderSupervisedSessionView(
823
+ options: ISupervisedSessionViewProps & {
824
+ readonly screenReader: boolean;
825
+ readonly screenReaderChannel?: TScreenReaderChannel;
826
+ readonly screenReaderHint?: boolean;
827
+ /** Print the screen-reader line; false when this process already printed it. */
828
+ readonly announce?: boolean;
829
+ },
830
+ ): Promise<TSupervisedViewExit> {
831
+ if (options.announce !== false) {
832
+ writeScreenReaderAnnouncement({
833
+ enabled: options.screenReader,
834
+ channel: options.screenReaderChannel,
835
+ hint: options.screenReaderHint,
836
+ });
837
+ }
838
+ let attach: ISupervisedAttachRequest | undefined;
839
+ // The attached App pauses stdin when it unmounts. A view reopened after detach must resume it
840
+ // before Ink subscribes again, otherwise the view paints but ignores q/Escape and other keys.
841
+ process.stdin.resume();
842
+ const instance = render(
843
+ <ScreenReaderProvider enabled={options.screenReader}>
844
+ <SupervisedSessionView
845
+ loadRows={options.loadRows}
846
+ onStop={options.onStop}
847
+ onStart={options.onStart}
848
+ startTrustQuestion={options.startTrustQuestion}
849
+ onOpenPr={options.onOpenPr}
850
+ onAttach={(request) => {
851
+ attach = request;
852
+ }}
853
+ filteredByCwd={options.filteredByCwd}
854
+ filteredByName={options.filteredByName}
855
+ filteredByPr={options.filteredByPr}
856
+ stateFilter={options.stateFilter}
857
+ refreshMs={options.refreshMs}
858
+ {...(options.initialSelectedId !== undefined ? { initialSelectedId: options.initialSelectedId } : {})}
859
+ {...(options.initialGroupByDirectory !== undefined
860
+ ? { initialGroupByDirectory: options.initialGroupByDirectory }
861
+ : {})}
862
+ />
863
+ </ScreenReaderProvider>,
864
+ { isScreenReaderEnabled: options.screenReader, exitOnCtrlC: false },
865
+ );
866
+ try {
867
+ await instance.waitUntilExit();
868
+ } finally {
869
+ instance.unmount();
870
+ }
871
+ return attach === undefined ? { kind: 'closed' } : { kind: 'attach', ...attach };
872
+ }