@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,138 @@
1
+ /**
2
+ * SCREEN-006 User Execution Test Scenarios — color/motion degradation and palette-token colors on
3
+ * the BUILT robota binary in a real PTY (run by `test:pty`; requires `pnpm build:deps` first).
4
+ *
5
+ * Scenario 1 (NO_COLOR legibility): `NO_COLOR=1` through a replayed conversation → the raw pty
6
+ * transcript contains ZERO SGR color sequences (30-38/40-48/90-97/100-107 params, incl. 38;5 /
7
+ * 48;5 extended forms). Because NO color code may appear at all, this also proves the repeated
8
+ * waiting-state frames show zero WaveText color churn (motion is static: the
9
+ * `isInteractiveColorTerminal()` gate creates no interval under NO_COLOR).
10
+ * Scenario 2 (normal-run consistency): color on → the persisted tool summary renders the
11
+ * `STATUS_SYMBOL.success` glyph in the resolved theme's status color (green, ESC[32m — sourced
12
+ * from `colors.status` through `statusGlyphColor`), the assistant label renders the accent token
13
+ * (cyan, ESC[36m), and the markdown diff block carries the theme's diff SGR pairs (38;5;120 on
14
+ * 48;5;22 for added lines under the default `dark` theme).
15
+ * Reachability note (recorded limit): a permission DENY short-circuits before tool-start, so the
16
+ * framework emits no tool-summary entry for it — the denied (`yellowBright`) unification is
17
+ * therefore pinned at component level (`message-list-rendering.test.tsx`), while this scenario
18
+ * evidences the same glyph→status-colour sourcing end-to-end via the success kind.
19
+ */
20
+
21
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import { dirname, join } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
27
+
28
+ import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
29
+ import { DARK_THEME } from '../../theme/built-in-themes.js';
30
+ import { background, foreground } from '../../theme/index.js';
31
+
32
+ import type { IPtySession } from './pty-driver.js';
33
+
34
+ const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
35
+ const REPLAY_FIXTURE = join(FIXTURES, 'replay-conversation.jsonl');
36
+ const COLORS_FIXTURE = join(FIXTURES, 'screen-006-colors.jsonl');
37
+
38
+ /** SGR params that set a color (foreground/background, basic/bright/extended). */
39
+ const COLOR_PARAM = /^(?:3[0-8]|39|4[0-8]|49|9[0-7]|10[0-7])$/;
40
+
41
+ /** Every SGR color param present in a raw pty transcript (e.g. ['33', '38;5;210'→'38']). */
42
+ function sgrColorParams(raw: string): string[] {
43
+ const params: string[] = [];
44
+ // eslint-disable-next-line no-control-regex -- scanning raw SGR escape bytes by design
45
+ for (const match of raw.matchAll(/\x1b\[([0-9;]*)m/g)) {
46
+ // In an extended sequence (38;5;n / 48;5;n) the payload params are not themselves
47
+ // color-setters, but the leading 38/48 is — flagging it is sufficient and exact.
48
+ for (const param of (match[1] === '' ? '0' : match[1]).split(';')) {
49
+ if (COLOR_PARAM.test(param)) {
50
+ params.push(param);
51
+ break; // one flagged param per sequence is enough evidence
52
+ }
53
+ }
54
+ }
55
+ return params;
56
+ }
57
+
58
+ describe('SCREEN-006 color/motion through the real binary', () => {
59
+ let projectDir: string;
60
+ let session: IPtySession | undefined;
61
+
62
+ beforeEach(() => {
63
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-screen006-pty-')));
64
+ writeTuiProviderSettings(projectDir);
65
+ });
66
+
67
+ afterEach(async () => {
68
+ await session?.disposeAsync();
69
+ session = undefined;
70
+ rmSync(projectDir, { recursive: true, force: true });
71
+ });
72
+
73
+ it('S1: NO_COLOR=1 → legible output, zero SGR color sequences, no animation', async () => {
74
+ session = spawnTui({
75
+ projectDir,
76
+ homeDir: join(projectDir, 'home'),
77
+ args: ['--session-log', REPLAY_FIXTURE],
78
+ env: { NO_COLOR: '1' },
79
+ });
80
+
81
+ await session.waitFor(/Type a message or \/help/);
82
+ await session.sendKeys('hello');
83
+ await session.pressEnter();
84
+
85
+ // The replayed turn completes and the output stays legible (content + labels present).
86
+ await session.waitFor(/REPLAYED_ANSWER_42/, 20_000);
87
+ await session.waitFor(/Type a message or \/help/, 20_000);
88
+
89
+ const stripped = session.snapshot();
90
+ expect(stripped).toContain('You:');
91
+ expect(stripped).toContain('REPLAYED_ANSWER_42');
92
+
93
+ // Zero SGR color sequences in the ENTIRE raw transcript — which also proves the
94
+ // waiting-state (WaveText) frames carried zero color churn under NO_COLOR.
95
+ expect(sgrColorParams(session.raw())).toEqual([]);
96
+ }, 60_000);
97
+
98
+ it('S2: color on → tool summary uses the themes status color, labels use accent, diff uses the themes pairs', async () => {
99
+ session = spawnTui({
100
+ projectDir,
101
+ homeDir: join(projectDir, 'home'),
102
+ args: ['--session-log', COLORS_FIXTURE],
103
+ });
104
+
105
+ await session.waitFor(/Type a message or \/help/);
106
+ await session.sendKeys('go');
107
+ await session.pressEnter();
108
+
109
+ // The replayed Shell call needs approval in default permission mode → allow it.
110
+ await session.waitFor(/Permission Required/, 20_000);
111
+ await session.sendKeys('y');
112
+
113
+ // The turn continues: the second replayed response carries the markdown diff block,
114
+ // and the persisted tool summary lands at turn end.
115
+ await session.waitFor(/SCREEN006_DIFF_DONE/, 20_000);
116
+ await session.waitFor(/✓ Shell/, 10_000);
117
+
118
+ const stripped = session.snapshot();
119
+ const raw = session.raw();
120
+
121
+ // Persisted tool summary: SSOT glyph + the theme's status colour (success → green, ESC[32m)
122
+ // rendered by the unified MessageList path (STATUS_SYMBOL + statusGlyphColor).
123
+ expect(stripped).toContain('✓ Shell');
124
+ // eslint-disable-next-line no-control-regex -- asserting on raw SGR escape bytes by design
125
+ expect(raw).toMatch(/\x1b\[32m[^\x1b]*✓ Shell/);
126
+
127
+ // Assistant label renders the accent token (cyan → ESC[36m ... "Robota:").
128
+ // eslint-disable-next-line no-control-regex -- asserting on raw SGR escape bytes by design
129
+ expect(raw).toMatch(/\x1b\[36m(?:\x1b\[[0-9;]*m)*Robota:/);
130
+
131
+ // Markdown diff block carries the resolved theme's SGR pair (added line: light green on a dark
132
+ // green background) — SCREEN-2002 moved the values into the theme, the bytes are unchanged.
133
+ const addedBackground = background(DARK_THEME.markdown.diffAddedBackground)('x').split('x')[0];
134
+ const addedForeground = foreground(DARK_THEME.markdown.diffAdded)('x').split('x')[0];
135
+ expect(raw).toContain(`${addedBackground}${addedForeground}`);
136
+ expect(stripped).toContain('+ added line');
137
+ }, 60_000);
138
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * SCREEN-010 TC-05: chat-window scrollback layout, on the real binary.
3
+ *
4
+ * Runs in the PTY vitest project against the BUILT robota CLI. Automates the former "manual" smoke
5
+ * (per the never-ask-the-user-to-test rule): generates committed output larger than a small viewport
6
+ * and asserts the committed history is emitted to the terminal's native scrollback while the input
7
+ * prompt + status bar stay pinned at the bottom. Once committed content is in scrollback and the input
8
+ * is pinned, native terminal scroll-back is the terminal's own behavior (not the app's), so this is
9
+ * the automatable essence of TC-05.
10
+ *
11
+ * CLI-2004 TC-22 adds the same proof under `--screen-reader`. Ink's `alternateScreen` defaults to
12
+ * false and nothing in the tree sets it — this is the GUARD on that, not new behaviour: the
13
+ * alternate screen has no scrollback, and reviewing earlier output is the whole reason a reader can
14
+ * use this TUI at all. The flagless case above cannot serve as the guard, because it asserts on the
15
+ * boot banner and on `Idle`, both of which the mode suppresses.
16
+ */
17
+
18
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
23
+
24
+ import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
25
+
26
+ import type { IPtySession } from './pty-driver.js';
27
+
28
+ /**
29
+ * The heading of the COMMITTED `/help` output, and the reason it is this precise.
30
+ *
31
+ * `Available commands` without the colon is also `/help`'s own DESCRIPTION, which the slash
32
+ * autocomplete renders while the command is still being typed — four times over, before Enter is
33
+ * handled at all. Waiting on that returns immediately and leaves the snapshot below racing the
34
+ * commit; the case then asserts "the prompt is below the committed output" against a frame in which
35
+ * nothing has been committed yet. It passed only because the commit usually won that race, and a
36
+ * loaded runner is where it does not. The colon appears only in the committed block.
37
+ */
38
+ const COMMITTED_HELP_HEADING = /Available commands:/;
39
+
40
+ /** Wait budgets: one `waitFor`, and a whole case, on a loaded CI runner. */
41
+ const PROMPT_WAIT_MS = 30_000;
42
+ const COMMANDS_WAIT_MS = 20_000;
43
+ const CASE_TIMEOUT_MS = 60_000;
44
+
45
+ describe('SCREEN-010 chat-window scrollback layout (real binary)', () => {
46
+ let projectDir: string;
47
+ let session: IPtySession | undefined;
48
+
49
+ beforeEach(() => {
50
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-scrollback-pty-')));
51
+ writeTuiProviderSettings(projectDir);
52
+ });
53
+
54
+ afterEach(async () => {
55
+ await session?.disposeAsync();
56
+ session = undefined;
57
+ rmSync(projectDir, { recursive: true, force: true });
58
+ });
59
+
60
+ it(
61
+ 'TC-05: committed history fills scrollback beyond the viewport while the input stays pinned',
62
+ async () => {
63
+ // Small viewport so the committed /help output exceeds it and scrolls into scrollback.
64
+ session = spawnTui({ projectDir, homeDir: join(projectDir, 'home'), rows: 16 });
65
+
66
+ await session.waitFor(/Type a message or \/help/);
67
+ await session.waitFor(/Idle/);
68
+
69
+ // /help commits a long command list (> 16 rows) to history → Ink <Static> → scrollback.
70
+ await session.sendKeys('/help');
71
+ await session.pressEnter();
72
+ await session.waitFor(COMMITTED_HELP_HEADING, COMMANDS_WAIT_MS);
73
+
74
+ const snap = session.snapshot();
75
+
76
+ // Committed content is present (emitted to scrollback), and the banner committed at boot remains.
77
+ const committedIdx = snap.search(COMMITTED_HELP_HEADING);
78
+ expect(committedIdx).toBeGreaterThanOrEqual(0);
79
+ expect((snap.match(/v\d+\.\d+\.\d+/g) ?? []).length).toBeGreaterThanOrEqual(1);
80
+
81
+ // The input prompt re-renders at the BOTTOM (live region): its last occurrence is below the
82
+ // committed command-list output — i.e. the input is pinned while committed history sits above it.
83
+ const lastPromptIdx = snap.lastIndexOf('Type a message');
84
+ expect(lastPromptIdx).toBeGreaterThan(committedIdx);
85
+
86
+ // The status bar is also pinned in the live region.
87
+ expect(snap.lastIndexOf('Idle')).toBeGreaterThan(committedIdx);
88
+ },
89
+ CASE_TIMEOUT_MS,
90
+ );
91
+
92
+ it(
93
+ 'CLI-2004 TC-22: --screen-reader never switches to the alternate screen and keeps history in scrollback',
94
+ async () => {
95
+ session = spawnTui({
96
+ projectDir,
97
+ homeDir: join(projectDir, 'home'),
98
+ rows: 16,
99
+ args: ['--screen-reader'],
100
+ env: { ROBOTA_SCREEN_READER_STARTUP_QUIET_MS: '0' },
101
+ });
102
+
103
+ await session.waitFor(/Type a message or \/help/, PROMPT_WAIT_MS);
104
+
105
+ await session.sendKeys('/help');
106
+ await session.pressEnter();
107
+ await session.waitFor(/Available commands|\/exit/i, COMMANDS_WAIT_MS);
108
+
109
+ // No alternate-screen switch at ANY point in the run — the mode must not buy a tidy frame at
110
+ // the price of the terminal's own scrollback.
111
+ expect(session.raw()).not.toContain('\x1b[?1049h');
112
+
113
+ const snap = session.snapshot();
114
+ const committedIdx = snap.search(/Available commands|\/exit/i);
115
+ expect(committedIdx).toBeGreaterThanOrEqual(0);
116
+
117
+ // The input stays pinned below the committed history — asserted without the banner or `Idle`,
118
+ // which the mode suppresses.
119
+ expect(snap.lastIndexOf('Type a message')).toBeGreaterThan(committedIdx);
120
+ },
121
+ CASE_TIMEOUT_MS,
122
+ );
123
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * SCREEN-1993 TC-10: the transcript decision's evidence, on the real binary.
3
+ *
4
+ * The item asked for a way to "search prompt history and conversation transcripts"; the spec ships
5
+ * prompt-history search and REJECTS an in-app transcript viewer because the terminal's own scrollback
6
+ * already holds the whole transcript (`<Static>` commits every message, and nothing ever switches to
7
+ * the alternate screen, which has no scrollback). That reason is only as good as its proof: resume a
8
+ * persisted session of more than 100 messages in a small viewport and assert every message text is
9
+ * in the captured output without a single key pressed — and that `ESC [ ? 1049 h` never appears.
10
+ */
11
+
12
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
17
+
18
+ import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
19
+
20
+ import type { IPtySession } from './pty-driver.js';
21
+
22
+ const SESSION_ID = 'screen-1993-transcript';
23
+ const TURNS = 60; // 120 messages: 60 user prompts and 60 assistant replies
24
+ const PROMPT_WAIT_MS = 30_000;
25
+ const FIXTURE_YEAR = 2026;
26
+ const CASE_TIMEOUT_MS = 90_000;
27
+
28
+ function messageText(index: number, role: 'user' | 'assistant'): string {
29
+ return role === 'user'
30
+ ? `transcript prompt ${index} of ${TURNS}`
31
+ : `transcript reply ${index} of ${TURNS}`;
32
+ }
33
+
34
+ /** A persisted session in the store's versioned envelope: `messages` for the model, `history` for the UI. */
35
+ function seedSession(homeDir: string, cwd: string): void {
36
+ const at = (index: number): string =>
37
+ new Date(Date.UTC(FIXTURE_YEAR, 0, 1, 0, 0, index)).toISOString();
38
+ const messages = [];
39
+ const history = [];
40
+ for (let index = 1; index <= TURNS; index += 1) {
41
+ for (const role of ['user', 'assistant'] as const) {
42
+ const content = messageText(index, role);
43
+ messages.push({
44
+ id: `m-${role}-${index}`,
45
+ timestamp: at(index),
46
+ state: 'complete',
47
+ role,
48
+ content,
49
+ });
50
+ history.push({
51
+ id: `${role}-${index}`,
52
+ timestamp: at(index),
53
+ category: 'chat',
54
+ type: role,
55
+ data: { role, content },
56
+ });
57
+ }
58
+ }
59
+ const sessionsDir = join(homeDir, '.robota', 'sessions');
60
+ mkdirSync(sessionsDir, { recursive: true });
61
+ writeFileSync(
62
+ join(sessionsDir, `${SESSION_ID}.json`),
63
+ JSON.stringify({
64
+ schemaVersion: 1,
65
+ record: {
66
+ id: SESSION_ID,
67
+ name: 'transcript-fixture',
68
+ cwd,
69
+ createdAt: at(0),
70
+ updatedAt: at(TURNS),
71
+ messages,
72
+ history,
73
+ },
74
+ }),
75
+ 'utf8',
76
+ );
77
+ }
78
+
79
+ describe('SCREEN-1993 TC-10: the resumed transcript lives in native scrollback', () => {
80
+ let projectDir: string;
81
+ let session: IPtySession | undefined;
82
+
83
+ beforeEach(() => {
84
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-1993-scrollback-')));
85
+ writeTuiProviderSettings(projectDir);
86
+ seedSession(join(projectDir, 'home'), projectDir);
87
+ });
88
+
89
+ afterEach(async () => {
90
+ await session?.disposeAsync();
91
+ session = undefined;
92
+ rmSync(projectDir, { recursive: true, force: true });
93
+ });
94
+
95
+ it(
96
+ 'every one of 120 restored messages is in the terminal output with no key pressed, and no alternate screen',
97
+ async () => {
98
+ session = spawnTui({
99
+ projectDir,
100
+ homeDir: join(projectDir, 'home'),
101
+ rows: 16,
102
+ args: ['--resume', SESSION_ID, '--name', 'transcript-fixture'],
103
+ });
104
+ await session.waitFor(/Type a message or \/help/, PROMPT_WAIT_MS);
105
+ await session.waitFor(new RegExp(messageText(TURNS, 'assistant')), PROMPT_WAIT_MS);
106
+
107
+ const snapshot = session.snapshot();
108
+ const missing: string[] = [];
109
+ for (let index = 1; index <= TURNS; index += 1) {
110
+ for (const role of ['user', 'assistant'] as const) {
111
+ if (!snapshot.includes(messageText(index, role))) missing.push(messageText(index, role));
112
+ }
113
+ }
114
+ expect(missing).toEqual([]);
115
+ // The transcript is above the live region: the input is pinned below the last message.
116
+ expect(snapshot.lastIndexOf('Type a message')).toBeGreaterThan(
117
+ snapshot.indexOf(messageText(TURNS, 'assistant')),
118
+ );
119
+ // The alternate screen has no scrollback; the TUI never enters it.
120
+ expect(session.raw()).not.toContain('\x1b[?1049h');
121
+ },
122
+ CASE_TIMEOUT_MS,
123
+ );
124
+ });
@@ -0,0 +1,292 @@
1
+ /**
2
+ * SCREEN-2002 TC-13 — themes, user theme files and plugin theme files, through the BUILT binary in
3
+ * a real PTY (run by `test:pty`; requires `pnpm build:deps` first).
4
+ *
5
+ * What only this level can show: that the theme a user chooses reaches the terminal's actual bytes
6
+ * without a restart, that a theme FILE they wrote is discovered from their home directory and a
7
+ * plugin's from its own, and that a file the run refused is visible to them — at startup and in the
8
+ * picker — rather than silently missing from the list.
9
+ *
10
+ * The transcript is Ink `<Static>` and is not repainted, so a theme change moves the LIVE region;
11
+ * assertions take an offset mark first and read only what arrived after it.
12
+ */
13
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
19
+
20
+ import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
21
+
22
+ import type { IPtySession } from './pty-driver.js';
23
+
24
+ const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
25
+ const THEME_FIXTURE = join(FIXTURES, 'screen-2002-themes.jsonl');
26
+
27
+ const PROMPT = /Type a message or \/help/;
28
+ const SNIPPET_DONE = /SCREEN2002_SNIPPET_DONE/;
29
+ const ACCENT = '#56b4e9';
30
+
31
+ /** Named because a bare number in a call is a lint warning, and because each one means something. */
32
+ const BOOT_MS = 30_000;
33
+ const STEP_MS = 20_000;
34
+ const TURN_MS = 40_000;
35
+ const CASE_MS = 150_000;
36
+ /** More than the single reset a plain frame carries. */
37
+ const MINIMUM_SGR_VARIETY = 1;
38
+
39
+ /** Every SGR sequence in a slice of the raw transcript, as a set — the frame's colour vocabulary. */
40
+ function sgrVocabulary(raw: string): Set<string> {
41
+ const found = new Set<string>();
42
+ // eslint-disable-next-line no-control-regex -- scanning raw SGR escape bytes by design
43
+ for (const match of raw.matchAll(/\x1b\[([0-9;]*)m/g)) found.add(match[1] ?? '');
44
+ return found;
45
+ }
46
+
47
+ /** The raw bytes of the rendered line that holds the snippet, if the frame carried it. */
48
+ function snippetLine(raw: string): string {
49
+ return raw.split(/\r?\n/u).find((line) => line.includes('answer') && line.includes('42')) ?? '';
50
+ }
51
+
52
+ function seedThemeFiles(homeDir: string): void {
53
+ const themes = join(homeDir, '.robota', 'themes');
54
+ mkdirSync(themes, { recursive: true });
55
+ writeFileSync(
56
+ join(themes, 'mine.json'),
57
+ JSON.stringify({
58
+ name: 'Mine',
59
+ base: 'dark',
60
+ // Tokens the IDLE frame actually paints with. Overriding `text.accent` alone proves nothing
61
+ // here: the accent labels live in the transcript, which is `<Static>` and is never repainted,
62
+ // so a run with nothing submitted would show the same bytes either way.
63
+ overrides: {
64
+ colors: {
65
+ text: { accent: ACCENT, muted: ACCENT },
66
+ border: { focused: ACCENT, muted: ACCENT },
67
+ status: { idle: ACCENT },
68
+ },
69
+ },
70
+ }),
71
+ );
72
+ writeFileSync(
73
+ join(themes, 'broken.json'),
74
+ JSON.stringify({ overrides: { colors: { text: { accent: 'not-a-colour' } } } }),
75
+ );
76
+
77
+ const pluginVersionDir = join(
78
+ homeDir,
79
+ '.robota',
80
+ 'plugins',
81
+ 'cache',
82
+ 'fixtures',
83
+ 'theme-fixture',
84
+ '1.0.0',
85
+ );
86
+ mkdirSync(join(pluginVersionDir, '.claude-plugin'), { recursive: true });
87
+ writeFileSync(
88
+ join(pluginVersionDir, '.claude-plugin', 'plugin.json'),
89
+ JSON.stringify({
90
+ name: 'theme-fixture',
91
+ version: '1.0.0',
92
+ description: 'a themes-only fixture plugin',
93
+ features: {},
94
+ }),
95
+ );
96
+ mkdirSync(join(pluginVersionDir, 'themes'), { recursive: true });
97
+ writeFileSync(
98
+ join(pluginVersionDir, 'themes', 'plugged.json'),
99
+ JSON.stringify({ name: 'Plugged', base: 'light' }),
100
+ );
101
+ }
102
+
103
+ describe('SCREEN-2002 themes through the real binary', () => {
104
+ let projectDir: string;
105
+ let homeDir: string;
106
+ let session: IPtySession | undefined;
107
+
108
+ beforeEach(() => {
109
+ projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-screen2002-pty-')));
110
+ writeTuiProviderSettings(projectDir);
111
+ homeDir = join(projectDir, 'home');
112
+ seedThemeFiles(homeDir);
113
+ });
114
+
115
+ afterEach(async () => {
116
+ await session?.disposeAsync();
117
+ session = undefined;
118
+ rmSync(projectDir, { recursive: true, force: true });
119
+ });
120
+
121
+ it(
122
+ 'S1: lists user and plugin themes, refuses a broken file out loud, and switches without a restart',
123
+ async () => {
124
+ session = spawnTui({
125
+ projectDir,
126
+ homeDir,
127
+ args: ['--session-log', THEME_FIXTURE],
128
+ env: { FORCE_COLOR: '3' },
129
+ });
130
+
131
+ // The refused file says so ONCE, at startup, with the path that refused it.
132
+ await session.waitFor(
133
+ /Skipped theme "broken\.json": \$\.overrides\.colors\.text\.accent/,
134
+ BOOT_MS,
135
+ );
136
+ await session.waitFor(PROMPT, BOOT_MS);
137
+
138
+ // `/theme list` sees the built-ins AND both files, each with where it came from.
139
+ const listMark = session.outputOffset();
140
+ await session.sendKeys('/theme list');
141
+ await session.pressEnter();
142
+ await session.waitForSince(listMark, /Available themes:/, STEP_MS);
143
+ const listed = session.snapshotSince(listMark);
144
+ expect(listed).toContain('dark — Dark');
145
+ expect(listed).toContain('custom:mine — Mine (dark, user)');
146
+ expect(listed).toContain('custom:theme-fixture:plugged — Plugged (light, plugin)');
147
+
148
+ // Applying the user's own theme puts ITS accent colour on the wire, with no restart.
149
+ const applyMark = session.outputOffset();
150
+ // A RAW mark of its own: `outputOffset()` indexes the ANSI-STRIPPED transcript, so slicing
151
+ // `raw()` with it lands at an unrelated byte.
152
+ const applyRawMark = session.raw().length;
153
+ await session.sendKeys('/theme custom:mine');
154
+ await session.pressEnter();
155
+ await session.waitForSince(applyMark, /Applied: theme Mine/, STEP_MS);
156
+ await session.waitForSince(applyMark, PROMPT, STEP_MS);
157
+ // `#56b4e9` as this terminal encodes it. The PTY is `xterm-256color` with no `COLORTERM`, so
158
+ // the depth is 256 and the hex downsamples to index 117 — asserting truecolor here would be
159
+ // asserting the harness's terminal rather than the theme.
160
+ expect(session.raw().slice(applyRawMark)).toContain('38;5;117');
161
+ },
162
+ CASE_MS,
163
+ );
164
+
165
+ it(
166
+ 'S2: the picker previews on move, restores on escape, and shows the refused file as a row',
167
+ async () => {
168
+ session = spawnTui({
169
+ projectDir,
170
+ homeDir,
171
+ args: ['--session-log', THEME_FIXTURE],
172
+ env: { FORCE_COLOR: '3' },
173
+ });
174
+ await session.waitFor(PROMPT, BOOT_MS);
175
+
176
+ const openMark = session.outputOffset();
177
+ const openRawMark = session.raw().length;
178
+ await session.sendKeys('/theme');
179
+ await session.pressEnter();
180
+ await session.waitForSince(openMark, /Theme/, STEP_MS);
181
+ // The refused file is a row that carries its reason — the startup line has long since scrolled.
182
+ await session.waitForSince(openMark, /Skipped "broken\.json"/, STEP_MS);
183
+
184
+ // Moving the highlight repaints the live region in the previewed theme: new SGR appears that
185
+ // the frame did not carry before the move. The arrow goes through `writeRaw` — `sendKeys`
186
+ // paces per character, so the leading Escape would arrive alone and CANCEL the picker.
187
+ const before = sgrVocabulary(session.raw().slice(openRawMark));
188
+ const moveMark = session.outputOffset();
189
+ const moveRawMark = session.raw().length;
190
+ session.writeRaw('\x1b[B');
191
+ await session.waitForSince(moveMark, /> Light — light, built-in/, STEP_MS);
192
+ const after = sgrVocabulary(session.raw().slice(moveRawMark));
193
+ expect([...after].some((sequence) => !before.has(sequence))).toBe(true);
194
+
195
+ // Escape leaves the persisted theme in place. Not only "nothing was applied": the colours the
196
+ // PREVIEW introduced are gone from the frame, which is what "restores" has to mean when the
197
+ // preview was visible rather than merely recorded.
198
+ const previewed = [...after].filter((sequence) => !before.has(sequence));
199
+ expect(previewed.length).toBeGreaterThan(0);
200
+ const escapeMark = session.outputOffset();
201
+ const escapeRawMark = session.raw().length;
202
+ session.pressEscape();
203
+ await session.waitForSince(escapeMark, PROMPT, STEP_MS);
204
+ const restored = sgrVocabulary(session.raw().slice(escapeRawMark));
205
+ // The symmetric guard: a post-escape slice carrying no SGR at all would satisfy the filter
206
+ // below without measuring anything, which is the fail-toward-green shape S3 also had.
207
+ expect(restored.size).toBeGreaterThan(0);
208
+ expect(previewed.filter((sequence) => restored.has(sequence))).toEqual([]);
209
+ expect(session.snapshotSince(escapeMark)).not.toContain('Applied: theme');
210
+ },
211
+ CASE_MS,
212
+ );
213
+
214
+ it(
215
+ 'S3: syntax highlighting is switched off for later renders and the scrollback keeps its own',
216
+ async () => {
217
+ session = spawnTui({
218
+ projectDir,
219
+ homeDir,
220
+ args: ['--session-log', THEME_FIXTURE],
221
+ env: { FORCE_COLOR: '3' },
222
+ });
223
+ await session.waitFor(PROMPT, BOOT_MS);
224
+
225
+ const firstMark = session.outputOffset();
226
+ const firstRawMark = session.raw().length;
227
+ await session.sendKeys('show me a snippet');
228
+ await session.pressEnter();
229
+ await session.waitForSince(firstMark, SNIPPET_DONE, TURN_MS);
230
+ const highlighted = session.raw().slice(firstRawMark);
231
+ // The TEXT is read from the stripped transcript: a highlighted block carries SGR BETWEEN its
232
+ // tokens, so the source line is not contiguous in the raw bytes — which is the point.
233
+ expect(session.snapshotSince(firstMark)).toContain('const answer = 42;');
234
+ // cli-highlight coloured the keyword: the block carries SGR the plain text would not.
235
+ expect(sgrVocabulary(highlighted).size).toBeGreaterThan(MINIMUM_SGR_VARIETY);
236
+
237
+ const offMark = session.outputOffset();
238
+ await session.sendKeys('/theme syntax off');
239
+ await session.pressEnter();
240
+ await session.waitForSince(offMark, /syntax highlighting off/, STEP_MS);
241
+
242
+ const secondMark = session.outputOffset();
243
+ const secondRawMark = session.raw().length;
244
+ await session.sendKeys('show me a snippet');
245
+ await session.pressEnter();
246
+ await session.waitForSince(secondMark, SNIPPET_DONE, TURN_MS);
247
+
248
+ // The same source, rendered again. Asserting only that the TEXT is there would pass whether
249
+ // or not `/theme syntax off` reached a render site — which is the defect this package has
250
+ // already shipped once — so the assertion is on the line's own SGR: the second render of the
251
+ // same line carries strictly fewer distinct sequences than the highlighted first.
252
+ const plain = session.raw().slice(secondRawMark);
253
+ expect(session.snapshotSince(secondMark)).toContain('const answer = 42;');
254
+ // Both lines must actually be there. Without this the comparison is asymmetric toward green:
255
+ // a missing second line measures zero sequences and every assertion below passes.
256
+ expect(snippetLine(highlighted)).not.toBe('');
257
+ expect(snippetLine(plain)).not.toBe('');
258
+ const highlightedLine = sgrVocabulary(snippetLine(highlighted));
259
+ const plainLine = sgrVocabulary(snippetLine(plain));
260
+ expect(highlightedLine.size).toBeGreaterThan(plainLine.size);
261
+ expect([...highlightedLine].some((sequence) => !plainLine.has(sequence))).toBe(true);
262
+ },
263
+ CASE_MS,
264
+ );
265
+
266
+ it(
267
+ 'S4: --reduced-motion is reported as pinned for the run, and colour stays on',
268
+ async () => {
269
+ session = spawnTui({
270
+ projectDir,
271
+ homeDir,
272
+ args: ['--session-log', THEME_FIXTURE, '--reduced-motion'],
273
+ env: { FORCE_COLOR: '3' },
274
+ });
275
+ await session.waitFor(PROMPT, BOOT_MS);
276
+
277
+ const mark = session.outputOffset();
278
+ const rawMark = session.raw().length;
279
+ await session.sendKeys('/theme list');
280
+ await session.pressEnter();
281
+ await session.waitForSince(mark, /Available themes:/, STEP_MS);
282
+ const listed = session.snapshotSince(mark);
283
+ // The pin is reported, not silently obeyed.
284
+ // What THIS RUN does, and what is saved — two facts, never one in place of the other. This
285
+ // line is the defect TC-13 found: it read `reduced motion: off (… pinned by flag)`.
286
+ expect(listed).toContain('reduced motion: on for this run (pinned by flag; saved off)');
287
+ // Colour is untouched by the motion decision.
288
+ expect(sgrVocabulary(session.raw().slice(rawMark)).size).toBeGreaterThan(MINIMUM_SGR_VARIETY);
289
+ },
290
+ CASE_MS,
291
+ );
292
+ });