@robota-sdk/agent-ui-terminal 3.0.0-beta.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +27 -0
- package/dist/node/index.cjs +32 -0
- package/dist/node/index.d.cts +1066 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1066 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +33 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +93 -0
- package/src/App.tsx +146 -0
- package/src/AppPresentation.tsx +190 -0
- package/src/AppView.tsx +26 -0
- package/src/BackgroundTaskPanel.tsx +73 -0
- package/src/CjkTextInput.tsx +336 -0
- package/src/ConfirmPrompt.tsx +140 -0
- package/src/ContextWarningBanner.tsx +45 -0
- package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
- package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
- package/src/HistorySearchOverlay.tsx +150 -0
- package/src/InputArea.tsx +338 -0
- package/src/ListPicker.tsx +158 -0
- package/src/MenuSelect.tsx +159 -0
- package/src/MessageList.tsx +308 -0
- package/src/MultiSelectList.tsx +294 -0
- package/src/PendingActionPrompt.tsx +121 -0
- package/src/PermissionPrompt.tsx +186 -0
- package/src/PluginTUI.tsx +258 -0
- package/src/RoleLabel.tsx +72 -0
- package/src/SafeText.tsx +66 -0
- package/src/SessionEventNotices.tsx +27 -0
- package/src/SessionPicker.tsx +72 -0
- package/src/SessionStatusBar.tsx +76 -0
- package/src/SlashAutocomplete.tsx +154 -0
- package/src/StatusBar.tsx +284 -0
- package/src/StreamingIndicator.tsx +145 -0
- package/src/SupervisedSessionView.tsx +455 -0
- package/src/TextPrompt.tsx +112 -0
- package/src/ThemePicker.tsx +366 -0
- package/src/ToolCommandOutput.tsx +48 -0
- package/src/ToolDiffBlock.tsx +44 -0
- package/src/TransportTUI.tsx +166 -0
- package/src/TuiInteractionChannel.ts +567 -0
- package/src/UpdateNotice.tsx +18 -0
- package/src/UsageSummaryEntry.tsx +49 -0
- package/src/WaveText.tsx +70 -0
- package/src/__tests__/ListPicker.test.tsx +188 -0
- package/src/__tests__/MenuSelect.test.tsx +127 -0
- package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
- package/src/__tests__/PluginTUI.test.tsx +167 -0
- package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
- package/src/__tests__/SupervisedSessionView-render.test.tsx +24 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +629 -0
- package/src/__tests__/TextPrompt.test.tsx +112 -0
- package/src/__tests__/TransportTUI.test.tsx +124 -0
- package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +284 -0
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +642 -0
- package/src/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/__tests__/abort-after-permission.test.tsx +160 -0
- package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/__tests__/attention-bell-and-marks.test.ts +144 -0
- package/src/__tests__/autocomplete-editing-keys.test.tsx +127 -0
- package/src/__tests__/background-task-panel-countdown.test.tsx +75 -0
- package/src/__tests__/background-task-panel.test.tsx +98 -0
- package/src/__tests__/background-task-row-format.test.ts +152 -0
- package/src/__tests__/channel-factory-integration.test.ts +150 -0
- package/src/__tests__/cjk-defer-submit.test.tsx +67 -0
- package/src/__tests__/cjk-fallback-render.test.tsx +78 -0
- package/src/__tests__/cjk-text-input-flow.test.ts +186 -0
- package/src/__tests__/cjk-text-input.test.ts +191 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
- package/src/__tests__/command-output-summary.test.ts +95 -0
- package/src/__tests__/command-result-handler.test.ts +171 -0
- package/src/__tests__/compact-event-bridge.test.ts +20 -0
- package/src/__tests__/confirm-permission-flow.test.ts +149 -0
- package/src/__tests__/confirm-prompt.test.tsx +103 -0
- package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +45 -0
- package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
- package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
- package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
- package/src/__tests__/external-prompt-notice.test.tsx +100 -0
- package/src/__tests__/fixtures/command-handoff-driver.tsx +111 -0
- package/src/__tests__/fixtures/fake-editor.sh +7 -0
- package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
- package/src/__tests__/fork-attach.test.tsx +235 -0
- package/src/__tests__/helpers/terminal-profiles.ts +152 -0
- package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
- package/src/__tests__/history-clear-broadcast.test.ts +66 -0
- package/src/__tests__/history-search-overlay.test.tsx +329 -0
- package/src/__tests__/humanize-tool-name.test.ts +30 -0
- package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
- package/src/__tests__/input-area-flow.test.ts +199 -0
- package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
- package/src/__tests__/interaction-channel-charter.test.ts +13 -0
- package/src/__tests__/key-hint-consistency.test.tsx +249 -0
- package/src/__tests__/key-hint-footer.test.tsx +69 -0
- package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
- package/src/__tests__/message-list-rendering.test.tsx +473 -0
- package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
- package/src/__tests__/output-style-reaches-session.test.ts +35 -0
- package/src/__tests__/palette-consistency.test.ts +330 -0
- package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
- package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
- package/src/__tests__/product-display-name.test.tsx +79 -0
- package/src/__tests__/prompt-queue.test.tsx +260 -0
- package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
- package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
- package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
- package/src/__tests__/pty/behavior-2437-git.ptytest.ts +342 -0
- package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
- package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
- package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
- package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
- package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
- package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +291 -0
- package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +165 -0
- package/src/__tests__/pty/ime-cursor.ptytest.ts +186 -0
- package/src/__tests__/pty/isolated-home.ts +64 -0
- package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
- package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
- package/src/__tests__/pty/pty-driver.ts +153 -0
- package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +134 -0
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +138 -0
- package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
- package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
- package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
- package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
- package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
- package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
- package/src/__tests__/pty/spawn-pty.test.ts +120 -0
- package/src/__tests__/pty/spawn-pty.ts +229 -0
- package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
- package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
- package/src/__tests__/pty-driver-dispose.test.ts +43 -0
- package/src/__tests__/real-cursor-positioning.test.tsx +263 -0
- package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
- package/src/__tests__/render-channel-options.test.ts +201 -0
- package/src/__tests__/render-lifecycle.test.ts +36 -0
- package/src/__tests__/render-markdown.test.ts +164 -0
- package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
- package/src/__tests__/safe-text-boundary.test.tsx +75 -0
- package/src/__tests__/screen-reader-input.test.tsx +106 -0
- package/src/__tests__/screen-reader-labels.test.ts +91 -0
- package/src/__tests__/screen-reader-menus.test.tsx +200 -0
- package/src/__tests__/screen-reader-pacing.test.ts +230 -0
- package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
- package/src/__tests__/screen-reader-render-options.test.ts +262 -0
- package/src/__tests__/screen-reader-stdout.test.ts +322 -0
- package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
- package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
- package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
- package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
- package/src/__tests__/selection-flow.test.ts +61 -0
- package/src/__tests__/session-event-notices.test.tsx +31 -0
- package/src/__tests__/session-init-poller.test.ts +102 -0
- package/src/__tests__/session-switch-channel.test.tsx +470 -0
- package/src/__tests__/shutdown-signal.test.ts +34 -0
- package/src/__tests__/status-activity.test.ts +71 -0
- package/src/__tests__/status-bar.test.tsx +282 -0
- package/src/__tests__/status-glyph.test.ts +30 -0
- package/src/__tests__/streaming-indicator.test.tsx +177 -0
- package/src/__tests__/terminal-capabilities.test.ts +153 -0
- package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
- package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
- package/src/__tests__/text-prompt-flow.test.ts +77 -0
- package/src/__tests__/theme-picker.test.tsx +345 -0
- package/src/__tests__/transport-boundary.test.ts +11 -0
- package/src/__tests__/tui-app-channel-port.test.ts +31 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +62 -0
- package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
- package/src/__tests__/tui-interaction-queues.test.ts +139 -0
- package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
- package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
- package/src/__tests__/tui-state-manager.test.ts +451 -0
- package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
- package/src/__tests__/wave-text.test.tsx +133 -0
- package/src/app-banner.tsx +42 -0
- package/src/app-static-items.ts +35 -0
- package/src/app-view-model.ts +136 -0
- package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
- package/src/attention/__tests__/attention-tracker.test.ts +103 -0
- package/src/attention/__tests__/countdown.test.ts +15 -0
- package/src/attention/__tests__/focus-input-filter.test.ts +92 -0
- package/src/attention/__tests__/interval-recap.test.ts +49 -0
- package/src/attention/attention-coordinator.ts +71 -0
- package/src/attention/attention-tracker.ts +124 -0
- package/src/attention/countdown.ts +24 -0
- package/src/attention/focus-input-filter.ts +131 -0
- package/src/attention/interval-recap.ts +148 -0
- package/src/attention/time-units.ts +6 -0
- package/src/attention-bell.ts +79 -0
- package/src/attributed-user-echo.ts +30 -0
- package/src/background-task-row-format.ts +99 -0
- package/src/command-interaction.ts +8 -0
- package/src/command-output-summary.ts +129 -0
- package/src/create-default-tui-cli-adapter.ts +45 -0
- package/src/execution-workspace-switcher-selection.ts +118 -0
- package/src/execution-workspace-view-model.ts +139 -0
- package/src/external-prompt-notice.tsx +38 -0
- package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
- package/src/flows/__tests__/defer-submit.test.ts +87 -0
- package/src/flows/__tests__/real-cursor-flow.test.ts +124 -0
- package/src/flows/background-focus-flow.ts +44 -0
- package/src/flows/cjk-cursor-motion.ts +75 -0
- package/src/flows/cjk-text-deletion.ts +53 -0
- package/src/flows/cjk-text-input-flow.ts +248 -0
- package/src/flows/cjk-text-input-types.ts +24 -0
- package/src/flows/confirm-prompt-flow.ts +90 -0
- package/src/flows/defer-submit.ts +67 -0
- package/src/flows/fork-attach-flow.ts +62 -0
- package/src/flows/input-area-flow.ts +188 -0
- package/src/flows/permission-prompt-flow.ts +108 -0
- package/src/flows/real-cursor-flow.ts +83 -0
- package/src/flows/selection-flow.ts +205 -0
- package/src/flows/session-init-poller.ts +76 -0
- package/src/flows/text-prompt-flow.ts +93 -0
- package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
- package/src/history-search/history-search-flow.ts +94 -0
- package/src/history-search/useHistorySearch.ts +336 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +74 -0
- package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
- package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
- package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
- package/src/hooks/command-result-handler.ts +44 -0
- package/src/hooks/side-effects-types.ts +45 -0
- package/src/hooks/useAppController.ts +267 -0
- package/src/hooks/useAppInputBindings.ts +145 -0
- package/src/hooks/useAppInteractionState.ts +81 -0
- package/src/hooks/useAppLifecycleState.ts +81 -0
- package/src/hooks/useAppOverlays.ts +74 -0
- package/src/hooks/useAppScreenState.ts +81 -0
- package/src/hooks/useAppSubmissionState.ts +45 -0
- package/src/hooks/useAppThemeState.ts +157 -0
- package/src/hooks/useAppWorkspaceState.ts +149 -0
- package/src/hooks/useAppearanceSettings.ts +30 -0
- package/src/hooks/useAutocomplete.ts +101 -0
- package/src/hooks/useBackgroundPanel.ts +58 -0
- package/src/hooks/useCountdownTick.ts +24 -0
- package/src/hooks/useExecutionDetailPage.ts +69 -0
- package/src/hooks/useForkAttach.ts +60 -0
- package/src/hooks/useInputAreaHistorySearch.ts +72 -0
- package/src/hooks/useInputAreaKeys.ts +114 -0
- package/src/hooks/useNumberedSelection.ts +76 -0
- package/src/hooks/usePluginCallbacks.ts +38 -0
- package/src/hooks/usePluginScreenData.ts +85 -0
- package/src/hooks/useRealCursorPosition.ts +125 -0
- package/src/hooks/useRenderMarkdown.ts +38 -0
- package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
- package/src/hooks/useSideEffects.ts +144 -0
- package/src/hooks/useStatusLineSettings.ts +50 -0
- package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
- package/src/hooks/useTuiChannel.ts +129 -0
- package/src/humanize-tool-name.ts +51 -0
- package/src/index.ts +56 -0
- package/src/input-area-rules.tsx +71 -0
- package/src/json-value.ts +18 -0
- package/src/key-hint-footer.tsx +54 -0
- package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
- package/src/keybindings/__tests__/node-keybindings-source.test.ts +111 -0
- package/src/keybindings/keybinding-catalogue.ts +171 -0
- package/src/keybindings/keybinding-registry.ts +303 -0
- package/src/keybindings/keybinding-syntax.ts +155 -0
- package/src/keybindings/keybindings-context.tsx +124 -0
- package/src/keybindings/node-keybindings-source.ts +189 -0
- package/src/numbered-list.tsx +113 -0
- package/src/plugin-tui-handlers.ts +163 -0
- package/src/product-display-name-context.tsx +30 -0
- package/src/render-markdown.ts +209 -0
- package/src/render.tsx +544 -0
- package/src/sanitize-terminal-text.ts +171 -0
- package/src/screen-reader-announcement.ts +52 -0
- package/src/screen-reader-context.tsx +32 -0
- package/src/screen-reader-labels.ts +64 -0
- package/src/screen-reader-pacing-context.tsx +47 -0
- package/src/screen-reader-pacing.ts +178 -0
- package/src/screen-reader-stdout.ts +328 -0
- package/src/shutdown-signal.ts +34 -0
- package/src/status-activity.ts +70 -0
- package/src/status-glyph.ts +78 -0
- package/src/terminal-capabilities-context.tsx +27 -0
- package/src/terminal-capabilities.ts +77 -0
- package/src/terminal-focus-reporting.ts +50 -0
- package/src/terminal-handoff-controller.ts +98 -0
- package/src/terminal-marks.ts +63 -0
- package/src/theme/__tests__/built-in-themes.test.ts +117 -0
- package/src/theme/__tests__/color-vision.test.ts +82 -0
- package/src/theme/__tests__/theme-context.test.tsx +146 -0
- package/src/theme/__tests__/theme-document.test.ts +222 -0
- package/src/theme/__tests__/theme-registry.test.ts +138 -0
- package/src/theme/__tests__/theme-styles.test.ts +233 -0
- package/src/theme/built-in-themes.ts +342 -0
- package/src/theme/color-vision.ts +171 -0
- package/src/theme/index.ts +45 -0
- package/src/theme/theme-context.tsx +81 -0
- package/src/theme/theme-contracts.ts +139 -0
- package/src/theme/theme-document.ts +278 -0
- package/src/theme/theme-registry.ts +127 -0
- package/src/theme/theme-styles.ts +177 -0
- package/src/tool-summary-status.ts +59 -0
- package/src/tui-app-channel-port.ts +83 -0
- package/src/tui-channel-lifecycle-coordinator.ts +149 -0
- package/src/tui-channel-options.ts +172 -0
- package/src/tui-cli-adapter-context.tsx +13 -0
- package/src/tui-cli-adapter.ts +24 -0
- package/src/tui-interaction-queues.ts +163 -0
- package/src/tui-session-binding.ts +52 -0
- package/src/tui-session-event-projector.ts +140 -0
- package/src/tui-session-events.ts +82 -0
- package/src/tui-session-notice-store.ts +43 -0
- package/src/tui-session-options.ts +118 -0
- package/src/tui-state-manager.ts +327 -0
- package/src/types.ts +19 -0
- package/src/use-terminal-title.ts +29 -0
- package/src/utils/__tests__/edit-diff.test.ts +426 -0
- package/src/utils/__tests__/paste-detection.test.ts +116 -0
- package/src/utils/__tests__/paste-labels.test.ts +46 -0
- package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
- package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
- package/src/utils/edit-diff.ts +151 -0
- package/src/utils/input-top-border.ts +19 -0
- package/src/utils/paste-labels.ts +9 -0
- package/src/utils/tool-call-extractor.ts +92 -0
- package/src/utils/tool-diff-summary.ts +75 -0
|
@@ -0,0 +1,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
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-2004 — the long-tool bell, at the hook that decides when a tool finished.
|
|
3
|
+
*
|
|
4
|
+
* `attention-bell.ts` already has its own tests, and they pass whatever this hook feeds it. The
|
|
5
|
+
* defect the review found lives HERE: completion was inferred from an entry leaving `activeTools`,
|
|
6
|
+
* while the session replaces the finished entry in place (`tui-state-manager.ts` `onToolEnd`) and
|
|
7
|
+
* empties the array only at turn end. Under that reading a one-second tool inside a ten-second turn
|
|
8
|
+
* rings, and every tool rings at once when the turn ends.
|
|
9
|
+
*
|
|
10
|
+
* So this drives the hook with the state sequence the session actually produces — running, then the
|
|
11
|
+
* same entry with `isRunning: false` — and asserts the bell against the clock.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { render } from 'ink-testing-library';
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import { BELL, LONG_TOOL_BELL_MS } from '../attention-bell.js';
|
|
19
|
+
import { useScreenReaderTurnSignals } from '../hooks/useScreenReaderTurnSignals.js';
|
|
20
|
+
|
|
21
|
+
import type { IToolState } from '@robota-sdk/agent-interface-session';
|
|
22
|
+
|
|
23
|
+
function tool(overrides: Partial<IToolState> = {}): IToolState {
|
|
24
|
+
return { toolName: 'Bash', firstArg: 'ls', isRunning: true, ...overrides };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Probe({ activeTools }: { activeTools: readonly IToolState[] }): React.ReactElement {
|
|
28
|
+
useScreenReaderTurnSignals({
|
|
29
|
+
enabled: true,
|
|
30
|
+
isThinking: true,
|
|
31
|
+
activeTools,
|
|
32
|
+
awaitingAnswer: false,
|
|
33
|
+
});
|
|
34
|
+
return <></>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Bell bytes written to stdout, with the writer restored afterwards. */
|
|
38
|
+
let bells: string[];
|
|
39
|
+
let restore: () => void;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.useFakeTimers();
|
|
43
|
+
bells = [];
|
|
44
|
+
const original = process.stdout.write.bind(process.stdout);
|
|
45
|
+
const spy = vi
|
|
46
|
+
.spyOn(process.stdout, 'write')
|
|
47
|
+
.mockImplementation((chunk: string | Uint8Array, ...rest: unknown[]): boolean => {
|
|
48
|
+
if (typeof chunk === 'string' && chunk.includes(BELL)) {
|
|
49
|
+
bells.push(chunk);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return (original as (...args: unknown[]) => boolean)(chunk, ...rest);
|
|
53
|
+
});
|
|
54
|
+
restore = (): void => spy.mockRestore();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
restore();
|
|
59
|
+
vi.useRealTimers();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('CLI-2004 — the long-tool bell fires on the isRunning transition', () => {
|
|
63
|
+
it('rings when a tool that outlived the threshold reports isRunning: false', () => {
|
|
64
|
+
const { rerender, unmount } = render(<Probe activeTools={[tool()]} />);
|
|
65
|
+
vi.advanceTimersByTime(LONG_TOOL_BELL_MS + 1000);
|
|
66
|
+
// The session REPLACES the entry in place; the array never shrinks.
|
|
67
|
+
rerender(<Probe activeTools={[tool({ isRunning: false, result: 'success' })]} />);
|
|
68
|
+
unmount();
|
|
69
|
+
|
|
70
|
+
expect(bells).toHaveLength(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('stays silent for a short tool, even though its entry is still in the array', () => {
|
|
74
|
+
const { rerender, unmount } = render(<Probe activeTools={[tool()]} />);
|
|
75
|
+
vi.advanceTimersByTime(1000);
|
|
76
|
+
rerender(<Probe activeTools={[tool({ isRunning: false, result: 'success' })]} />);
|
|
77
|
+
unmount();
|
|
78
|
+
|
|
79
|
+
expect(bells).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not ring a short tool a second time when the turn later empties the array', () => {
|
|
83
|
+
const { rerender, unmount } = render(<Probe activeTools={[tool()]} />);
|
|
84
|
+
vi.advanceTimersByTime(1000);
|
|
85
|
+
rerender(<Probe activeTools={[tool({ isRunning: false, result: 'success' })]} />);
|
|
86
|
+
vi.advanceTimersByTime(LONG_TOOL_BELL_MS + 1000);
|
|
87
|
+
// `onThinking(false)` clears activeTools — the completion already happened above.
|
|
88
|
+
rerender(<Probe activeTools={[]} />);
|
|
89
|
+
unmount();
|
|
90
|
+
|
|
91
|
+
expect(bells).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('tracks concurrent runs of the same tool separately by executionId', () => {
|
|
95
|
+
const first = tool({ toolName: 'Bash', executionId: 'e1' });
|
|
96
|
+
const second = tool({ toolName: 'Bash', executionId: 'e2' });
|
|
97
|
+
const { rerender, unmount } = render(<Probe activeTools={[first]} />);
|
|
98
|
+
vi.advanceTimersByTime(LONG_TOOL_BELL_MS + 1000);
|
|
99
|
+
rerender(<Probe activeTools={[first, second]} />);
|
|
100
|
+
vi.advanceTimersByTime(1000);
|
|
101
|
+
// The long one finishes, the short one is still running: exactly one bell.
|
|
102
|
+
rerender(<Probe activeTools={[{ ...first, isRunning: false }, second]} />);
|
|
103
|
+
unmount();
|
|
104
|
+
|
|
105
|
+
expect(bells).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('writes nothing at all when the mode is off', () => {
|
|
109
|
+
function OffProbe({ activeTools }: { activeTools: readonly IToolState[] }): React.ReactElement {
|
|
110
|
+
useScreenReaderTurnSignals({
|
|
111
|
+
enabled: false,
|
|
112
|
+
isThinking: true,
|
|
113
|
+
activeTools,
|
|
114
|
+
awaitingAnswer: false,
|
|
115
|
+
});
|
|
116
|
+
return <></>;
|
|
117
|
+
}
|
|
118
|
+
const { rerender, unmount } = render(<OffProbe activeTools={[tool()]} />);
|
|
119
|
+
vi.advanceTimersByTime(LONG_TOOL_BELL_MS + 1000);
|
|
120
|
+
rerender(<OffProbe activeTools={[tool({ isRunning: false })]} />);
|
|
121
|
+
unmount();
|
|
122
|
+
|
|
123
|
+
expect(bells).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
});
|