@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.
- package/LICENSE +661 -0
- package/README.md +27 -0
- package/dist/node/index.cjs +32 -0
- package/dist/node/index.d.cts +1066 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1066 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +33 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +93 -0
- package/src/App.tsx +146 -0
- package/src/AppPresentation.tsx +190 -0
- package/src/AppView.tsx +26 -0
- package/src/BackgroundTaskPanel.tsx +73 -0
- package/src/CjkTextInput.tsx +336 -0
- package/src/ConfirmPrompt.tsx +140 -0
- package/src/ContextWarningBanner.tsx +45 -0
- package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
- package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
- package/src/HistorySearchOverlay.tsx +150 -0
- package/src/InputArea.tsx +338 -0
- package/src/ListPicker.tsx +158 -0
- package/src/MenuSelect.tsx +159 -0
- package/src/MessageList.tsx +308 -0
- package/src/MultiSelectList.tsx +294 -0
- package/src/PendingActionPrompt.tsx +121 -0
- package/src/PermissionPrompt.tsx +186 -0
- package/src/PluginTUI.tsx +258 -0
- package/src/RoleLabel.tsx +72 -0
- package/src/SafeText.tsx +66 -0
- package/src/SessionEventNotices.tsx +27 -0
- package/src/SessionPicker.tsx +72 -0
- package/src/SessionStatusBar.tsx +76 -0
- package/src/SlashAutocomplete.tsx +154 -0
- package/src/StatusBar.tsx +284 -0
- package/src/StreamingIndicator.tsx +145 -0
- package/src/SupervisedSessionView.tsx +455 -0
- package/src/TextPrompt.tsx +112 -0
- package/src/ThemePicker.tsx +366 -0
- package/src/ToolCommandOutput.tsx +48 -0
- package/src/ToolDiffBlock.tsx +44 -0
- package/src/TransportTUI.tsx +166 -0
- package/src/TuiInteractionChannel.ts +567 -0
- package/src/UpdateNotice.tsx +18 -0
- package/src/UsageSummaryEntry.tsx +49 -0
- package/src/WaveText.tsx +70 -0
- package/src/__tests__/ListPicker.test.tsx +188 -0
- package/src/__tests__/MenuSelect.test.tsx +127 -0
- package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
- package/src/__tests__/PluginTUI.test.tsx +167 -0
- package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
- package/src/__tests__/SupervisedSessionView-render.test.tsx +24 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +629 -0
- package/src/__tests__/TextPrompt.test.tsx +112 -0
- package/src/__tests__/TransportTUI.test.tsx +124 -0
- package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +284 -0
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +642 -0
- package/src/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/__tests__/abort-after-permission.test.tsx +160 -0
- package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/__tests__/attention-bell-and-marks.test.ts +144 -0
- package/src/__tests__/autocomplete-editing-keys.test.tsx +127 -0
- package/src/__tests__/background-task-panel-countdown.test.tsx +75 -0
- package/src/__tests__/background-task-panel.test.tsx +98 -0
- package/src/__tests__/background-task-row-format.test.ts +152 -0
- package/src/__tests__/channel-factory-integration.test.ts +150 -0
- package/src/__tests__/cjk-defer-submit.test.tsx +67 -0
- package/src/__tests__/cjk-fallback-render.test.tsx +78 -0
- package/src/__tests__/cjk-text-input-flow.test.ts +186 -0
- package/src/__tests__/cjk-text-input.test.ts +191 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
- package/src/__tests__/command-output-summary.test.ts +95 -0
- package/src/__tests__/command-result-handler.test.ts +171 -0
- package/src/__tests__/compact-event-bridge.test.ts +20 -0
- package/src/__tests__/confirm-permission-flow.test.ts +149 -0
- package/src/__tests__/confirm-prompt.test.tsx +103 -0
- package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +45 -0
- package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
- package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
- package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
- package/src/__tests__/external-prompt-notice.test.tsx +100 -0
- package/src/__tests__/fixtures/command-handoff-driver.tsx +111 -0
- package/src/__tests__/fixtures/fake-editor.sh +7 -0
- package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
- package/src/__tests__/fork-attach.test.tsx +235 -0
- package/src/__tests__/helpers/terminal-profiles.ts +152 -0
- package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
- package/src/__tests__/history-clear-broadcast.test.ts +66 -0
- package/src/__tests__/history-search-overlay.test.tsx +329 -0
- package/src/__tests__/humanize-tool-name.test.ts +30 -0
- package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
- package/src/__tests__/input-area-flow.test.ts +199 -0
- package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
- package/src/__tests__/interaction-channel-charter.test.ts +13 -0
- package/src/__tests__/key-hint-consistency.test.tsx +249 -0
- package/src/__tests__/key-hint-footer.test.tsx +69 -0
- package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
- package/src/__tests__/message-list-rendering.test.tsx +473 -0
- package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
- package/src/__tests__/output-style-reaches-session.test.ts +35 -0
- package/src/__tests__/palette-consistency.test.ts +330 -0
- package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
- package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
- package/src/__tests__/product-display-name.test.tsx +79 -0
- package/src/__tests__/prompt-queue.test.tsx +260 -0
- package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
- package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
- package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
- package/src/__tests__/pty/behavior-2437-git.ptytest.ts +342 -0
- package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
- package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
- package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
- package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
- package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
- package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +291 -0
- package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +165 -0
- package/src/__tests__/pty/ime-cursor.ptytest.ts +186 -0
- package/src/__tests__/pty/isolated-home.ts +64 -0
- package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
- package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
- package/src/__tests__/pty/pty-driver.ts +153 -0
- package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +134 -0
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +138 -0
- package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
- package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
- package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
- package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
- package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
- package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
- package/src/__tests__/pty/spawn-pty.test.ts +120 -0
- package/src/__tests__/pty/spawn-pty.ts +229 -0
- package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
- package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
- package/src/__tests__/pty-driver-dispose.test.ts +43 -0
- package/src/__tests__/real-cursor-positioning.test.tsx +263 -0
- package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
- package/src/__tests__/render-channel-options.test.ts +201 -0
- package/src/__tests__/render-lifecycle.test.ts +36 -0
- package/src/__tests__/render-markdown.test.ts +164 -0
- package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
- package/src/__tests__/safe-text-boundary.test.tsx +75 -0
- package/src/__tests__/screen-reader-input.test.tsx +106 -0
- package/src/__tests__/screen-reader-labels.test.ts +91 -0
- package/src/__tests__/screen-reader-menus.test.tsx +200 -0
- package/src/__tests__/screen-reader-pacing.test.ts +230 -0
- package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
- package/src/__tests__/screen-reader-render-options.test.ts +262 -0
- package/src/__tests__/screen-reader-stdout.test.ts +322 -0
- package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
- package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
- package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
- package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
- package/src/__tests__/selection-flow.test.ts +61 -0
- package/src/__tests__/session-event-notices.test.tsx +31 -0
- package/src/__tests__/session-init-poller.test.ts +102 -0
- package/src/__tests__/session-switch-channel.test.tsx +470 -0
- package/src/__tests__/shutdown-signal.test.ts +34 -0
- package/src/__tests__/status-activity.test.ts +71 -0
- package/src/__tests__/status-bar.test.tsx +282 -0
- package/src/__tests__/status-glyph.test.ts +30 -0
- package/src/__tests__/streaming-indicator.test.tsx +177 -0
- package/src/__tests__/terminal-capabilities.test.ts +153 -0
- package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
- package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
- package/src/__tests__/text-prompt-flow.test.ts +77 -0
- package/src/__tests__/theme-picker.test.tsx +345 -0
- package/src/__tests__/transport-boundary.test.ts +11 -0
- package/src/__tests__/tui-app-channel-port.test.ts +31 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +62 -0
- package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
- package/src/__tests__/tui-interaction-queues.test.ts +139 -0
- package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
- package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
- package/src/__tests__/tui-state-manager.test.ts +451 -0
- package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
- package/src/__tests__/wave-text.test.tsx +133 -0
- package/src/app-banner.tsx +42 -0
- package/src/app-static-items.ts +35 -0
- package/src/app-view-model.ts +136 -0
- package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
- package/src/attention/__tests__/attention-tracker.test.ts +103 -0
- package/src/attention/__tests__/countdown.test.ts +15 -0
- package/src/attention/__tests__/focus-input-filter.test.ts +92 -0
- package/src/attention/__tests__/interval-recap.test.ts +49 -0
- package/src/attention/attention-coordinator.ts +71 -0
- package/src/attention/attention-tracker.ts +124 -0
- package/src/attention/countdown.ts +24 -0
- package/src/attention/focus-input-filter.ts +131 -0
- package/src/attention/interval-recap.ts +148 -0
- package/src/attention/time-units.ts +6 -0
- package/src/attention-bell.ts +79 -0
- package/src/attributed-user-echo.ts +30 -0
- package/src/background-task-row-format.ts +99 -0
- package/src/command-interaction.ts +8 -0
- package/src/command-output-summary.ts +129 -0
- package/src/create-default-tui-cli-adapter.ts +45 -0
- package/src/execution-workspace-switcher-selection.ts +118 -0
- package/src/execution-workspace-view-model.ts +139 -0
- package/src/external-prompt-notice.tsx +38 -0
- package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
- package/src/flows/__tests__/defer-submit.test.ts +87 -0
- package/src/flows/__tests__/real-cursor-flow.test.ts +124 -0
- package/src/flows/background-focus-flow.ts +44 -0
- package/src/flows/cjk-cursor-motion.ts +75 -0
- package/src/flows/cjk-text-deletion.ts +53 -0
- package/src/flows/cjk-text-input-flow.ts +248 -0
- package/src/flows/cjk-text-input-types.ts +24 -0
- package/src/flows/confirm-prompt-flow.ts +90 -0
- package/src/flows/defer-submit.ts +67 -0
- package/src/flows/fork-attach-flow.ts +62 -0
- package/src/flows/input-area-flow.ts +188 -0
- package/src/flows/permission-prompt-flow.ts +108 -0
- package/src/flows/real-cursor-flow.ts +83 -0
- package/src/flows/selection-flow.ts +205 -0
- package/src/flows/session-init-poller.ts +76 -0
- package/src/flows/text-prompt-flow.ts +93 -0
- package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
- package/src/history-search/history-search-flow.ts +94 -0
- package/src/history-search/useHistorySearch.ts +336 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +74 -0
- package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
- package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
- package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
- package/src/hooks/command-result-handler.ts +44 -0
- package/src/hooks/side-effects-types.ts +45 -0
- package/src/hooks/useAppController.ts +267 -0
- package/src/hooks/useAppInputBindings.ts +145 -0
- package/src/hooks/useAppInteractionState.ts +81 -0
- package/src/hooks/useAppLifecycleState.ts +81 -0
- package/src/hooks/useAppOverlays.ts +74 -0
- package/src/hooks/useAppScreenState.ts +81 -0
- package/src/hooks/useAppSubmissionState.ts +45 -0
- package/src/hooks/useAppThemeState.ts +157 -0
- package/src/hooks/useAppWorkspaceState.ts +149 -0
- package/src/hooks/useAppearanceSettings.ts +30 -0
- package/src/hooks/useAutocomplete.ts +101 -0
- package/src/hooks/useBackgroundPanel.ts +58 -0
- package/src/hooks/useCountdownTick.ts +24 -0
- package/src/hooks/useExecutionDetailPage.ts +69 -0
- package/src/hooks/useForkAttach.ts +60 -0
- package/src/hooks/useInputAreaHistorySearch.ts +72 -0
- package/src/hooks/useInputAreaKeys.ts +114 -0
- package/src/hooks/useNumberedSelection.ts +76 -0
- package/src/hooks/usePluginCallbacks.ts +38 -0
- package/src/hooks/usePluginScreenData.ts +85 -0
- package/src/hooks/useRealCursorPosition.ts +125 -0
- package/src/hooks/useRenderMarkdown.ts +38 -0
- package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
- package/src/hooks/useSideEffects.ts +144 -0
- package/src/hooks/useStatusLineSettings.ts +50 -0
- package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
- package/src/hooks/useTuiChannel.ts +129 -0
- package/src/humanize-tool-name.ts +51 -0
- package/src/index.ts +56 -0
- package/src/input-area-rules.tsx +71 -0
- package/src/json-value.ts +18 -0
- package/src/key-hint-footer.tsx +54 -0
- package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
- package/src/keybindings/__tests__/node-keybindings-source.test.ts +111 -0
- package/src/keybindings/keybinding-catalogue.ts +171 -0
- package/src/keybindings/keybinding-registry.ts +303 -0
- package/src/keybindings/keybinding-syntax.ts +155 -0
- package/src/keybindings/keybindings-context.tsx +124 -0
- package/src/keybindings/node-keybindings-source.ts +189 -0
- package/src/numbered-list.tsx +113 -0
- package/src/plugin-tui-handlers.ts +163 -0
- package/src/product-display-name-context.tsx +30 -0
- package/src/render-markdown.ts +209 -0
- package/src/render.tsx +544 -0
- package/src/sanitize-terminal-text.ts +171 -0
- package/src/screen-reader-announcement.ts +52 -0
- package/src/screen-reader-context.tsx +32 -0
- package/src/screen-reader-labels.ts +64 -0
- package/src/screen-reader-pacing-context.tsx +47 -0
- package/src/screen-reader-pacing.ts +178 -0
- package/src/screen-reader-stdout.ts +328 -0
- package/src/shutdown-signal.ts +34 -0
- package/src/status-activity.ts +70 -0
- package/src/status-glyph.ts +78 -0
- package/src/terminal-capabilities-context.tsx +27 -0
- package/src/terminal-capabilities.ts +77 -0
- package/src/terminal-focus-reporting.ts +50 -0
- package/src/terminal-handoff-controller.ts +98 -0
- package/src/terminal-marks.ts +63 -0
- package/src/theme/__tests__/built-in-themes.test.ts +117 -0
- package/src/theme/__tests__/color-vision.test.ts +82 -0
- package/src/theme/__tests__/theme-context.test.tsx +146 -0
- package/src/theme/__tests__/theme-document.test.ts +222 -0
- package/src/theme/__tests__/theme-registry.test.ts +138 -0
- package/src/theme/__tests__/theme-styles.test.ts +233 -0
- package/src/theme/built-in-themes.ts +342 -0
- package/src/theme/color-vision.ts +171 -0
- package/src/theme/index.ts +45 -0
- package/src/theme/theme-context.tsx +81 -0
- package/src/theme/theme-contracts.ts +139 -0
- package/src/theme/theme-document.ts +278 -0
- package/src/theme/theme-registry.ts +127 -0
- package/src/theme/theme-styles.ts +177 -0
- package/src/tool-summary-status.ts +59 -0
- package/src/tui-app-channel-port.ts +83 -0
- package/src/tui-channel-lifecycle-coordinator.ts +149 -0
- package/src/tui-channel-options.ts +172 -0
- package/src/tui-cli-adapter-context.tsx +13 -0
- package/src/tui-cli-adapter.ts +24 -0
- package/src/tui-interaction-queues.ts +163 -0
- package/src/tui-session-binding.ts +52 -0
- package/src/tui-session-event-projector.ts +140 -0
- package/src/tui-session-events.ts +82 -0
- package/src/tui-session-notice-store.ts +43 -0
- package/src/tui-session-options.ts +118 -0
- package/src/tui-state-manager.ts +327 -0
- package/src/types.ts +19 -0
- package/src/use-terminal-title.ts +29 -0
- package/src/utils/__tests__/edit-diff.test.ts +426 -0
- package/src/utils/__tests__/paste-detection.test.ts +116 -0
- package/src/utils/__tests__/paste-labels.test.ts +46 -0
- package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
- package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
- package/src/utils/edit-diff.ts +151 -0
- package/src/utils/input-top-border.ts +19 -0
- package/src/utils/paste-labels.ts +9 -0
- package/src/utils/tool-call-extractor.ts +92 -0
- package/src/utils/tool-diff-summary.ts +75 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TERM-002: unit tests for the TUI terminal-handoff controller LOGIC.
|
|
3
|
+
*
|
|
4
|
+
* The real Ink suspend/resume + child-process TTY handoff can only be validated on a live terminal
|
|
5
|
+
* (manual / User Execution evidence). These tests pin the controller's observable contract: TTY +
|
|
6
|
+
* mounted-App gating, and the suspend → clear → fn → resume order (including resume-on-throw).
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { TerminalHandoffController } from '../terminal-handoff-controller.js';
|
|
11
|
+
|
|
12
|
+
const origStdin = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY');
|
|
13
|
+
const origStdout = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY');
|
|
14
|
+
|
|
15
|
+
function setTty(stdin: boolean, stdout: boolean): void {
|
|
16
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: stdin, configurable: true });
|
|
17
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: stdout, configurable: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (origStdin) Object.defineProperty(process.stdin, 'isTTY', origStdin);
|
|
22
|
+
if (origStdout) Object.defineProperty(process.stdout, 'isTTY', origStdout);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('TerminalHandoffController', () => {
|
|
26
|
+
it('canHandoffTerminal requires a TTY on both streams AND registered App hooks', () => {
|
|
27
|
+
const c = new TerminalHandoffController();
|
|
28
|
+
setTty(true, true);
|
|
29
|
+
expect(c.canHandoffTerminal).toBe(false); // no hooks yet
|
|
30
|
+
|
|
31
|
+
const unregister = c.registerSuspendHooks({ suspend: async () => {}, resume: () => {} });
|
|
32
|
+
expect(c.canHandoffTerminal).toBe(true);
|
|
33
|
+
|
|
34
|
+
setTty(false, true);
|
|
35
|
+
expect(c.canHandoffTerminal).toBe(false); // stdin not a TTY
|
|
36
|
+
|
|
37
|
+
setTty(true, true);
|
|
38
|
+
unregister();
|
|
39
|
+
expect(c.canHandoffTerminal).toBe(false); // App unmounted
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('runs suspend → clear → fn → resume in order', async () => {
|
|
43
|
+
const order: string[] = [];
|
|
44
|
+
const c = new TerminalHandoffController();
|
|
45
|
+
setTty(true, true);
|
|
46
|
+
c.registerSuspendHooks({
|
|
47
|
+
suspend: async () => {
|
|
48
|
+
order.push('suspend');
|
|
49
|
+
},
|
|
50
|
+
resume: () => order.push('resume'),
|
|
51
|
+
});
|
|
52
|
+
c.setInkInstance({ clear: () => order.push('clear') });
|
|
53
|
+
|
|
54
|
+
const result = await c.runWithTerminal(async () => {
|
|
55
|
+
order.push('child');
|
|
56
|
+
return 7;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result).toBe(7);
|
|
60
|
+
expect(order).toEqual(['suspend', 'clear', 'child', 'resume']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('resumes even when the child fn throws', async () => {
|
|
64
|
+
const order: string[] = [];
|
|
65
|
+
const c = new TerminalHandoffController();
|
|
66
|
+
setTty(true, true);
|
|
67
|
+
c.registerSuspendHooks({
|
|
68
|
+
suspend: async () => void order.push('suspend'),
|
|
69
|
+
resume: () => order.push('resume'),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await expect(
|
|
73
|
+
c.runWithTerminal(async () => {
|
|
74
|
+
throw new Error('child failed');
|
|
75
|
+
}),
|
|
76
|
+
).rejects.toThrow('child failed');
|
|
77
|
+
expect(order).toEqual(['suspend', 'resume']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects (does not run fn) when no interactive TTY is available', async () => {
|
|
81
|
+
const c = new TerminalHandoffController();
|
|
82
|
+
setTty(false, false);
|
|
83
|
+
c.registerSuspendHooks({ suspend: async () => {}, resume: () => {} });
|
|
84
|
+
const fn = vi.fn();
|
|
85
|
+
await expect(c.runWithTerminal(fn as never)).rejects.toThrow(/unavailable/);
|
|
86
|
+
expect(fn).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** SCREEN-1992 TC-06: the negotiated terminal modes bracket the handoff. */
|
|
91
|
+
describe('TerminalHandoffController terminal-mode hooks', () => {
|
|
92
|
+
it('runs preSuspend after suspend and postResume after resume, even when the child throws', async () => {
|
|
93
|
+
const order: string[] = [];
|
|
94
|
+
const c = new TerminalHandoffController();
|
|
95
|
+
setTty(true, true);
|
|
96
|
+
c.registerSuspendHooks({
|
|
97
|
+
suspend: async () => {
|
|
98
|
+
order.push('suspend');
|
|
99
|
+
},
|
|
100
|
+
resume: () => order.push('resume'),
|
|
101
|
+
});
|
|
102
|
+
c.setInkInstance({ clear: () => order.push('clear') });
|
|
103
|
+
c.setTerminalModeHooks({
|
|
104
|
+
preSuspend: () => {
|
|
105
|
+
order.push('mode-off');
|
|
106
|
+
},
|
|
107
|
+
postResume: () => {
|
|
108
|
+
order.push('mode-on');
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await expect(
|
|
113
|
+
c.runWithTerminal(async () => {
|
|
114
|
+
order.push('child');
|
|
115
|
+
throw new Error('child failed');
|
|
116
|
+
}),
|
|
117
|
+
).rejects.toThrow('child failed');
|
|
118
|
+
expect(order).toEqual(['suspend', 'mode-off', 'clear', 'child', 'resume', 'mode-on']);
|
|
119
|
+
|
|
120
|
+
c.setTerminalModeHooks(undefined);
|
|
121
|
+
order.length = 0;
|
|
122
|
+
await c.runWithTerminal(async () => order.push('child'));
|
|
123
|
+
expect(order).toEqual(['suspend', 'clear', 'child', 'resume']);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* SCREEN-2670: `preSuspend` may be async — it drains the parked screen-reader output — and the
|
|
128
|
+
* screen is cleared only after it settles. Without the await, `clear` and the child would run
|
|
129
|
+
* while the drain was still pending, and parked frames would land on top of the child's output.
|
|
130
|
+
*/
|
|
131
|
+
it('awaits an async preSuspend before clearing the screen and starting the child', async () => {
|
|
132
|
+
const order: string[] = [];
|
|
133
|
+
const c = new TerminalHandoffController();
|
|
134
|
+
setTty(true, true);
|
|
135
|
+
c.registerSuspendHooks({
|
|
136
|
+
suspend: async () => {
|
|
137
|
+
order.push('suspend');
|
|
138
|
+
},
|
|
139
|
+
resume: () => order.push('resume'),
|
|
140
|
+
});
|
|
141
|
+
c.setInkInstance({ clear: () => order.push('clear') });
|
|
142
|
+
let finishDrain: () => void = () => {};
|
|
143
|
+
const drained = new Promise<void>((resolve) => {
|
|
144
|
+
finishDrain = resolve;
|
|
145
|
+
});
|
|
146
|
+
c.setTerminalModeHooks({
|
|
147
|
+
preSuspend: async () => {
|
|
148
|
+
order.push('drain-start');
|
|
149
|
+
await drained;
|
|
150
|
+
order.push('drain-end');
|
|
151
|
+
},
|
|
152
|
+
postResume: () => {
|
|
153
|
+
order.push('mode-on');
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const run = c.runWithTerminal(async () => order.push('child'));
|
|
158
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
159
|
+
// The drain has not settled: nothing past it may have run yet.
|
|
160
|
+
expect(order).toEqual(['suspend', 'drain-start']);
|
|
161
|
+
finishDrain();
|
|
162
|
+
await run;
|
|
163
|
+
expect(order).toEqual([
|
|
164
|
+
'suspend',
|
|
165
|
+
'drain-start',
|
|
166
|
+
'drain-end',
|
|
167
|
+
'clear',
|
|
168
|
+
'child',
|
|
169
|
+
'resume',
|
|
170
|
+
'mode-on',
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TEST-007 / TERM-002: real-terminal handoff, end-to-end through a PTY.
|
|
3
|
+
*
|
|
4
|
+
* Drives `fixtures/terminal-handoff-driver.tsx` (a real Ink app using the real
|
|
5
|
+
* `TerminalHandoffController` + `useTerminalHandoffSuspension`) inside a pseudo-terminal. This is the
|
|
6
|
+
* automated replacement for the former manual-only smoke: it proves on a real TTY that
|
|
7
|
+
*
|
|
8
|
+
* 1. the handoff is available (`canHandoffTerminal === true` under a PTY),
|
|
9
|
+
* 2. Ink releases raw mode so the inherited child receives the driver's keystrokes, and
|
|
10
|
+
* 3. `runWithTerminal` RETURNS after the child exits and the TUI resumes (regression guard for the
|
|
11
|
+
* event-loop-starvation hang fixed in the controller — without the stdin release the child's
|
|
12
|
+
* exit is never observed and the TUI hangs forever).
|
|
13
|
+
*/
|
|
14
|
+
import { mkdtempSync, readFileSync, rmSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
20
|
+
|
|
21
|
+
import { createPtyEnv } from './pty/isolated-home.js';
|
|
22
|
+
import { spawnPtyFixture } from './pty/spawn-pty.js';
|
|
23
|
+
|
|
24
|
+
import type { IPtyRunSession } from './pty/spawn-pty.js';
|
|
25
|
+
|
|
26
|
+
const FIXTURE = fileURLToPath(new URL('./fixtures/terminal-handoff-driver.tsx', import.meta.url));
|
|
27
|
+
const PACKAGE_DIR = fileURLToPath(new URL('../..', import.meta.url));
|
|
28
|
+
const TEST_TIMEOUT_MS = 30_000;
|
|
29
|
+
|
|
30
|
+
const tempDirs: string[] = [];
|
|
31
|
+
const sessions: IPtyRunSession[] = [];
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
for (const session of sessions.splice(0)) session.dispose();
|
|
35
|
+
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('terminal handoff PTY E2E', () => {
|
|
39
|
+
it(
|
|
40
|
+
'hands the real terminal to a child, receives its input, and resumes the TUI',
|
|
41
|
+
async () => {
|
|
42
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-handoff-pty-')));
|
|
43
|
+
tempDirs.push(dir);
|
|
44
|
+
const outputPath = join(dir, 'result.json');
|
|
45
|
+
|
|
46
|
+
const session = spawnPtyFixture(FIXTURE, {
|
|
47
|
+
argv: [outputPath],
|
|
48
|
+
cwd: PACKAGE_DIR,
|
|
49
|
+
// HARNESS-025: isolated HOME — the child must not read the developer's `~`.
|
|
50
|
+
env: createPtyEnv(),
|
|
51
|
+
});
|
|
52
|
+
sessions.push(session);
|
|
53
|
+
|
|
54
|
+
await session.waitFor('READY canHandoff=true', 10_000);
|
|
55
|
+
await session.waitFor('HANDOFF_STARTED', 10_000);
|
|
56
|
+
// Let the child reach its read() before sending the line.
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
58
|
+
await session.sendKeys('hello-from-pty', 10);
|
|
59
|
+
session.write('\r');
|
|
60
|
+
|
|
61
|
+
// The crux: the handoff must RETURN and the TUI must resume (no hang).
|
|
62
|
+
await session.waitFor('RESUMED exit=0', 10_000);
|
|
63
|
+
expect(await session.expectExit(5_000)).toBe(0);
|
|
64
|
+
|
|
65
|
+
const snapshot = session.snapshot();
|
|
66
|
+
expect(snapshot).toContain('CHILD_GOT:[hello-from-pty]');
|
|
67
|
+
|
|
68
|
+
const result = JSON.parse(readFileSync(outputPath, 'utf8')) as {
|
|
69
|
+
canHandoff: boolean;
|
|
70
|
+
exitCode: number;
|
|
71
|
+
returned: boolean;
|
|
72
|
+
};
|
|
73
|
+
expect(result).toEqual({ canHandoff: true, exitCode: 0, returned: true });
|
|
74
|
+
},
|
|
75
|
+
TEST_TIMEOUT_MS,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
applyTextPromptInput,
|
|
4
|
+
createTextPromptFlowState,
|
|
5
|
+
getTextPromptInputAction,
|
|
6
|
+
type ITextPromptFlowState,
|
|
7
|
+
} from '../flows/text-prompt-flow.js';
|
|
8
|
+
|
|
9
|
+
describe('text prompt flow', () => {
|
|
10
|
+
it('Given typed text When submit is applied Then it emits trimmed submit value', () => {
|
|
11
|
+
const typed = applyTextPromptInput(
|
|
12
|
+
createTextPromptFlowState(),
|
|
13
|
+
{ type: 'insert', value: ' hello ' },
|
|
14
|
+
{ allowEmpty: false },
|
|
15
|
+
).state;
|
|
16
|
+
|
|
17
|
+
const result = applyTextPromptInput(typed, { type: 'submit' }, { allowEmpty: false });
|
|
18
|
+
|
|
19
|
+
expect(result.effect).toEqual({ type: 'submit', value: 'hello' });
|
|
20
|
+
expect(result.state.resolved).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('Given empty required text When submit is applied Then validation error stays in state', () => {
|
|
24
|
+
const result = applyTextPromptInput(
|
|
25
|
+
createTextPromptFlowState(),
|
|
26
|
+
{ type: 'submit' },
|
|
27
|
+
{ allowEmpty: false, validate: (value) => (value.length === 0 ? 'Required' : undefined) },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(result.effect).toEqual({ type: 'none' });
|
|
31
|
+
expect(result.state.error).toBe('Required');
|
|
32
|
+
expect(result.state.resolved).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('Given defaultable empty text When submit is applied Then empty submit is allowed', () => {
|
|
36
|
+
const result = applyTextPromptInput(
|
|
37
|
+
createTextPromptFlowState(),
|
|
38
|
+
{ type: 'submit' },
|
|
39
|
+
{ allowEmpty: true },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(result.effect).toEqual({ type: 'submit', value: '' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('Given text with an error When delete or insert is applied Then the error is cleared', () => {
|
|
46
|
+
const state: ITextPromptFlowState = { value: 'ab', error: 'Invalid', resolved: false };
|
|
47
|
+
|
|
48
|
+
const deleted = applyTextPromptInput(state, { type: 'delete' }, { allowEmpty: false });
|
|
49
|
+
const inserted = applyTextPromptInput(
|
|
50
|
+
state,
|
|
51
|
+
{ type: 'insert', value: 'c' },
|
|
52
|
+
{ allowEmpty: false },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(deleted.state).toMatchObject({ value: 'a', error: undefined });
|
|
56
|
+
expect(inserted.state).toMatchObject({ value: 'abc', error: undefined });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('Given prompt is already resolved When input is applied Then no second effect is emitted', () => {
|
|
60
|
+
const state: ITextPromptFlowState = { value: 'done', resolved: true };
|
|
61
|
+
|
|
62
|
+
const result = applyTextPromptInput(state, { type: 'submit' }, { allowEmpty: false });
|
|
63
|
+
|
|
64
|
+
expect(result.effect).toEqual({ type: 'none' });
|
|
65
|
+
expect(result.state).toBe(state);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('Given raw Ink key info When mapped Then terminal details become prompt actions', () => {
|
|
69
|
+
expect(getTextPromptInputAction('', { escape: true })).toEqual({ type: 'cancel' });
|
|
70
|
+
expect(getTextPromptInputAction('', { return: true })).toEqual({ type: 'submit' });
|
|
71
|
+
expect(getTextPromptInputAction('', { backspace: true })).toEqual({ type: 'delete' });
|
|
72
|
+
expect(getTextPromptInputAction('x', { ctrl: false, meta: false })).toEqual({
|
|
73
|
+
type: 'insert',
|
|
74
|
+
value: 'x',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-2002 TC-10 — the theme picker.
|
|
3
|
+
*
|
|
4
|
+
* Preview-on-move is the behaviour worth pinning: the highlighted row IS the live region's theme
|
|
5
|
+
* while the picker is open, so moving the highlight must call `preview` and LEAVING must not
|
|
6
|
+
* persist it. Selecting does not write — it submits `/theme <id>` through the normal command path,
|
|
7
|
+
* which is what keeps the host the only writer of the settings document.
|
|
8
|
+
*/
|
|
9
|
+
import { render } from 'ink-testing-library';
|
|
10
|
+
import React from 'react';
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { ScreenReaderProvider } from '../screen-reader-context.js';
|
|
14
|
+
|
|
15
|
+
const gateMock = vi.hoisted(() => ({ value: true }));
|
|
16
|
+
// Spread the real module and override ONE export: a hand-listed factory silently drops whatever
|
|
17
|
+
// the module gains later, and the failure lands as "not a function" in an unrelated file.
|
|
18
|
+
vi.mock('../terminal-capabilities.js', async (importOriginal) => ({
|
|
19
|
+
...(await importOriginal<typeof import('../terminal-capabilities.js')>()),
|
|
20
|
+
isInteractiveColorTerminal: (): boolean => gateMock.value,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
gateMock.value = true;
|
|
25
|
+
});
|
|
26
|
+
import { DARK_THEME, LIGHT_THEME, listBuiltInThemes } from '../theme/built-in-themes.js';
|
|
27
|
+
import ThemePicker from '../ThemePicker.js';
|
|
28
|
+
|
|
29
|
+
import type { IAppThemePickerViewModel } from '../hooks/useAppThemeState.js';
|
|
30
|
+
|
|
31
|
+
const ARROW_DOWN = '\x1b[B';
|
|
32
|
+
const ARROW_UP = '\x1b[A';
|
|
33
|
+
const ENTER = '\r';
|
|
34
|
+
const ESCAPE = '\x1b';
|
|
35
|
+
|
|
36
|
+
function picker(overrides: Partial<IAppThemePickerViewModel> = {}): IAppThemePickerViewModel {
|
|
37
|
+
return {
|
|
38
|
+
visible: true,
|
|
39
|
+
themes: listBuiltInThemes(),
|
|
40
|
+
skipped: [],
|
|
41
|
+
activeThemeId: 'dark',
|
|
42
|
+
syntaxHighlighting: true,
|
|
43
|
+
reducedMotion: false,
|
|
44
|
+
preview: vi.fn(),
|
|
45
|
+
select: vi.fn(),
|
|
46
|
+
cancel: vi.fn(),
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function tick(ms = 20): Promise<void> {
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('the theme picker (SCREEN-2002 TC-10)', () => {
|
|
56
|
+
it('lists every theme with its appearance and source, and marks the active one', () => {
|
|
57
|
+
const frame = render(<ThemePicker picker={picker()} />).lastFrame() ?? '';
|
|
58
|
+
|
|
59
|
+
expect(frame).toContain(`${DARK_THEME.name} — dark, built-in (current)`);
|
|
60
|
+
expect(frame).toContain(`${LIGHT_THEME.name} — light, built-in`);
|
|
61
|
+
expect(frame).not.toContain(`${LIGHT_THEME.name} — light, built-in (current)`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('starts focused on the ACTIVE theme, not on the first row', async () => {
|
|
65
|
+
const view = picker({ activeThemeId: 'dark-daltonized' });
|
|
66
|
+
const { stdin } = render(<ThemePicker picker={view} />);
|
|
67
|
+
await tick();
|
|
68
|
+
|
|
69
|
+
stdin.write(ARROW_DOWN);
|
|
70
|
+
await tick();
|
|
71
|
+
|
|
72
|
+
// From `dark-daltonized` (index 2), down lands on `light-daltonized` — not on `light`.
|
|
73
|
+
expect(view.preview).toHaveBeenCalledWith('light-daltonized');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('previews on move, and does not select or cancel while moving', async () => {
|
|
77
|
+
const view = picker();
|
|
78
|
+
const { stdin } = render(<ThemePicker picker={view} />);
|
|
79
|
+
await tick();
|
|
80
|
+
|
|
81
|
+
stdin.write(ARROW_DOWN);
|
|
82
|
+
await tick();
|
|
83
|
+
|
|
84
|
+
expect(view.preview).toHaveBeenCalledWith('light');
|
|
85
|
+
expect(view.select).not.toHaveBeenCalled();
|
|
86
|
+
expect(view.cancel).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('wraps at both ends rather than stopping', async () => {
|
|
90
|
+
const view = picker();
|
|
91
|
+
const { stdin } = render(<ThemePicker picker={view} />);
|
|
92
|
+
await tick();
|
|
93
|
+
|
|
94
|
+
stdin.write(ARROW_UP);
|
|
95
|
+
await tick();
|
|
96
|
+
|
|
97
|
+
expect(view.preview).toHaveBeenCalledWith('light-daltonized');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('selects the focused row, and selects the PREVIEWED one after moving', async () => {
|
|
101
|
+
const view = picker();
|
|
102
|
+
const { stdin } = render(<ThemePicker picker={view} />);
|
|
103
|
+
await tick();
|
|
104
|
+
|
|
105
|
+
stdin.write(ARROW_DOWN);
|
|
106
|
+
await tick();
|
|
107
|
+
stdin.write(ENTER);
|
|
108
|
+
await tick();
|
|
109
|
+
|
|
110
|
+
expect(view.select).toHaveBeenCalledWith('light', {
|
|
111
|
+
syntaxHighlighting: true,
|
|
112
|
+
reducedMotion: false,
|
|
113
|
+
});
|
|
114
|
+
expect(view.select).toHaveBeenCalledTimes(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('cancels on escape WITHOUT selecting — the persisted theme is what remains', async () => {
|
|
118
|
+
const view = picker();
|
|
119
|
+
const { stdin } = render(<ThemePicker picker={view} />);
|
|
120
|
+
await tick();
|
|
121
|
+
|
|
122
|
+
stdin.write(ARROW_DOWN);
|
|
123
|
+
await tick();
|
|
124
|
+
stdin.write(ESCAPE);
|
|
125
|
+
await tick();
|
|
126
|
+
|
|
127
|
+
expect(view.cancel).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(view.select).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('toggles syntax and motion locally, and submits them WITH the theme as one patch', async () => {
|
|
132
|
+
const view = picker();
|
|
133
|
+
const { stdin, lastFrame } = render(<ThemePicker picker={view} />);
|
|
134
|
+
await tick();
|
|
135
|
+
|
|
136
|
+
expect(lastFrame() ?? '').toContain('syntax on · motion on');
|
|
137
|
+
stdin.write('s');
|
|
138
|
+
await tick();
|
|
139
|
+
stdin.write('m');
|
|
140
|
+
await tick();
|
|
141
|
+
expect(lastFrame() ?? '').toContain('syntax off · motion off');
|
|
142
|
+
// Nothing is written until select — a toggle is pending, like the previewed theme.
|
|
143
|
+
expect(view.select).not.toHaveBeenCalled();
|
|
144
|
+
|
|
145
|
+
stdin.write(ENTER);
|
|
146
|
+
await tick();
|
|
147
|
+
|
|
148
|
+
expect(view.select).toHaveBeenCalledWith('dark', {
|
|
149
|
+
syntaxHighlighting: false,
|
|
150
|
+
reducedMotion: true,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('abandons pending toggles on escape, exactly as it abandons the preview', async () => {
|
|
155
|
+
const view = picker();
|
|
156
|
+
const { stdin } = render(<ThemePicker picker={view} />);
|
|
157
|
+
await tick();
|
|
158
|
+
|
|
159
|
+
stdin.write('s');
|
|
160
|
+
await tick();
|
|
161
|
+
stdin.write(ESCAPE);
|
|
162
|
+
await tick();
|
|
163
|
+
|
|
164
|
+
expect(view.cancel).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(view.select).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('seeds the toggles from what is PERSISTED, not from the defaults', async () => {
|
|
169
|
+
const view = picker({ syntaxHighlighting: false, reducedMotion: true });
|
|
170
|
+
const { lastFrame } = render(<ThemePicker picker={view} />);
|
|
171
|
+
await tick();
|
|
172
|
+
|
|
173
|
+
expect(lastFrame() ?? '').toContain('syntax off · motion off');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('admits a motion pin, so the row cannot read `motion on` on a visibly still run', () => {
|
|
177
|
+
const frame =
|
|
178
|
+
render(
|
|
179
|
+
<ThemePicker
|
|
180
|
+
picker={picker({
|
|
181
|
+
reducedMotion: false,
|
|
182
|
+
reducedMotionPin: { tier: 'flag', reducedMotion: true },
|
|
183
|
+
})}
|
|
184
|
+
/>,
|
|
185
|
+
).lastFrame() ?? '';
|
|
186
|
+
|
|
187
|
+
// The saved choice and what THIS RUN does are different facts and are shown as two. A tier
|
|
188
|
+
// alone cannot say which way it went — `--no-reduced-motion` is an override that pins the
|
|
189
|
+
// opposite value — so rendering the saved value beside the tier is how the row ends up reading
|
|
190
|
+
// `motion on (pinned by flag)` on a visibly still run.
|
|
191
|
+
expect(frame).toContain('motion on (this run: motion off, pinned by flag)');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('shows the opposite pin the same way, so the two runs are told apart', () => {
|
|
195
|
+
const frame =
|
|
196
|
+
render(
|
|
197
|
+
<ThemePicker
|
|
198
|
+
picker={picker({
|
|
199
|
+
reducedMotion: true,
|
|
200
|
+
reducedMotionPin: { tier: 'environment', reducedMotion: false },
|
|
201
|
+
})}
|
|
202
|
+
/>,
|
|
203
|
+
).lastFrame() ?? '';
|
|
204
|
+
|
|
205
|
+
expect(frame).toContain('motion off (this run: motion on, pinned by environment)');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('says previews cannot show when colour is off, rather than looking broken', () => {
|
|
209
|
+
gateMock.value = false;
|
|
210
|
+
|
|
211
|
+
const frame = render(<ThemePicker picker={picker()} />).lastFrame() ?? '';
|
|
212
|
+
|
|
213
|
+
expect(frame).toContain('Colour is off for this terminal');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('renders the empty state rather than an empty box', () => {
|
|
217
|
+
const frame = render(<ThemePicker picker={picker({ themes: [] })} />).lastFrame() ?? '';
|
|
218
|
+
|
|
219
|
+
expect(frame).toContain('No themes installed');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('the theme picker in screen-reader mode (SCREEN-2002 TC-10)', () => {
|
|
224
|
+
function renderInMode(view: IAppThemePickerViewModel): ReturnType<typeof render> {
|
|
225
|
+
return render(
|
|
226
|
+
<ScreenReaderProvider enabled>
|
|
227
|
+
<ThemePicker picker={view} />
|
|
228
|
+
</ScreenReaderProvider>,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
it('numbers the rows and prompts for a typed answer instead of drawing a highlight', () => {
|
|
233
|
+
const frame = renderInMode(picker()).lastFrame() ?? '';
|
|
234
|
+
|
|
235
|
+
expect(frame).toContain('1.');
|
|
236
|
+
expect(frame).toContain('4.');
|
|
237
|
+
// No box-drawing chrome in the mode.
|
|
238
|
+
expect(frame).not.toMatch(/[─-╿]/u);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('applies on SELECT and never previews per keystroke — there is no highlight to hear', async () => {
|
|
242
|
+
const view = picker();
|
|
243
|
+
const { stdin } = renderInMode(view);
|
|
244
|
+
await tick();
|
|
245
|
+
|
|
246
|
+
stdin.write('2');
|
|
247
|
+
await tick();
|
|
248
|
+
stdin.write(ENTER);
|
|
249
|
+
await tick();
|
|
250
|
+
|
|
251
|
+
expect(view.select).toHaveBeenCalledWith('light', {
|
|
252
|
+
syntaxHighlighting: true,
|
|
253
|
+
reducedMotion: false,
|
|
254
|
+
});
|
|
255
|
+
expect(view.preview).not.toHaveBeenCalled();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('cancels on the mode s own cancel path', async () => {
|
|
259
|
+
const view = picker();
|
|
260
|
+
const { stdin } = renderInMode(view);
|
|
261
|
+
await tick();
|
|
262
|
+
|
|
263
|
+
stdin.write(ESCAPE);
|
|
264
|
+
await tick();
|
|
265
|
+
|
|
266
|
+
expect(view.cancel).toHaveBeenCalledTimes(1);
|
|
267
|
+
expect(view.select).not.toHaveBeenCalled();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('the theme picker over files it refused (SCREEN-2002 TC-11)', () => {
|
|
272
|
+
const skipped = [
|
|
273
|
+
{ id: 'custom:broken', fileName: 'broken.json', reason: '$.overrides.colors.text.accent: bad' },
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
it('shows a refused file as a row that carries its reason and cannot be chosen', async () => {
|
|
277
|
+
const view = picker({ skipped });
|
|
278
|
+
const { lastFrame, stdin } = render(<ThemePicker picker={view} />);
|
|
279
|
+
const frame = lastFrame() ?? '';
|
|
280
|
+
|
|
281
|
+
expect(frame).toContain('broken.json');
|
|
282
|
+
expect(frame).toContain('$.overrides.colors.text.accent: bad');
|
|
283
|
+
|
|
284
|
+
// Walking past the end of the applicable rows wraps to the first one; it never lands on a file
|
|
285
|
+
// the run has already refused, because selecting it could only fail.
|
|
286
|
+
await tick();
|
|
287
|
+
for (let index = 0; index <= listBuiltInThemes().length; index += 1) {
|
|
288
|
+
stdin.write(ARROW_DOWN);
|
|
289
|
+
await tick();
|
|
290
|
+
}
|
|
291
|
+
stdin.write(ENTER);
|
|
292
|
+
await tick();
|
|
293
|
+
|
|
294
|
+
expect(view.select).toHaveBeenCalledTimes(1);
|
|
295
|
+
const [selectedId] = (view.select as ReturnType<typeof vi.fn>).mock.calls[0] ?? [];
|
|
296
|
+
expect(listBuiltInThemes().map((theme) => theme.id)).toContain(selectedId);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('says nothing about skipped files when there are none', () => {
|
|
300
|
+
expect(render(<ThemePicker picker={picker()} />).lastFrame() ?? '').not.toContain('Skipped');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('numbers only the applicable rows in screen-reader mode', async () => {
|
|
304
|
+
const view = picker({ skipped });
|
|
305
|
+
const { lastFrame, stdin } = render(
|
|
306
|
+
<ScreenReaderProvider enabled>
|
|
307
|
+
<ThemePicker picker={view} />
|
|
308
|
+
</ScreenReaderProvider>,
|
|
309
|
+
);
|
|
310
|
+
const frame = lastFrame() ?? '';
|
|
311
|
+
|
|
312
|
+
expect(frame).toContain('broken.json');
|
|
313
|
+
expect(frame).not.toMatch(/5\.\s*Skipped/u);
|
|
314
|
+
|
|
315
|
+
await tick();
|
|
316
|
+
stdin.write('5');
|
|
317
|
+
await tick();
|
|
318
|
+
stdin.write(ENTER);
|
|
319
|
+
await tick();
|
|
320
|
+
|
|
321
|
+
expect(view.select).not.toHaveBeenCalled();
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe('the toggles row names a route the mode can take (SCREEN-2002 TC-11)', () => {
|
|
326
|
+
it('names the commands in screen-reader mode, where `s` and `m` are not bound', () => {
|
|
327
|
+
const frame =
|
|
328
|
+
render(
|
|
329
|
+
<ScreenReaderProvider enabled>
|
|
330
|
+
<ThemePicker picker={picker()} />
|
|
331
|
+
</ScreenReaderProvider>,
|
|
332
|
+
).lastFrame() ?? '';
|
|
333
|
+
|
|
334
|
+
expect(frame).toContain('syntax on · motion on');
|
|
335
|
+
expect(frame).toContain('/theme syntax on|off');
|
|
336
|
+
expect(frame).toContain('/theme motion on|off');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('does NOT name them in sighted mode, where the keys are bound and hinted', () => {
|
|
340
|
+
const frame = render(<ThemePicker picker={picker()} />).lastFrame() ?? '';
|
|
341
|
+
|
|
342
|
+
expect(frame).toContain('syntax on · motion on');
|
|
343
|
+
expect(frame).not.toContain('/theme syntax');
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as tui from '../index.js';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
describe('TUI presentation boundary (ARCH-011)', () => {
|
|
6
|
+
it('exports presentation hosts without claiming borrowed-session transport conformance', () => {
|
|
7
|
+
expect(tui).not.toHaveProperty('TuiTransport');
|
|
8
|
+
expect(tui).toHaveProperty('renderApp');
|
|
9
|
+
expect(tui).toHaveProperty('createDefaultTuiCliAdapter');
|
|
10
|
+
});
|
|
11
|
+
});
|