@robota-sdk/agent-ui-terminal 3.0.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1204 -0
- package/LICENSE +661 -0
- package/README.md +50 -0
- package/dist/node/index.d.ts +1224 -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 +88 -0
- package/src/App.tsx +157 -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 +236 -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 +92 -0
- package/src/SessionStatusBar.tsx +80 -0
- package/src/SlashAutocomplete.tsx +154 -0
- package/src/StatusBar.tsx +308 -0
- package/src/StreamingIndicator.tsx +145 -0
- package/src/SupervisedSessionView.tsx +872 -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 +530 -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 +52 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +1112 -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 +294 -0
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +650 -0
- package/src/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/__tests__/__fixtures__/korean-text.json +17 -0
- package/src/__tests__/abort-after-permission.test.tsx +160 -0
- package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/__tests__/attached-app-session-switch.test.tsx +317 -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__/checkpoint-store-per-channel.test.ts +81 -0
- package/src/__tests__/cjk-defer-submit.test.tsx +68 -0
- package/src/__tests__/cjk-fallback-render.test.tsx +89 -0
- package/src/__tests__/cjk-text-input-flow.test.ts +187 -0
- package/src/__tests__/cjk-text-input.test.ts +196 -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 +47 -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 +114 -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__/grant-history-reaches-the-session.test.ts +31 -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 +474 -0
- package/src/__tests__/numbered-selection-chunk.test.tsx +41 -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 +329 -0
- package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
- package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
- package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
- package/src/__tests__/permission-prompt-requester.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 +350 -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 +320 -0
- package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +211 -0
- package/src/__tests__/pty/ime-cursor.ptytest.ts +187 -0
- package/src/__tests__/pty/isolated-home.ts +97 -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 +155 -0
- package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +135 -0
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +140 -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/session-attach.ptytest.ts +182 -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/trust-prompt.ptytest.ts +134 -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 +264 -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__/sandbox-reaches-the-session.test.ts +37 -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-picker.test.tsx +93 -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 +290 -0
- package/src/__tests__/status-glyph.test.ts +30 -0
- package/src/__tests__/streaming-indicator.test.tsx +178 -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 +70 -0
- package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
- package/src/__tests__/tui-interaction-queues.test.ts +149 -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 +482 -0
- package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
- package/src/__tests__/wave-text.test.tsx +133 -0
- package/src/__tests__/wire-tui-channel.test.ts +1457 -0
- package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
- package/src/app-banner.tsx +42 -0
- package/src/app-static-items.ts +35 -0
- package/src/app-view-model.ts +140 -0
- package/src/attached-session-connection.ts +14 -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 +93 -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 +88 -0
- package/src/flows/__tests__/real-cursor-flow.test.ts +139 -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-detach-key.test.tsx +72 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +87 -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 +271 -0
- package/src/hooks/useAppInputBindings.ts +163 -0
- package/src/hooks/useAppInteractionState.ts +84 -0
- package/src/hooks/useAppLifecycleState.ts +81 -0
- package/src/hooks/useAppOverlays.ts +90 -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 +93 -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 +141 -0
- package/src/humanize-tool-name.ts +51 -0
- package/src/index.ts +72 -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 +118 -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 +746 -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/short-session-id.ts +9 -0
- package/src/shutdown-signal.ts +34 -0
- package/src/slash-command-input.ts +11 -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 +111 -0
- package/src/tui-channel-lifecycle-coordinator.ts +149 -0
- package/src/tui-channel-options.ts +184 -0
- package/src/tui-cli-adapter-context.tsx +13 -0
- package/src/tui-cli-adapter.ts +24 -0
- package/src/tui-interaction-queues.ts +171 -0
- package/src/tui-session-binding.ts +52 -0
- package/src/tui-session-event-projector.ts +149 -0
- package/src/tui-session-events.ts +89 -0
- package/src/tui-session-notice-store.ts +43 -0
- package/src/tui-session-options.ts +134 -0
- package/src/tui-state-manager.ts +347 -0
- package/src/types.ts +21 -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
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +963 -0
- package/src/wire-tui-client-commands.ts +142 -0
- package/src/wire-tui-projection.ts +136 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-2002 TC-13 — themes, user theme files and plugin theme files, through the BUILT binary in
|
|
3
|
+
* a real PTY (run by `test:pty`; requires `pnpm build:deps` first).
|
|
4
|
+
*
|
|
5
|
+
* What only this level can show: that the theme a user chooses reaches the terminal's actual bytes
|
|
6
|
+
* without a restart, that a theme FILE they wrote is discovered from their home directory and a
|
|
7
|
+
* plugin's from its own, and that a file the run refused is visible to them — at startup and in the
|
|
8
|
+
* picker — rather than silently missing from the list.
|
|
9
|
+
*
|
|
10
|
+
* The transcript is Ink `<Static>` and is not repainted, so a theme change moves the LIVE region;
|
|
11
|
+
* assertions take an offset mark first and read only what arrived after it.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
19
|
+
|
|
20
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
21
|
+
|
|
22
|
+
import type { IPtySession } from './pty-driver.js';
|
|
23
|
+
|
|
24
|
+
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
|
25
|
+
const THEME_FIXTURE = join(FIXTURES, 'screen-2002-themes.jsonl');
|
|
26
|
+
|
|
27
|
+
const PROMPT = /Type a message or \/help/;
|
|
28
|
+
const SNIPPET_DONE = /SCREEN2002_SNIPPET_DONE/;
|
|
29
|
+
const ACCENT = '#56b4e9';
|
|
30
|
+
|
|
31
|
+
/** Named because a bare number in a call is a lint warning, and because each one means something. */
|
|
32
|
+
const BOOT_MS = 30_000;
|
|
33
|
+
const STEP_MS = 20_000;
|
|
34
|
+
const TURN_MS = 40_000;
|
|
35
|
+
const CASE_MS = 150_000;
|
|
36
|
+
/** More than the single reset a plain frame carries. */
|
|
37
|
+
const MINIMUM_SGR_VARIETY = 1;
|
|
38
|
+
|
|
39
|
+
/** Every SGR sequence in a slice of the raw transcript, as a set — the frame's colour vocabulary. */
|
|
40
|
+
function sgrVocabulary(raw: string): Set<string> {
|
|
41
|
+
const found = new Set<string>();
|
|
42
|
+
// eslint-disable-next-line no-control-regex -- scanning raw SGR escape bytes by design
|
|
43
|
+
for (const match of raw.matchAll(/\x1b\[([0-9;]*)m/g)) found.add(match[1] ?? '');
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The raw bytes of the rendered line that holds the snippet, if the frame carried it. */
|
|
48
|
+
function snippetLine(raw: string): string {
|
|
49
|
+
return raw.split(/\r?\n/u).find((line) => line.includes('answer') && line.includes('42')) ?? '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function seedThemeFiles(homeDir: string): void {
|
|
53
|
+
const themes = join(homeDir, 'state', 'themes');
|
|
54
|
+
mkdirSync(themes, { recursive: true });
|
|
55
|
+
writeFileSync(
|
|
56
|
+
join(themes, 'mine.json'),
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
name: 'Mine',
|
|
59
|
+
base: 'dark',
|
|
60
|
+
// Tokens the IDLE frame actually paints with. Overriding `text.accent` alone proves nothing
|
|
61
|
+
// here: the accent labels live in the transcript, which is `<Static>` and is never repainted,
|
|
62
|
+
// so a run with nothing submitted would show the same bytes either way.
|
|
63
|
+
overrides: {
|
|
64
|
+
colors: {
|
|
65
|
+
text: { accent: ACCENT, muted: ACCENT },
|
|
66
|
+
border: { focused: ACCENT, muted: ACCENT },
|
|
67
|
+
status: { idle: ACCENT },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
writeFileSync(
|
|
73
|
+
join(themes, 'broken.json'),
|
|
74
|
+
JSON.stringify({ overrides: { colors: { text: { accent: 'not-a-colour' } } } }),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const pluginVersionDir = join(
|
|
78
|
+
homeDir,
|
|
79
|
+
'state',
|
|
80
|
+
'plugins',
|
|
81
|
+
'cache',
|
|
82
|
+
'fixtures',
|
|
83
|
+
'theme-fixture',
|
|
84
|
+
'1.0.0',
|
|
85
|
+
);
|
|
86
|
+
mkdirSync(join(pluginVersionDir, '.claude-plugin'), { recursive: true });
|
|
87
|
+
writeFileSync(
|
|
88
|
+
join(pluginVersionDir, '.claude-plugin', 'plugin.json'),
|
|
89
|
+
JSON.stringify({
|
|
90
|
+
name: 'theme-fixture',
|
|
91
|
+
version: '1.0.0',
|
|
92
|
+
description: 'a themes-only fixture plugin',
|
|
93
|
+
features: {},
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
mkdirSync(join(pluginVersionDir, 'themes'), { recursive: true });
|
|
97
|
+
writeFileSync(
|
|
98
|
+
join(pluginVersionDir, 'themes', 'plugged.json'),
|
|
99
|
+
JSON.stringify({ name: 'Plugged', base: 'light' }),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('SCREEN-2002 themes through the real binary', () => {
|
|
104
|
+
let projectDir: string;
|
|
105
|
+
let homeDir: string;
|
|
106
|
+
let session: IPtySession | undefined;
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'agent-screen2002-pty-')));
|
|
110
|
+
writeTuiProviderSettings(projectDir);
|
|
111
|
+
homeDir = join(projectDir, 'home');
|
|
112
|
+
seedThemeFiles(homeDir);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(async () => {
|
|
116
|
+
await session?.disposeAsync();
|
|
117
|
+
session = undefined;
|
|
118
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it(
|
|
122
|
+
'S1: lists user and plugin themes, refuses a broken file out loud, and switches without a restart',
|
|
123
|
+
async () => {
|
|
124
|
+
session = spawnTui({
|
|
125
|
+
projectDir,
|
|
126
|
+
homeDir,
|
|
127
|
+
args: ['--session-log', THEME_FIXTURE],
|
|
128
|
+
env: { FORCE_COLOR: '3' },
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// The refused file says so ONCE, at startup, with the path that refused it.
|
|
132
|
+
await session.waitFor(
|
|
133
|
+
/Skipped theme "broken\.json": \$\.overrides\.colors\.text\.accent/,
|
|
134
|
+
BOOT_MS,
|
|
135
|
+
);
|
|
136
|
+
await session.waitFor(PROMPT, BOOT_MS);
|
|
137
|
+
|
|
138
|
+
// `/theme list` sees the built-ins AND both files, each with where it came from.
|
|
139
|
+
const listMark = session.outputOffset();
|
|
140
|
+
await session.sendKeys('/theme list');
|
|
141
|
+
await session.pressEnter();
|
|
142
|
+
await session.waitForSince(listMark, /Available themes:/, STEP_MS);
|
|
143
|
+
const listed = session.snapshotSince(listMark);
|
|
144
|
+
expect(listed).toContain('dark — Dark');
|
|
145
|
+
expect(listed).toContain('custom:mine — Mine (dark, user)');
|
|
146
|
+
expect(listed).toContain('custom:theme-fixture:plugged — Plugged (light, plugin)');
|
|
147
|
+
|
|
148
|
+
// Applying the user's own theme puts ITS accent colour on the wire, with no restart.
|
|
149
|
+
const applyMark = session.outputOffset();
|
|
150
|
+
// A RAW mark of its own: `outputOffset()` indexes the ANSI-STRIPPED transcript, so slicing
|
|
151
|
+
// `raw()` with it lands at an unrelated byte.
|
|
152
|
+
const applyRawMark = session.raw().length;
|
|
153
|
+
await session.sendKeys('/theme custom:mine');
|
|
154
|
+
await session.pressEnter();
|
|
155
|
+
await session.waitForSince(applyMark, /Applied: theme Mine/, STEP_MS);
|
|
156
|
+
await session.waitForSince(applyMark, PROMPT, STEP_MS);
|
|
157
|
+
// `#56b4e9` as this terminal encodes it. The PTY is `xterm-256color` with no `COLORTERM`, so
|
|
158
|
+
// the depth is 256 and the hex downsamples to index 117 — asserting truecolor here would be
|
|
159
|
+
// asserting the harness's terminal rather than the theme.
|
|
160
|
+
expect(session.raw().slice(applyRawMark)).toContain('38;5;117');
|
|
161
|
+
},
|
|
162
|
+
CASE_MS,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
it(
|
|
166
|
+
'S2: the picker previews on move, restores on escape, and shows the refused file as a row',
|
|
167
|
+
async () => {
|
|
168
|
+
session = spawnTui({
|
|
169
|
+
projectDir,
|
|
170
|
+
homeDir,
|
|
171
|
+
args: ['--session-log', THEME_FIXTURE],
|
|
172
|
+
env: { FORCE_COLOR: '3' },
|
|
173
|
+
});
|
|
174
|
+
await session.waitFor(PROMPT, BOOT_MS);
|
|
175
|
+
|
|
176
|
+
const openMark = session.outputOffset();
|
|
177
|
+
const openRawMark = session.raw().length;
|
|
178
|
+
await session.sendKeys('/theme');
|
|
179
|
+
await session.pressEnter();
|
|
180
|
+
await session.waitForSince(openMark, /Theme/, STEP_MS);
|
|
181
|
+
// The refused file is a row that carries its reason — the startup line has long since scrolled.
|
|
182
|
+
await session.waitForSince(openMark, /Skipped "broken\.json"/, STEP_MS);
|
|
183
|
+
|
|
184
|
+
// Moving the highlight repaints the live region in the previewed theme: new SGR appears that
|
|
185
|
+
// the frame did not carry before the move. The arrow goes through `writeRaw` — `sendKeys`
|
|
186
|
+
// paces per character, so the leading Escape would arrive alone and CANCEL the picker.
|
|
187
|
+
const before = sgrVocabulary(session.raw().slice(openRawMark));
|
|
188
|
+
const moveMark = session.outputOffset();
|
|
189
|
+
const moveRawMark = session.raw().length;
|
|
190
|
+
session.writeRaw('\x1b[B');
|
|
191
|
+
await session.waitForSince(moveMark, /> Light — light, built-in/, STEP_MS);
|
|
192
|
+
const after = sgrVocabulary(session.raw().slice(moveRawMark));
|
|
193
|
+
expect([...after].some((sequence) => !before.has(sequence))).toBe(true);
|
|
194
|
+
|
|
195
|
+
// Escape leaves the persisted theme in place. Not only "nothing was applied": the colours the
|
|
196
|
+
// PREVIEW introduced are gone from the frame, which is what "restores" has to mean when the
|
|
197
|
+
// preview was visible rather than merely recorded.
|
|
198
|
+
const previewed = [...after].filter((sequence) => !before.has(sequence));
|
|
199
|
+
expect(previewed.length).toBeGreaterThan(0);
|
|
200
|
+
const escapeMark = session.outputOffset();
|
|
201
|
+
const escapeRawMark = session.raw().length;
|
|
202
|
+
session.pressEscape();
|
|
203
|
+
await session.waitForSince(escapeMark, PROMPT, STEP_MS);
|
|
204
|
+
const restored = sgrVocabulary(session.raw().slice(escapeRawMark));
|
|
205
|
+
// The symmetric guard: a post-escape slice carrying no SGR at all would satisfy the filter
|
|
206
|
+
// below without measuring anything, which is the fail-toward-green shape S3 also had.
|
|
207
|
+
expect(restored.size).toBeGreaterThan(0);
|
|
208
|
+
expect(previewed.filter((sequence) => restored.has(sequence))).toEqual([]);
|
|
209
|
+
expect(session.snapshotSince(escapeMark)).not.toContain('Applied: theme');
|
|
210
|
+
},
|
|
211
|
+
CASE_MS,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
it(
|
|
215
|
+
'S3: syntax highlighting is switched off for later renders and the scrollback keeps its own',
|
|
216
|
+
async () => {
|
|
217
|
+
session = spawnTui({
|
|
218
|
+
projectDir,
|
|
219
|
+
homeDir,
|
|
220
|
+
args: ['--session-log', THEME_FIXTURE],
|
|
221
|
+
env: { FORCE_COLOR: '3' },
|
|
222
|
+
});
|
|
223
|
+
await session.waitFor(PROMPT, BOOT_MS);
|
|
224
|
+
|
|
225
|
+
const firstMark = session.outputOffset();
|
|
226
|
+
const firstRawMark = session.raw().length;
|
|
227
|
+
await session.sendKeys('show me a snippet');
|
|
228
|
+
await session.pressEnter();
|
|
229
|
+
await session.waitForSince(firstMark, SNIPPET_DONE, TURN_MS);
|
|
230
|
+
const highlighted = session.raw().slice(firstRawMark);
|
|
231
|
+
// The TEXT is read from the stripped transcript: a highlighted block carries SGR BETWEEN its
|
|
232
|
+
// tokens, so the source line is not contiguous in the raw bytes — which is the point.
|
|
233
|
+
expect(session.snapshotSince(firstMark)).toContain('const answer = 42;');
|
|
234
|
+
// cli-highlight coloured the keyword: the block carries SGR the plain text would not.
|
|
235
|
+
expect(sgrVocabulary(highlighted).size).toBeGreaterThan(MINIMUM_SGR_VARIETY);
|
|
236
|
+
|
|
237
|
+
const offMark = session.outputOffset();
|
|
238
|
+
await session.sendKeys('/theme syntax off');
|
|
239
|
+
await session.pressEnter();
|
|
240
|
+
await session.waitForSince(offMark, /syntax highlighting off/, STEP_MS);
|
|
241
|
+
|
|
242
|
+
const secondMark = session.outputOffset();
|
|
243
|
+
const secondRawMark = session.raw().length;
|
|
244
|
+
await session.sendKeys('show me a snippet');
|
|
245
|
+
await session.pressEnter();
|
|
246
|
+
await session.waitForSince(secondMark, SNIPPET_DONE, TURN_MS);
|
|
247
|
+
|
|
248
|
+
// The same source, rendered again. Asserting only that the TEXT is there would pass whether
|
|
249
|
+
// or not `/theme syntax off` reached a render site — which is the defect this package has
|
|
250
|
+
// already shipped once — so the assertion is on the line's own SGR: the second render of the
|
|
251
|
+
// same line carries strictly fewer distinct sequences than the highlighted first.
|
|
252
|
+
const plain = session.raw().slice(secondRawMark);
|
|
253
|
+
expect(session.snapshotSince(secondMark)).toContain('const answer = 42;');
|
|
254
|
+
// Both lines must actually be there. Without this the comparison is asymmetric toward green:
|
|
255
|
+
// a missing second line measures zero sequences and every assertion below passes.
|
|
256
|
+
expect(snippetLine(highlighted)).not.toBe('');
|
|
257
|
+
expect(snippetLine(plain)).not.toBe('');
|
|
258
|
+
const highlightedLine = sgrVocabulary(snippetLine(highlighted));
|
|
259
|
+
const plainLine = sgrVocabulary(snippetLine(plain));
|
|
260
|
+
expect(highlightedLine.size).toBeGreaterThan(plainLine.size);
|
|
261
|
+
expect([...highlightedLine].some((sequence) => !plainLine.has(sequence))).toBe(true);
|
|
262
|
+
},
|
|
263
|
+
CASE_MS,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
it(
|
|
267
|
+
'S4: --reduced-motion is reported as pinned for the run, and colour stays on',
|
|
268
|
+
async () => {
|
|
269
|
+
session = spawnTui({
|
|
270
|
+
projectDir,
|
|
271
|
+
homeDir,
|
|
272
|
+
args: ['--session-log', THEME_FIXTURE, '--reduced-motion'],
|
|
273
|
+
env: { FORCE_COLOR: '3' },
|
|
274
|
+
});
|
|
275
|
+
await session.waitFor(PROMPT, BOOT_MS);
|
|
276
|
+
|
|
277
|
+
const mark = session.outputOffset();
|
|
278
|
+
const rawMark = session.raw().length;
|
|
279
|
+
await session.sendKeys('/theme list');
|
|
280
|
+
await session.pressEnter();
|
|
281
|
+
await session.waitForSince(mark, /Available themes:/, STEP_MS);
|
|
282
|
+
const listed = session.snapshotSince(mark);
|
|
283
|
+
// The pin is reported, not silently obeyed.
|
|
284
|
+
// What THIS RUN does, and what is saved — two facts, never one in place of the other. This
|
|
285
|
+
// line is the defect TC-13 found: it read `reduced motion: off (… pinned by flag)`.
|
|
286
|
+
expect(listed).toContain('reduced motion: on for this run (pinned by flag; saved off)');
|
|
287
|
+
// Colour is untouched by the motion decision.
|
|
288
|
+
expect(sgrVocabulary(session.raw().slice(rawMark)).size).toBeGreaterThan(MINIMUM_SGR_VARIETY);
|
|
289
|
+
},
|
|
290
|
+
CASE_MS,
|
|
291
|
+
);
|
|
292
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-2670 TC-09 — the pre-write park on the BUILT CLI binary, in a real PTY.
|
|
3
|
+
*
|
|
4
|
+
* A screen reader sees bytes and time, nothing else, so the claim is checked where both exist: the
|
|
5
|
+
* raw PTY stream is sampled every few milliseconds and each growth is stamped. A COMMIT the park
|
|
6
|
+
* released arrives as one growth (its chunks are written contiguously); the interval shows up as
|
|
7
|
+
* the gap between consecutive printable growths. The same fixture runs three ways — park 250 ms,
|
|
8
|
+
* park 0, mode off — so the interval is attributed to the park and not to the replay.
|
|
9
|
+
*
|
|
10
|
+
* Runs under `test:pty` against the built CLI (`pnpm build` first).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
19
|
+
|
|
20
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
21
|
+
|
|
22
|
+
import type { IPtySession } from './pty-driver.js';
|
|
23
|
+
|
|
24
|
+
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
|
25
|
+
const REPLAY_FIXTURE = join(FIXTURES, 'replay-conversation.jsonl');
|
|
26
|
+
|
|
27
|
+
const WAIT_MS = 30_000;
|
|
28
|
+
const CASE_TIMEOUT_MS = 120_000;
|
|
29
|
+
/** The park under test — large against the frame cadence, small against the case budget. */
|
|
30
|
+
const PARK_MS = 250;
|
|
31
|
+
/** Sampling period; the gap resolution is twice this. */
|
|
32
|
+
const SAMPLE_MS = 5;
|
|
33
|
+
/** OS and PTY jitter allowance on the asserted interval. */
|
|
34
|
+
const SLACK_MS = 60;
|
|
35
|
+
/** Growths closer than this are one burst: a PTY read can split one contiguous write. */
|
|
36
|
+
const COALESCE_MS = 30;
|
|
37
|
+
|
|
38
|
+
/** Startup frames settle for this long before the keystroke is measured against a quiet stream. */
|
|
39
|
+
const STARTUP_SETTLE_MS = 750;
|
|
40
|
+
/* eslint-disable no-control-regex -- matching the escapes IS the point here */
|
|
41
|
+
const OSC_133 = /\x1b\]133;[ABCD]\x07/g;
|
|
42
|
+
const ESCAPES = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1b[()][A-Z0-9]|\x1b[=>]/g;
|
|
43
|
+
/* eslint-enable no-control-regex */
|
|
44
|
+
|
|
45
|
+
interface IGrowth {
|
|
46
|
+
at: number;
|
|
47
|
+
bytes: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sample the raw stream from `since` (an offset taken BEFORE the action — the driver's key pacing
|
|
52
|
+
* means output can land before this loop starts) until `until` matches or the budget ends; return
|
|
53
|
+
* every growth stamped.
|
|
54
|
+
*/
|
|
55
|
+
interface IMark {
|
|
56
|
+
/** Index into the RAW stream (growth detection). */
|
|
57
|
+
raw: number;
|
|
58
|
+
/** The driver's mark into the STRIPPED transcript (pattern matching) — the two are not the same. */
|
|
59
|
+
stripped: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function markNow(session: IPtySession): IMark {
|
|
63
|
+
return { raw: session.raw().length, stripped: session.outputOffset() };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function sampleUntil(
|
|
67
|
+
session: IPtySession,
|
|
68
|
+
since: IMark,
|
|
69
|
+
until: RegExp,
|
|
70
|
+
budgetMs: number,
|
|
71
|
+
): Promise<IGrowth[]> {
|
|
72
|
+
const growths: IGrowth[] = [];
|
|
73
|
+
let seen = since.raw;
|
|
74
|
+
const deadline = Date.now() + budgetMs;
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
const raw = session.raw();
|
|
77
|
+
if (raw.length > seen) {
|
|
78
|
+
growths.push({ at: Date.now(), bytes: raw.slice(seen) });
|
|
79
|
+
seen = raw.length;
|
|
80
|
+
}
|
|
81
|
+
if (until.test(session.snapshotSince(since.stripped))) break;
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, SAMPLE_MS));
|
|
83
|
+
}
|
|
84
|
+
return growths;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const visible = (bytes: string): string => bytes.replace(ESCAPES, '');
|
|
88
|
+
|
|
89
|
+
/** Merge growths that landed within `COALESCE_MS` of each other — one contiguous write, one burst. */
|
|
90
|
+
function bursts(growths: IGrowth[]): IGrowth[] {
|
|
91
|
+
const merged: IGrowth[] = [];
|
|
92
|
+
for (const growth of growths) {
|
|
93
|
+
const last = merged.at(-1);
|
|
94
|
+
if (last !== undefined && growth.at - last.at < COALESCE_MS) last.bytes += growth.bytes;
|
|
95
|
+
else merged.push({ ...growth });
|
|
96
|
+
}
|
|
97
|
+
return merged;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The marks travel through the owned path and keep their place. A replayed reply resolves inside
|
|
102
|
+
* one commit, so the turn's own B/C/D/A marks are not observable here (their ordering against a
|
|
103
|
+
* parked batch is pinned by the unit test); what this stream does carry is the mount-time
|
|
104
|
+
* prompt-start, which must precede the answer rather than surface inside the parked gap after it.
|
|
105
|
+
*/
|
|
106
|
+
function expectMarksOrderedWithTheTurn(raw: string): void {
|
|
107
|
+
const marks = [...raw.matchAll(OSC_133)].map((match) => ({
|
|
108
|
+
code: match[0],
|
|
109
|
+
at: match.index ?? 0,
|
|
110
|
+
}));
|
|
111
|
+
const answerAt = raw.indexOf('REPLAYED_ANSWER_42');
|
|
112
|
+
const promptStarts = marks.filter((mark) => mark.code.includes('133;A'));
|
|
113
|
+
expect(promptStarts.length).toBeGreaterThanOrEqual(1);
|
|
114
|
+
expect(promptStarts[0]?.at).toBeLessThan(answerAt);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One paced-free line: the echo commit (exempt) and the turn commit (parked) are provoked within
|
|
119
|
+
* milliseconds of each other, so the interval between them is the park and nothing else. The reply
|
|
120
|
+
* itself is replayed as a single chunk, so the turn is ONE commit — which is also what proves the
|
|
121
|
+
* chunks of a commit stay contiguous: the answer arrives inside one synchronized-output window.
|
|
122
|
+
*/
|
|
123
|
+
interface ITurnTiming {
|
|
124
|
+
typedAt: number;
|
|
125
|
+
echoAt: number;
|
|
126
|
+
answerAt: number;
|
|
127
|
+
answerBytes: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function typeAndSubmit(session: IPtySession): Promise<ITurnTiming> {
|
|
131
|
+
const mark = markNow(session);
|
|
132
|
+
const sampling = sampleUntil(session, mark, /REPLAYED_ANSWER_42[\s\S]*Type a message/, WAIT_MS);
|
|
133
|
+
// Characters are paced (a single chunk would be read as a paste); the echo latency is measured
|
|
134
|
+
// from the LAST key, so the driver's per-key pacing is not charged to the park. Enter follows the
|
|
135
|
+
// last echo immediately, so the turn's commit is provoked within milliseconds of an exempt one.
|
|
136
|
+
await session.sendKeys('hell');
|
|
137
|
+
const typedAt = Date.now();
|
|
138
|
+
await session.sendKeys('o');
|
|
139
|
+
session.writeRaw('\r');
|
|
140
|
+
const growths = bursts(await sampling);
|
|
141
|
+
const echo = growths.find((g) => visible(g.bytes).includes('hello'));
|
|
142
|
+
const answer = growths.find((g) => visible(g.bytes).includes('REPLAYED_ANSWER_42'));
|
|
143
|
+
expect(echo, 'the echo of the typed line').toBeDefined();
|
|
144
|
+
expect(answer, 'the replayed answer').toBeDefined();
|
|
145
|
+
return {
|
|
146
|
+
typedAt,
|
|
147
|
+
echoAt: (echo as IGrowth).at,
|
|
148
|
+
answerAt: (answer as IGrowth).at,
|
|
149
|
+
answerBytes: (answer as IGrowth).bytes,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function spawnScenario(projectDir: string, mode: 'park' | 'zero' | 'off'): IPtySession {
|
|
154
|
+
const screenReader = mode === 'off' ? [] : ['--screen-reader'];
|
|
155
|
+
const prepark = mode === 'zero' ? '0' : String(PARK_MS);
|
|
156
|
+
return spawnTui({
|
|
157
|
+
projectDir,
|
|
158
|
+
homeDir: join(projectDir, 'home'),
|
|
159
|
+
args: ['--session-log', REPLAY_FIXTURE, ...screenReader, '--name', 'prepark-scenario'],
|
|
160
|
+
env: { PRODUCT_SCREEN_READER_STARTUP_QUIET_MS: '0', PRODUCT_SCREEN_READER_PREPARK_MS: prepark },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function settled(session: IPtySession): Promise<void> {
|
|
165
|
+
await session.waitFor(/Type a message or \/help/, WAIT_MS);
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, STARTUP_SETTLE_MS));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
describe('SCREEN-2670 the pre-write park through the real binary', () => {
|
|
170
|
+
let projectDir: string;
|
|
171
|
+
let session: IPtySession | undefined;
|
|
172
|
+
|
|
173
|
+
beforeEach(() => {
|
|
174
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'agent-prepark-pty-')));
|
|
175
|
+
writeTuiProviderSettings(projectDir);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
afterEach(async () => {
|
|
179
|
+
await session?.disposeAsync();
|
|
180
|
+
session = undefined;
|
|
181
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it(
|
|
185
|
+
'TC-09: the echo is not delayed, the turn commit waits the interval after it, its chunks are contiguous, and the marks stay in order',
|
|
186
|
+
async () => {
|
|
187
|
+
session = spawnScenario(projectDir, 'park');
|
|
188
|
+
await settled(session);
|
|
189
|
+
|
|
190
|
+
const timing = await typeAndSubmit(session);
|
|
191
|
+
// The keystroke's commit is exempt: its echo lands well inside the interval.
|
|
192
|
+
expect(timing.echoAt - timing.typedAt).toBeLessThan(PARK_MS - SLACK_MS);
|
|
193
|
+
// The turn's commit is parked: at least the interval after the previous printable release.
|
|
194
|
+
// Assumption: with a single-chunk replay the answer commit is the LAST printable commit of the
|
|
195
|
+
// turn's burst; only the last waiting batch is parked, so a later commit inside the interval
|
|
196
|
+
// would make this measure turn latency instead.
|
|
197
|
+
expect(timing.answerAt - timing.echoAt).toBeGreaterThanOrEqual(PARK_MS - SLACK_MS);
|
|
198
|
+
// And it arrived whole: the synchronized-output window opened and closed in one growth.
|
|
199
|
+
expect(timing.answerBytes).toContain('\x1b[?2026h');
|
|
200
|
+
expect(timing.answerBytes).toContain('\x1b[?2026l');
|
|
201
|
+
expectMarksOrderedWithTheTurn(session.raw());
|
|
202
|
+
},
|
|
203
|
+
CASE_TIMEOUT_MS,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
it(
|
|
207
|
+
'TC-09: with the interval 0 no delay is injected',
|
|
208
|
+
async () => {
|
|
209
|
+
session = spawnScenario(projectDir, 'zero');
|
|
210
|
+
await settled(session);
|
|
211
|
+
const timing = await typeAndSubmit(session);
|
|
212
|
+
expect(timing.answerAt - timing.echoAt).toBeLessThan(PARK_MS - SLACK_MS);
|
|
213
|
+
},
|
|
214
|
+
CASE_TIMEOUT_MS,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
it(
|
|
218
|
+
'TC-09: with the mode off the park is absent — no interval, bordered UI as before',
|
|
219
|
+
async () => {
|
|
220
|
+
session = spawnScenario(projectDir, 'off');
|
|
221
|
+
await settled(session);
|
|
222
|
+
const timing = await typeAndSubmit(session);
|
|
223
|
+
expect(timing.answerAt - timing.echoAt).toBeLessThan(PARK_MS - SLACK_MS);
|
|
224
|
+
expect(session.snapshot()).toMatch(/[│─╭╮╰╯┌┐└┘]/u);
|
|
225
|
+
},
|
|
226
|
+
CASE_TIMEOUT_MS,
|
|
227
|
+
);
|
|
228
|
+
});
|