@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,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-2004 TC-02 / TC-03 / TC-17 / TC-18 — the mode's entry point.
|
|
3
|
+
*
|
|
4
|
+
* TC-02 asserts BOTH halves of the threading, because they fail independently: Ink's own
|
|
5
|
+
* `isScreenReaderEnabled` option, and the hand-maintained `toChannelOptions` projection that
|
|
6
|
+
* ARCH-110 records as able to drop an option silently.
|
|
7
|
+
* TC-03/TC-18 assert the first line the process prints — the confirmation, the advisory, and the
|
|
8
|
+
* two cases where nothing at all is printed.
|
|
9
|
+
* TC-17 asserts the banner suppression, which TC-11's box-drawing sweep cannot see because the
|
|
10
|
+
* banner is ASCII art, not box-drawing characters.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React from 'react';
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
|
|
16
|
+
import { BANNER_GLYPHS, BANNER_ART } from '../app-banner.js';
|
|
17
|
+
import { buildStaticItems } from '../app-static-items.js';
|
|
18
|
+
import {
|
|
19
|
+
screenReaderAnnouncement,
|
|
20
|
+
writeScreenReaderAnnouncement,
|
|
21
|
+
} from '../screen-reader-announcement.js';
|
|
22
|
+
import { toChannelOptions } from '../render.js';
|
|
23
|
+
|
|
24
|
+
import type { IRenderOptions } from '../render.js';
|
|
25
|
+
import type { IKeybindingsSource } from '../keybindings/node-keybindings-source.js';
|
|
26
|
+
import type { IHistoryEntry, IAIProvider } from '@robota-sdk/agent-core';
|
|
27
|
+
import type { ITuiCliAdapter } from '../tui-cli-adapter.js';
|
|
28
|
+
|
|
29
|
+
const inkRender = vi.hoisted(() =>
|
|
30
|
+
vi.fn(
|
|
31
|
+
(
|
|
32
|
+
_node: unknown,
|
|
33
|
+
_options?: { isScreenReaderEnabled?: boolean; stdout?: NodeJS.WriteStream },
|
|
34
|
+
) => ({
|
|
35
|
+
waitUntilExit: async (): Promise<void> => {},
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
vi.mock('ink', async (importOriginal) => {
|
|
41
|
+
const actual = await importOriginal<typeof import('ink')>();
|
|
42
|
+
return { ...actual, render: inkRender };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// The App boots a channel and starts I/O; the mode's threading is what is under test, not the App.
|
|
46
|
+
vi.mock('../App.js', () => ({
|
|
47
|
+
default: (): React.ReactElement => React.createElement('agent-test-app'),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
function baseOptions(): IRenderOptions {
|
|
51
|
+
return {
|
|
52
|
+
cwd: '/tmp/project',
|
|
53
|
+
provider: {} as IAIProvider,
|
|
54
|
+
cliAdapter: {} as ITuiCliAdapter,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('TC-02: the mode reaches Ink AND the channel projection', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
inkRender.mockClear();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
vi.unstubAllEnvs();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('renderApp passes isScreenReaderEnabled: true to Ink render when the mode is on', async () => {
|
|
68
|
+
const { renderApp } = await import('../render.js');
|
|
69
|
+
await renderApp({ ...baseOptions(), screenReader: true });
|
|
70
|
+
|
|
71
|
+
expect(inkRender).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(inkRender.mock.calls[0]?.[1]?.isScreenReaderEnabled).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('renderApp passes isScreenReaderEnabled: false when no mode input is present (default-off)', async () => {
|
|
76
|
+
const { renderApp } = await import('../render.js');
|
|
77
|
+
await renderApp(baseOptions());
|
|
78
|
+
|
|
79
|
+
expect(inkRender.mock.calls[0]?.[1]?.isScreenReaderEnabled).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('toChannelOptions projects screenReader — the ARCH-110 drop hazard', () => {
|
|
83
|
+
expect(toChannelOptions({ ...baseOptions(), screenReader: true }).screenReader).toBe(true);
|
|
84
|
+
expect(toChannelOptions({ ...baseOptions(), screenReader: false }).screenReader).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('toChannelOptions leaves screenReader undefined when the option is absent', () => {
|
|
88
|
+
expect(toChannelOptions(baseOptions()).screenReader).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('BEHAVIOR-2003: keybinding source lifecycle', () => {
|
|
93
|
+
it('disposes the source when initialization fails before Ink starts', async () => {
|
|
94
|
+
const startupFailure = new Error('watcher startup failed');
|
|
95
|
+
const dispose = vi.fn();
|
|
96
|
+
const source = {
|
|
97
|
+
start: vi.fn().mockRejectedValue(startupFailure),
|
|
98
|
+
dispose,
|
|
99
|
+
} as unknown as IKeybindingsSource;
|
|
100
|
+
inkRender.mockClear();
|
|
101
|
+
|
|
102
|
+
const { renderApp } = await import('../render.js');
|
|
103
|
+
await expect(renderApp({ ...baseOptions(), keybindingsSource: source })).rejects.toBe(
|
|
104
|
+
startupFailure,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(inkRender).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('TC-03: the confirmation line names the channel', () => {
|
|
113
|
+
it('prints the channel that actually turned the mode on', () => {
|
|
114
|
+
expect(screenReaderAnnouncement({ enabled: true, channel: 'flag' })).toBe(
|
|
115
|
+
'[Screen reader mode: on via flag]',
|
|
116
|
+
);
|
|
117
|
+
expect(screenReaderAnnouncement({ enabled: true, channel: 'env' })).toBe(
|
|
118
|
+
'[Screen reader mode: on via env]',
|
|
119
|
+
);
|
|
120
|
+
expect(screenReaderAnnouncement({ enabled: true, channel: 'settings' })).toBe(
|
|
121
|
+
'[Screen reader mode: on via settings]',
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('prints nothing when the mode is off and no reader hint is present', () => {
|
|
126
|
+
expect(screenReaderAnnouncement({ enabled: false })).toBeUndefined();
|
|
127
|
+
const written: string[] = [];
|
|
128
|
+
writeScreenReaderAnnouncement({ enabled: false }, (text) => written.push(text));
|
|
129
|
+
expect(written).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('renderApp writes the confirmation line to stdout before Ink is called', async () => {
|
|
133
|
+
const written: string[] = [];
|
|
134
|
+
const spy = vi
|
|
135
|
+
.spyOn(process.stdout, 'write')
|
|
136
|
+
.mockImplementation((chunk: string | Uint8Array): boolean => {
|
|
137
|
+
written.push(String(chunk));
|
|
138
|
+
return true;
|
|
139
|
+
});
|
|
140
|
+
inkRender.mockClear();
|
|
141
|
+
inkRender.mockImplementation(() => {
|
|
142
|
+
// Ink is reached only after the line is already on stdout.
|
|
143
|
+
expect(written.join('')).toContain('[Screen reader mode: on via flag]');
|
|
144
|
+
return { waitUntilExit: async (): Promise<void> => {} };
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
const { renderApp } = await import('../render.js');
|
|
148
|
+
await renderApp({ ...baseOptions(), screenReader: true, screenReaderChannel: 'flag' });
|
|
149
|
+
} finally {
|
|
150
|
+
spy.mockRestore();
|
|
151
|
+
inkRender.mockImplementation(() => ({ waitUntilExit: async (): Promise<void> => {} }));
|
|
152
|
+
}
|
|
153
|
+
expect(written.join('')).toContain('[Screen reader mode: on via flag]\n');
|
|
154
|
+
expect(inkRender).toHaveBeenCalledTimes(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('TC-18: the advisory line, and its two negatives', () => {
|
|
159
|
+
it('is the first line when the mode is off and a reader-shaped hint is present', () => {
|
|
160
|
+
const written: string[] = [];
|
|
161
|
+
writeScreenReaderAnnouncement({ enabled: false, hint: true }, (text) => written.push(text));
|
|
162
|
+
expect(written.join('').split('\n')[0]).toBe(
|
|
163
|
+
'[Screen reader mode: off — run with --screen-reader]',
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('is absent when the mode is off and there is no hint', () => {
|
|
168
|
+
expect(screenReaderAnnouncement({ enabled: false, hint: false })).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('is replaced by the confirmation line when the mode is on, never printed alongside it', () => {
|
|
172
|
+
const written: string[] = [];
|
|
173
|
+
writeScreenReaderAnnouncement({ enabled: true, channel: 'env', hint: true }, (text) =>
|
|
174
|
+
written.push(text),
|
|
175
|
+
);
|
|
176
|
+
const output = written.join('');
|
|
177
|
+
expect(output).toBe('[Screen reader mode: on via env]\n');
|
|
178
|
+
expect(output).not.toContain('run with --screen-reader');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('TC-17: the ASCII banner is not committed in the mode', () => {
|
|
183
|
+
const history = [{ id: 'h1' } as unknown as IHistoryEntry];
|
|
184
|
+
|
|
185
|
+
it('omits the banner item entirely when the mode is on', () => {
|
|
186
|
+
const items = buildStaticItems({ history, version: '1.2.3', screenReader: true });
|
|
187
|
+
expect(items.some((item) => item.kind === 'banner')).toBe(false);
|
|
188
|
+
expect(items).toEqual([{ kind: 'entry', entry: history[0] }]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('keeps the banner item when the mode is off', () => {
|
|
192
|
+
const items = buildStaticItems({ history, version: '1.2.3', screenReader: false });
|
|
193
|
+
expect(items[0]).toEqual({ kind: 'banner', version: '1.2.3' });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('the suppressed art is what carries the glyphs a box-drawing sweep would miss', () => {
|
|
197
|
+
for (const glyph of BANNER_GLYPHS) {
|
|
198
|
+
expect(BANNER_ART).toContain(glyph);
|
|
199
|
+
}
|
|
200
|
+
// None of them is a box-drawing character — hence the separate criterion.
|
|
201
|
+
expect(BANNER_ART).not.toMatch(/[│─┌┐└┘├┤┬┴┼╭╮╰╯]/u);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('SCREEN-2670 TC-05: with the mode off, render() is called with no stdout key at all', () => {
|
|
206
|
+
beforeEach(() => {
|
|
207
|
+
inkRender.mockClear();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
afterEach(() => {
|
|
211
|
+
vi.unstubAllEnvs();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('passes no stdout when the mode is off — Ink defaults to process.stdout and keys its map by it', async () => {
|
|
215
|
+
const { renderApp } = await import('../render.js');
|
|
216
|
+
await renderApp(baseOptions());
|
|
217
|
+
const options = inkRender.mock.calls[0]?.[1] ?? {};
|
|
218
|
+
expect(Object.keys(options)).not.toContain('stdout');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('passes no stdout when the mode is on but the park is disabled with 0', async () => {
|
|
222
|
+
const { renderApp } = await import('../render.js');
|
|
223
|
+
await renderApp({
|
|
224
|
+
...baseOptions(),
|
|
225
|
+
screenReader: true,
|
|
226
|
+
screenReaderPacing: {
|
|
227
|
+
startupQuiet: { raw: '0', label: 'ACME_STARTUP_QUIET_MS' },
|
|
228
|
+
prepark: { raw: '0', label: 'ACME_PREPARK_MS' },
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
expect(Object.keys(inkRender.mock.calls[0]?.[1] ?? {})).not.toContain('stdout');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('ignores an ambient Fixture Agent park override when the host supplies no park choice', async () => {
|
|
235
|
+
vi.stubEnv('PRODUCT_SCREEN_READER_PREPARK_MS', '0');
|
|
236
|
+
const { renderApp } = await import('../render.js');
|
|
237
|
+
await renderApp({
|
|
238
|
+
...baseOptions(),
|
|
239
|
+
screenReader: true,
|
|
240
|
+
screenReaderPacing: {
|
|
241
|
+
startupQuiet: { raw: '0', label: 'ACME_STARTUP_QUIET_MS' },
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
expect(inkRender.mock.calls[0]?.[1]?.stdout).toBeDefined();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('hands Ink the owned stdout when the mode is on and the park is enabled', async () => {
|
|
248
|
+
const { renderApp } = await import('../render.js');
|
|
249
|
+
await renderApp({
|
|
250
|
+
...baseOptions(),
|
|
251
|
+
screenReader: true,
|
|
252
|
+
screenReaderPacing: {
|
|
253
|
+
startupQuiet: { raw: '0', label: 'ACME_STARTUP_QUIET_MS' },
|
|
254
|
+
prepark: { raw: '50', label: 'ACME_PREPARK_MS' },
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
const stdout = inkRender.mock.calls[0]?.[1]?.stdout;
|
|
258
|
+
expect(stdout).toBeDefined();
|
|
259
|
+
expect(stdout).not.toBe(process.stdout);
|
|
260
|
+
expect(typeof stdout?.write).toBe('function');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-2670 TC-02 / TC-03 / TC-04 / TC-06 / TC-11 — the owned write path.
|
|
3
|
+
*
|
|
4
|
+
* The unit under test is a COMMIT: what Ink writes in one synchronous run must leave the proxy
|
|
5
|
+
* contiguously, with the interval BEFORE the batch and none inside it. The echo flag is consumed
|
|
6
|
+
* when a batch opens and expires on `setImmediate`; the surplus-token case (an arm with no commit
|
|
7
|
+
* behind it) is asserted, not assumed. Dropped batches settle their callbacks; the barrier is never
|
|
8
|
+
* dropped; control-only batches add no park; the first batch of a session is never parked.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { EventEmitter } from 'node:events';
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import { createParkedStdout } from '../screen-reader-stdout.js';
|
|
16
|
+
|
|
17
|
+
import type { TParkedStdoutSource } from '../screen-reader-stdout.js';
|
|
18
|
+
|
|
19
|
+
interface IWritten {
|
|
20
|
+
text: string;
|
|
21
|
+
at: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class FakeStdout extends EventEmitter {
|
|
25
|
+
readonly isTTY = true;
|
|
26
|
+
readonly columns = 100;
|
|
27
|
+
readonly rows = 32;
|
|
28
|
+
readonly destroyed = false;
|
|
29
|
+
readonly writable = true;
|
|
30
|
+
readonly writableEnded = false;
|
|
31
|
+
writableLength = 0;
|
|
32
|
+
writableNeedDrain = false;
|
|
33
|
+
readonly written: IWritten[] = [];
|
|
34
|
+
failNext: Error | undefined;
|
|
35
|
+
write = (
|
|
36
|
+
chunk: string | Uint8Array,
|
|
37
|
+
encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
|
|
38
|
+
maybeCallback?: (error?: Error | null) => void,
|
|
39
|
+
): boolean => {
|
|
40
|
+
const callback = typeof encodingOrCallback === 'function' ? encodingOrCallback : maybeCallback;
|
|
41
|
+
this.written.push({ text: String(chunk), at: Date.now() });
|
|
42
|
+
const error = this.failNext;
|
|
43
|
+
this.failNext = undefined;
|
|
44
|
+
callback?.(error ?? null);
|
|
45
|
+
return !this.writableNeedDrain;
|
|
46
|
+
};
|
|
47
|
+
asSource(): TParkedStdoutSource {
|
|
48
|
+
return this as unknown as TParkedStdoutSource;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run the nextTick queue (batch close), let a REAL setImmediate pass (the echo flag's expiry runs on
|
|
54
|
+
* the real check phase, exactly as in production), then any due fake timers.
|
|
55
|
+
*/
|
|
56
|
+
async function settle(): Promise<void> {
|
|
57
|
+
await new Promise<void>((resolve) => process.nextTick(resolve));
|
|
58
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
59
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One screen-reader commit as ink.js:371-412 writes it: BSU, static erase, frame, ESU. */
|
|
63
|
+
const BSU = '\x1b[?2026h';
|
|
64
|
+
const ESU = '\x1b[?2026l';
|
|
65
|
+
const ERASE = '\x1b[1A\x1b[2K\x1b[G';
|
|
66
|
+
|
|
67
|
+
function commit(stdout: ReturnType<typeof createParkedStdout>, text: string): void {
|
|
68
|
+
stdout.write(BSU);
|
|
69
|
+
stdout.write(`${ERASE}${text}\n`);
|
|
70
|
+
stdout.write(ESU);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let fake: FakeStdout;
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
// Only the park's clock is faked; nextTick and setImmediate keep their real ordering.
|
|
77
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
|
|
78
|
+
fake = new FakeStdout();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.useRealTimers();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('TC-11: the first batch of a session is released with no park; the second is parked', () => {
|
|
86
|
+
it('writes the first commit immediately and parks the next one for the whole interval', async () => {
|
|
87
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 250 });
|
|
88
|
+
commit(parked, 'first');
|
|
89
|
+
await settle();
|
|
90
|
+
expect(fake.written.map((w) => w.text)).toEqual([BSU, `${ERASE}first\n`, ESU]);
|
|
91
|
+
|
|
92
|
+
commit(parked, 'second');
|
|
93
|
+
await settle();
|
|
94
|
+
expect(fake.written).toHaveLength(3);
|
|
95
|
+
await vi.advanceTimersByTimeAsync(249);
|
|
96
|
+
expect(fake.written).toHaveLength(3);
|
|
97
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
98
|
+
expect(fake.written.map((w) => w.text).slice(3)).toEqual([BSU, `${ERASE}second\n`, ESU]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('TC-02: the unit is a COMMIT — chunks of one run leave contiguously', () => {
|
|
103
|
+
it('parks once BEFORE the batch and puts no interval between its chunks', async () => {
|
|
104
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 250 });
|
|
105
|
+
commit(parked, 'prime');
|
|
106
|
+
await settle();
|
|
107
|
+
const primedAt = Date.now();
|
|
108
|
+
|
|
109
|
+
commit(parked, 'reply');
|
|
110
|
+
await settle();
|
|
111
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
112
|
+
|
|
113
|
+
const released = fake.written.slice(3);
|
|
114
|
+
expect(released.map((w) => w.text)).toEqual([BSU, `${ERASE}reply\n`, ESU]);
|
|
115
|
+
// The interval sits in front of the batch …
|
|
116
|
+
expect(released[0]?.at).toBe(primedAt + 250);
|
|
117
|
+
// … and nowhere inside it: a synchronized-output pair and a <Static> erase are never split.
|
|
118
|
+
expect(new Set(released.map((w) => w.at)).size).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('TC-03: the echo release — scope is the handler`s, formation and expiry are the proxy`s', () => {
|
|
123
|
+
it('releases a batch stamped while armed without a park, and parks the next one again', async () => {
|
|
124
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 250 });
|
|
125
|
+
commit(parked, 'prime');
|
|
126
|
+
await settle();
|
|
127
|
+
|
|
128
|
+
parked.armEchoRelease();
|
|
129
|
+
commit(parked, 'echo: h');
|
|
130
|
+
await settle();
|
|
131
|
+
expect(fake.written.map((w) => w.text)).toContain(`${ERASE}echo: h\n`);
|
|
132
|
+
|
|
133
|
+
commit(parked, 'assistant line');
|
|
134
|
+
await settle();
|
|
135
|
+
expect(fake.written.map((w) => w.text)).not.toContain(`${ERASE}assistant line\n`);
|
|
136
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
137
|
+
expect(fake.written.map((w) => w.text)).toContain(`${ERASE}assistant line\n`);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('keeps the exemption on a batch stamped at formation even when it waits behind a parked one', async () => {
|
|
141
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 250 });
|
|
142
|
+
commit(parked, 'prime');
|
|
143
|
+
await settle();
|
|
144
|
+
commit(parked, 'streaming token');
|
|
145
|
+
await settle();
|
|
146
|
+
// The reply batch is parked; a keystroke batch forms behind it while the flag is armed.
|
|
147
|
+
parked.armEchoRelease();
|
|
148
|
+
commit(parked, 'echo: h');
|
|
149
|
+
await settle();
|
|
150
|
+
// Both are still waiting: FIFO order is untouched, so the echo does not overtake the reply …
|
|
151
|
+
expect(fake.written).toHaveLength(3);
|
|
152
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
153
|
+
// … and when the reply releases, the echo follows at once with no second park.
|
|
154
|
+
const texts = fake.written.map((w) => w.text);
|
|
155
|
+
expect(texts.indexOf(`${ERASE}streaming token\n`)).toBeLessThan(
|
|
156
|
+
texts.indexOf(`${ERASE}echo: h\n`),
|
|
157
|
+
);
|
|
158
|
+
expect(texts).toContain(`${ERASE}echo: h\n`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('arms at most one batch per turn, and an arm with no batch behind it expires', async () => {
|
|
162
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 250 });
|
|
163
|
+
commit(parked, 'prime');
|
|
164
|
+
await settle();
|
|
165
|
+
|
|
166
|
+
// Three arms in one turn, then a commit: one exemption.
|
|
167
|
+
parked.armEchoRelease();
|
|
168
|
+
parked.armEchoRelease();
|
|
169
|
+
parked.armEchoRelease();
|
|
170
|
+
commit(parked, 'echo: a');
|
|
171
|
+
await settle();
|
|
172
|
+
expect(fake.written.map((w) => w.text)).toContain(`${ERASE}echo: a\n`);
|
|
173
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
174
|
+
|
|
175
|
+
// An arm that commits nothing: the flag must be gone by the time the next turn's batch forms.
|
|
176
|
+
parked.armEchoRelease();
|
|
177
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
178
|
+
commit(parked, 'transcript line');
|
|
179
|
+
await settle();
|
|
180
|
+
expect(fake.written.map((w) => w.text)).not.toContain(`${ERASE}transcript line\n`);
|
|
181
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
182
|
+
expect(fake.written.map((w) => w.text)).toContain(`${ERASE}transcript line\n`);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('TC-04: ordering, callbacks, backpressure and early release', () => {
|
|
187
|
+
it('fires every callback exactly once, after the underlying write, with its error', async () => {
|
|
188
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 250 });
|
|
189
|
+
const seen: Array<Error | null | undefined> = [];
|
|
190
|
+
parked.write('first\n', (error) => seen.push(error));
|
|
191
|
+
await settle();
|
|
192
|
+
expect(seen).toEqual([null]);
|
|
193
|
+
|
|
194
|
+
fake.failNext = new Error('EPIPE');
|
|
195
|
+
parked.write('second\n', (error) => seen.push(error));
|
|
196
|
+
await settle();
|
|
197
|
+
expect(seen).toHaveLength(1);
|
|
198
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
199
|
+
expect(seen).toHaveLength(2);
|
|
200
|
+
expect(seen[1]).toBeInstanceOf(Error);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('never drops a batch: a newer printable commit releases the older parked one at once, in order', async () => {
|
|
204
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 250 });
|
|
205
|
+
commit(parked, 'prime');
|
|
206
|
+
await settle();
|
|
207
|
+
|
|
208
|
+
const settledCallbacks: string[] = [];
|
|
209
|
+
parked.write('redraw 1\n', () => settledCallbacks.push('redraw 1'));
|
|
210
|
+
await settle();
|
|
211
|
+
parked.write('', () => settledCallbacks.push('barrier'));
|
|
212
|
+
await settle();
|
|
213
|
+
// Nothing yet: redraw 1 is parked and the barrier waits behind it.
|
|
214
|
+
expect(fake.written).toHaveLength(3);
|
|
215
|
+
|
|
216
|
+
parked.write('redraw 2\n', () => settledCallbacks.push('redraw 2'));
|
|
217
|
+
await settle();
|
|
218
|
+
// redraw 1 and the barrier were released the moment redraw 2 closed — Ink's erase bookkeeping
|
|
219
|
+
// and its once-only Static writes both assume redraw 1 reached the terminal.
|
|
220
|
+
const texts = fake.written.map((w) => w.text);
|
|
221
|
+
expect(texts.slice(3)).toEqual(['redraw 1\n', '']);
|
|
222
|
+
expect(settledCallbacks).toEqual(['redraw 1', 'barrier']);
|
|
223
|
+
// redraw 2 is the one that waits: at most one batch is ever parked.
|
|
224
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
225
|
+
expect(fake.written.map((w) => w.text).at(-1)).toBe('redraw 2\n');
|
|
226
|
+
expect(settledCallbacks).toEqual(['redraw 1', 'barrier', 'redraw 2']);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('reads the underlying backpressure through, and delegates writableLength', () => {
|
|
230
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 250 });
|
|
231
|
+
expect(parked.write('x')).toBe(true);
|
|
232
|
+
fake.writableNeedDrain = true;
|
|
233
|
+
expect(parked.write('y')).toBe(false);
|
|
234
|
+
fake.writableLength = 42;
|
|
235
|
+
expect(parked.asInkStdout().writableLength).toBe(42);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('releases a control-only batch without a park while keeping it behind a pending one', async () => {
|
|
239
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 250 });
|
|
240
|
+
commit(parked, 'prime');
|
|
241
|
+
await settle();
|
|
242
|
+
commit(parked, 'reply');
|
|
243
|
+
await settle();
|
|
244
|
+
parked.write('\x1b]133;A\x07');
|
|
245
|
+
await settle();
|
|
246
|
+
// Nothing yet: the mark waits behind the parked reply rather than overtaking it.
|
|
247
|
+
expect(fake.written).toHaveLength(3);
|
|
248
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
249
|
+
const texts = fake.written.map((w) => w.text);
|
|
250
|
+
expect(texts.indexOf(`${ERASE}reply\n`)).toBeLessThan(texts.indexOf('\x1b]133;A\x07'));
|
|
251
|
+
// And it added no interval of its own: the mark left in the same tick as the reply.
|
|
252
|
+
expect(fake.written.at(-1)?.at).toBe(fake.written.at(-2)?.at);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('TC-06: dimensions, resize, identity and teardown', () => {
|
|
257
|
+
it('reads columns/rows/isTTY through and forwards a resize listener to the real stream', () => {
|
|
258
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 250 });
|
|
259
|
+
const ink = parked.asInkStdout();
|
|
260
|
+
expect(ink.columns).toBe(100);
|
|
261
|
+
expect(ink.rows).toBe(32);
|
|
262
|
+
expect(ink.isTTY).toBe(true);
|
|
263
|
+
const onResize = vi.fn();
|
|
264
|
+
ink.on('resize', onResize);
|
|
265
|
+
fake.emit('resize');
|
|
266
|
+
expect(onResize).toHaveBeenCalledTimes(1);
|
|
267
|
+
ink.off('resize', onResize);
|
|
268
|
+
fake.emit('resize');
|
|
269
|
+
expect(onResize).toHaveBeenCalledTimes(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('is one stable object for the session — Ink keys its instance map by it', () => {
|
|
273
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 250 });
|
|
274
|
+
expect(parked.asInkStdout()).toBe(parked.asInkStdout());
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('drain() releases what is parked for a terminal handoff and keeps parking afterwards', async () => {
|
|
278
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 5000 });
|
|
279
|
+
commit(parked, 'prime');
|
|
280
|
+
await settle();
|
|
281
|
+
commit(parked, 'before the handoff');
|
|
282
|
+
await settle();
|
|
283
|
+
expect(fake.written).toHaveLength(3);
|
|
284
|
+
await parked.drain();
|
|
285
|
+
expect(fake.written.map((w) => w.text)).toContain(`${ERASE}before the handoff\n`);
|
|
286
|
+
// Not terminal: the next printable commit is parked again.
|
|
287
|
+
commit(parked, 'after the handoff');
|
|
288
|
+
await settle();
|
|
289
|
+
expect(fake.written.map((w) => w.text)).not.toContain(`${ERASE}after the handoff\n`);
|
|
290
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
291
|
+
expect(fake.written.map((w) => w.text)).toContain(`${ERASE}after the handoff\n`);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('flush() releases what is still parked, in order, and resolves only after it is written', async () => {
|
|
295
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 5000 });
|
|
296
|
+
commit(parked, 'prime');
|
|
297
|
+
await settle();
|
|
298
|
+
commit(parked, 'last frame of the session');
|
|
299
|
+
await settle();
|
|
300
|
+
expect(fake.written).toHaveLength(3);
|
|
301
|
+
await parked.flush();
|
|
302
|
+
expect(fake.written.map((w) => w.text)).toContain(`${ERASE}last frame of the session\n`);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('preparkMs 0 disables the park exactly', () => {
|
|
307
|
+
it('releases every batch as it closes, in order', async () => {
|
|
308
|
+
const parked = createParkedStdout({ stdout: fake.asSource(), preparkMs: 0 });
|
|
309
|
+
commit(parked, 'one');
|
|
310
|
+
await settle();
|
|
311
|
+
commit(parked, 'two');
|
|
312
|
+
await settle();
|
|
313
|
+
expect(fake.written.map((w) => w.text)).toEqual([
|
|
314
|
+
BSU,
|
|
315
|
+
`${ERASE}one\n`,
|
|
316
|
+
ESU,
|
|
317
|
+
BSU,
|
|
318
|
+
`${ERASE}two\n`,
|
|
319
|
+
ESU,
|
|
320
|
+
]);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-2670 TC-07 — the OSC 133 turn marks travel through the owned write path.
|
|
3
|
+
*
|
|
4
|
+
* The marks are positional: the terminal records the line they arrive on. Written to
|
|
5
|
+
* `process.stdout` directly they would land between a parked batch and its release, on the wrong
|
|
6
|
+
* line. So the hook reads the pacing port and writes through it; outside a provider the port is
|
|
7
|
+
* the process's own stdout, which is exactly the pre-change path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { render } from 'ink-testing-library';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import { useScreenReaderTurnSignals } from '../hooks/useScreenReaderTurnSignals.js';
|
|
15
|
+
import { ScreenReaderPacingProvider } from '../screen-reader-pacing-context.js';
|
|
16
|
+
import { TerminalCapabilitiesProvider } from '../terminal-capabilities-context.js';
|
|
17
|
+
import { turnMarkSequence } from '../terminal-marks.js';
|
|
18
|
+
|
|
19
|
+
import type { IScreenReaderPacingPort } from '../screen-reader-pacing-context.js';
|
|
20
|
+
|
|
21
|
+
function Probe({ isThinking }: { isThinking: boolean }): React.ReactElement {
|
|
22
|
+
useScreenReaderTurnSignals({ enabled: true, isThinking, activeTools: [], awaitingAnswer: false });
|
|
23
|
+
return <></>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let port: IScreenReaderPacingPort & { writes: string[] };
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
const writes: string[] = [];
|
|
30
|
+
port = {
|
|
31
|
+
writes,
|
|
32
|
+
armEchoRelease: vi.fn(),
|
|
33
|
+
write: (text: string): void => {
|
|
34
|
+
writes.push(text);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.unstubAllEnvs();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('TC-07: the turn marks go through the port, ordered with the turn they belong to', () => {
|
|
44
|
+
it('emits prompt-start on mount and the B/C pair when a turn starts, all through the port', () => {
|
|
45
|
+
const { rerender } = render(
|
|
46
|
+
<ScreenReaderPacingProvider port={port}>
|
|
47
|
+
<TerminalCapabilitiesProvider overrides={{ turnMarks: true }}>
|
|
48
|
+
<Probe isThinking={false} />
|
|
49
|
+
</TerminalCapabilitiesProvider>
|
|
50
|
+
</ScreenReaderPacingProvider>,
|
|
51
|
+
);
|
|
52
|
+
expect(port.writes).toEqual([turnMarkSequence('promptStart')]);
|
|
53
|
+
|
|
54
|
+
rerender(
|
|
55
|
+
<ScreenReaderPacingProvider port={port}>
|
|
56
|
+
<TerminalCapabilitiesProvider overrides={{ turnMarks: true }}>
|
|
57
|
+
<Probe isThinking />
|
|
58
|
+
</TerminalCapabilitiesProvider>
|
|
59
|
+
</ScreenReaderPacingProvider>,
|
|
60
|
+
);
|
|
61
|
+
expect(port.writes).toEqual([
|
|
62
|
+
turnMarkSequence('promptStart'),
|
|
63
|
+
turnMarkSequence('promptEnd'),
|
|
64
|
+
turnMarkSequence('turnStart'),
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('never reaches process.stdout directly while a port is provided', () => {
|
|
69
|
+
const spy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
70
|
+
try {
|
|
71
|
+
render(
|
|
72
|
+
<ScreenReaderPacingProvider port={port}>
|
|
73
|
+
<TerminalCapabilitiesProvider overrides={{ turnMarks: true }}>
|
|
74
|
+
<Probe isThinking={false} />
|
|
75
|
+
</TerminalCapabilitiesProvider>
|
|
76
|
+
</ScreenReaderPacingProvider>,
|
|
77
|
+
);
|
|
78
|
+
const direct = spy.mock.calls.filter((call) => String(call[0]).includes('\x1b]133;'));
|
|
79
|
+
expect(direct).toEqual([]);
|
|
80
|
+
expect(port.writes).toHaveLength(1);
|
|
81
|
+
} finally {
|
|
82
|
+
spy.mockRestore();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|