@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,222 @@
1
+ /**
2
+ * SCREEN-2002 TC-11 — the theme file boundary.
3
+ *
4
+ * Everything a user or a plugin can put in a theme file arrives here as text. The contract is
5
+ * whole-file refusal with a path-named diagnostic: a file is either applied entirely or skipped
6
+ * entirely, because a partially-applied theme is the state where a reader cannot tell which colours
7
+ * are theirs and which are the base's.
8
+ */
9
+ import { describe, expect, it } from 'vitest';
10
+
11
+ import { DARK_THEME, LIGHT_THEME } from '../built-in-themes.js';
12
+ import { parseThemeDocument } from '../theme-document.js';
13
+
14
+ import type { TThemeDocumentResult } from '../theme-document.js';
15
+
16
+ function parse(text: string, id = 'custom:mine'): TThemeDocumentResult {
17
+ return parseThemeDocument({ id, fileName: 'mine.json', source: 'user', text });
18
+ }
19
+
20
+ function expectRefusal(text: string): string {
21
+ const result = parse(text);
22
+ expect(result.ok).toBe(false);
23
+ if (result.ok) throw new Error('expected a refusal');
24
+ return result.error;
25
+ }
26
+
27
+ describe('parseThemeDocument', () => {
28
+ it('applies a sparse override over its base and leaves every other token alone', () => {
29
+ const result = parse(
30
+ JSON.stringify({
31
+ name: 'Mine',
32
+ base: 'light',
33
+ overrides: { colors: { text: { accent: '#56b4e9' } } },
34
+ }),
35
+ );
36
+ expect(result.ok).toBe(true);
37
+ if (!result.ok) throw new Error('expected a theme');
38
+ expect(result.theme.id).toBe('custom:mine');
39
+ expect(result.theme.name).toBe('Mine');
40
+ expect(result.theme.source).toBe('user');
41
+ expect(result.theme.appearance).toBe(LIGHT_THEME.appearance);
42
+ expect(result.theme.colors.text.accent).toBe('#56b4e9');
43
+ expect(result.theme.colors.text.muted).toBe(LIGHT_THEME.colors.text.muted);
44
+ expect(result.theme.syntax).toEqual(LIGHT_THEME.syntax);
45
+ expect(result.theme.motion).toEqual(LIGHT_THEME.motion);
46
+ });
47
+
48
+ it('defaults to the dark base and to the id as a name', () => {
49
+ const result = parse('{}');
50
+ expect(result.ok).toBe(true);
51
+ if (!result.ok) throw new Error('expected a theme');
52
+ expect(result.theme.name).toBe('custom:mine');
53
+ expect(result.theme.colors).toEqual(DARK_THEME.colors);
54
+ });
55
+
56
+ it('refuses an unknown token path, naming it', () => {
57
+ const error = expectRefusal(
58
+ JSON.stringify({ overrides: { colors: { text: { accnt: 'red' } } } }),
59
+ );
60
+ expect(error).toBe('$.overrides.colors.text.accnt is not a theme token');
61
+ });
62
+
63
+ it('refuses an invalid colour value, naming its path and the value', () => {
64
+ const error = expectRefusal(
65
+ JSON.stringify({ overrides: { colors: { text: { accent: 'not-a-colour' } } } }),
66
+ );
67
+ expect(error).toMatch(/^\$\.overrides\.colors\.text\.accent: "not-a-colour" is not a colour/u);
68
+ });
69
+
70
+ const ESCAPE = String.fromCharCode(27);
71
+
72
+ it('refuses a raw SGR string — the grammar is the injection floor', () => {
73
+ const error = expectRefusal(
74
+ JSON.stringify({ overrides: { markdown: { code: `${ESCAPE}[31m` } } }),
75
+ );
76
+ expect(error).toMatch(/^\$\.overrides\.markdown\.code: /u);
77
+ });
78
+
79
+ it('keeps the refusal itself off the terminal — the message quotes what it rejected', () => {
80
+ // The diagnostic is the one part of a refused file that reaches a terminal, and it quotes the
81
+ // value. Interpolating it raw defeats the grammar's injection floor with the very message that
82
+ // reports a violation of it — and a plugin's theme file is third-party content.
83
+ const errors = [
84
+ expectRefusal(JSON.stringify({ overrides: { markdown: { code: `${ESCAPE}[2J` } } })),
85
+ expectRefusal(JSON.stringify({ overrides: { [`${ESCAPE}[2Jx`]: {} } })),
86
+ expectRefusal(JSON.stringify({ [`${ESCAPE}[2Jx`]: 1 })),
87
+ expectRefusal(`{ ${ESCAPE}[2J not json`),
88
+ expectRefusal(JSON.stringify({ base: `${ESCAPE}[2J` })),
89
+ ];
90
+ for (const error of errors) expect(error).not.toContain(ESCAPE);
91
+ // Escaped, not dropped: an author has to be able to SEE what their file holds.
92
+ expect(errors[0]).toContain('\\u001b[2J');
93
+ });
94
+
95
+ it('escapes the 8-BIT spelling too, which JSON.stringify leaves standing', () => {
96
+ // A terminal accepts `ESC [` and the single byte U+009B as the same CSI, and `JSON.stringify`
97
+ // escapes only U+0000-U+001F — so quoting the 7-bit spelling alone leaves the other one's
98
+ // parameters as a live sequence. `sanitize-terminal-text.ts` states the same rule.
99
+ const csi = String.fromCharCode(0x9b);
100
+ const osc = String.fromCharCode(0x9d);
101
+ const del = String.fromCharCode(0x7f);
102
+ const errors = [
103
+ expectRefusal(JSON.stringify({ overrides: { markdown: { code: `${csi}2J` } } })),
104
+ expectRefusal(JSON.stringify({ base: `${osc}0;x` })),
105
+ expectRefusal(JSON.stringify({ [`${del}x`]: 1 })),
106
+ ];
107
+ for (const error of errors) {
108
+ expect(error).not.toContain(csi);
109
+ expect(error).not.toContain(osc);
110
+ expect(error).not.toContain(del);
111
+ }
112
+ expect(errors[0]).toContain('\\u009b2J');
113
+ });
114
+
115
+ it('holds the MINTED id to the LENGTH bound too, not only the control-character one', () => {
116
+ // The id becomes the rendered name when a document supplies none, so a bound on one and not the
117
+ // other leaves the row it is drawn in exactly as broken.
118
+ const tooLong = parseThemeDocument({
119
+ id: `custom:${'x'.repeat(60)}`,
120
+ fileName: 'mine.json',
121
+ source: 'user',
122
+ text: '{}',
123
+ });
124
+ expect(tooLong.ok).toBe(false);
125
+ if (tooLong.ok) throw new Error('expected a refusal');
126
+ expect(tooLong.error).toMatch(/^\$\.name: the minted id must be at most 60 characters$/u);
127
+
128
+ // A 60-character id is accepted, which is the bound itself rather than a value near it. The
129
+ // RELATION to what `agent-cli` can mint is not asserted here — this package does not depend on
130
+ // that one — but in `theme-sources.test.ts`, where the longest mintable id is loaded end to end
131
+ // with its fixture derived from `MAX_ID_SEGMENT`, so widening that bound turns it red.
132
+ const composite = parseThemeDocument({
133
+ id: `custom:${'p'.repeat(53)}`,
134
+ fileName: 'mine.json',
135
+ source: 'plugin',
136
+ text: '{}',
137
+ });
138
+ expect(composite.ok).toBe(true);
139
+ });
140
+
141
+ it('checks the MINTED id on the same terms as a name the file supplied', () => {
142
+ const result = parseThemeDocument({
143
+ id: `custom:${String.fromCharCode(0x9b)}2J`,
144
+ fileName: 'mine.json',
145
+ source: 'user',
146
+ text: '{}',
147
+ });
148
+ expect(result.ok).toBe(false);
149
+ });
150
+
151
+ it('refuses a name it would then render for the whole session', () => {
152
+ // Unlike a diagnostic, a name is APPLIED: it is drawn on every `/theme list` row and every
153
+ // picker row until the setting changes.
154
+ expect(expectRefusal(JSON.stringify({ name: `${ESCAPE}[2JPWNED` }))).toBe(
155
+ '$.name: must not contain control characters',
156
+ );
157
+ expect(expectRefusal(JSON.stringify({ name: 'two\nlines' }))).toBe(
158
+ '$.name: must not contain control characters',
159
+ );
160
+ expect(expectRefusal(JSON.stringify({ name: 'x'.repeat(61) }))).toMatch(
161
+ /^\$\.name: must be at most 60 characters$/u,
162
+ );
163
+ });
164
+
165
+ it('refuses an explicit null rather than reading it as "unset"', () => {
166
+ expect(expectRefusal(JSON.stringify({ base: null }))).toBe(
167
+ '$.base: expected a built-in theme id',
168
+ );
169
+ expect(expectRefusal(JSON.stringify({ overrides: null }))).toMatch(
170
+ /^\$\.overrides: expected an object of tokens$/u,
171
+ );
172
+ });
173
+
174
+ it('refuses a background chalk name as a foreground token value', () => {
175
+ const error = expectRefusal(
176
+ JSON.stringify({ overrides: { colors: { border: { focused: 'bgRed' } } } }),
177
+ );
178
+ expect(error).toMatch(/^\$\.overrides\.colors\.border\.focused: "bgRed" is not a colour/u);
179
+ });
180
+
181
+ it('refuses a malformed file WHOLE, naming the file root', () => {
182
+ expect(expectRefusal('{ not json')).toMatch(/^\$: /u);
183
+ });
184
+
185
+ it('refuses an unknown base', () => {
186
+ expect(expectRefusal(JSON.stringify({ base: 'solarized' }))).toBe(
187
+ '$.base: "solarized" is not a built-in theme',
188
+ );
189
+ });
190
+
191
+ it('refuses an unknown top-level key', () => {
192
+ expect(expectRefusal(JSON.stringify({ colors: {} }))).toBe('$.colors is not a theme token');
193
+ });
194
+
195
+ it('refuses a token group replaced by a scalar, and a colour replaced by an object', () => {
196
+ expect(expectRefusal(JSON.stringify({ overrides: { colors: 'red' } }))).toMatch(
197
+ /^\$\.overrides\.colors: /u,
198
+ );
199
+ expect(
200
+ expectRefusal(JSON.stringify({ overrides: { colors: { text: { accent: {} } } } })),
201
+ ).toMatch(/^\$\.overrides\.colors\.text\.accent: /u);
202
+ });
203
+
204
+ it('refuses a motion ramp that is not exactly four colours', () => {
205
+ expect(
206
+ expectRefusal(JSON.stringify({ overrides: { motion: { wave: ['red', 'blue'] } } })),
207
+ ).toMatch(/^\$\.overrides\.motion\.wave: /u);
208
+ const ok = parse(
209
+ JSON.stringify({ overrides: { motion: { wave: ['red', 'blue', 'green', 'cyan'] } } }),
210
+ );
211
+ expect(ok.ok).toBe(true);
212
+ });
213
+
214
+ it('refuses a name that is not a non-empty string', () => {
215
+ expect(expectRefusal(JSON.stringify({ name: '' }))).toBe('$.name: expected a non-empty string');
216
+ });
217
+
218
+ it('never lets a document reach the prototype', () => {
219
+ const error = expectRefusal('{"overrides":{"colors":{"text":{"__proto__":{"a":true}}}}}');
220
+ expect(error).toBe('$.overrides.colors.text.__proto__ is not a theme token');
221
+ });
222
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * SCREEN-2002 TC-09/TC-10 — the registry and the catalogue port.
3
+ *
4
+ * The property worth guarding is that an unknown id NAMES itself. Resolving silently would make a
5
+ * removed plugin theme look like a theme that stopped working, and the id is the only thing that
6
+ * can point at the cause.
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+
10
+ import { DARK_THEME, LIGHT_THEME, listBuiltInThemes } from '../built-in-themes.js';
11
+ import {
12
+ createThemeCataloguePort,
13
+ createThemeRegistry,
14
+ formatUnknownThemeNotice,
15
+ } from '../theme-registry.js';
16
+
17
+ import type { IAppearanceSettings } from '@robota-sdk/agent-interface-command';
18
+
19
+ const APPEARANCE: IAppearanceSettings = {
20
+ theme: 'dark',
21
+ syntaxHighlighting: true,
22
+ reducedMotion: false,
23
+ };
24
+
25
+ describe('the theme registry (SCREEN-2002 TC-09)', () => {
26
+ it('lists the built-ins and finds one by id', () => {
27
+ const registry = createThemeRegistry();
28
+
29
+ expect(registry.list().map((theme) => theme.id)).toEqual([
30
+ 'dark',
31
+ 'light',
32
+ 'dark-daltonized',
33
+ 'light-daltonized',
34
+ ]);
35
+ expect(registry.get('light')?.name).toBe(LIGHT_THEME.name);
36
+ expect(registry.get('nope')).toBeUndefined();
37
+ });
38
+
39
+ it('resolves a known id without naming a miss', () => {
40
+ const resolution = createThemeRegistry().resolve('light');
41
+
42
+ expect(resolution.theme.id).toBe('light');
43
+ expect(resolution.unknownId).toBeUndefined();
44
+ });
45
+
46
+ it('falls back to the default AND names the id it could not find', () => {
47
+ const resolution = createThemeRegistry().resolve('solarized');
48
+
49
+ expect(resolution.theme.id).toBe(DARK_THEME.id);
50
+ // The whole point: the caller can say WHICH theme is missing.
51
+ expect(resolution.unknownId).toBe('solarized');
52
+ expect(formatUnknownThemeNotice('solarized')).toContain('solarized');
53
+ });
54
+
55
+ it('treats an absent id as no request at all, not as a miss', () => {
56
+ const resolution = createThemeRegistry().resolve(undefined);
57
+
58
+ expect(resolution.theme.id).toBe(DARK_THEME.id);
59
+ expect(resolution.unknownId).toBeUndefined();
60
+ });
61
+
62
+ it('carries a caller-supplied catalogue, which is how work unit 3 adds user themes', () => {
63
+ const registry = createThemeRegistry([LIGHT_THEME]);
64
+
65
+ expect(registry.list()).toEqual([LIGHT_THEME]);
66
+ expect(registry.get('dark')).toBeUndefined();
67
+ });
68
+ });
69
+
70
+ describe('the catalogue port (SCREEN-2002 TC-09)', () => {
71
+ it('projects identity and source, never a colour', () => {
72
+ const port = createThemeCataloguePort({
73
+ registry: createThemeRegistry(),
74
+ readAppearance: () => APPEARANCE,
75
+ });
76
+
77
+ const entry = port.getTheme('dark');
78
+
79
+ expect(entry).toEqual({
80
+ id: 'dark',
81
+ name: DARK_THEME.name,
82
+ appearance: DARK_THEME.appearance,
83
+ source: DARK_THEME.source,
84
+ });
85
+ // A command asks about ids; colours belong to whatever is rendering.
86
+ expect(Object.keys(entry ?? {})).not.toContain('colors');
87
+ });
88
+
89
+ it('RE-READS the appearance on every call, so a just-applied patch is visible', () => {
90
+ let stored: IAppearanceSettings = { ...APPEARANCE };
91
+ const port = createThemeCataloguePort({
92
+ registry: createThemeRegistry(),
93
+ readAppearance: () => stored,
94
+ });
95
+
96
+ expect(port.getAppearance().settings.theme).toBe('dark');
97
+ // The host wrote the settings document between the two calls.
98
+ stored = { ...stored, theme: 'light' };
99
+ expect(port.getAppearance().settings.theme).toBe('light');
100
+ });
101
+
102
+ it('omits the override entirely when nothing pinned this run', () => {
103
+ const port = createThemeCataloguePort({
104
+ registry: createThemeRegistry(),
105
+ readAppearance: () => APPEARANCE,
106
+ });
107
+
108
+ expect('reducedMotionPin' in port.getAppearance()).toBe(false);
109
+ });
110
+
111
+ it('reports the tier that pinned reduced motion AND what it pinned', () => {
112
+ // One pair, not two optional fields: a tier alone cannot say which way a run went, because
113
+ // `--no-reduced-motion` is an override too. The shape makes the half-supplied case unwritable.
114
+ const port = createThemeCataloguePort({
115
+ registry: createThemeRegistry(),
116
+ readAppearance: () => APPEARANCE,
117
+ reducedMotionPin: { tier: 'flag', reducedMotion: false },
118
+ });
119
+
120
+ expect(port.getAppearance().reducedMotionPin).toEqual({ tier: 'flag', reducedMotion: false });
121
+ });
122
+ });
123
+
124
+ describe('a registry that also carries what it could not load (SCREEN-2002 TC-11)', () => {
125
+ const skip = { id: 'custom:broken', fileName: 'broken.json', reason: '$.overrides: boom' };
126
+
127
+ it('keeps skipped files out of the themes it can resolve', () => {
128
+ const registry = createThemeRegistry(listBuiltInThemes(), [skip]);
129
+ expect(registry.get('custom:broken')).toBeUndefined();
130
+ expect(registry.list().some((theme) => theme.id === 'custom:broken')).toBe(false);
131
+ expect(registry.resolve('custom:broken').unknownId).toBe('custom:broken');
132
+ });
133
+
134
+ it('reports them separately, so a surface can SHOW a file it will not apply', () => {
135
+ expect(createThemeRegistry(listBuiltInThemes(), [skip]).skipped()).toEqual([skip]);
136
+ expect(createThemeRegistry().skipped()).toEqual([]);
137
+ });
138
+ });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * SCREEN-2002 TC-01/TC-02 — the token model and the style builder.
3
+ *
4
+ * The load-bearing assertion is the `dark` theme against the values the package shipped before the
5
+ * theme existed: the whole unit is only safe if a user who sets nothing sees the same colours. The
6
+ * four recorded byte exceptions (chalk's paired closers, the builder's SGR chain order, `type` keeping
7
+ * `dim`, and `strong`/`em`/`listitem` not being theme tokens) are asserted by name here rather than
8
+ * discovered later.
9
+ */
10
+ import chalk from 'chalk';
11
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
12
+
13
+ import { BUILT_IN_THEMES, DARK_THEME } from '../built-in-themes.js';
14
+ import { THEME_SYNTAX_KEYS } from '../theme-contracts.js';
15
+ import {
16
+ background,
17
+ diffRowStyles,
18
+ foreground,
19
+ isThemeColor,
20
+ markdownRendererOptions,
21
+ syntaxHighlightTheme,
22
+ } from '../theme-styles.js';
23
+
24
+ /** cli-highlight's own coloured keys, read from the dependency when this test was written. */
25
+ const CLI_HIGHLIGHT_COLOURED_KEYS = [
26
+ 'keyword',
27
+ 'built_in',
28
+ 'type',
29
+ 'literal',
30
+ 'number',
31
+ 'regexp',
32
+ 'string',
33
+ 'class',
34
+ 'function',
35
+ 'comment',
36
+ 'doctag',
37
+ 'meta',
38
+ 'tag',
39
+ 'name',
40
+ 'attr',
41
+ 'addition',
42
+ 'deletion',
43
+ ];
44
+
45
+ const SGR = '\u001b[';
46
+ const TRUECOLOR = 3;
47
+
48
+ // chalk emits nothing when the test process has no TTY; these assertions are ABOUT the bytes, so the
49
+ // level is forced for the file and restored afterwards.
50
+ const originalLevel = chalk.level;
51
+ beforeAll(() => {
52
+ chalk.level = TRUECOLOR;
53
+ });
54
+ afterAll(() => {
55
+ chalk.level = originalLevel;
56
+ });
57
+
58
+ describe('theme colour grammar (SCREEN-2002 TC-01)', () => {
59
+ it('accepts Inks grammar and nothing else', () => {
60
+ for (const value of [
61
+ 'cyan',
62
+ 'yellowBright',
63
+ 'gray',
64
+ '#abc',
65
+ '#123456',
66
+ 'ansi256(22)',
67
+ 'rgb(1, 2, 3)',
68
+ ]) {
69
+ expect(isThemeColor(value)).toBe(true);
70
+ }
71
+ for (const value of [
72
+ '',
73
+ 'nosuchcolour',
74
+ '#12345',
75
+ 'ansi256(256)',
76
+ 'rgb(1,2)',
77
+ `${SGR}31m`,
78
+ 'cyan bold',
79
+ // A chalk BACKGROUND name is a real chalk style but never a token value: every token in the
80
+ // model is a foreground, and `background()` derives the `bg…` name from it.
81
+ 'bgRed',
82
+ 'bgCyan',
83
+ ]) {
84
+ expect(isThemeColor(value)).toBe(false);
85
+ }
86
+ });
87
+
88
+ it('builds a complete cli-highlight theme so no key falls back to its red/green default', () => {
89
+ expect([...THEME_SYNTAX_KEYS]).toEqual(CLI_HIGHLIGHT_COLOURED_KEYS);
90
+ for (const theme of BUILT_IN_THEMES) {
91
+ const built = syntaxHighlightTheme(theme.syntax);
92
+ expect(Object.keys(built).sort()).toEqual([...CLI_HIGHLIGHT_COLOURED_KEYS].sort());
93
+ }
94
+ });
95
+ });
96
+
97
+ describe('the dark theme reproduces todays rendering (SCREEN-2002 TC-02)', () => {
98
+ it('keeps every Ink colour value the package shipped', () => {
99
+ expect(DARK_THEME.colors).toEqual({
100
+ text: {
101
+ accent: 'cyan',
102
+ emphasis: 'white',
103
+ success: 'green',
104
+ warning: 'yellow',
105
+ error: 'red',
106
+ session: 'magenta',
107
+ muted: 'gray',
108
+ onAccent: 'black',
109
+ },
110
+ border: {
111
+ attention: 'yellow',
112
+ focused: 'cyan',
113
+ active: 'green',
114
+ muted: 'gray',
115
+ error: 'red',
116
+ },
117
+ status: {
118
+ running: 'yellow',
119
+ success: 'green',
120
+ error: 'red',
121
+ denied: 'yellowBright',
122
+ waiting: 'yellow',
123
+ cancelled: 'yellow',
124
+ idle: 'gray',
125
+ },
126
+ });
127
+ expect(DARK_THEME.motion.wave).toEqual(['#555555', '#777777', '#999999', '#bbbbbb']);
128
+ });
129
+
130
+ it('renders each markdown token in marked-terminals own colour, with its structure fixed', () => {
131
+ const options = markdownRendererOptions(DARK_THEME.markdown);
132
+ // Byte equality against marked-terminal's own defaults: the builder reproduces each chain in the
133
+ // dependency's order, so the `dark` theme leaves markdown rendering untouched.
134
+ expect(options.heading?.('H')).toBe(chalk.green.bold('H'));
135
+ expect(options.code?.('C')).toBe(chalk.yellow('C'));
136
+ expect(options.codespan?.('C')).toBe(chalk.yellow('C'));
137
+ expect(options.link?.('L')).toBe(chalk.blue('L'));
138
+ expect(options.html?.('X')).toBe(chalk.gray('X'));
139
+ expect(options.firstHeading?.('F')).toBe(chalk.magenta.underline.bold('F'));
140
+ expect(options.blockquote?.('B')).toBe(chalk.gray.italic('B'));
141
+ expect(options.del?.('D')).toBe(chalk.dim.gray.strikethrough('D'));
142
+ expect(options.href?.('A')).toBe(chalk.blue.underline('A'));
143
+ // `strong`, `em`, `listitem` and `hr` are NOT theme tokens: the builder never supplies them, so
144
+ // marked-terminal keeps `chalk.bold` / `chalk.italic` / `chalk.reset` under every theme.
145
+ expect(options.strong).toBeUndefined();
146
+ expect(options.em).toBeUndefined();
147
+ expect(options.listitem).toBeUndefined();
148
+ expect(options.hr).toBeUndefined();
149
+ });
150
+
151
+ it('keeps cli-highlights `type` dim while taking its colour from the theme', () => {
152
+ const syntax = syntaxHighlightTheme(DARK_THEME.syntax);
153
+ expect(syntax.type?.('T')).toBe(chalk.cyan.dim('T'));
154
+ expect(syntax.keyword?.('K')).toBe(chalk.blue('K'));
155
+ expect(syntax.string?.('S')).toBe(chalk.red('S'));
156
+ });
157
+
158
+ it('paints diff rows with the former ANSI background/foreground pair', () => {
159
+ const styles = diffRowStyles(DARK_THEME);
160
+ const added = styles.added('+row');
161
+ // The former literal escapes: 48;5;22 background, 38;5;120 foreground.
162
+ expect(added).toContain(`${SGR}48;5;22m`);
163
+ expect(added).toContain(`${SGR}38;5;120m`);
164
+ const removed = styles.removed('-row');
165
+ expect(removed).toContain(`${SGR}48;5;52m`);
166
+ expect(removed).toContain(`${SGR}38;5;210m`);
167
+ // The hunk header takes the theme's colour; the `diff `/`index ` metadata rows take dim over the
168
+ // INHERITED foreground, exactly as this renderer always wrote them — no colour, no exception.
169
+ expect(styles.hunk('@@ -1 +1 @@')).toBe(chalk.cyan('@@ -1 +1 @@'));
170
+ expect(styles.meta('diff --git a/x b/x')).toBe(chalk.dim('diff --git a/x b/x'));
171
+ // Recorded exception: chalk closes with the paired resets, not the blanket reset.
172
+ expect(added.endsWith(`${SGR}0m`)).toBe(false);
173
+ expect(added).toContain(`${SGR}39m`);
174
+ expect(added).toContain(`${SGR}49m`);
175
+ });
176
+
177
+ it('refuses a background name where a foreground was asked for', () => {
178
+ // `isThemeColor` refuses `bgRed` as a token value; the builder must agree rather than hand back
179
+ // a background style. The style table holds the `bg…` entries for `background()`'s use.
180
+ expect(() => foreground('bgRed')).toThrow(/bgRed/u);
181
+ expect(background('red')('x')).toBe(chalk.bgRed('x'));
182
+ });
183
+
184
+ it('THROWS on a value outside the grammar rather than answering with a default style', () => {
185
+ // SCREEN-2002 work unit 3. These two answered the same question differently and silently —
186
+ // `foreground` with its base, `background` with `chalk.reset` — so a value neither could encode
187
+ // rendered as unstyled text in one place and as a reset in another. Unreachable while every
188
+ // value came from a validated built-in; reachable the moment a parsed file reaches the builder,
189
+ // which is why `parseThemeDocument` refuses a file WHOLE and this refuses what gets past it.
190
+ expect(() => foreground('not-a-colour')).toThrow(/not-a-colour/u);
191
+ expect(() => background('not-a-colour')).toThrow(/not-a-colour/u);
192
+ // The refusal names the grammar, because the value came from a file someone has to fix — and
193
+ // it names it COMPLETELY. `HEX` has always accepted the three-digit form, so a grammar string
194
+ // that lists only `#rrggbb` tells an author a value the builder accepts is invalid, in the one
195
+ // message they will read about it.
196
+ expect(() => foreground('not-a-colour')).toThrow(/#rgb \| #rrggbb/u);
197
+ });
198
+
199
+ it('accepts the three-digit hex form the grammar advertises', () => {
200
+ expect(isThemeColor('#0f0')).toBe(true);
201
+ expect(foreground('#0f0')('x')).toBe(chalk.hex('#0f0')('x'));
202
+ expect(background('#0f0')('x')).toBe(chalk.bgHex('#0f0')('x'));
203
+ });
204
+
205
+ it('keeps the terminals own colour DEPTH for diff rows when there is one', () => {
206
+ const daltonized = BUILT_IN_THEMES.find((theme) => theme.id === 'dark-daltonized');
207
+ expect(daltonized).toBeDefined();
208
+ const ANSI256 = 2;
209
+ const previous = chalk.level;
210
+ try {
211
+ // A 256-colour terminal: the row must downsample with the rest of the frame, not emit
212
+ // truecolor on its own because the diff renderer forced a level.
213
+ chalk.level = ANSI256;
214
+ const row = diffRowStyles(daltonized ?? DARK_THEME).added('+row');
215
+ expect(row).toContain(`${SGR}38;5;`);
216
+ expect(row).not.toContain(`${SGR}38;2;`);
217
+ // Colour OFF is the one case that IS overridden — the caller asked for colour.
218
+ chalk.level = 0;
219
+ const forced = diffRowStyles(daltonized ?? DARK_THEME).added('+row');
220
+ expect(forced).toContain(`${SGR}38;2;`);
221
+ } finally {
222
+ chalk.level = previous;
223
+ }
224
+ });
225
+
226
+ it('renders a light theme in different bytes from the dark one', () => {
227
+ const light = BUILT_IN_THEMES.find((theme) => theme.id === 'light');
228
+ expect(light).toBeDefined();
229
+ expect(foreground(light?.colors.text.accent ?? 'cyan')('x')).not.toBe(
230
+ foreground(DARK_THEME.colors.text.accent)('x'),
231
+ );
232
+ });
233
+ });