@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,166 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { AttentionCoordinator } from '../attention-coordinator.js';
|
|
4
|
+
|
|
5
|
+
import type { IAttentionSource, TAttentionListener } from '../attention-tracker.js';
|
|
6
|
+
import type { IExecutionWorkspaceSnapshot } from '@robota-sdk/agent-interface-execution';
|
|
7
|
+
|
|
8
|
+
class FakeSource implements IAttentionSource {
|
|
9
|
+
attended = true;
|
|
10
|
+
readonly source = 'focus' as const;
|
|
11
|
+
private readonly listeners = new Set<TAttentionListener>();
|
|
12
|
+
subscribe(listener: TAttentionListener): () => void {
|
|
13
|
+
this.listeners.add(listener);
|
|
14
|
+
return () => this.listeners.delete(listener);
|
|
15
|
+
}
|
|
16
|
+
lost(at: string): void {
|
|
17
|
+
this.attended = false;
|
|
18
|
+
for (const listener of this.listeners) listener({ kind: 'lost', at });
|
|
19
|
+
}
|
|
20
|
+
returned(at: string): void {
|
|
21
|
+
this.attended = true;
|
|
22
|
+
for (const listener of this.listeners) listener({ kind: 'returned', at });
|
|
23
|
+
}
|
|
24
|
+
get listenerCount(): number {
|
|
25
|
+
return this.listeners.size;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function snapshot(
|
|
30
|
+
entries: ReadonlyArray<{
|
|
31
|
+
id: string;
|
|
32
|
+
state: 'working' | 'completed' | 'failed';
|
|
33
|
+
kind?: 'main_thread' | 'background_task';
|
|
34
|
+
}>,
|
|
35
|
+
) {
|
|
36
|
+
return {
|
|
37
|
+
sessionId: 's1',
|
|
38
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
39
|
+
entries: entries.map((entry) => ({
|
|
40
|
+
id: entry.id,
|
|
41
|
+
sourceId: entry.id,
|
|
42
|
+
kind: entry.kind ?? ('background_task' as const),
|
|
43
|
+
origin: { kind: 'slash_command' as const, sessionId: 's1' },
|
|
44
|
+
status: 'running' as const,
|
|
45
|
+
state: entry.state,
|
|
46
|
+
title: entry.id,
|
|
47
|
+
unread: false,
|
|
48
|
+
attention: 'none' as const,
|
|
49
|
+
visibility: 'default' as const,
|
|
50
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
51
|
+
controls: ['select' as const],
|
|
52
|
+
})),
|
|
53
|
+
} satisfies IExecutionWorkspaceSnapshot;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('AttentionCoordinator (SCREEN-1992 TC-02 wiring)', () => {
|
|
57
|
+
it('pushes one recap per unattended interval from the channel events it is fed', () => {
|
|
58
|
+
const source = new FakeSource();
|
|
59
|
+
const recaps: string[] = [];
|
|
60
|
+
const coordinator = new AttentionCoordinator({ source, onRecap: (line) => recaps.push(line) });
|
|
61
|
+
coordinator.wire();
|
|
62
|
+
coordinator.wire();
|
|
63
|
+
expect(source.listenerCount).toBe(1);
|
|
64
|
+
|
|
65
|
+
// Before the interval: an idle main thread and a task that already finished.
|
|
66
|
+
coordinator.onWorkspaceSnapshot(
|
|
67
|
+
snapshot([
|
|
68
|
+
{ id: 'main:s1', state: 'completed', kind: 'main_thread' },
|
|
69
|
+
{ id: 'task:done', state: 'completed' },
|
|
70
|
+
{ id: 'task:a', state: 'working' },
|
|
71
|
+
]),
|
|
72
|
+
);
|
|
73
|
+
source.lost('2026-01-01T00:00:00.000Z');
|
|
74
|
+
coordinator.onTurnSource('agent-wakeup');
|
|
75
|
+
coordinator.onWorkspaceSnapshot(
|
|
76
|
+
snapshot([
|
|
77
|
+
{ id: 'main:s1', state: 'working', kind: 'main_thread' },
|
|
78
|
+
{ id: 'task:done', state: 'completed' },
|
|
79
|
+
{ id: 'task:a', state: 'working' },
|
|
80
|
+
]),
|
|
81
|
+
);
|
|
82
|
+
coordinator.onComplete();
|
|
83
|
+
coordinator.onNeedsInput();
|
|
84
|
+
coordinator.onWorkspaceSnapshot(
|
|
85
|
+
snapshot([
|
|
86
|
+
{ id: 'main:s1', state: 'completed', kind: 'main_thread' },
|
|
87
|
+
{ id: 'task:done', state: 'completed' },
|
|
88
|
+
{ id: 'task:a', state: 'failed' },
|
|
89
|
+
]),
|
|
90
|
+
);
|
|
91
|
+
source.returned('2026-01-01T00:03:00.000Z');
|
|
92
|
+
expect(recaps).toEqual(['While away 3m: 1 turn finished (1 wake) · 1 needs input · 1 failed']);
|
|
93
|
+
|
|
94
|
+
// Attended activity is never recapped.
|
|
95
|
+
coordinator.onComplete();
|
|
96
|
+
source.lost('2026-01-01T00:10:00.000Z');
|
|
97
|
+
source.returned('2026-01-01T00:11:00.000Z');
|
|
98
|
+
expect(recaps).toHaveLength(1);
|
|
99
|
+
|
|
100
|
+
coordinator.unwire();
|
|
101
|
+
expect(source.listenerCount).toBe(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('starts an interval immediately when wired while the user is already away', () => {
|
|
105
|
+
const source = new FakeSource();
|
|
106
|
+
source.attended = false;
|
|
107
|
+
const recaps: string[] = [];
|
|
108
|
+
const coordinator = new AttentionCoordinator({
|
|
109
|
+
source,
|
|
110
|
+
onRecap: (line) => recaps.push(line),
|
|
111
|
+
now: () => Date.parse('2026-01-01T00:00:00.000Z'),
|
|
112
|
+
});
|
|
113
|
+
coordinator.wire();
|
|
114
|
+
coordinator.onTurnSource('user');
|
|
115
|
+
coordinator.onComplete();
|
|
116
|
+
source.returned('2026-01-01T00:05:00.000Z');
|
|
117
|
+
expect(recaps).toEqual(['While away 5m: 1 turn finished']);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('takes the first snapshot as the baseline when wired while already away', () => {
|
|
121
|
+
const source = new FakeSource();
|
|
122
|
+
source.attended = false;
|
|
123
|
+
const recaps: string[] = [];
|
|
124
|
+
const coordinator = new AttentionCoordinator({
|
|
125
|
+
source,
|
|
126
|
+
onRecap: (line) => recaps.push(line),
|
|
127
|
+
now: () => Date.parse('2026-01-01T00:00:00.000Z'),
|
|
128
|
+
});
|
|
129
|
+
coordinator.wire();
|
|
130
|
+
// A resumed session restores an already-finished task: not news.
|
|
131
|
+
coordinator.onWorkspaceSnapshot(
|
|
132
|
+
snapshot([
|
|
133
|
+
{ id: 'task:restored', state: 'completed' },
|
|
134
|
+
{ id: 'task:live', state: 'working' },
|
|
135
|
+
]),
|
|
136
|
+
);
|
|
137
|
+
coordinator.onWorkspaceSnapshot(
|
|
138
|
+
snapshot([
|
|
139
|
+
{ id: 'task:restored', state: 'completed' },
|
|
140
|
+
{ id: 'task:live', state: 'failed' },
|
|
141
|
+
]),
|
|
142
|
+
);
|
|
143
|
+
source.returned('2026-01-01T00:04:00.000Z');
|
|
144
|
+
expect(recaps).toEqual(['While away 4m: 1 failed']);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('never reports the main thread finishing a turn as a completed entry', () => {
|
|
148
|
+
const source = new FakeSource();
|
|
149
|
+
const recaps: string[] = [];
|
|
150
|
+
const coordinator = new AttentionCoordinator({ source, onRecap: (line) => recaps.push(line) });
|
|
151
|
+
coordinator.wire();
|
|
152
|
+
// The user leaves mid-turn: the main thread is `working` when attention is lost …
|
|
153
|
+
coordinator.onWorkspaceSnapshot(
|
|
154
|
+
snapshot([{ id: 'main:s1', state: 'working', kind: 'main_thread' }]),
|
|
155
|
+
);
|
|
156
|
+
source.lost('2026-01-01T00:00:00.000Z');
|
|
157
|
+
coordinator.onTurnSource('user');
|
|
158
|
+
coordinator.onComplete();
|
|
159
|
+
// … and idle (`completed`) when they return. That is the turn, already counted, not an entry.
|
|
160
|
+
coordinator.onWorkspaceSnapshot(
|
|
161
|
+
snapshot([{ id: 'main:s1', state: 'completed', kind: 'main_thread' }]),
|
|
162
|
+
);
|
|
163
|
+
source.returned('2026-01-01T00:02:00.000Z');
|
|
164
|
+
expect(recaps).toEqual(['While away 2m: 1 turn finished']);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -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,92 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { filterFocusSequences, FocusReportingStdin } from '../focus-input-filter.js';
|
|
6
|
+
import { createFocusReportingWriter } from '../../terminal-focus-reporting.js';
|
|
7
|
+
|
|
8
|
+
class FakeStdin extends EventEmitter {
|
|
9
|
+
isTTY = true;
|
|
10
|
+
setRawMode = vi.fn();
|
|
11
|
+
ref = vi.fn();
|
|
12
|
+
unref = vi.fn();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function collect(stream: FocusReportingStdin): Buffer[] {
|
|
16
|
+
const chunks: Buffer[] = [];
|
|
17
|
+
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
18
|
+
return chunks;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('focus input filter (SCREEN-1992 TC-06)', () => {
|
|
22
|
+
it('strips CSI I / CSI O only while negotiated and forwards everything else byte-identical', () => {
|
|
23
|
+
const negotiated = filterFocusSequences(Buffer.from('a\x1b[Ob\x1b[Ic'), true);
|
|
24
|
+
expect(negotiated.forwarded.toString()).toBe('abc');
|
|
25
|
+
expect(negotiated).toMatchObject({ focusIn: 1, focusOut: 1 });
|
|
26
|
+
|
|
27
|
+
const passthrough = filterFocusSequences(Buffer.from('a\x1b[Ob'), false);
|
|
28
|
+
expect(passthrough.forwarded.toString()).toBe('a\x1b[Ob');
|
|
29
|
+
expect(passthrough).toMatchObject({ focusIn: 0, focusOut: 0 });
|
|
30
|
+
|
|
31
|
+
// A split escape sequence and a bracketed paste are Ink's to parse — untouched.
|
|
32
|
+
expect(filterFocusSequences(Buffer.from('\x1b['), true).forwarded.toString()).toBe('\x1b[');
|
|
33
|
+
expect(filterFocusSequences(Buffer.from('A'), true).forwarded.toString()).toBe('A');
|
|
34
|
+
const paste = '\x1b[200~pasted \x1b[I text\x1b[201~';
|
|
35
|
+
expect(filterFocusSequences(Buffer.from(paste), false).forwarded.toString()).toBe(paste);
|
|
36
|
+
const utf8 = Buffer.from('한글\x1b[O😀');
|
|
37
|
+
expect(filterFocusSequences(utf8, true).forwarded.toString()).toBe('한글😀');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('proxies stdin: reports focus and keystrokes, forwards isTTY/setRawMode/ref/unref', async () => {
|
|
41
|
+
const source = new FakeStdin();
|
|
42
|
+
const hooks = {
|
|
43
|
+
negotiated: () => true,
|
|
44
|
+
onFocusIn: vi.fn(),
|
|
45
|
+
onFocusOut: vi.fn(),
|
|
46
|
+
onKeystroke: vi.fn(),
|
|
47
|
+
};
|
|
48
|
+
const proxy = new FocusReportingStdin(source, hooks);
|
|
49
|
+
const chunks = collect(proxy);
|
|
50
|
+
source.emit('data', Buffer.from('\x1b[O'));
|
|
51
|
+
source.emit('data', Buffer.from('x\x1b[I'));
|
|
52
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
53
|
+
expect(hooks.onFocusOut).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(hooks.onFocusIn).toHaveBeenCalledTimes(1);
|
|
55
|
+
expect(hooks.onKeystroke).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(Buffer.concat(chunks).toString()).toBe('x');
|
|
57
|
+
|
|
58
|
+
expect(proxy.isTTY).toBe(true);
|
|
59
|
+
proxy.setRawMode(true);
|
|
60
|
+
proxy.ref();
|
|
61
|
+
proxy.unref();
|
|
62
|
+
expect(source.setRawMode).toHaveBeenCalledWith(true);
|
|
63
|
+
expect(source.ref).toHaveBeenCalledTimes(1);
|
|
64
|
+
expect(source.unref).toHaveBeenCalledTimes(1);
|
|
65
|
+
|
|
66
|
+
proxy.detach();
|
|
67
|
+
source.emit('data', Buffer.from('y'));
|
|
68
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
69
|
+
expect(Buffer.concat(chunks).toString()).toBe('x');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('writes DECSET 1004 only when the gate allows, and DECRST once on disable', () => {
|
|
73
|
+
const written: string[] = [];
|
|
74
|
+
let supported = false;
|
|
75
|
+
const writer = createFocusReportingWriter({
|
|
76
|
+
supported: () => supported,
|
|
77
|
+
write: (text) => written.push(text),
|
|
78
|
+
});
|
|
79
|
+
writer.enable();
|
|
80
|
+
expect(writer.negotiated).toBe(false);
|
|
81
|
+
expect(written).toEqual([]);
|
|
82
|
+
supported = true;
|
|
83
|
+
writer.enable();
|
|
84
|
+
writer.enable();
|
|
85
|
+
expect(writer.negotiated).toBe(true);
|
|
86
|
+
expect(written).toEqual(['\x1b[?1004h']);
|
|
87
|
+
writer.disable();
|
|
88
|
+
writer.disable();
|
|
89
|
+
expect(writer.negotiated).toBe(false);
|
|
90
|
+
expect(written).toEqual(['\x1b[?1004h', '\x1b[?1004l']);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -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
|
+
}
|