@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,103 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { AttentionTracker, DEFAULT_IDLE_THRESHOLD_MS } from '../attention-tracker.js';
|
|
4
|
+
|
|
5
|
+
function tracker(options: { focusReporting: boolean }) {
|
|
6
|
+
const events: string[] = [];
|
|
7
|
+
const instance = new AttentionTracker({
|
|
8
|
+
focusReporting: options.focusReporting,
|
|
9
|
+
now: () => Date.now(),
|
|
10
|
+
});
|
|
11
|
+
instance.subscribe((change) => events.push(`${change.kind}@${change.at}`));
|
|
12
|
+
return { instance, events };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('AttentionTracker (SCREEN-1992 TC-01)', () => {
|
|
16
|
+
it('with focus reporting, focus-out and focus-in emit once per transition and the idle source is inert', () => {
|
|
17
|
+
vi.useFakeTimers();
|
|
18
|
+
try {
|
|
19
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
|
20
|
+
const { instance, events } = tracker({ focusReporting: true });
|
|
21
|
+
instance.start();
|
|
22
|
+
expect(instance.attended).toBe(true);
|
|
23
|
+
// Requested is not confirmed: until a focus event arrives the idle source decides.
|
|
24
|
+
expect(instance.source).toBe('idle');
|
|
25
|
+
instance.focusOut();
|
|
26
|
+
expect(instance.source).toBe('focus');
|
|
27
|
+
instance.focusOut();
|
|
28
|
+
expect(instance.attended).toBe(false);
|
|
29
|
+
expect(events).toEqual(['lost@2026-01-01T00:00:00.000Z']);
|
|
30
|
+
vi.advanceTimersByTime(DEFAULT_IDLE_THRESHOLD_MS * 3);
|
|
31
|
+
instance.keystroke(); // a keystroke while focus reporting is authoritative changes nothing
|
|
32
|
+
expect(instance.attended).toBe(false);
|
|
33
|
+
vi.setSystemTime(new Date('2026-01-01T00:20:00.000Z'));
|
|
34
|
+
instance.focusIn();
|
|
35
|
+
instance.focusIn();
|
|
36
|
+
expect(events).toEqual([
|
|
37
|
+
'lost@2026-01-01T00:00:00.000Z',
|
|
38
|
+
'returned@2026-01-01T00:20:00.000Z',
|
|
39
|
+
]);
|
|
40
|
+
instance.dispose();
|
|
41
|
+
} finally {
|
|
42
|
+
vi.useRealTimers();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('with focus reporting requested but never answered, the idle source still runs', () => {
|
|
47
|
+
vi.useFakeTimers();
|
|
48
|
+
try {
|
|
49
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
|
50
|
+
const { instance, events } = tracker({ focusReporting: true });
|
|
51
|
+
instance.start();
|
|
52
|
+
vi.advanceTimersByTime(DEFAULT_IDLE_THRESHOLD_MS);
|
|
53
|
+
expect(instance.attended).toBe(false);
|
|
54
|
+
expect(instance.source).toBe('idle');
|
|
55
|
+
instance.keystroke();
|
|
56
|
+
expect(instance.attended).toBe(true);
|
|
57
|
+
expect(events.map((event) => event.split('@')[0])).toEqual(['lost', 'returned']);
|
|
58
|
+
// The first focus event disarms the idle source for good.
|
|
59
|
+
instance.focusIn();
|
|
60
|
+
vi.advanceTimersByTime(DEFAULT_IDLE_THRESHOLD_MS * 2);
|
|
61
|
+
expect(instance.attended).toBe(true);
|
|
62
|
+
expect(instance.source).toBe('focus');
|
|
63
|
+
instance.dispose();
|
|
64
|
+
} finally {
|
|
65
|
+
vi.useRealTimers();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('refuses a focus event when focus reporting is off', () => {
|
|
70
|
+
const { instance } = tracker({ focusReporting: false });
|
|
71
|
+
expect(() => instance.focusIn()).toThrow(/focus reporting is off/u);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('without focus reporting, the idle threshold marks unattended and the next keystroke marks attended', () => {
|
|
75
|
+
vi.useFakeTimers();
|
|
76
|
+
try {
|
|
77
|
+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
|
78
|
+
const { instance, events } = tracker({ focusReporting: false });
|
|
79
|
+
expect(instance.source).toBe('idle');
|
|
80
|
+
instance.start();
|
|
81
|
+
vi.advanceTimersByTime(DEFAULT_IDLE_THRESHOLD_MS - 1);
|
|
82
|
+
instance.keystroke(); // activity resets the idle clock
|
|
83
|
+
vi.advanceTimersByTime(DEFAULT_IDLE_THRESHOLD_MS - 1);
|
|
84
|
+
expect(instance.attended).toBe(true);
|
|
85
|
+
expect(events).toEqual([]);
|
|
86
|
+
vi.advanceTimersByTime(1);
|
|
87
|
+
expect(instance.attended).toBe(false);
|
|
88
|
+
expect(events).toHaveLength(1);
|
|
89
|
+
expect(events[0]).toMatch(/^lost@/);
|
|
90
|
+
instance.keystroke();
|
|
91
|
+
expect(instance.attended).toBe(true);
|
|
92
|
+
expect(events).toHaveLength(2);
|
|
93
|
+
expect(events[1]).toMatch(/^returned@/);
|
|
94
|
+
instance.keystroke();
|
|
95
|
+
expect(events).toHaveLength(2);
|
|
96
|
+
instance.dispose();
|
|
97
|
+
vi.advanceTimersByTime(DEFAULT_IDLE_THRESHOLD_MS * 2);
|
|
98
|
+
expect(events).toHaveLength(2);
|
|
99
|
+
} finally {
|
|
100
|
+
vi.useRealTimers();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { formatCountdown } from '../countdown.js';
|
|
4
|
+
|
|
5
|
+
describe('formatCountdown (SCREEN-1992 TC-04)', () => {
|
|
6
|
+
it('renders seconds, minutes and hours against the given now, and "now" once due', () => {
|
|
7
|
+
const now = new Date('2026-01-01T00:00:00.000Z');
|
|
8
|
+
expect(formatCountdown('2026-01-01T00:00:59.000Z', now)).toBe('in 59s');
|
|
9
|
+
expect(formatCountdown('2026-01-01T00:04:59.000Z', now)).toBe('in 4m 59s');
|
|
10
|
+
expect(formatCountdown('2026-01-01T02:05:00.000Z', now)).toBe('in 2h 5m');
|
|
11
|
+
expect(formatCountdown('2026-01-01T00:00:00.000Z', now)).toBe('now');
|
|
12
|
+
expect(formatCountdown('2025-12-31T23:59:00.000Z', now)).toBe('now');
|
|
13
|
+
expect(formatCountdown('not-a-date', now)).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import korean from '../../__tests__/__fixtures__/korean-text.json';
|
|
5
|
+
|
|
6
|
+
import { filterFocusSequences, FocusReportingStdin } from '../focus-input-filter.js';
|
|
7
|
+
import { createFocusReportingWriter } from '../../terminal-focus-reporting.js';
|
|
8
|
+
|
|
9
|
+
class FakeStdin extends EventEmitter {
|
|
10
|
+
isTTY = true;
|
|
11
|
+
setRawMode = vi.fn();
|
|
12
|
+
ref = vi.fn();
|
|
13
|
+
unref = vi.fn();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function collect(stream: FocusReportingStdin): Buffer[] {
|
|
17
|
+
const chunks: Buffer[] = [];
|
|
18
|
+
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
19
|
+
return chunks;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('focus input filter (SCREEN-1992 TC-06)', () => {
|
|
23
|
+
it('strips CSI I / CSI O only while negotiated and forwards everything else byte-identical', () => {
|
|
24
|
+
const negotiated = filterFocusSequences(Buffer.from('a\x1b[Ob\x1b[Ic'), true);
|
|
25
|
+
expect(negotiated.forwarded.toString()).toBe('abc');
|
|
26
|
+
expect(negotiated).toMatchObject({ focusIn: 1, focusOut: 1 });
|
|
27
|
+
|
|
28
|
+
const passthrough = filterFocusSequences(Buffer.from('a\x1b[Ob'), false);
|
|
29
|
+
expect(passthrough.forwarded.toString()).toBe('a\x1b[Ob');
|
|
30
|
+
expect(passthrough).toMatchObject({ focusIn: 0, focusOut: 0 });
|
|
31
|
+
|
|
32
|
+
// A split escape sequence and a bracketed paste are Ink's to parse — untouched.
|
|
33
|
+
expect(filterFocusSequences(Buffer.from('\x1b['), true).forwarded.toString()).toBe('\x1b[');
|
|
34
|
+
expect(filterFocusSequences(Buffer.from('A'), true).forwarded.toString()).toBe('A');
|
|
35
|
+
const paste = '\x1b[200~pasted \x1b[I text\x1b[201~';
|
|
36
|
+
expect(filterFocusSequences(Buffer.from(paste), false).forwarded.toString()).toBe(paste);
|
|
37
|
+
const utf8 = Buffer.from(`${korean.hangulWord}\x1b[O😀`);
|
|
38
|
+
expect(filterFocusSequences(utf8, true).forwarded.toString()).toBe(`${korean.hangulWord}😀`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('proxies stdin: reports focus and keystrokes, forwards isTTY/setRawMode/ref/unref', async () => {
|
|
42
|
+
const source = new FakeStdin();
|
|
43
|
+
const hooks = {
|
|
44
|
+
negotiated: () => true,
|
|
45
|
+
onFocusIn: vi.fn(),
|
|
46
|
+
onFocusOut: vi.fn(),
|
|
47
|
+
onKeystroke: vi.fn(),
|
|
48
|
+
};
|
|
49
|
+
const proxy = new FocusReportingStdin(source, hooks);
|
|
50
|
+
const chunks = collect(proxy);
|
|
51
|
+
source.emit('data', Buffer.from('\x1b[O'));
|
|
52
|
+
source.emit('data', Buffer.from('x\x1b[I'));
|
|
53
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
54
|
+
expect(hooks.onFocusOut).toHaveBeenCalledTimes(1);
|
|
55
|
+
expect(hooks.onFocusIn).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(hooks.onKeystroke).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(Buffer.concat(chunks).toString()).toBe('x');
|
|
58
|
+
|
|
59
|
+
expect(proxy.isTTY).toBe(true);
|
|
60
|
+
proxy.setRawMode(true);
|
|
61
|
+
proxy.ref();
|
|
62
|
+
proxy.unref();
|
|
63
|
+
expect(source.setRawMode).toHaveBeenCalledWith(true);
|
|
64
|
+
expect(source.ref).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(source.unref).toHaveBeenCalledTimes(1);
|
|
66
|
+
|
|
67
|
+
proxy.detach();
|
|
68
|
+
source.emit('data', Buffer.from('y'));
|
|
69
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
70
|
+
expect(Buffer.concat(chunks).toString()).toBe('x');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('writes DECSET 1004 only when the gate allows, and DECRST once on disable', () => {
|
|
74
|
+
const written: string[] = [];
|
|
75
|
+
let supported = false;
|
|
76
|
+
const writer = createFocusReportingWriter({
|
|
77
|
+
supported: () => supported,
|
|
78
|
+
write: (text) => written.push(text),
|
|
79
|
+
});
|
|
80
|
+
writer.enable();
|
|
81
|
+
expect(writer.negotiated).toBe(false);
|
|
82
|
+
expect(written).toEqual([]);
|
|
83
|
+
supported = true;
|
|
84
|
+
writer.enable();
|
|
85
|
+
writer.enable();
|
|
86
|
+
expect(writer.negotiated).toBe(true);
|
|
87
|
+
expect(written).toEqual(['\x1b[?1004h']);
|
|
88
|
+
writer.disable();
|
|
89
|
+
writer.disable();
|
|
90
|
+
expect(writer.negotiated).toBe(false);
|
|
91
|
+
expect(written).toEqual(['\x1b[?1004h', '\x1b[?1004l']);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { IntervalRecap, RECAP_MAX_WIDTH } from '../interval-recap.js';
|
|
4
|
+
|
|
5
|
+
describe('IntervalRecap (SCREEN-1992 TC-02)', () => {
|
|
6
|
+
it('counts turns by source, needs-input requests and terminal entries, and yields one line on return', () => {
|
|
7
|
+
const recap = new IntervalRecap();
|
|
8
|
+
// Attended: nothing is counted.
|
|
9
|
+
recap.turnSource('user');
|
|
10
|
+
recap.turnCompleted();
|
|
11
|
+
expect(recap.onReturned('2026-01-01T00:00:00.000Z')).toBeUndefined();
|
|
12
|
+
|
|
13
|
+
// Already terminal before the interval: old news, never counted.
|
|
14
|
+
recap.entryState('task:old', 'completed');
|
|
15
|
+
recap.onLost('2026-01-01T00:00:00.000Z');
|
|
16
|
+
recap.entryState('task:old', 'completed');
|
|
17
|
+
recap.turnSource('agent-wakeup');
|
|
18
|
+
recap.turnCompleted();
|
|
19
|
+
recap.turnSource('user');
|
|
20
|
+
recap.turnCompleted();
|
|
21
|
+
recap.needsInput();
|
|
22
|
+
recap.entryState('task:1', 'working');
|
|
23
|
+
recap.entryState('task:1', 'failed');
|
|
24
|
+
recap.entryState('task:1', 'failed'); // same terminal state twice counts once
|
|
25
|
+
recap.entryState('task:2', 'completed');
|
|
26
|
+
recap.entryState('task:3', 'completed');
|
|
27
|
+
recap.entryState('task:3', 'working'); // left the terminal state again: dropped
|
|
28
|
+
recap.error();
|
|
29
|
+
const line = recap.onReturned('2026-01-01T00:12:00.000Z');
|
|
30
|
+
expect(line).toBe(
|
|
31
|
+
'While away 12m: 2 turns finished (1 wake) · 1 needs input · 1 completed · 1 failed · 1 error',
|
|
32
|
+
);
|
|
33
|
+
// A second return without a new interval yields nothing.
|
|
34
|
+
expect(recap.onReturned('2026-01-01T00:13:00.000Z')).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('yields nothing for an empty interval and bounds a long line', () => {
|
|
38
|
+
const recap = new IntervalRecap();
|
|
39
|
+
recap.onLost('2026-01-01T00:00:00.000Z');
|
|
40
|
+
expect(recap.onReturned('2026-01-01T01:00:00.000Z')).toBeUndefined();
|
|
41
|
+
|
|
42
|
+
recap.entryState('task:seed', 'working'); // a snapshot has been seen: the baseline is known
|
|
43
|
+
recap.onLost('2026-01-01T02:00:00.000Z');
|
|
44
|
+
for (let i = 0; i < 1000; i += 1) recap.entryState(`task:${i}`, 'completed');
|
|
45
|
+
const line = recap.onReturned('2026-01-01T03:30:00.000Z')!;
|
|
46
|
+
expect(line.length).toBeLessThanOrEqual(RECAP_MAX_WIDTH);
|
|
47
|
+
expect(line).toContain('While away 1h 30m');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { IntervalRecap } from './interval-recap.js';
|
|
2
|
+
|
|
3
|
+
import type { IAttentionSource } from './attention-tracker.js';
|
|
4
|
+
import type { IExecutionWorkspaceSnapshot } from '@robota-sdk/agent-interface-execution';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SCREEN-1992: joins the attention level to the recap accumulator for one channel.
|
|
8
|
+
*
|
|
9
|
+
* The tracker outlives channels (it is the terminal's), the recap belongs to the session being
|
|
10
|
+
* shown; the coordinator subscribes for the channel's lifetime and pushes each recap line into the
|
|
11
|
+
* channel's notice store. It lives here rather than in `tui-state-manager.ts` (at its line ceiling).
|
|
12
|
+
*/
|
|
13
|
+
export interface IAttentionCoordinatorOptions {
|
|
14
|
+
readonly source: IAttentionSource;
|
|
15
|
+
readonly onRecap: (line: string) => void;
|
|
16
|
+
readonly now?: () => number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class AttentionCoordinator {
|
|
20
|
+
private readonly recap = new IntervalRecap();
|
|
21
|
+
private unsubscribe: (() => void) | undefined;
|
|
22
|
+
private readonly now: () => number;
|
|
23
|
+
|
|
24
|
+
constructor(private readonly options: IAttentionCoordinatorOptions) {
|
|
25
|
+
this.now = options.now ?? Date.now;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
wire(): void {
|
|
29
|
+
if (this.unsubscribe !== undefined) return;
|
|
30
|
+
// A channel created while the user is already away starts its interval now.
|
|
31
|
+
if (!this.options.source.attended) this.recap.onLost(new Date(this.now()).toISOString());
|
|
32
|
+
this.unsubscribe = this.options.source.subscribe((change) => {
|
|
33
|
+
if (change.kind === 'lost') {
|
|
34
|
+
this.recap.onLost(change.at);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const line = this.recap.onReturned(change.at);
|
|
38
|
+
if (line !== undefined) this.options.onRecap(line);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
unwire(): void {
|
|
43
|
+
this.unsubscribe?.();
|
|
44
|
+
this.unsubscribe = undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
onTurnSource = (source: string): void => {
|
|
48
|
+
this.recap.turnSource(source);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
onComplete = (): void => {
|
|
52
|
+
this.recap.turnCompleted();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
onError = (): void => {
|
|
56
|
+
this.recap.error();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
onNeedsInput = (): void => {
|
|
60
|
+
this.recap.needsInput();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
onWorkspaceSnapshot = (snapshot: IExecutionWorkspaceSnapshot): void => {
|
|
64
|
+
for (const entry of snapshot.entries) {
|
|
65
|
+
// The main thread's turns are counted from `complete`; its idle `completed` is not an event.
|
|
66
|
+
if (entry.kind === 'main_thread') continue;
|
|
67
|
+
this.recap.entryState(entry.id, entry.state);
|
|
68
|
+
}
|
|
69
|
+
this.recap.snapshotComplete();
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-1992: whether the user is at the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Two sources feed one boolean. Focus reporting (DECSET 1004) was REQUESTED, not confirmed — a
|
|
5
|
+
* terminal that does not implement the mode discards the request and never answers — so focus
|
|
6
|
+
* becomes authoritative only once the first `CSI I`/`CSI O` has actually arrived; from then on
|
|
7
|
+
* keystrokes carry no attention meaning. Until then, and wherever the mode is off, the only signal
|
|
8
|
+
* is input: silence past the idle threshold means "away", the next keystroke means "back".
|
|
9
|
+
*/
|
|
10
|
+
import { MS_PER_MINUTE } from './time-units.js';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_IDLE_THRESHOLD_MINUTES = 5;
|
|
13
|
+
export const DEFAULT_IDLE_THRESHOLD_MS = DEFAULT_IDLE_THRESHOLD_MINUTES * MS_PER_MINUTE;
|
|
14
|
+
|
|
15
|
+
export type TAttentionChangeKind = 'lost' | 'returned';
|
|
16
|
+
|
|
17
|
+
export interface IAttentionChange {
|
|
18
|
+
readonly kind: TAttentionChangeKind;
|
|
19
|
+
/** ISO timestamp of the transition, from the injected clock. */
|
|
20
|
+
readonly at: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type TAttentionListener = (change: IAttentionChange) => void;
|
|
24
|
+
|
|
25
|
+
/** What a consumer of attention sees: the level, and every transition. */
|
|
26
|
+
export interface IAttentionSource {
|
|
27
|
+
readonly attended: boolean;
|
|
28
|
+
/** Which source decides the level right now — recorded so a degraded run is never a silent default. */
|
|
29
|
+
readonly source: 'focus' | 'idle';
|
|
30
|
+
subscribe(listener: TAttentionListener): () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface IAttentionTrackerOptions {
|
|
34
|
+
/** Whether DECSET 1004 was requested; the first focus event is what makes it authoritative. */
|
|
35
|
+
readonly focusReporting: boolean;
|
|
36
|
+
readonly now: () => number;
|
|
37
|
+
readonly idleThresholdMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class AttentionTracker implements IAttentionSource {
|
|
41
|
+
private attendedState = true;
|
|
42
|
+
private focusObserved = false;
|
|
43
|
+
private readonly listeners = new Set<TAttentionListener>();
|
|
44
|
+
private idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
45
|
+
private readonly idleThresholdMs: number;
|
|
46
|
+
|
|
47
|
+
constructor(private readonly options: IAttentionTrackerOptions) {
|
|
48
|
+
this.idleThresholdMs = options.idleThresholdMs ?? DEFAULT_IDLE_THRESHOLD_MS;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get attended(): boolean {
|
|
52
|
+
return this.attendedState;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get source(): 'focus' | 'idle' {
|
|
56
|
+
return this.focusObserved ? 'focus' : 'idle';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
subscribe(listener: TAttentionListener): () => void {
|
|
60
|
+
this.listeners.add(listener);
|
|
61
|
+
return () => {
|
|
62
|
+
this.listeners.delete(listener);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Arms the idle fallback; the first focus event disarms it for good. */
|
|
67
|
+
start(): void {
|
|
68
|
+
if (this.focusObserved) return;
|
|
69
|
+
this.armIdleTimer();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
focusIn(): void {
|
|
73
|
+
this.observeFocus();
|
|
74
|
+
this.transition(true);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
focusOut(): void {
|
|
78
|
+
this.observeFocus();
|
|
79
|
+
this.transition(false);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Any key the user pressed. Only the idle source reads it. */
|
|
83
|
+
keystroke(): void {
|
|
84
|
+
if (this.focusObserved) return;
|
|
85
|
+
this.transition(true);
|
|
86
|
+
this.armIdleTimer();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private observeFocus(): void {
|
|
90
|
+
if (!this.options.focusReporting) {
|
|
91
|
+
throw new Error('AttentionTracker: a focus event arrived although focus reporting is off.');
|
|
92
|
+
}
|
|
93
|
+
if (this.focusObserved) return;
|
|
94
|
+
this.focusObserved = true;
|
|
95
|
+
if (this.idleTimer !== undefined) clearTimeout(this.idleTimer);
|
|
96
|
+
this.idleTimer = undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
dispose(): void {
|
|
100
|
+
if (this.idleTimer !== undefined) clearTimeout(this.idleTimer);
|
|
101
|
+
this.idleTimer = undefined;
|
|
102
|
+
this.listeners.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private armIdleTimer(): void {
|
|
106
|
+
if (this.idleTimer !== undefined) clearTimeout(this.idleTimer);
|
|
107
|
+
this.idleTimer = setTimeout(() => {
|
|
108
|
+
this.idleTimer = undefined;
|
|
109
|
+
this.transition(false);
|
|
110
|
+
}, this.idleThresholdMs);
|
|
111
|
+
// Never keep the process alive for an attention timer.
|
|
112
|
+
if (typeof this.idleTimer === 'object' && 'unref' in this.idleTimer) this.idleTimer.unref();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private transition(attended: boolean): void {
|
|
116
|
+
if (this.attendedState === attended) return;
|
|
117
|
+
this.attendedState = attended;
|
|
118
|
+
const change: IAttentionChange = {
|
|
119
|
+
kind: attended ? 'returned' : 'lost',
|
|
120
|
+
at: new Date(this.options.now()).toISOString(),
|
|
121
|
+
};
|
|
122
|
+
for (const listener of this.listeners) listener(change);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MINUTES_PER_HOUR,
|
|
3
|
+
MS_PER_SECOND,
|
|
4
|
+
SECONDS_PER_HOUR,
|
|
5
|
+
SECONDS_PER_MINUTE,
|
|
6
|
+
} from './time-units.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SCREEN-1992: the time until a sleeping schedule fires, rendered against a given clock so the
|
|
10
|
+
* row can tick once a second without the projection changing.
|
|
11
|
+
*/
|
|
12
|
+
export function formatCountdown(nextFireAt: string, now: Date): string | undefined {
|
|
13
|
+
const target = Date.parse(nextFireAt);
|
|
14
|
+
if (Number.isNaN(target)) return undefined;
|
|
15
|
+
const remainingMs = target - now.getTime();
|
|
16
|
+
if (remainingMs <= 0) return 'now';
|
|
17
|
+
const totalSeconds = Math.floor(remainingMs / MS_PER_SECOND);
|
|
18
|
+
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
|
|
19
|
+
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR;
|
|
20
|
+
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
|
21
|
+
if (hours > 0) return `in ${hours}h ${minutes}m`;
|
|
22
|
+
if (minutes > 0) return `in ${minutes}m ${seconds}s`;
|
|
23
|
+
return `in ${seconds}s`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SCREEN-1992: the stdin proxy handed to Ink's `render({ stdin })`.
|
|
5
|
+
*
|
|
6
|
+
* Ink tokenizes `ESC [ I` as a keypress and every `useInput` listener would receive `[I`, so the
|
|
7
|
+
* composer would type it. The proxy sits in front of the real stdin, strips the two focus sequences
|
|
8
|
+
* while mode 1004 is negotiated, reports focus and keystrokes, and forwards every other byte
|
|
9
|
+
* unchanged — including a split escape sequence and a bracketed paste, which are Ink's to parse.
|
|
10
|
+
*/
|
|
11
|
+
const ESC = 0x1b;
|
|
12
|
+
const FOCUS_IN = '\x1b[I';
|
|
13
|
+
const FOCUS_OUT = '\x1b[O';
|
|
14
|
+
|
|
15
|
+
export interface IFocusInputFilterResult {
|
|
16
|
+
/** The bytes Ink receives, byte-identical to the input apart from the stripped focus sequences. */
|
|
17
|
+
readonly forwarded: Buffer;
|
|
18
|
+
readonly focusIn: number;
|
|
19
|
+
readonly focusOut: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Pure: split one chunk into focus events and the bytes that remain. */
|
|
23
|
+
export function filterFocusSequences(chunk: Buffer, negotiated: boolean): IFocusInputFilterResult {
|
|
24
|
+
if (!negotiated || !chunk.includes(ESC)) return { forwarded: chunk, focusIn: 0, focusOut: 0 };
|
|
25
|
+
const text = chunk.toString('latin1');
|
|
26
|
+
let focusIn = 0;
|
|
27
|
+
let focusOut = 0;
|
|
28
|
+
let forwarded = '';
|
|
29
|
+
let index = 0;
|
|
30
|
+
while (index < text.length) {
|
|
31
|
+
if (text.startsWith(FOCUS_IN, index)) {
|
|
32
|
+
focusIn += 1;
|
|
33
|
+
index += FOCUS_IN.length;
|
|
34
|
+
} else if (text.startsWith(FOCUS_OUT, index)) {
|
|
35
|
+
focusOut += 1;
|
|
36
|
+
index += FOCUS_OUT.length;
|
|
37
|
+
} else {
|
|
38
|
+
forwarded += text[index];
|
|
39
|
+
index += 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { forwarded: Buffer.from(forwarded, 'latin1'), focusIn, focusOut };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface IFocusReportingStdinHooks {
|
|
46
|
+
/** Whether mode 1004 is currently negotiated; read per chunk so a handoff can flip it. */
|
|
47
|
+
readonly negotiated: () => boolean;
|
|
48
|
+
readonly onFocusIn: () => void;
|
|
49
|
+
readonly onFocusOut: () => void;
|
|
50
|
+
/** Any byte that was not a focus sequence. */
|
|
51
|
+
readonly onKeystroke: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type TStdinDataListener = (chunk: Buffer | string) => void;
|
|
55
|
+
|
|
56
|
+
/** What ink 7.1.1 reads from the stream it is given (`App.js` input handling, `ink.js` kitty probe). */
|
|
57
|
+
type TInkStdinSurface = Pick<
|
|
58
|
+
Readable,
|
|
59
|
+
'read' | 'setEncoding' | 'unshift' | 'on' | 'addListener' | 'removeListener'
|
|
60
|
+
> & {
|
|
61
|
+
readonly isTTY: boolean;
|
|
62
|
+
// `tty.ReadStream` types these as returning itself; Ink discards the return value.
|
|
63
|
+
setRawMode(mode: boolean): TInkStdinSurface;
|
|
64
|
+
ref(): TInkStdinSurface;
|
|
65
|
+
unref(): TInkStdinSurface;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The subset of the real stdin the proxy touches (what `process.stdin` provides). */
|
|
69
|
+
export interface IFocusReportingStdinSource {
|
|
70
|
+
readonly isTTY?: boolean;
|
|
71
|
+
on(event: 'data', listener: TStdinDataListener): void;
|
|
72
|
+
off(event: 'data', listener: TStdinDataListener): void;
|
|
73
|
+
setRawMode?: (mode: boolean) => void;
|
|
74
|
+
ref?: () => void;
|
|
75
|
+
unref?: () => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class FocusReportingStdin extends Readable {
|
|
79
|
+
private readonly onData = (data: Buffer | string): void => {
|
|
80
|
+
const chunk = typeof data === 'string' ? Buffer.from(data) : data;
|
|
81
|
+
const result = filterFocusSequences(chunk, this.hooks.negotiated());
|
|
82
|
+
for (let i = 0; i < result.focusOut; i += 1) this.hooks.onFocusOut();
|
|
83
|
+
for (let i = 0; i < result.focusIn; i += 1) this.hooks.onFocusIn();
|
|
84
|
+
if (result.forwarded.length === 0) return;
|
|
85
|
+
this.hooks.onKeystroke();
|
|
86
|
+
this.push(result.forwarded);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
constructor(
|
|
90
|
+
private readonly source: IFocusReportingStdinSource,
|
|
91
|
+
private readonly hooks: IFocusReportingStdinHooks,
|
|
92
|
+
) {
|
|
93
|
+
super({ read() {} });
|
|
94
|
+
source.on('data', this.onData);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get isTTY(): boolean {
|
|
98
|
+
return this.source.isTTY === true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setRawMode(mode: boolean): this {
|
|
102
|
+
this.source.setRawMode?.(mode);
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
ref(): this {
|
|
107
|
+
this.source.ref?.();
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
unref(): this {
|
|
112
|
+
this.source.unref?.();
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Ink declares `stdin?: NodeJS.ReadStream` (a `tty.ReadStream`, which cannot be constructed
|
|
118
|
+
* without an fd) but touches only the members named in `TInkStdinSurface` — verified against ink
|
|
119
|
+
* 7.1.1's App and Ink classes. Assigning `this` to that surface is a typed check that every one of
|
|
120
|
+
* them exists here; the final narrowing is from the surface Ink reads to the type it declares.
|
|
121
|
+
*/
|
|
122
|
+
asInkStdin(): NodeJS.ReadStream {
|
|
123
|
+
const surface: TInkStdinSurface = this;
|
|
124
|
+
return surface as NodeJS.ReadStream;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Detach from the underlying stdin; the proxy emits nothing afterwards. */
|
|
128
|
+
detach(): void {
|
|
129
|
+
this.source.off('data', this.onData);
|
|
130
|
+
}
|
|
131
|
+
}
|