@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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-019 (issue #2022) - the one place untrusted text is made safe to put on a terminal.
|
|
3
|
+
*
|
|
4
|
+
* Model output, tool output, file contents and plugin text all reach Ink `<Text>`, and from there a
|
|
5
|
+
* terminal. What that terminal actually receives was MEASURED against a real stream rather than
|
|
6
|
+
* assumed, because the first version of this module assumed Ink passed everything through and that
|
|
7
|
+
* is not true:
|
|
8
|
+
*
|
|
9
|
+
* stripped by Ink OSC 52 (clipboard), OSC 0 (title), CSI erase/cursor/alt-screen, DCS, APC,
|
|
10
|
+
* 8-bit CSI
|
|
11
|
+
* REACHES the tty SGR colour, OSC 8 hyperlink, a bare carriage return
|
|
12
|
+
*
|
|
13
|
+
* So through `<Text>` the live attack is a link whose visible text and target differ, and a `\r`
|
|
14
|
+
* that overwrites the line the transcript just printed. `useTerminalTitle` writes to stdout directly
|
|
15
|
+
* and is subject to none of Ink's filtering, so everything reaches the terminal there.
|
|
16
|
+
*
|
|
17
|
+
* ## Why sanitize the whole class when Ink removes most of it
|
|
18
|
+
*
|
|
19
|
+
* Because Ink's removal is INCIDENTAL. It falls out of slicing text for layout; it is in no
|
|
20
|
+
* contract, no test of Ink's asserts it, and a dependency upgrade can return the entire class
|
|
21
|
+
* without a line changing here. A boundary that rests on another project's implementation detail is
|
|
22
|
+
* a boundary in name. The two sequences that reach the terminal today prove the exposure is real;
|
|
23
|
+
* the ones Ink happens to remove are the reason this must not be scoped to them.
|
|
24
|
+
*
|
|
25
|
+
* ## Allowlist, not denylist
|
|
26
|
+
*
|
|
27
|
+
* Everything is passed through EXCEPT the control range, and inside that range exactly two
|
|
28
|
+
* characters survive - tab and newline. A denylist of "known dangerous sequences" is
|
|
29
|
+
* wrong for the same reason it is wrong for shell metacharacters: the set belongs to the terminal
|
|
30
|
+
* emulator, and a list written today is incomplete the next time one adds an escape.
|
|
31
|
+
*
|
|
32
|
+
* ## Ordering: sanitize BEFORE rendering, never after
|
|
33
|
+
*
|
|
34
|
+
* The renderer ADDS ANSI - colours, bold, code-block framing. Sanitizing its output would strip the
|
|
35
|
+
* repository's own presentation along with the attacker's. Sanitizing its input removes the escapes
|
|
36
|
+
* that arrived from outside and leaves the ones generated after. That ordering is the whole design,
|
|
37
|
+
* and it is why this is a function on the untrusted string rather than a filter on the rendered one.
|
|
38
|
+
*
|
|
39
|
+
* ## Streaming
|
|
40
|
+
*
|
|
41
|
+
* A sequence split across two deltas - `\x1b]5` then `2;c;...\x07` - is invisible to a sanitizer that
|
|
42
|
+
* sees each chunk alone. {@link createStreamingTerminalSanitizer} holds the incomplete tail back until
|
|
43
|
+
* the chunk that completes it arrives. The TUI's own stream path accumulates before rendering
|
|
44
|
+
* (`tui-state-manager` appends each delta to a buffer and renders the buffer), so the stateless
|
|
45
|
+
* function is sufficient THERE - but a caller that sanitizes per chunk needs the stateful one, and
|
|
46
|
+
* having only the stateless function available is how that caller would get it wrong.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Tab and newline are content. Carriage return is NOT, and that is a correction.
|
|
51
|
+
*
|
|
52
|
+
* A bare `\r` returns the cursor to column zero, so `safe text\rEVIL` prints `EVIL` over what the
|
|
53
|
+
* transcript said — the same "what you read is not what happened" attack as a deceptive hyperlink,
|
|
54
|
+
* with no escape sequence involved. It was kept here as content in the first version of this module
|
|
55
|
+
* on the assumption that CR is a line ending; measurement against a real terminal stream showed a
|
|
56
|
+
* bare CR reaching it intact.
|
|
57
|
+
*
|
|
58
|
+
* `\r\n` IS a line ending, and is normalized to `\n` before the control filter runs, so a document
|
|
59
|
+
* written on Windows keeps its line structure and loses only the overwrite primitive.
|
|
60
|
+
*/
|
|
61
|
+
const KEPT_C0 = new Set(['\t', '\n']);
|
|
62
|
+
|
|
63
|
+
/** `\r\n` is one line break written in two bytes; a `\r` that is not part of one is a cursor move. */
|
|
64
|
+
const CRLF = /\r\n/g;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One escape sequence, or a lone control character.
|
|
68
|
+
*
|
|
69
|
+
* The alternation is ordered longest-context-first so a two-character C1 introducer (`ESC ]`) is
|
|
70
|
+
* consumed as the start of its sequence rather than as a bare `ESC`, and every 8-bit C1 form is
|
|
71
|
+
* matched with its BODY before the lone-C1 alternative can take the introducer on its own.
|
|
72
|
+
*
|
|
73
|
+
* Each introducer appears in both spellings, because a terminal accepts both: `ESC ]` and the single
|
|
74
|
+
* byte `\x9d` are the same OSC. An alternation that removed only the 7-bit spelling would leave the
|
|
75
|
+
* 8-bit one's parameters standing as visible text.
|
|
76
|
+
*
|
|
77
|
+
* - `\x1b][^\x07\x1b]*(?:\x07|\x1b\\)?` OSC, terminated by BEL or ST - or unterminated at the end
|
|
78
|
+
* of the input, which is the streaming case and must still be removed rather than left visible.
|
|
79
|
+
* - `\x9d[^\x07\x1b\x9c]*(?:\x07|\x9c|\x1b\\)?` the same, 8-bit introducer, with `\x9c` as a
|
|
80
|
+
* third accepted terminator because 8-bit ST is one byte.
|
|
81
|
+
* - `\x1b[P^_X][\s\S]*?(?:\x1b\\|\x07)` DCS, PM, APC, SOS, terminated by ST or BEL. The
|
|
82
|
+
* terminator is MANDATORY here, and that is the difference from the OSC alternative above. OSC's
|
|
83
|
+
* body is `[^\x07\x1b]*`, which is greedy and cannot cross its own terminator, so an optional
|
|
84
|
+
* terminator costs nothing. A DCS body is `[\s\S]*?`, which is lazy - and a lazy quantifier
|
|
85
|
+
* followed by an OPTIONAL group prefers the empty match at every position, so the alternative
|
|
86
|
+
* would consume the two-character introducer and nothing else, leaving `q#0;2;0;0;0` from a Sixel
|
|
87
|
+
* sequence standing as visible text. Found in review of PR #2212, and the reason every case in
|
|
88
|
+
* the test table now asserts the exact output rather than "no control byte survives" - the weaker
|
|
89
|
+
* assertion holds for a sanitizer that strips only introducers.
|
|
90
|
+
* - `\x1b[P^_X][\s\S]*$` the same, unterminated at the end of the input. Written as its own
|
|
91
|
+
* alternative rather than an optional terminator, for the reason directly above.
|
|
92
|
+
* - `[\x90\x98\x9e\x9f][\s\S]*?(?:\x9c|\x1b\\|\x07)` and `[\x90\x98\x9e\x9f][\s\S]*$`
|
|
93
|
+
* DCS/SOS/PM/APC again, 8-bit introducers, terminated and unterminated.
|
|
94
|
+
* - `\x1b\[[0-?]*[ -/]*[@-~]` CSI. The parameter class is the ECMA-48 range 0x30–0x3F written as
|
|
95
|
+
* a range, not the digits-plus-`;`-plus-`?` an author reaches for from memory. That shorter class
|
|
96
|
+
* omits `:`, `<`, `=` and `>`, and the omission is not academic: `ESC [ 38:2:255:0:0 m` is the
|
|
97
|
+
* colon form of a truecolor SGR and `ESC [ < 0;10;20 M` is an SGR mouse report. Neither matched
|
|
98
|
+
* any alternative, so the lone-control fallback took the ESC and left `38:2:255:0:0m` as visible
|
|
99
|
+
* text. Found in review of PR #2212.
|
|
100
|
+
* - `\x9b[0-?]*[ -/]*[@-~]` the same, 8-bit introducer.
|
|
101
|
+
* - `\x1b[@-Z\\-_]` any other two-character escape.
|
|
102
|
+
* - `[\x00-\x1f\x7f]` a lone control character, including a bare ESC or BEL.
|
|
103
|
+
* - `[\x80-\x9f]` a lone C1 control that started no sequence any alternative above matched.
|
|
104
|
+
*/
|
|
105
|
+
const CONTROL_SEQUENCE =
|
|
106
|
+
// eslint-disable-next-line no-control-regex
|
|
107
|
+
/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x9d[^\x07\x1b\x9c]*(?:\x07|\x9c|\x1b\\)?|\x1b[P^_X][\s\S]*?(?:\x1b\\|\x07)|\x1b[P^_X][\s\S]*$|[\x90\x98\x9e\x9f][\s\S]*?(?:\x9c|\x1b\\|\x07)|[\x90\x98\x9e\x9f][\s\S]*$|\x1b\[[0-?]*[ -/]*[@-~]|\x9b[0-?]*[ -/]*[@-~]|\x1b[@-Z\\-_]|[\x00-\x1f\x7f]|[\x80-\x9f]/g;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The longest prefix of `text` that could still become a control sequence if more text arrived.
|
|
111
|
+
*
|
|
112
|
+
* "Could still become" is the whole contract, and the DCS/PM/APC/SOS bodies are where it is easy to
|
|
113
|
+
* lose. Written as a plain `[\s\S]*`, they match a sequence that is ALREADY terminated inside the
|
|
114
|
+
* chunk — `'hello' ESC P … ESC \ 'world'` anchors from the introducer to the end, so `world` is held
|
|
115
|
+
* back and does not appear until the next `push` or the `flush`. Nothing dangerous escapes, but
|
|
116
|
+
* ordinary text stops arriving, which for a streaming renderer is a stall.
|
|
117
|
+
*
|
|
118
|
+
* So each body excludes its own terminators through a negative lookahead: a sequence that has already
|
|
119
|
+
* ended is not an incomplete tail. The OSC and CSI branches need no such guard — `[^\x07\x1b]*`
|
|
120
|
+
* cannot cross BEL or ESC, and a CSI without its final byte is incomplete by construction. Found in
|
|
121
|
+
* review of PR #2212, one round after the same asymmetry was fixed in {@link CONTROL_SEQUENCE}: the
|
|
122
|
+
* two patterns describe the same grammar and have to be changed together.
|
|
123
|
+
*/
|
|
124
|
+
const INCOMPLETE_TAIL =
|
|
125
|
+
// eslint-disable-next-line no-control-regex
|
|
126
|
+
/(?:\x1b(?:\][^\x07\x1b]*|[P^_X](?:(?!\x1b\\|\x07)[\s\S])*|\[[0-?]*[ -/]*|)?|\x9d[^\x07\x1b\x9c]*|[\x90\x98\x9e\x9f](?:(?!\x9c|\x1b\\|\x07)[\s\S])*|\x9b[0-?]*[ -/]*)$/;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `text` with every terminal control sequence removed, and tab/newline/carriage return preserved.
|
|
130
|
+
*
|
|
131
|
+
* Ordinary Unicode - including characters far outside ASCII - is untouched: the goal is to remove
|
|
132
|
+
* what the TERMINAL acts on, not to restrict what a document may say.
|
|
133
|
+
*/
|
|
134
|
+
export function sanitizeTerminalText(text: string): string {
|
|
135
|
+
return text
|
|
136
|
+
.replace(CRLF, '\n')
|
|
137
|
+
.replace(CONTROL_SEQUENCE, (match) => (KEPT_C0.has(match) ? match : ''));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A sanitizer that carries an incomplete escape across chunk boundaries. */
|
|
141
|
+
export interface IStreamingTerminalSanitizer {
|
|
142
|
+
/** Sanitize one chunk, holding back any tail that could still become an escape. */
|
|
143
|
+
push(chunk: string): string;
|
|
144
|
+
/** Emit whatever is held back, sanitized. Call when the stream ends. */
|
|
145
|
+
flush(): string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A stateful sanitizer for callers that sanitize each chunk as it arrives.
|
|
150
|
+
*
|
|
151
|
+
* The held-back tail is bounded by the incomplete-prefix pattern rather than by a byte count: a
|
|
152
|
+
* partial OSC can be arbitrarily long, and truncating the buffer would emit the middle of a sequence
|
|
153
|
+
* as visible text while dropping the part that made it dangerous.
|
|
154
|
+
*/
|
|
155
|
+
export function createStreamingTerminalSanitizer(): IStreamingTerminalSanitizer {
|
|
156
|
+
let pending = '';
|
|
157
|
+
return {
|
|
158
|
+
push(chunk: string): string {
|
|
159
|
+
const combined = pending + chunk;
|
|
160
|
+
const tail = INCOMPLETE_TAIL.exec(combined);
|
|
161
|
+
const holdFrom = tail && tail[0].length > 0 ? tail.index : combined.length;
|
|
162
|
+
pending = combined.slice(holdFrom);
|
|
163
|
+
return sanitizeTerminalText(combined.slice(0, holdFrom));
|
|
164
|
+
},
|
|
165
|
+
flush(): string {
|
|
166
|
+
const remaining = pending;
|
|
167
|
+
pending = '';
|
|
168
|
+
return sanitizeTerminalText(remaining);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-2004 — the first line the process prints.
|
|
3
|
+
*
|
|
4
|
+
* Two mutually exclusive lines, and never more than one:
|
|
5
|
+
* - the mode is ON → `[Screen reader mode: on via flag|env|settings]`. It is the only
|
|
6
|
+
* self-diagnostic in the design: when someone reports "I passed the flag and nothing changed",
|
|
7
|
+
* this line says whether the mode is on and which input turned it on.
|
|
8
|
+
* - the mode is OFF and the environment looks like a reader is present → one advisory line naming
|
|
9
|
+
* the flag. That is the whole of this project's answer to the auto-detection question: opt-in
|
|
10
|
+
* keeps determinism, and a false positive costs one line of text instead of reshaping a sighted
|
|
11
|
+
* user's interface.
|
|
12
|
+
*
|
|
13
|
+
* Printed before Ink's first frame and never during a turn, so it cannot interleave with output a
|
|
14
|
+
* reader is consuming.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Which input turned the mode on. Mirrors the CLI resolver's channel. */
|
|
18
|
+
export type TScreenReaderChannel = 'flag' | 'env' | 'settings';
|
|
19
|
+
|
|
20
|
+
export interface IScreenReaderAnnouncementInputs {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
channel?: TScreenReaderChannel | undefined;
|
|
23
|
+
/** True when the mode is off but the environment suggests a reader is running. */
|
|
24
|
+
hint?: boolean | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The confirmation / advisory line, or `undefined` when neither applies. Pure. */
|
|
28
|
+
export function screenReaderAnnouncement(
|
|
29
|
+
inputs: IScreenReaderAnnouncementInputs,
|
|
30
|
+
): string | undefined {
|
|
31
|
+
if (inputs.enabled) {
|
|
32
|
+
// A mode that is on always names a channel; `settings` is the lowest tier and the safe read
|
|
33
|
+
// when a caller enables the mode without saying how.
|
|
34
|
+
return `[Screen reader mode: on via ${inputs.channel ?? 'settings'}]`;
|
|
35
|
+
}
|
|
36
|
+
if (inputs.hint === true) {
|
|
37
|
+
return '[Screen reader mode: off — run with --screen-reader]';
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Write the line (with its newline) when there is one. Silent otherwise. */
|
|
43
|
+
export function writeScreenReaderAnnouncement(
|
|
44
|
+
inputs: IScreenReaderAnnouncementInputs,
|
|
45
|
+
write: (text: string) => void = (text) => {
|
|
46
|
+
process.stdout.write(text);
|
|
47
|
+
},
|
|
48
|
+
): void {
|
|
49
|
+
const line = screenReaderAnnouncement(inputs);
|
|
50
|
+
if (line === undefined) return;
|
|
51
|
+
write(`${line}\n`);
|
|
52
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-2004 — the one place a component asks "is screen-reader mode on?".
|
|
3
|
+
*
|
|
4
|
+
* The mode is a single resolved boolean threaded from the CLI into `renderApp`, handed to Ink as
|
|
5
|
+
* `isScreenReaderEnabled` AND published here. Two sources of truth for one fact is the hazard this
|
|
6
|
+
* design accepts (Ink's option and this context); both are set from the same resolved value in
|
|
7
|
+
* `render.tsx`, and no component takes the mode as a prop from its parent — it reads it here — so a
|
|
8
|
+
* missed prop cannot silently leave one component in the wrong mode.
|
|
9
|
+
*
|
|
10
|
+
* Default `false`: a component rendered outside the provider (every existing test) behaves exactly
|
|
11
|
+
* as it does today.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import React, { createContext, useContext } from 'react';
|
|
15
|
+
|
|
16
|
+
const ScreenReaderContext = createContext<boolean>(false);
|
|
17
|
+
|
|
18
|
+
/** Publish the resolved mode to the tree. */
|
|
19
|
+
export function ScreenReaderProvider({
|
|
20
|
+
enabled,
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
}): React.ReactElement {
|
|
26
|
+
return <ScreenReaderContext.Provider value={enabled}>{children}</ScreenReaderContext.Provider>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read the resolved mode. `false` outside a provider. */
|
|
30
|
+
export function useScreenReader(): boolean {
|
|
31
|
+
return useContext(ScreenReaderContext);
|
|
32
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-2004 — the screen-reader label vocabulary (SSOT).
|
|
3
|
+
*
|
|
4
|
+
* A screen reader has no equivalent of `aria-live`: the only channel to it is which characters land
|
|
5
|
+
* in the terminal buffer, in what order. A searchable prefix on every transcript line is therefore
|
|
6
|
+
* the whole of the "who is speaking" affordance, and it has to be stable enough to grep for.
|
|
7
|
+
*
|
|
8
|
+
* PROVIDER-INVARIANT BY CONSTRUCTION. The label derives from the message ROLE, never from the
|
|
9
|
+
* vendor behind it — `assistant:`, not the provider's product name — so the transcript reads
|
|
10
|
+
* identically under every `agent-provider-*`. `screenReaderLabelValues()` exists so a test can
|
|
11
|
+
* assert that negative mechanically instead of trusting review.
|
|
12
|
+
*
|
|
13
|
+
* All nine are lowercase. The reference this vocabulary is modelled on Title-Cases two of its nine;
|
|
14
|
+
* that split is not reproduced, because a reader announces case and the inconsistency is noise.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { TUniversalMessage } from '@robota-sdk/agent-core';
|
|
18
|
+
|
|
19
|
+
/** The nine label kinds. */
|
|
20
|
+
export type TScreenReaderLabelKind =
|
|
21
|
+
| 'user'
|
|
22
|
+
| 'assistant'
|
|
23
|
+
| 'thinking'
|
|
24
|
+
| 'tool'
|
|
25
|
+
| 'toolError'
|
|
26
|
+
| 'error'
|
|
27
|
+
| 'warning'
|
|
28
|
+
| 'permissionRequired'
|
|
29
|
+
| 'cost';
|
|
30
|
+
|
|
31
|
+
/** The label vocabulary. Every spoken prefix in the mode comes from exactly this map. */
|
|
32
|
+
export const SCREEN_READER_LABELS: Readonly<Record<TScreenReaderLabelKind, string>> = {
|
|
33
|
+
user: 'you:',
|
|
34
|
+
assistant: 'assistant:',
|
|
35
|
+
thinking: 'thinking:',
|
|
36
|
+
tool: 'tool:',
|
|
37
|
+
toolError: 'tool error:',
|
|
38
|
+
error: 'error:',
|
|
39
|
+
warning: 'warning:',
|
|
40
|
+
permissionRequired: 'permission required:',
|
|
41
|
+
cost: 'cost:',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Every label value, for mechanical assertions over the vocabulary. */
|
|
45
|
+
export function screenReaderLabelValues(): readonly string[] {
|
|
46
|
+
return Object.values(SCREEN_READER_LABELS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Map a transcript message role onto the vocabulary. `system` speaks as `warning:` — it is the role
|
|
51
|
+
* the TUI already renders in the warning colour, and the mode may not lean on colour.
|
|
52
|
+
*/
|
|
53
|
+
export function screenReaderLabelForRole(role: TUniversalMessage['role']): string {
|
|
54
|
+
switch (role) {
|
|
55
|
+
case 'user':
|
|
56
|
+
return SCREEN_READER_LABELS.user;
|
|
57
|
+
case 'assistant':
|
|
58
|
+
return SCREEN_READER_LABELS.assistant;
|
|
59
|
+
case 'tool':
|
|
60
|
+
return SCREEN_READER_LABELS.tool;
|
|
61
|
+
case 'system':
|
|
62
|
+
return SCREEN_READER_LABELS.warning;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-2670 — the one place a component reaches the owned write path.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `screen-reader-context.tsx`: the port is published from `render.tsx`, no component takes
|
|
5
|
+
* it as a prop from its parent, so a missed prop cannot silently leave one component writing past
|
|
6
|
+
* the queue. It also keeps a live writer handle out of the presentation tree, which declares itself
|
|
7
|
+
* free of channels, sessions and mutable state.
|
|
8
|
+
*
|
|
9
|
+
* Outside a provider (every existing test, and every run with the mode off) the port is inert:
|
|
10
|
+
* arming does nothing and writes go straight to the process's own stdout, exactly as today.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React, { createContext, useContext } from 'react';
|
|
14
|
+
|
|
15
|
+
export interface IScreenReaderPacingPort {
|
|
16
|
+
/** Arm the echo flag for the batch the current keystroke's commit will open. */
|
|
17
|
+
armEchoRelease(): void;
|
|
18
|
+
/** Write a positional sequence (an OSC 133 mark) through the same ordered path. */
|
|
19
|
+
write(text: string): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const inertPort: IScreenReaderPacingPort = {
|
|
23
|
+
armEchoRelease(): void {},
|
|
24
|
+
write(text: string): void {
|
|
25
|
+
process.stdout.write(text);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const ScreenReaderPacingContext = createContext<IScreenReaderPacingPort>(inertPort);
|
|
30
|
+
|
|
31
|
+
/** Publish the owned write path to the tree. */
|
|
32
|
+
export function ScreenReaderPacingProvider({
|
|
33
|
+
port,
|
|
34
|
+
children,
|
|
35
|
+
}: {
|
|
36
|
+
port: IScreenReaderPacingPort;
|
|
37
|
+
children: React.ReactNode;
|
|
38
|
+
}): React.ReactElement {
|
|
39
|
+
return (
|
|
40
|
+
<ScreenReaderPacingContext.Provider value={port}>{children}</ScreenReaderPacingContext.Provider>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read the port. Inert outside a provider. */
|
|
45
|
+
export function useScreenReaderPacing(): IScreenReaderPacingPort {
|
|
46
|
+
return useContext(ScreenReaderPacingContext);
|
|
47
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-2004 — the startup quiet period.
|
|
3
|
+
*
|
|
4
|
+
* A terminal has no `aria-live`. The ONLY way to influence what a reader announces is which bytes
|
|
5
|
+
* land in the buffer and WHEN, which is why this wait exists at all: it lets the reader finish
|
|
6
|
+
* speaking the confirmation line before the first prompt frame overwrites the region it is reading.
|
|
7
|
+
* A keypress ends it early.
|
|
8
|
+
*
|
|
9
|
+
* THE PRE-WRITE PARK (SCREEN-2670) is the second wait § Decision verdict (i) adopted. It lives in
|
|
10
|
+
* `screen-reader-stdout.ts`: an owned write path handed to Ink as `stdout`, which separates COMMITS
|
|
11
|
+
* in time so a diff-based reader's snapshot can fall between two of them. This module only resolves
|
|
12
|
+
* its interval. The default is PROVISIONAL: screen-reader mode runs Ink unthrottled, so no frame
|
|
13
|
+
* interval derives it, and the governing timescale — the reader's own sampling cadence — is not
|
|
14
|
+
* measurable from this harness. `50` matches the sole product precedent until it is measured
|
|
15
|
+
* against a real reader (recorded as an open item in this package's SPEC).
|
|
16
|
+
*
|
|
17
|
+
* The DEFAULT IS MEASURED AGAINST THIS RENDER LOOP, not copied: `DEFAULT_STARTUP_QUIET_MS` is the
|
|
18
|
+
* observed boot-to-first-prompt interval of this binary (~450 ms in the PTY harness) doubled, so the
|
|
19
|
+
* confirmation line is spoken before the prompt lands without the reference's 3 s stall, which is
|
|
20
|
+
* tuned to a heavier boot.
|
|
21
|
+
*
|
|
22
|
+
* The cap is a sanity bound, not policy: a value above it is clamped AND REPORTED. Nothing here
|
|
23
|
+
* silently substitutes a number — an unparseable value is refused with a note on stderr and the
|
|
24
|
+
* default stands (No-Fallback: the refusal is visible).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Sanity bound on the startup quiet period (10 minutes). */
|
|
28
|
+
export const STARTUP_QUIET_MS_MAX = 600_000;
|
|
29
|
+
/** Boot-to-first-prompt of this binary, doubled. */
|
|
30
|
+
export const DEFAULT_STARTUP_QUIET_MS = 900;
|
|
31
|
+
|
|
32
|
+
/** Sanity bound on the pre-write park (5 seconds), matching the product precedent verdict (i) adopted. */
|
|
33
|
+
export const PREPARK_MS_MAX = 5000;
|
|
34
|
+
/** PROVISIONAL — taken to match the sole product precedent, pending measurement against a reader. */
|
|
35
|
+
export const DEFAULT_PREPARK_MS = 50;
|
|
36
|
+
|
|
37
|
+
/** The resolved pacing. `0` whenever the mode is off. */
|
|
38
|
+
export interface IScreenReaderPacing {
|
|
39
|
+
startupQuietMs: number;
|
|
40
|
+
/** Interval parked in front of each printable commit after the first. `0` disables the park. */
|
|
41
|
+
preparkMs: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Raw timing choices supplied by the embedding host, with names for visible diagnostics. */
|
|
45
|
+
export interface IScreenReaderPacingOverrides {
|
|
46
|
+
readonly startupQuiet?: { readonly raw: string | undefined; readonly label: string };
|
|
47
|
+
readonly prepark?: { readonly raw: string | undefined; readonly label: string };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface IResolvePacingInputs {
|
|
51
|
+
/** Screen-reader mode. Off ⇒ the wait is 0 regardless of host overrides. */
|
|
52
|
+
enabled: boolean;
|
|
53
|
+
/** Absent values use the renderer's numeric defaults; no environment is read here. */
|
|
54
|
+
overrides?: IScreenReaderPacingOverrides;
|
|
55
|
+
/** Where a clamp or a refusal is reported. Defaults to stderr. */
|
|
56
|
+
warn?: (message: string) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function reportTo(warn: IResolvePacingInputs['warn']): (message: string) => void {
|
|
60
|
+
return (
|
|
61
|
+
warn ??
|
|
62
|
+
((message: string): void => {
|
|
63
|
+
process.stderr.write(`${message}\n`);
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the duration variable. Absent ⇒ the default. Non-numeric or negative ⇒ the default WITH a
|
|
70
|
+
* note (never a silent 0). Above the bound ⇒ the bound WITH a note. `0` is honoured exactly — it is
|
|
71
|
+
* the documented way to ask for no wait at all.
|
|
72
|
+
*/
|
|
73
|
+
function resolveDuration(
|
|
74
|
+
raw: string | undefined,
|
|
75
|
+
name: string,
|
|
76
|
+
fallback: number,
|
|
77
|
+
bound: number,
|
|
78
|
+
warn: (message: string) => void,
|
|
79
|
+
): number {
|
|
80
|
+
if (raw === undefined || raw.trim() === '') return fallback;
|
|
81
|
+
const parsed = Number(raw.trim());
|
|
82
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
|
|
83
|
+
warn(
|
|
84
|
+
`${name}: ignoring "${raw}" — expected a whole number of milliseconds. Using ${fallback}.`,
|
|
85
|
+
);
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
if (parsed > bound) {
|
|
89
|
+
warn(`${name}: clamping ${parsed} to the ${bound} ms sanity bound.`);
|
|
90
|
+
return bound;
|
|
91
|
+
}
|
|
92
|
+
return parsed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resolve the wait once, at startup. */
|
|
96
|
+
export function resolvePacing(inputs: IResolvePacingInputs): IScreenReaderPacing {
|
|
97
|
+
if (!inputs.enabled) return { startupQuietMs: 0, preparkMs: 0 };
|
|
98
|
+
const warn = reportTo(inputs.warn);
|
|
99
|
+
return {
|
|
100
|
+
startupQuietMs: resolveDuration(
|
|
101
|
+
inputs.overrides?.startupQuiet?.raw,
|
|
102
|
+
inputs.overrides?.startupQuiet?.label ?? 'startup quiet period',
|
|
103
|
+
DEFAULT_STARTUP_QUIET_MS,
|
|
104
|
+
STARTUP_QUIET_MS_MAX,
|
|
105
|
+
warn,
|
|
106
|
+
),
|
|
107
|
+
preparkMs: resolveDuration(
|
|
108
|
+
inputs.overrides?.prepark?.raw,
|
|
109
|
+
inputs.overrides?.prepark?.label ?? 'pre-write park',
|
|
110
|
+
DEFAULT_PREPARK_MS,
|
|
111
|
+
PREPARK_MS_MAX,
|
|
112
|
+
warn,
|
|
113
|
+
),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The one-shot keypress source that can settle the startup wait early. */
|
|
118
|
+
export interface IKeypressSource {
|
|
119
|
+
once(event: 'data', listener: () => void): void;
|
|
120
|
+
off(event: 'data', listener: () => void): void;
|
|
121
|
+
resume?(): void;
|
|
122
|
+
pause?(): void;
|
|
123
|
+
/** Present on a TTY stdin. Without raw mode a lone key produces no `data` event. */
|
|
124
|
+
isTTY?: boolean;
|
|
125
|
+
setRawMode?(mode: boolean): void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Put a TTY stdin into raw mode for the duration of the wait, returning the undo.
|
|
130
|
+
*
|
|
131
|
+
* Without this the promise below only settles on Enter: in canonical mode the terminal buffers a
|
|
132
|
+
* line, so "any keypress ends the wait" — which this package's SPEC states — would be false on a
|
|
133
|
+
* real terminal and true only against a synthetic source. Ink sets raw mode itself once it renders;
|
|
134
|
+
* this window closes before that, and restores whatever was set before.
|
|
135
|
+
*/
|
|
136
|
+
function withRawMode(keys: IKeypressSource | undefined): () => void {
|
|
137
|
+
if (keys?.isTTY !== true || typeof keys.setRawMode !== 'function') return (): void => {};
|
|
138
|
+
keys.setRawMode(true);
|
|
139
|
+
return (): void => {
|
|
140
|
+
keys.setRawMode?.(false);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Wait out the startup quiet period. Resolves at the deadline, or as soon as a key is pressed —
|
|
146
|
+
* whichever comes first. A `0` wait resolves without touching the keypress source at all.
|
|
147
|
+
*
|
|
148
|
+
* The keystroke that ends the wait is CONSUMED, which is the documented behaviour: the wait exists
|
|
149
|
+
* so the reader is not interrupted, and the key that says "I am ready" is the answer to it.
|
|
150
|
+
*/
|
|
151
|
+
export async function awaitStartupQuietPeriod(
|
|
152
|
+
ms: number,
|
|
153
|
+
keys?: IKeypressSource,
|
|
154
|
+
): Promise<'elapsed' | 'keypress'> {
|
|
155
|
+
if (ms <= 0) return 'elapsed';
|
|
156
|
+
const restoreRawMode = withRawMode(keys);
|
|
157
|
+
return new Promise<'elapsed' | 'keypress'>((resolve) => {
|
|
158
|
+
const settle = (outcome: 'elapsed' | 'keypress'): void => {
|
|
159
|
+
restoreRawMode();
|
|
160
|
+
keys?.pause?.();
|
|
161
|
+
resolve(outcome);
|
|
162
|
+
};
|
|
163
|
+
const onKey = (): void => {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
settle('keypress');
|
|
166
|
+
};
|
|
167
|
+
const timer = setTimeout(() => {
|
|
168
|
+
if (keys !== undefined) keys.off('data', onKey);
|
|
169
|
+
settle('elapsed');
|
|
170
|
+
}, ms);
|
|
171
|
+
// Do not hold the process open on the pacing timer alone.
|
|
172
|
+
timer.unref?.();
|
|
173
|
+
if (keys !== undefined) {
|
|
174
|
+
keys.once('data', onKey);
|
|
175
|
+
keys.resume?.();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|