@robota-sdk/agent-ui-terminal 3.0.0-beta.81

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 (328) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +27 -0
  3. package/dist/node/index.cjs +32 -0
  4. package/dist/node/index.d.cts +1066 -0
  5. package/dist/node/index.d.cts.map +1 -0
  6. package/dist/node/index.d.ts +1066 -0
  7. package/dist/node/index.d.ts.map +1 -0
  8. package/dist/node/index.js +33 -0
  9. package/dist/node/index.js.map +1 -0
  10. package/package.json +93 -0
  11. package/src/App.tsx +146 -0
  12. package/src/AppPresentation.tsx +190 -0
  13. package/src/AppView.tsx +26 -0
  14. package/src/BackgroundTaskPanel.tsx +73 -0
  15. package/src/CjkTextInput.tsx +336 -0
  16. package/src/ConfirmPrompt.tsx +140 -0
  17. package/src/ContextWarningBanner.tsx +45 -0
  18. package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
  19. package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
  20. package/src/HistorySearchOverlay.tsx +150 -0
  21. package/src/InputArea.tsx +338 -0
  22. package/src/ListPicker.tsx +158 -0
  23. package/src/MenuSelect.tsx +159 -0
  24. package/src/MessageList.tsx +308 -0
  25. package/src/MultiSelectList.tsx +294 -0
  26. package/src/PendingActionPrompt.tsx +121 -0
  27. package/src/PermissionPrompt.tsx +186 -0
  28. package/src/PluginTUI.tsx +258 -0
  29. package/src/RoleLabel.tsx +72 -0
  30. package/src/SafeText.tsx +66 -0
  31. package/src/SessionEventNotices.tsx +27 -0
  32. package/src/SessionPicker.tsx +72 -0
  33. package/src/SessionStatusBar.tsx +76 -0
  34. package/src/SlashAutocomplete.tsx +154 -0
  35. package/src/StatusBar.tsx +284 -0
  36. package/src/StreamingIndicator.tsx +145 -0
  37. package/src/SupervisedSessionView.tsx +455 -0
  38. package/src/TextPrompt.tsx +112 -0
  39. package/src/ThemePicker.tsx +366 -0
  40. package/src/ToolCommandOutput.tsx +48 -0
  41. package/src/ToolDiffBlock.tsx +44 -0
  42. package/src/TransportTUI.tsx +166 -0
  43. package/src/TuiInteractionChannel.ts +567 -0
  44. package/src/UpdateNotice.tsx +18 -0
  45. package/src/UsageSummaryEntry.tsx +49 -0
  46. package/src/WaveText.tsx +70 -0
  47. package/src/__tests__/ListPicker.test.tsx +188 -0
  48. package/src/__tests__/MenuSelect.test.tsx +127 -0
  49. package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
  50. package/src/__tests__/PluginTUI.test.tsx +167 -0
  51. package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
  52. package/src/__tests__/SupervisedSessionView-render.test.tsx +24 -0
  53. package/src/__tests__/SupervisedSessionView.test.tsx +629 -0
  54. package/src/__tests__/TextPrompt.test.tsx +112 -0
  55. package/src/__tests__/TransportTUI.test.tsx +124 -0
  56. package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
  57. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +284 -0
  58. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +642 -0
  59. package/src/__tests__/UpdateNotice.test.tsx +15 -0
  60. package/src/__tests__/abort-after-permission.test.tsx +160 -0
  61. package/src/__tests__/abort-streaming-e2e.test.tsx +183 -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__/cjk-defer-submit.test.tsx +67 -0
  69. package/src/__tests__/cjk-fallback-render.test.tsx +78 -0
  70. package/src/__tests__/cjk-text-input-flow.test.ts +186 -0
  71. package/src/__tests__/cjk-text-input.test.ts +191 -0
  72. package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
  73. package/src/__tests__/command-output-summary.test.ts +95 -0
  74. package/src/__tests__/command-result-handler.test.ts +171 -0
  75. package/src/__tests__/compact-event-bridge.test.ts +20 -0
  76. package/src/__tests__/confirm-permission-flow.test.ts +149 -0
  77. package/src/__tests__/confirm-prompt.test.tsx +103 -0
  78. package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
  79. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +45 -0
  80. package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
  81. package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
  82. package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
  83. package/src/__tests__/external-prompt-notice.test.tsx +100 -0
  84. package/src/__tests__/fixtures/command-handoff-driver.tsx +111 -0
  85. package/src/__tests__/fixtures/fake-editor.sh +7 -0
  86. package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
  87. package/src/__tests__/fork-attach.test.tsx +235 -0
  88. package/src/__tests__/helpers/terminal-profiles.ts +152 -0
  89. package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
  90. package/src/__tests__/history-clear-broadcast.test.ts +66 -0
  91. package/src/__tests__/history-search-overlay.test.tsx +329 -0
  92. package/src/__tests__/humanize-tool-name.test.ts +30 -0
  93. package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
  94. package/src/__tests__/input-area-flow.test.ts +199 -0
  95. package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
  96. package/src/__tests__/interaction-channel-charter.test.ts +13 -0
  97. package/src/__tests__/key-hint-consistency.test.tsx +249 -0
  98. package/src/__tests__/key-hint-footer.test.tsx +69 -0
  99. package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
  100. package/src/__tests__/message-list-rendering.test.tsx +473 -0
  101. package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
  102. package/src/__tests__/output-style-reaches-session.test.ts +35 -0
  103. package/src/__tests__/palette-consistency.test.ts +330 -0
  104. package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
  105. package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
  106. package/src/__tests__/product-display-name.test.tsx +79 -0
  107. package/src/__tests__/prompt-queue.test.tsx +260 -0
  108. package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
  109. package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
  110. package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
  111. package/src/__tests__/pty/behavior-2437-git.ptytest.ts +342 -0
  112. package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
  113. package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
  114. package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
  115. package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
  116. package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
  117. package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
  118. package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
  119. package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +291 -0
  120. package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +165 -0
  121. package/src/__tests__/pty/ime-cursor.ptytest.ts +186 -0
  122. package/src/__tests__/pty/isolated-home.ts +64 -0
  123. package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
  124. package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
  125. package/src/__tests__/pty/pty-driver.ts +153 -0
  126. package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
  127. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +134 -0
  128. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +138 -0
  129. package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
  130. package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
  131. package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
  132. package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
  133. package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
  134. package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
  135. package/src/__tests__/pty/spawn-pty.test.ts +120 -0
  136. package/src/__tests__/pty/spawn-pty.ts +229 -0
  137. package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
  138. package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
  139. package/src/__tests__/pty-driver-dispose.test.ts +43 -0
  140. package/src/__tests__/real-cursor-positioning.test.tsx +263 -0
  141. package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
  142. package/src/__tests__/render-channel-options.test.ts +201 -0
  143. package/src/__tests__/render-lifecycle.test.ts +36 -0
  144. package/src/__tests__/render-markdown.test.ts +164 -0
  145. package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
  146. package/src/__tests__/safe-text-boundary.test.tsx +75 -0
  147. package/src/__tests__/screen-reader-input.test.tsx +106 -0
  148. package/src/__tests__/screen-reader-labels.test.ts +91 -0
  149. package/src/__tests__/screen-reader-menus.test.tsx +200 -0
  150. package/src/__tests__/screen-reader-pacing.test.ts +230 -0
  151. package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
  152. package/src/__tests__/screen-reader-render-options.test.ts +262 -0
  153. package/src/__tests__/screen-reader-stdout.test.ts +322 -0
  154. package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
  155. package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
  156. package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
  157. package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
  158. package/src/__tests__/selection-flow.test.ts +61 -0
  159. package/src/__tests__/session-event-notices.test.tsx +31 -0
  160. package/src/__tests__/session-init-poller.test.ts +102 -0
  161. package/src/__tests__/session-switch-channel.test.tsx +470 -0
  162. package/src/__tests__/shutdown-signal.test.ts +34 -0
  163. package/src/__tests__/status-activity.test.ts +71 -0
  164. package/src/__tests__/status-bar.test.tsx +282 -0
  165. package/src/__tests__/status-glyph.test.ts +30 -0
  166. package/src/__tests__/streaming-indicator.test.tsx +177 -0
  167. package/src/__tests__/terminal-capabilities.test.ts +153 -0
  168. package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
  169. package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
  170. package/src/__tests__/text-prompt-flow.test.ts +77 -0
  171. package/src/__tests__/theme-picker.test.tsx +345 -0
  172. package/src/__tests__/transport-boundary.test.ts +11 -0
  173. package/src/__tests__/tui-app-channel-port.test.ts +31 -0
  174. package/src/__tests__/tui-channel-init-failure.test.ts +62 -0
  175. package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
  176. package/src/__tests__/tui-interaction-queues.test.ts +139 -0
  177. package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
  178. package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
  179. package/src/__tests__/tui-state-manager.test.ts +451 -0
  180. package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
  181. package/src/__tests__/wave-text.test.tsx +133 -0
  182. package/src/app-banner.tsx +42 -0
  183. package/src/app-static-items.ts +35 -0
  184. package/src/app-view-model.ts +136 -0
  185. package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
  186. package/src/attention/__tests__/attention-tracker.test.ts +103 -0
  187. package/src/attention/__tests__/countdown.test.ts +15 -0
  188. package/src/attention/__tests__/focus-input-filter.test.ts +92 -0
  189. package/src/attention/__tests__/interval-recap.test.ts +49 -0
  190. package/src/attention/attention-coordinator.ts +71 -0
  191. package/src/attention/attention-tracker.ts +124 -0
  192. package/src/attention/countdown.ts +24 -0
  193. package/src/attention/focus-input-filter.ts +131 -0
  194. package/src/attention/interval-recap.ts +148 -0
  195. package/src/attention/time-units.ts +6 -0
  196. package/src/attention-bell.ts +79 -0
  197. package/src/attributed-user-echo.ts +30 -0
  198. package/src/background-task-row-format.ts +99 -0
  199. package/src/command-interaction.ts +8 -0
  200. package/src/command-output-summary.ts +129 -0
  201. package/src/create-default-tui-cli-adapter.ts +45 -0
  202. package/src/execution-workspace-switcher-selection.ts +118 -0
  203. package/src/execution-workspace-view-model.ts +139 -0
  204. package/src/external-prompt-notice.tsx +38 -0
  205. package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
  206. package/src/flows/__tests__/defer-submit.test.ts +87 -0
  207. package/src/flows/__tests__/real-cursor-flow.test.ts +124 -0
  208. package/src/flows/background-focus-flow.ts +44 -0
  209. package/src/flows/cjk-cursor-motion.ts +75 -0
  210. package/src/flows/cjk-text-deletion.ts +53 -0
  211. package/src/flows/cjk-text-input-flow.ts +248 -0
  212. package/src/flows/cjk-text-input-types.ts +24 -0
  213. package/src/flows/confirm-prompt-flow.ts +90 -0
  214. package/src/flows/defer-submit.ts +67 -0
  215. package/src/flows/fork-attach-flow.ts +62 -0
  216. package/src/flows/input-area-flow.ts +188 -0
  217. package/src/flows/permission-prompt-flow.ts +108 -0
  218. package/src/flows/real-cursor-flow.ts +83 -0
  219. package/src/flows/selection-flow.ts +205 -0
  220. package/src/flows/session-init-poller.ts +76 -0
  221. package/src/flows/text-prompt-flow.ts +93 -0
  222. package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
  223. package/src/history-search/history-search-flow.ts +94 -0
  224. package/src/history-search/useHistorySearch.ts +336 -0
  225. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +74 -0
  226. package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
  227. package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
  228. package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
  229. package/src/hooks/command-result-handler.ts +44 -0
  230. package/src/hooks/side-effects-types.ts +45 -0
  231. package/src/hooks/useAppController.ts +267 -0
  232. package/src/hooks/useAppInputBindings.ts +145 -0
  233. package/src/hooks/useAppInteractionState.ts +81 -0
  234. package/src/hooks/useAppLifecycleState.ts +81 -0
  235. package/src/hooks/useAppOverlays.ts +74 -0
  236. package/src/hooks/useAppScreenState.ts +81 -0
  237. package/src/hooks/useAppSubmissionState.ts +45 -0
  238. package/src/hooks/useAppThemeState.ts +157 -0
  239. package/src/hooks/useAppWorkspaceState.ts +149 -0
  240. package/src/hooks/useAppearanceSettings.ts +30 -0
  241. package/src/hooks/useAutocomplete.ts +101 -0
  242. package/src/hooks/useBackgroundPanel.ts +58 -0
  243. package/src/hooks/useCountdownTick.ts +24 -0
  244. package/src/hooks/useExecutionDetailPage.ts +69 -0
  245. package/src/hooks/useForkAttach.ts +60 -0
  246. package/src/hooks/useInputAreaHistorySearch.ts +72 -0
  247. package/src/hooks/useInputAreaKeys.ts +114 -0
  248. package/src/hooks/useNumberedSelection.ts +76 -0
  249. package/src/hooks/usePluginCallbacks.ts +38 -0
  250. package/src/hooks/usePluginScreenData.ts +85 -0
  251. package/src/hooks/useRealCursorPosition.ts +125 -0
  252. package/src/hooks/useRenderMarkdown.ts +38 -0
  253. package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
  254. package/src/hooks/useSideEffects.ts +144 -0
  255. package/src/hooks/useStatusLineSettings.ts +50 -0
  256. package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
  257. package/src/hooks/useTuiChannel.ts +129 -0
  258. package/src/humanize-tool-name.ts +51 -0
  259. package/src/index.ts +56 -0
  260. package/src/input-area-rules.tsx +71 -0
  261. package/src/json-value.ts +18 -0
  262. package/src/key-hint-footer.tsx +54 -0
  263. package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
  264. package/src/keybindings/__tests__/node-keybindings-source.test.ts +111 -0
  265. package/src/keybindings/keybinding-catalogue.ts +171 -0
  266. package/src/keybindings/keybinding-registry.ts +303 -0
  267. package/src/keybindings/keybinding-syntax.ts +155 -0
  268. package/src/keybindings/keybindings-context.tsx +124 -0
  269. package/src/keybindings/node-keybindings-source.ts +189 -0
  270. package/src/numbered-list.tsx +113 -0
  271. package/src/plugin-tui-handlers.ts +163 -0
  272. package/src/product-display-name-context.tsx +30 -0
  273. package/src/render-markdown.ts +209 -0
  274. package/src/render.tsx +544 -0
  275. package/src/sanitize-terminal-text.ts +171 -0
  276. package/src/screen-reader-announcement.ts +52 -0
  277. package/src/screen-reader-context.tsx +32 -0
  278. package/src/screen-reader-labels.ts +64 -0
  279. package/src/screen-reader-pacing-context.tsx +47 -0
  280. package/src/screen-reader-pacing.ts +178 -0
  281. package/src/screen-reader-stdout.ts +328 -0
  282. package/src/shutdown-signal.ts +34 -0
  283. package/src/status-activity.ts +70 -0
  284. package/src/status-glyph.ts +78 -0
  285. package/src/terminal-capabilities-context.tsx +27 -0
  286. package/src/terminal-capabilities.ts +77 -0
  287. package/src/terminal-focus-reporting.ts +50 -0
  288. package/src/terminal-handoff-controller.ts +98 -0
  289. package/src/terminal-marks.ts +63 -0
  290. package/src/theme/__tests__/built-in-themes.test.ts +117 -0
  291. package/src/theme/__tests__/color-vision.test.ts +82 -0
  292. package/src/theme/__tests__/theme-context.test.tsx +146 -0
  293. package/src/theme/__tests__/theme-document.test.ts +222 -0
  294. package/src/theme/__tests__/theme-registry.test.ts +138 -0
  295. package/src/theme/__tests__/theme-styles.test.ts +233 -0
  296. package/src/theme/built-in-themes.ts +342 -0
  297. package/src/theme/color-vision.ts +171 -0
  298. package/src/theme/index.ts +45 -0
  299. package/src/theme/theme-context.tsx +81 -0
  300. package/src/theme/theme-contracts.ts +139 -0
  301. package/src/theme/theme-document.ts +278 -0
  302. package/src/theme/theme-registry.ts +127 -0
  303. package/src/theme/theme-styles.ts +177 -0
  304. package/src/tool-summary-status.ts +59 -0
  305. package/src/tui-app-channel-port.ts +83 -0
  306. package/src/tui-channel-lifecycle-coordinator.ts +149 -0
  307. package/src/tui-channel-options.ts +172 -0
  308. package/src/tui-cli-adapter-context.tsx +13 -0
  309. package/src/tui-cli-adapter.ts +24 -0
  310. package/src/tui-interaction-queues.ts +163 -0
  311. package/src/tui-session-binding.ts +52 -0
  312. package/src/tui-session-event-projector.ts +140 -0
  313. package/src/tui-session-events.ts +82 -0
  314. package/src/tui-session-notice-store.ts +43 -0
  315. package/src/tui-session-options.ts +118 -0
  316. package/src/tui-state-manager.ts +327 -0
  317. package/src/types.ts +19 -0
  318. package/src/use-terminal-title.ts +29 -0
  319. package/src/utils/__tests__/edit-diff.test.ts +426 -0
  320. package/src/utils/__tests__/paste-detection.test.ts +116 -0
  321. package/src/utils/__tests__/paste-labels.test.ts +46 -0
  322. package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
  323. package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
  324. package/src/utils/edit-diff.ts +151 -0
  325. package/src/utils/input-top-border.ts +19 -0
  326. package/src/utils/paste-labels.ts +9 -0
  327. package/src/utils/tool-call-extractor.ts +92 -0
  328. package/src/utils/tool-diff-summary.ts +75 -0
@@ -0,0 +1,455 @@
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 { writeScreenReaderAnnouncement, type TScreenReaderChannel } from './screen-reader-announcement.js';
9
+ import { ScreenReaderProvider, useScreenReader } from './screen-reader-context.js';
10
+
11
+ export interface ISupervisedViewRow {
12
+ readonly id: string;
13
+ readonly liveness: 'alive' | 'dead' | 'unknown';
14
+ readonly control: 'available' | 'unavailable';
15
+ readonly activity: 'working' | 'needs-input' | 'idle' | 'unknown';
16
+ readonly nextLoopAt?: string;
17
+ readonly name?: string;
18
+ readonly cwd?: string;
19
+ readonly pr?: { readonly url: string; readonly host: string; readonly number: number;
20
+ readonly kind: 'pull' | 'merge-request' };
21
+ readonly problem?: 'invalid-registration';
22
+ }
23
+
24
+ export interface ISupervisedSessionViewProps {
25
+ readonly loadRows: (signal: AbortSignal) => Promise<readonly ISupervisedViewRow[]>;
26
+ readonly onStop?: (id: string) => Promise<void>;
27
+ readonly onStart?: () => Promise<string>;
28
+ readonly onOpenPr?: (id: string, url: string) => Promise<void>;
29
+ readonly filteredByCwd?: boolean;
30
+ readonly filteredByName?: boolean;
31
+ readonly filteredByPr?: boolean;
32
+ readonly stateFilter?: TGroup;
33
+ readonly refreshMs?: number;
34
+ }
35
+
36
+ const GROUP_ORDER = ['needs-input', 'working', 'idle', 'unknown', 'unverified', 'dead'] as const;
37
+ type TGroup = typeof GROUP_ORDER[number];
38
+ type TDisplayLine =
39
+ | { readonly kind: 'group'; readonly label: string }
40
+ | { readonly kind: 'row'; readonly row: ISupervisedViewRow; readonly index: number };
41
+
42
+ function groupOf(row: ISupervisedViewRow): TGroup {
43
+ if (row.liveness === 'dead') return 'dead';
44
+ if (row.liveness !== 'alive' || row.control !== 'available') return 'unverified';
45
+ return row.activity;
46
+ }
47
+
48
+ function sortedRows(rows: readonly ISupervisedViewRow[]): readonly ISupervisedViewRow[] {
49
+ return [...rows].sort((a, b) =>
50
+ GROUP_ORDER.indexOf(groupOf(a)) - GROUP_ORDER.indexOf(groupOf(b)) || a.id.localeCompare(b.id));
51
+ }
52
+
53
+ function sortedDirectoryRows(rows: readonly ISupervisedViewRow[]): readonly ISupervisedViewRow[] {
54
+ return [...rows].sort((a, b) =>
55
+ (a.cwd === undefined ? 1 : 0) - (b.cwd === undefined ? 1 : 0) ||
56
+ (a.cwd ?? '').localeCompare(b.cwd ?? '') ||
57
+ GROUP_ORDER.indexOf(groupOf(a)) - GROUP_ORDER.indexOf(groupOf(b)) || a.id.localeCompare(b.id));
58
+ }
59
+
60
+ function uniqueDirectorySuffix(cwd: string, directories: readonly string[], budget: number): string {
61
+ if (cwd === '/') return cwd;
62
+ let longestSharedSuffix = 0;
63
+ for (const other of directories) {
64
+ if (other === cwd) continue;
65
+ let shared = 0;
66
+ while (shared < cwd.length && shared < other.length &&
67
+ cwd[cwd.length - shared - 1] === other[other.length - shared - 1]) shared++;
68
+ longestSharedSuffix = Math.max(longestSharedSuffix, shared);
69
+ }
70
+ const basenameLength = cwd.split(/[\\/]/u).filter(Boolean).at(-1)?.length ?? cwd.length;
71
+ const uniqueLength = Math.min(cwd.length, Math.max(basenameLength, longestSharedSuffix + 3));
72
+ const suffixStart = cwd.length - uniqueLength;
73
+ const boundary = Math.max(cwd.lastIndexOf('/', suffixStart - 1), cwd.lastIndexOf('\\', suffixStart - 1));
74
+ const componentSuffix = cwd.slice(boundary + 1);
75
+ return stringWidth(displayPath(componentSuffix)) <= budget ? componentSuffix : cwd.slice(suffixStart);
76
+ }
77
+
78
+ function pathTokens(path: string): readonly string[] {
79
+ return Array.from(path, (character) => /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(character)
80
+ ? `\\u{${character.codePointAt(0)!.toString(16)}}` : character);
81
+ }
82
+
83
+ function displayPath(path: string): string {
84
+ return pathTokens(path).join('');
85
+ }
86
+
87
+ function compactPath(path: string, budget: number): string {
88
+ const safe = pathTokens(path);
89
+ if (stringWidth(safe.join('')) <= budget) return safe.join('');
90
+ const headBudget = Math.ceil((budget - 1) / 2);
91
+ const tailBudget = budget - headBudget - 1;
92
+ let head = '';
93
+ for (const token of safe) {
94
+ if (stringWidth(head) + stringWidth(token) > headBudget) break;
95
+ head += token;
96
+ }
97
+ let tail = '';
98
+ for (const token of [...safe].reverse()) {
99
+ if (stringWidth(tail) + stringWidth(token) > tailBudget) break;
100
+ tail = token + tail;
101
+ }
102
+ return `${head}…${tail}`;
103
+ }
104
+
105
+ function sameRows(a: readonly ISupervisedViewRow[], b: readonly ISupervisedViewRow[]): boolean {
106
+ return a.length === b.length && a.every((row, index) => {
107
+ const other = b[index];
108
+ return other !== undefined && row.id === other.id && row.liveness === other.liveness &&
109
+ row.control === other.control && row.activity === other.activity && row.problem === other.problem &&
110
+ row.nextLoopAt === other.nextLoopAt && row.name === other.name && row.cwd === other.cwd &&
111
+ row.pr?.url === other.pr?.url;
112
+ });
113
+ }
114
+
115
+ function loopWaitLabel(nextLoopAt: string, observedAtMs: number): string {
116
+ const remaining = Date.parse(nextLoopAt) - observedAtMs;
117
+ if (!Number.isFinite(remaining)) return '';
118
+ if (remaining <= 0) return 'loop eligible now';
119
+ return remaining < 60_000 ? 'loop eligible in <1m' : `loop eligible in ${Math.ceil(remaining / 60_000)}m`;
120
+ }
121
+
122
+ export default function SupervisedSessionView({
123
+ loadRows,
124
+ onStop,
125
+ onStart,
126
+ onOpenPr,
127
+ filteredByCwd = false,
128
+ filteredByName = false,
129
+ filteredByPr = false,
130
+ stateFilter,
131
+ refreshMs = 2_000,
132
+ }: ISupervisedSessionViewProps): React.ReactElement {
133
+ const { exit } = useApp();
134
+ const { stdout } = useStdout();
135
+ const columns = Math.max(12, stdout.columns ?? 80);
136
+ const screenReader = useScreenReader();
137
+ const [rows, setRows] = useState<readonly ISupervisedViewRow[]>([]);
138
+ const [observedAtMs, setObservedAtMs] = useState(Date.now);
139
+ const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable'>('loading');
140
+ const [selectedId, setSelectedId] = useState<string | undefined>();
141
+ const [showHelp, setShowHelp] = useState(false);
142
+ const [groupByDirectory, setGroupByDirectory] = useState(false);
143
+ const [confirmStopId, setConfirmStopId] = useState<string | undefined>();
144
+ const [stopStatus, setStopStatus] = useState<'idle' | 'unavailable' | 'stopping' | 'stopped' | 'failed'>('idle');
145
+ const [lastStoppedId, setLastStoppedId] = useState<string | undefined>();
146
+ const stoppingRef = useRef(false);
147
+ const [startStatus, setStartStatus] = useState<'idle' | 'starting' | 'started' | 'failed'>('idle');
148
+ const [lastStartedId, setLastStartedId] = useState<string | undefined>();
149
+ const startingRef = useRef(false);
150
+ const [prOpenStatus, setPrOpenStatus] = useState<'idle' | 'opening' | 'opened' | 'failed' | 'unavailable'>('idle');
151
+ const openingPrRef = useRef(false);
152
+ const mountedRef = useRef(true);
153
+ const ordered = useMemo(() => {
154
+ const filtered = rows.filter((row) => stateFilter === undefined || groupOf(row) === stateFilter);
155
+ return groupByDirectory ? sortedDirectoryRows(filtered) : sortedRows(filtered);
156
+ }, [rows, stateFilter, groupByDirectory]);
157
+ const displayLines = useMemo((): readonly TDisplayLine[] => {
158
+ const lines: TDisplayLine[] = [];
159
+ const directories = [...new Set(ordered.map((row) => row.cwd).filter((cwd): cwd is string => cwd !== undefined))];
160
+ let previousGroup: string | undefined;
161
+ let directoryNumber = 0;
162
+ ordered.forEach((row, index) => {
163
+ const group = groupByDirectory ? row.cwd ?? 'unverified' : groupOf(row);
164
+ if (group !== previousGroup) {
165
+ const label = groupByDirectory
166
+ ? row.cwd === undefined ? 'Directory: unverified'
167
+ : (() => {
168
+ const prefix = `Dir ${++directoryNumber}: `;
169
+ const budget = Math.max(4, columns - prefix.length);
170
+ const suffix = compactPath(uniqueDirectorySuffix(row.cwd, directories, budget), budget);
171
+ const full = `${prefix}${suffix} — ${displayPath(row.cwd)}`;
172
+ return screenReader || stringWidth(full) <= columns ? full : `${prefix}${suffix}`;
173
+ })()
174
+ : `${group}:`;
175
+ lines.push({ kind: 'group', label });
176
+ }
177
+ lines.push({ kind: 'row', row, index });
178
+ previousGroup = group;
179
+ });
180
+ return lines;
181
+ }, [ordered, groupByDirectory, columns, screenReader]);
182
+
183
+ useEffect(() => {
184
+ const controller = new AbortController();
185
+ let mounted = true;
186
+ let timer: ReturnType<typeof setTimeout> | undefined;
187
+ const poll = async (): Promise<void> => {
188
+ try {
189
+ const next = sortedRows(await loadRows(controller.signal));
190
+ if (!mounted || controller.signal.aborted) return;
191
+ const nowMs = Date.now();
192
+ setRows((previous) => sameRows(previous, next) ? previous : next);
193
+ setObservedAtMs((previous) => next.some((row) => row.nextLoopAt !== undefined &&
194
+ loopWaitLabel(row.nextLoopAt, previous) !== loopWaitLabel(row.nextLoopAt, nowMs)) ? nowMs : previous);
195
+ setStatus('ready');
196
+ } catch {
197
+ if (mounted && !controller.signal.aborted) setStatus('unavailable');
198
+ } finally {
199
+ if (mounted && !controller.signal.aborted) timer = setTimeout(() => { void poll(); }, refreshMs);
200
+ }
201
+ };
202
+ void poll();
203
+ return () => {
204
+ mounted = false;
205
+ if (timer !== undefined) clearTimeout(timer);
206
+ controller.abort();
207
+ };
208
+ }, [loadRows, refreshMs]);
209
+
210
+ useEffect(() => {
211
+ mountedRef.current = true;
212
+ return () => { mountedRef.current = false; };
213
+ }, []);
214
+
215
+ useEffect(() => {
216
+ setSelectedId((current) => current !== undefined && ordered.some((row) => row.id === current)
217
+ ? current : ordered[0]?.id);
218
+ }, [ordered]);
219
+
220
+ useInput((input, key) => {
221
+ if (stoppingRef.current) return;
222
+ if (openingPrRef.current && input !== 'q' && !(!screenReader && key.escape) && !(key.ctrl && input === 'c')) return;
223
+ if (startingRef.current && input !== 'q' && !(!screenReader && key.escape) && !(key.ctrl && input === 'c')) return;
224
+ if (confirmStopId !== undefined) {
225
+ if (input === 'n' || key.escape) {
226
+ setConfirmStopId(undefined);
227
+ return;
228
+ }
229
+ if (input === 'y') {
230
+ const row = ordered.find((candidate) => candidate.id === confirmStopId);
231
+ setConfirmStopId(undefined);
232
+ if (status !== 'ready' || row?.liveness !== 'alive' || row.control !== 'available' || onStop === undefined) {
233
+ setStopStatus('unavailable');
234
+ return;
235
+ }
236
+ stoppingRef.current = true;
237
+ setStopStatus('stopping');
238
+ void onStop(confirmStopId).then(() => {
239
+ if (!mountedRef.current) return;
240
+ setLastStoppedId(confirmStopId);
241
+ setStopStatus('stopped');
242
+ }).catch(() => {
243
+ if (mountedRef.current) setStopStatus('failed');
244
+ }).finally(() => { stoppingRef.current = false; });
245
+ }
246
+ return;
247
+ }
248
+ if (input === 'q' || (!screenReader && key.escape) || (key.ctrl && input === 'c')) {
249
+ exit();
250
+ return;
251
+ }
252
+ if (input === '?') {
253
+ setShowHelp((value) => !value);
254
+ return;
255
+ }
256
+ if (input === 'g') {
257
+ numbered.clear();
258
+ setGroupByDirectory((value) => !value);
259
+ return;
260
+ }
261
+ if (input === 'n' && onStart !== undefined) {
262
+ startingRef.current = true;
263
+ setStartStatus('starting');
264
+ void Promise.resolve().then(onStart).then((id) => {
265
+ if (!mountedRef.current) return;
266
+ setLastStartedId(id);
267
+ setStartStatus('started');
268
+ }).catch(() => {
269
+ if (mountedRef.current) setStartStatus('failed');
270
+ }).finally(() => { startingRef.current = false; });
271
+ return;
272
+ }
273
+ if (input === 'p' && onOpenPr !== undefined) {
274
+ const row = ordered.find((candidate) => candidate.id === selectedId);
275
+ if (status !== 'ready' || row?.liveness !== 'alive' || row.control !== 'available' || !row.pr) {
276
+ setPrOpenStatus('unavailable');
277
+ return;
278
+ }
279
+ openingPrRef.current = true;
280
+ setPrOpenStatus('opening');
281
+ void onOpenPr(row.id, row.pr.url).then(() => {
282
+ if (mountedRef.current) setPrOpenStatus('opened');
283
+ }).catch(() => {
284
+ if (mountedRef.current) setPrOpenStatus('failed');
285
+ }).finally(() => { openingPrRef.current = false; });
286
+ return;
287
+ }
288
+ if (input === 's' && onStop !== undefined) {
289
+ const row = ordered.find((candidate) => candidate.id === selectedId);
290
+ if (status !== 'ready' || row?.liveness !== 'alive' || row.control !== 'available') {
291
+ setStopStatus('unavailable');
292
+ } else {
293
+ setStopStatus('idle');
294
+ setConfirmStopId(row.id);
295
+ }
296
+ return;
297
+ }
298
+ if (screenReader || ordered.length === 0) return;
299
+ if (key.upArrow || key.downArrow) {
300
+ const index = Math.max(0, ordered.findIndex((row) => row.id === selectedId));
301
+ const next = Math.max(0, Math.min(ordered.length - 1, index + (key.downArrow ? 1 : -1)));
302
+ setSelectedId(ordered[next]?.id);
303
+ }
304
+ });
305
+
306
+ const numbered = useNumberedSelection({
307
+ enabled: screenReader && confirmStopId === undefined && stopStatus !== 'stopping',
308
+ itemCount: ordered.length,
309
+ cancellable: true,
310
+ repeatable: true,
311
+ onSelect: (index) => {
312
+ setSelectedId(ordered[index]?.id);
313
+ setStopStatus('idle');
314
+ },
315
+ onCancel: () => { if (!stoppingRef.current) exit(); },
316
+ });
317
+
318
+ const selectedRow = ordered.find((row) => row.id === selectedId);
319
+ const selectedName = status === 'ready' && selectedRow?.liveness === 'alive' &&
320
+ selectedRow.control === 'available' ? selectedRow.name : undefined;
321
+ const selectedLoopStatus = status === 'ready' && selectedRow?.liveness === 'alive' &&
322
+ selectedRow.control === 'available' && selectedRow.activity === 'idle' && selectedRow.nextLoopAt
323
+ ? loopWaitLabel(selectedRow.nextLoopAt, observedAtMs) : '';
324
+ const selectedPr = status === 'ready' && selectedRow?.liveness === 'alive' &&
325
+ selectedRow.control === 'available' ? selectedRow.pr : undefined;
326
+
327
+ const height = Math.max(8, stdout.rows ?? 24);
328
+ const helpLines = [
329
+ 'Keys:',
330
+ screenReader ? 'Number+Enter Select' : '↑/↓ Select',
331
+ 's Request stop',
332
+ 'g Group state/dir',
333
+ ...(onStart === undefined ? [] : ['n New session']),
334
+ ...(onOpenPr === undefined ? [] : ['p Open linked PR']),
335
+ 'y Confirm stop',
336
+ 'n/Esc Cancel stop',
337
+ 'q/Esc/Ctrl+C Close',
338
+ '? Toggle help',
339
+ 'Activity ≠ liveness',
340
+ 'Idle ≠ attach-ready',
341
+ 'Close keeps sessions',
342
+ ];
343
+ // Reserve all fixed chrome plus both possible overflow indicators before choosing row lines.
344
+ const helpVisible = showHelp && confirmStopId === undefined && stopStatus !== 'stopping';
345
+ const fixedLines = 2 + (stateFilter === undefined ? 0 : 1)
346
+ + (status === 'loading' || status === 'unavailable' || (status === 'ready' && ordered.length === 0) ? 1 : 0)
347
+ + (selectedId === undefined ? 0 : 1)
348
+ + (selectedName === undefined ? 0 : 1)
349
+ + (selectedPr === undefined ? 0 : 2)
350
+ + (selectedLoopStatus ? 1 : 0)
351
+ + (prOpenStatus === 'idle' ? 0 : 1)
352
+ + (startStatus === 'idle' ? 0 : 1)
353
+ + (confirmStopId !== undefined ? (screenReader ? 1 : 2) : stopStatus !== 'idle' ? 1 : 0)
354
+ + 1 + (helpVisible ? helpLines.length : 0) + 2;
355
+ const viewport = Math.max(1, height - fixedLines);
356
+ const selectedLine = Math.max(0, displayLines.findIndex((line) => line.kind === 'row' && line.row.id === selectedId));
357
+ const start = Math.min(Math.max(0, selectedLine - Math.floor(viewport / 2)), Math.max(0, displayLines.length - viewport));
358
+ const visible = screenReader ? displayLines : displayLines.slice(start, start + viewport);
359
+ const chromeWrap = screenReader ? {} : { wrap: 'truncate-end' as const };
360
+ const footer = stopStatus === 'stopping' ? 'Stop in progress; wait for result.'
361
+ : confirmStopId !== undefined ? 'Confirm stop or cancel before closing.'
362
+ : screenReader ? `Type a number and Enter to select; s Stop;${onStart ? ' n New;' : ''}${onOpenPr ? ' p Open PR;' : ''} g Group; Escape to close; ? Help.`
363
+ : `↑↓ Navigate s Stop${onStart ? ' n New' : ''}${onOpenPr ? ' p Open PR' : ''} g Group ? Help q/Esc Close`;
364
+ return (
365
+ <Box flexDirection="column" {...(screenReader ? {} : { height })}>
366
+ <Text {...chromeWrap}>
367
+ {filteredByCwd ? 'Background sessions in selected directory' : 'Background sessions across projects'}
368
+ {filteredByName ? ' · name filter active' : ''}
369
+ {filteredByPr ? ' · PR filter active' : ''}
370
+ </Text>
371
+ <Text {...chromeWrap}>{ordered.length} supervised session(s). Foreground peers and saved records are separate.</Text>
372
+ {stateFilter !== undefined && <Text {...chromeWrap}>State: {stateFilter}</Text>}
373
+ {status === 'loading' && <Text {...chromeWrap}>Loading supervised sessions...</Text>}
374
+ {status === 'unavailable' && <Text {...chromeWrap}>Supervised session discovery unavailable; last verified rows remain below.</Text>}
375
+ {status === 'ready' && ordered.length === 0 && <Text {...chromeWrap}>No supervised sessions.</Text>}
376
+ {start > 0 && !screenReader && <Text>{start} more above</Text>}
377
+ {visible.map((line) => line.kind === 'group'
378
+ ? <Text key={`group-${line.label}`} {...chromeWrap}>{line.label}</Text>
379
+ : <Text key={`row-${line.row.id}`} {...(screenReader ? {} : { wrap: 'truncate-end' as const })}>
380
+ {screenReader ? numberedRowPrefix(line.index) : line.row.id === selectedId ? '> ' : ' '}
381
+ {line.row.name && line.row.liveness === 'alive' && line.row.control === 'available'
382
+ ? screenReader
383
+ ? `${line.row.name} (${line.row.id})`
384
+ : `${Array.from(line.row.name).slice(0, 24).join('')}${Array.from(line.row.name).length > 24 ? '…' : ''} [${line.row.id.slice(-8)}]`
385
+ : line.row.id} activity {line.row.activity} liveness {line.row.liveness} control {line.row.control}
386
+ {status === 'ready' && line.row.liveness === 'alive' && line.row.control === 'available' &&
387
+ line.row.activity === 'idle' && line.row.nextLoopAt
388
+ ? ` ${loopWaitLabel(line.row.nextLoopAt, observedAtMs)}` : ''}
389
+ {line.row.problem ? ` ${line.row.problem}` : ''}
390
+ {status === 'ready' && line.row.liveness === 'alive' && line.row.control === 'available' && line.row.pr
391
+ ? ` ${line.row.pr.host} ${line.row.pr.kind === 'pull' ? '#' : '!'}${line.row.pr.number}` : ''}
392
+ </Text>)}
393
+ {!screenReader && start + visible.length < displayLines.length &&
394
+ <Text>{displayLines.length - start - visible.length} more below</Text>}
395
+ {selectedId !== undefined && <Text {...chromeWrap}>Selected {selectedId}</Text>}
396
+ {selectedName !== undefined && <Text {...chromeWrap}>Name: {selectedName}</Text>}
397
+ {selectedPr !== undefined && <Text {...chromeWrap}>PR: {selectedPr.host} {selectedPr.kind === 'pull' ? '#' : '!'}{selectedPr.number}</Text>}
398
+ {selectedPr !== undefined && <Text {...chromeWrap}>URL: {selectedPr.url}</Text>}
399
+ {selectedLoopStatus && <Text {...chromeWrap}>{selectedLoopStatus}</Text>}
400
+ {prOpenStatus === 'opening' && <Text {...chromeWrap}>Opening linked PR...</Text>}
401
+ {prOpenStatus === 'opened' && <Text {...chromeWrap}>Opened linked PR.</Text>}
402
+ {prOpenStatus === 'failed' && <Text {...chromeWrap}>PR link changed or could not be opened.</Text>}
403
+ {prOpenStatus === 'unavailable' && <Text {...chromeWrap}>No verified PR link is available.</Text>}
404
+ {startStatus === 'starting' && <Text {...chromeWrap}>Starting a background session...</Text>}
405
+ {startStatus === 'started' && <Text {...chromeWrap}>Started {lastStartedId}{filteredByName || stateFilter !== undefined ? ' (may be hidden by filter)' : ''}</Text>}
406
+ {startStatus === 'failed' && <Text {...chromeWrap}>Start failed; check workspace trust or run session start --background for details.</Text>}
407
+ {confirmStopId !== undefined && (screenReader
408
+ ? <Text>Stop {confirmStopId}? y Yes / n No</Text>
409
+ : <>
410
+ <Text {...chromeWrap}>Stop …{confirmStopId.slice(-8)}?</Text>
411
+ <Text {...chromeWrap}>y Yes / n No</Text>
412
+ </>)}
413
+ {confirmStopId === undefined && stopStatus === 'unavailable' &&
414
+ <Text {...chromeWrap}>This session cannot be stopped from the view.</Text>}
415
+ {stopStatus === 'stopping' && <Text {...chromeWrap}>Stopping selected session; wait for confirmation.</Text>}
416
+ {stopStatus === 'stopped' && <Text {...chromeWrap}>Stopped {lastStoppedId}</Text>}
417
+ {stopStatus === 'failed' && <Text {...chromeWrap}>Stop failed; session remains listed until verified otherwise.</Text>}
418
+ {screenReader && ordered.length > 0 && confirmStopId === undefined && stopStatus !== 'stopping' &&
419
+ <Text>{formatNumberedSelectionPrompt(ordered.length, true)}{numbered.buffer ? ` ${numbered.buffer}` : ''}</Text>}
420
+ {screenReader && numbered.invalid && <Text>Selection out of range.</Text>}
421
+ <Text {...chromeWrap}>{footer}</Text>
422
+ {helpVisible && helpLines.map((line) => <Text key={line} {...chromeWrap}>{line}</Text>)}
423
+ </Box>
424
+ );
425
+ }
426
+
427
+ export async function renderSupervisedSessionView(
428
+ options: ISupervisedSessionViewProps & {
429
+ readonly screenReader: boolean;
430
+ readonly screenReaderChannel?: TScreenReaderChannel;
431
+ readonly screenReaderHint?: boolean;
432
+ },
433
+ ): Promise<void> {
434
+ writeScreenReaderAnnouncement({
435
+ enabled: options.screenReader,
436
+ channel: options.screenReaderChannel,
437
+ hint: options.screenReaderHint,
438
+ });
439
+ const instance = render(
440
+ <ScreenReaderProvider enabled={options.screenReader}>
441
+ <SupervisedSessionView loadRows={options.loadRows} onStop={options.onStop}
442
+ onStart={options.onStart}
443
+ onOpenPr={options.onOpenPr}
444
+ filteredByCwd={options.filteredByCwd} filteredByName={options.filteredByName}
445
+ filteredByPr={options.filteredByPr}
446
+ stateFilter={options.stateFilter} refreshMs={options.refreshMs} />
447
+ </ScreenReaderProvider>,
448
+ { isScreenReaderEnabled: options.screenReader, exitOnCtrlC: false },
449
+ );
450
+ try {
451
+ await instance.waitUntilExit();
452
+ } finally {
453
+ instance.unmount();
454
+ }
455
+ }
@@ -0,0 +1,112 @@
1
+ import { Box } from 'ink';
2
+ import React, { useState, useRef, useCallback } from 'react';
3
+
4
+ import {
5
+ applyTextPromptInput,
6
+ createTextPromptFlowState,
7
+ type ITextPromptFlowState,
8
+ type TTextPromptInputAction,
9
+ } from './flows/text-prompt-flow.js';
10
+ import { KeyHintFooter, type IKeyHint } from './key-hint-footer.js';
11
+ import { useKeybindingActions, useKeybindingHints } from './keybindings/keybindings-context.js';
12
+ import { Text } from './SafeText.js';
13
+ import { useScreenReader } from './screen-reader-context.js';
14
+ import { usePalette } from './theme/index.js';
15
+
16
+ /** Footer for the free-text prompt. */
17
+ export const TEXT_PROMPT_FOOTER_HINTS: readonly IKeyHint[] = [
18
+ { keys: 'Enter', label: 'Submit' },
19
+ { keys: 'Esc', label: 'Cancel' },
20
+ ];
21
+
22
+ interface IProps {
23
+ title: string;
24
+ description?: string;
25
+ placeholder?: string;
26
+ onSubmit: (value: string) => void;
27
+ onCancel: () => void;
28
+ validate?: (value: string) => string | undefined;
29
+ allowEmpty?: boolean;
30
+ masked?: boolean;
31
+ }
32
+
33
+ export default function TextPrompt({
34
+ title,
35
+ description,
36
+ placeholder,
37
+ onSubmit,
38
+ onCancel,
39
+ validate,
40
+ allowEmpty = false,
41
+ masked = false,
42
+ }: IProps): React.ReactElement {
43
+ const palette = usePalette();
44
+ const [state, setState] = useState<ITextPromptFlowState>(() => createTextPromptFlowState());
45
+ const stateRef = useRef(state);
46
+ const applyAction = useCallback(
47
+ (action: TTextPromptInputAction): void => {
48
+ const result = applyTextPromptInput(stateRef.current, action, { allowEmpty, validate });
49
+ stateRef.current = result.state;
50
+ setState(result.state);
51
+ if (result.effect.type === 'cancel') {
52
+ onCancel();
53
+ } else if (result.effect.type === 'submit') {
54
+ onSubmit(result.effect.value);
55
+ }
56
+ },
57
+ [allowEmpty, validate, onCancel, onSubmit],
58
+ );
59
+
60
+ const screenReader = useScreenReader();
61
+
62
+ useKeybindingActions('text-prompt', (actions, input, key, consumed) => {
63
+ for (const action of actions) {
64
+ if (action === 'submit') applyAction({ type: 'submit' });
65
+ else if (action === 'cancel') applyAction({ type: 'cancel' });
66
+ else if (action === 'delete-backward') applyAction({ type: 'delete' });
67
+ }
68
+ if (actions.length === 0 && !consumed && input && key.ctrl !== true && key.meta !== true) {
69
+ applyAction({ type: 'insert', value: input });
70
+ }
71
+ });
72
+ const footerHints = useKeybindingHints('text-prompt', [
73
+ ['submit', 'Submit'],
74
+ ['cancel', 'Cancel'],
75
+ ]);
76
+
77
+ // CLI-2004: the box is dropped rather than restyled — the mode omits the prop, so no colour
78
+ // literal is introduced and the palette floor stays green.
79
+ return (
80
+ <Box
81
+ flexDirection="column"
82
+ {...(screenReader
83
+ ? {}
84
+ : { borderStyle: 'round' as const, borderColor: palette.border.attention })}
85
+ paddingX={1}
86
+ >
87
+ <Text color={palette.text.warning} bold>
88
+ {title}
89
+ </Text>
90
+ <PromptDescription description={description} />
91
+ <Box marginTop={1}>
92
+ <Text color={palette.text.accent}>&gt; </Text>
93
+ {state.value ? (
94
+ <Text>{masked ? '*'.repeat(state.value.length) : state.value}</Text>
95
+ ) : placeholder ? (
96
+ <Text dimColor>{placeholder}</Text>
97
+ ) : null}
98
+ <Text color={palette.text.accent}>█</Text>
99
+ </Box>
100
+ {state.error && <Text color={palette.text.error}>{state.error}</Text>}
101
+ <KeyHintFooter hints={footerHints} />
102
+ </Box>
103
+ );
104
+ }
105
+
106
+ function PromptDescription({ description }: { description?: string }): React.ReactElement | null {
107
+ if (description === undefined || description.length === 0) {
108
+ return null;
109
+ }
110
+
111
+ return <Text dimColor>{description}</Text>;
112
+ }