@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,93 @@
|
|
|
1
|
+
export interface ITextPromptFlowState {
|
|
2
|
+
value: string;
|
|
3
|
+
error?: string;
|
|
4
|
+
resolved: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ITextPromptInputKey {
|
|
8
|
+
escape?: boolean;
|
|
9
|
+
return?: boolean;
|
|
10
|
+
backspace?: boolean;
|
|
11
|
+
delete?: boolean;
|
|
12
|
+
ctrl?: boolean;
|
|
13
|
+
meta?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TTextPromptInputAction =
|
|
17
|
+
{ type: 'cancel' } | { type: 'submit' } | { type: 'delete' } | { type: 'insert'; value: string };
|
|
18
|
+
|
|
19
|
+
export type TTextPromptEffect =
|
|
20
|
+
{ type: 'none' } | { type: 'cancel' } | { type: 'submit'; value: string };
|
|
21
|
+
|
|
22
|
+
export interface ITextPromptFlowOptions {
|
|
23
|
+
allowEmpty: boolean;
|
|
24
|
+
validate?: (value: string) => string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createTextPromptFlowState(): ITextPromptFlowState {
|
|
28
|
+
return { value: '', resolved: false };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getTextPromptInputAction(
|
|
32
|
+
input: string,
|
|
33
|
+
key: ITextPromptInputKey,
|
|
34
|
+
): TTextPromptInputAction | undefined {
|
|
35
|
+
if (key.escape === true) {
|
|
36
|
+
return { type: 'cancel' };
|
|
37
|
+
}
|
|
38
|
+
if (key.return === true) {
|
|
39
|
+
return { type: 'submit' };
|
|
40
|
+
}
|
|
41
|
+
if (key.backspace === true || key.delete === true) {
|
|
42
|
+
return { type: 'delete' };
|
|
43
|
+
}
|
|
44
|
+
if (input && key.ctrl !== true && key.meta !== true) {
|
|
45
|
+
return { type: 'insert', value: input };
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function applyTextPromptInput(
|
|
51
|
+
state: ITextPromptFlowState,
|
|
52
|
+
action: TTextPromptInputAction,
|
|
53
|
+
options: ITextPromptFlowOptions,
|
|
54
|
+
): { state: ITextPromptFlowState; effect: TTextPromptEffect } {
|
|
55
|
+
if (state.resolved) {
|
|
56
|
+
return { state, effect: { type: 'none' } };
|
|
57
|
+
}
|
|
58
|
+
if (action.type === 'cancel') {
|
|
59
|
+
return { state: { ...state, resolved: true }, effect: { type: 'cancel' } };
|
|
60
|
+
}
|
|
61
|
+
if (action.type === 'delete') {
|
|
62
|
+
return {
|
|
63
|
+
state: { ...state, value: state.value.slice(0, -1), error: undefined },
|
|
64
|
+
effect: { type: 'none' },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (action.type === 'insert') {
|
|
68
|
+
return {
|
|
69
|
+
state: { ...state, value: state.value + action.value, error: undefined },
|
|
70
|
+
effect: { type: 'none' },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return submitTextPromptValue(state, options);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function submitTextPromptValue(
|
|
77
|
+
state: ITextPromptFlowState,
|
|
78
|
+
options: ITextPromptFlowOptions,
|
|
79
|
+
): { state: ITextPromptFlowState; effect: TTextPromptEffect } {
|
|
80
|
+
const trimmed = state.value.trim();
|
|
81
|
+
if (!trimmed && !options.allowEmpty) {
|
|
82
|
+
const emptyError = options.validate?.(trimmed);
|
|
83
|
+
return {
|
|
84
|
+
state: emptyError ? { ...state, error: emptyError } : state,
|
|
85
|
+
effect: { type: 'none' },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const error = options.validate?.(trimmed);
|
|
89
|
+
if (error !== undefined) {
|
|
90
|
+
return { state: { ...state, error }, effect: { type: 'none' } };
|
|
91
|
+
}
|
|
92
|
+
return { state: { ...state, resolved: true }, effect: { type: 'submit', value: trimmed } };
|
|
93
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
collapseToNewest,
|
|
5
|
+
cycleScope,
|
|
6
|
+
filterPrompts,
|
|
7
|
+
inScope,
|
|
8
|
+
splitByRanges,
|
|
9
|
+
} from '../history-search-flow.js';
|
|
10
|
+
|
|
11
|
+
import type { IPromptHistoryEntry } from '@robota-sdk/agent-interface-session';
|
|
12
|
+
|
|
13
|
+
function entry(text: string, sessionId = 's1', project = '/p1'): IPromptHistoryEntry {
|
|
14
|
+
return { at: '2026-01-01T00:00:00.000Z', sessionId, project, text };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('history-search-flow (SCREEN-1993 TC-03)', () => {
|
|
18
|
+
it('filters case-insensitively with every match range, and an empty query matches all', () => {
|
|
19
|
+
const entries = [
|
|
20
|
+
entry('Deploy the canary'),
|
|
21
|
+
entry('redeploy after the deploy hook'),
|
|
22
|
+
entry('tidy'),
|
|
23
|
+
];
|
|
24
|
+
const matches = filterPrompts(entries, 'DEPLOY');
|
|
25
|
+
expect(matches.map((match) => match.entry.text)).toEqual([
|
|
26
|
+
'Deploy the canary',
|
|
27
|
+
'redeploy after the deploy hook',
|
|
28
|
+
]);
|
|
29
|
+
expect(matches[0]?.ranges).toEqual([[0, 6]]);
|
|
30
|
+
expect(matches[1]?.ranges).toEqual([
|
|
31
|
+
[2, 8],
|
|
32
|
+
[19, 25],
|
|
33
|
+
]);
|
|
34
|
+
expect(filterPrompts(entries, '').map((match) => match.ranges)).toEqual([[], [], []]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('collapses duplicates to the newest occurrence and keeps newest-first order', () => {
|
|
38
|
+
const collapsed = collapseToNewest([
|
|
39
|
+
entry('deploy staging'),
|
|
40
|
+
entry('rotate secrets'),
|
|
41
|
+
entry('deploy staging '),
|
|
42
|
+
entry('tidy'),
|
|
43
|
+
entry('rotate secrets'),
|
|
44
|
+
]);
|
|
45
|
+
expect(collapsed.map((item) => item.text)).toEqual([
|
|
46
|
+
'deploy staging',
|
|
47
|
+
'rotate secrets',
|
|
48
|
+
'tidy',
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('cycles all → session → project → all and scopes by session id or project', () => {
|
|
53
|
+
expect(cycleScope('all')).toBe('session');
|
|
54
|
+
expect(cycleScope('session')).toBe('project');
|
|
55
|
+
expect(cycleScope('project')).toBe('all');
|
|
56
|
+
const context = { sessionId: 's1', project: '/p1' };
|
|
57
|
+
const here = entry('a', 's1', '/p1');
|
|
58
|
+
const otherSession = entry('b', 's2', '/p1');
|
|
59
|
+
const otherProject = entry('c', 's3', '/p2');
|
|
60
|
+
expect([here, otherSession, otherProject].map((item) => inScope(item, 'all', context))).toEqual(
|
|
61
|
+
[true, true, true],
|
|
62
|
+
);
|
|
63
|
+
expect(
|
|
64
|
+
[here, otherSession, otherProject].map((item) => inScope(item, 'session', context)),
|
|
65
|
+
).toEqual([true, false, false]);
|
|
66
|
+
expect(
|
|
67
|
+
[here, otherSession, otherProject].map((item) => inScope(item, 'project', context)),
|
|
68
|
+
).toEqual([true, true, false]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('splits text into plain and matched runs for colour-free highlighting', () => {
|
|
72
|
+
expect(
|
|
73
|
+
splitByRanges('redeploy after the deploy hook', [
|
|
74
|
+
[2, 8],
|
|
75
|
+
[19, 25],
|
|
76
|
+
]),
|
|
77
|
+
).toEqual([
|
|
78
|
+
{ text: 're', matched: false },
|
|
79
|
+
{ text: 'deploy', matched: true },
|
|
80
|
+
{ text: ' after the ', matched: false },
|
|
81
|
+
{ text: 'deploy', matched: true },
|
|
82
|
+
{ text: ' hook', matched: false },
|
|
83
|
+
]);
|
|
84
|
+
expect(splitByRanges('plain', [])).toEqual([{ text: 'plain', matched: false }]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-1993 — the pure half of reverse prompt-history search: filtering with match ranges,
|
|
3
|
+
* collapse-to-newest dedup, the scope cycle and the run split a match is rendered from.
|
|
4
|
+
*/
|
|
5
|
+
import type { IPromptHistoryEntry } from '@robota-sdk/agent-interface-session';
|
|
6
|
+
|
|
7
|
+
export type THistorySearchScope = 'all' | 'session' | 'project';
|
|
8
|
+
|
|
9
|
+
/** `all → session → project → all`: the widest first, narrowing on each press. */
|
|
10
|
+
const HISTORY_SEARCH_SCOPES: readonly THistorySearchScope[] = ['all', 'session', 'project'];
|
|
11
|
+
|
|
12
|
+
export function cycleScope(scope: THistorySearchScope): THistorySearchScope {
|
|
13
|
+
const index = HISTORY_SEARCH_SCOPES.indexOf(scope);
|
|
14
|
+
return HISTORY_SEARCH_SCOPES[(index + 1) % HISTORY_SEARCH_SCOPES.length] ?? 'all';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IHistorySearchScopeContext {
|
|
18
|
+
readonly sessionId: string;
|
|
19
|
+
readonly project: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whether an entry belongs to the scope for this session and project. */
|
|
23
|
+
export function inScope(
|
|
24
|
+
entry: IPromptHistoryEntry,
|
|
25
|
+
scope: THistorySearchScope,
|
|
26
|
+
context: IHistorySearchScopeContext,
|
|
27
|
+
): boolean {
|
|
28
|
+
switch (scope) {
|
|
29
|
+
case 'all':
|
|
30
|
+
return true;
|
|
31
|
+
case 'session':
|
|
32
|
+
return entry.sessionId === context.sessionId;
|
|
33
|
+
case 'project':
|
|
34
|
+
return entry.project === context.project;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Search-time dedup: the first (newest) occurrence of a trimmed text wins; order is preserved. */
|
|
39
|
+
export function collapseToNewest(entries: readonly IPromptHistoryEntry[]): IPromptHistoryEntry[] {
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
const collapsed: IPromptHistoryEntry[] = [];
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const key = entry.text.trim();
|
|
44
|
+
if (seen.has(key)) continue;
|
|
45
|
+
seen.add(key);
|
|
46
|
+
collapsed.push(entry);
|
|
47
|
+
}
|
|
48
|
+
return collapsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface IHistorySearchMatch {
|
|
52
|
+
readonly entry: IPromptHistoryEntry;
|
|
53
|
+
/** `[start, end)` of the query inside `entry.text`; empty when the query is empty. */
|
|
54
|
+
readonly ranges: readonly (readonly [number, number])[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Case-insensitive substring filter; an empty query matches everything with no ranges. */
|
|
58
|
+
export function filterPrompts(
|
|
59
|
+
entries: readonly IPromptHistoryEntry[],
|
|
60
|
+
query: string,
|
|
61
|
+
): IHistorySearchMatch[] {
|
|
62
|
+
const needle = query.toLowerCase();
|
|
63
|
+
if (needle.length === 0) return entries.map((entry) => ({ entry, ranges: [] }));
|
|
64
|
+
const matches: IHistorySearchMatch[] = [];
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
const haystack = entry.text.toLowerCase();
|
|
67
|
+
const ranges: (readonly [number, number])[] = [];
|
|
68
|
+
let from = 0;
|
|
69
|
+
for (;;) {
|
|
70
|
+
const index = haystack.indexOf(needle, from);
|
|
71
|
+
if (index === -1) break;
|
|
72
|
+
ranges.push([index, index + needle.length]);
|
|
73
|
+
from = index + needle.length;
|
|
74
|
+
}
|
|
75
|
+
if (ranges.length > 0) matches.push({ entry, ranges });
|
|
76
|
+
}
|
|
77
|
+
return matches;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Text split into plain and highlighted runs, for rendering a match without colour dependence. */
|
|
81
|
+
export function splitByRanges(
|
|
82
|
+
text: string,
|
|
83
|
+
ranges: readonly (readonly [number, number])[],
|
|
84
|
+
): { readonly text: string; readonly matched: boolean }[] {
|
|
85
|
+
const runs: { text: string; matched: boolean }[] = [];
|
|
86
|
+
let cursor = 0;
|
|
87
|
+
for (const [start, end] of ranges) {
|
|
88
|
+
if (start > cursor) runs.push({ text: text.slice(cursor, start), matched: false });
|
|
89
|
+
runs.push({ text: text.slice(start, end), matched: true });
|
|
90
|
+
cursor = end;
|
|
91
|
+
}
|
|
92
|
+
if (cursor < text.length) runs.push({ text: text.slice(cursor), matched: false });
|
|
93
|
+
return runs;
|
|
94
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-1993 — the reverse prompt-history search overlay's state.
|
|
3
|
+
*
|
|
4
|
+
* One loader per open: opening starts one read of the injected source under one `AbortController`,
|
|
5
|
+
* and every close — insert, execute or cancel — aborts it. Blocks arrive newest-first and are appended
|
|
6
|
+
* as they land, so the first frame shows the newest prompts before the rest of the file is read. The
|
|
7
|
+
* `session` scope never touches the file: it is the live prompt list the composer already holds.
|
|
8
|
+
*
|
|
9
|
+
* The composer's draft is restored by construction, not by copying: the overlay never writes the
|
|
10
|
+
* input while it is open, so a cancel leaves text and cursor exactly as they were.
|
|
11
|
+
*/
|
|
12
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
|
+
|
|
14
|
+
import { useKeybindingActions } from '../keybindings/keybindings-context.js';
|
|
15
|
+
import { useScreenReader } from '../screen-reader-context.js';
|
|
16
|
+
import {
|
|
17
|
+
collapseToNewest,
|
|
18
|
+
cycleScope,
|
|
19
|
+
filterPrompts,
|
|
20
|
+
inScope,
|
|
21
|
+
type IHistorySearchMatch,
|
|
22
|
+
type IHistorySearchScopeContext,
|
|
23
|
+
type THistorySearchScope,
|
|
24
|
+
} from './history-search-flow.js';
|
|
25
|
+
|
|
26
|
+
import type { IKeyInput } from '../keybindings/keybinding-registry.js';
|
|
27
|
+
import type {
|
|
28
|
+
IPromptHistoryEntry,
|
|
29
|
+
IPromptHistorySource,
|
|
30
|
+
} from '@robota-sdk/agent-interface-session';
|
|
31
|
+
|
|
32
|
+
/** What the surface injects to turn the overlay on; absent ⇒ `ctrl+r` does nothing. */
|
|
33
|
+
export interface IHistorySearchSurface {
|
|
34
|
+
readonly source: IPromptHistorySource;
|
|
35
|
+
readonly sessionId: string;
|
|
36
|
+
readonly project: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface IUseHistorySearchOptions {
|
|
40
|
+
readonly surface: IHistorySearchSurface | undefined;
|
|
41
|
+
/** The composer's live prompt list, oldest-first — the `session` scope. */
|
|
42
|
+
readonly sessionPrompts: readonly string[];
|
|
43
|
+
readonly onInsert: (text: string) => void;
|
|
44
|
+
readonly onExecute: (text: string) => void;
|
|
45
|
+
readonly onOpenChange?: ((open: boolean) => void) | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface IHistorySearchView {
|
|
49
|
+
readonly open: boolean;
|
|
50
|
+
readonly query: string;
|
|
51
|
+
readonly scope: THistorySearchScope;
|
|
52
|
+
readonly selectedIndex: number;
|
|
53
|
+
readonly matches: readonly IHistorySearchMatch[];
|
|
54
|
+
readonly loading: boolean;
|
|
55
|
+
readonly skippedLines: number;
|
|
56
|
+
readonly error: string | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ILoadedState {
|
|
60
|
+
readonly entries: readonly IPromptHistoryEntry[];
|
|
61
|
+
readonly skippedLines: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const EMPTY_LOADED: ILoadedState = { entries: [], skippedLines: 0 };
|
|
65
|
+
|
|
66
|
+
function isPrintable(input: string, key: IKeyInput): boolean {
|
|
67
|
+
const special =
|
|
68
|
+
key.ctrl === true ||
|
|
69
|
+
key.meta === true ||
|
|
70
|
+
Boolean(key.return || key.escape || key.tab) ||
|
|
71
|
+
Boolean(key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) ||
|
|
72
|
+
Boolean(key.backspace || key.delete);
|
|
73
|
+
return input.length > 0 && !special;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function liveEntries(
|
|
77
|
+
prompts: readonly string[],
|
|
78
|
+
context: IHistorySearchScopeContext,
|
|
79
|
+
): IPromptHistoryEntry[] {
|
|
80
|
+
return [...prompts]
|
|
81
|
+
.reverse()
|
|
82
|
+
.map((text) => ({ at: '', sessionId: context.sessionId, project: context.project, text }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ILoader {
|
|
86
|
+
readonly loaded: ILoadedState;
|
|
87
|
+
readonly loading: boolean;
|
|
88
|
+
readonly error: string | undefined;
|
|
89
|
+
readonly start: () => void;
|
|
90
|
+
readonly stop: () => void;
|
|
91
|
+
/** Screen-reader mode: the list is published only here — per keystroke or at load end. */
|
|
92
|
+
readonly publish: () => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface ILoaderRun {
|
|
96
|
+
readonly source: IPromptHistorySource;
|
|
97
|
+
readonly signal: AbortSignal;
|
|
98
|
+
readonly pending: { current: ILoadedState };
|
|
99
|
+
readonly publishEachBlock: boolean;
|
|
100
|
+
readonly publish: () => void;
|
|
101
|
+
readonly setError: (message: string) => void;
|
|
102
|
+
readonly setLoading: (loading: boolean) => void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* A real source reads synchronously and yields blocks with only a microtask between them; Ink's
|
|
107
|
+
* throttled write never runs inside that, so without this the whole file lands in one frame.
|
|
108
|
+
*/
|
|
109
|
+
const yieldToTerminal = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
|
|
110
|
+
|
|
111
|
+
async function runLoader(run: ILoaderRun): Promise<void> {
|
|
112
|
+
try {
|
|
113
|
+
for await (const block of run.source.read({ signal: run.signal })) {
|
|
114
|
+
if (run.signal.aborted) return;
|
|
115
|
+
run.pending.current = {
|
|
116
|
+
entries: [...run.pending.current.entries, ...block.entries],
|
|
117
|
+
skippedLines: run.pending.current.skippedLines + block.skippedLines,
|
|
118
|
+
};
|
|
119
|
+
if (run.publishEachBlock) {
|
|
120
|
+
run.publish();
|
|
121
|
+
await yieldToTerminal();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!run.signal.aborted) run.publish();
|
|
125
|
+
} catch (cause) {
|
|
126
|
+
if (!run.signal.aborted) run.setError(cause instanceof Error ? cause.message : String(cause));
|
|
127
|
+
} finally {
|
|
128
|
+
if (!run.signal.aborted) run.setLoading(false);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Reads the source once per open under one abort signal. */
|
|
133
|
+
function useHistoryLoader(
|
|
134
|
+
surface: IHistorySearchSurface | undefined,
|
|
135
|
+
screenReader: boolean,
|
|
136
|
+
): ILoader {
|
|
137
|
+
const [loaded, setLoaded] = useState<ILoadedState>(EMPTY_LOADED);
|
|
138
|
+
const [loading, setLoading] = useState(false);
|
|
139
|
+
const [error, setError] = useState<string | undefined>(undefined);
|
|
140
|
+
const controller = useRef<AbortController | undefined>(undefined);
|
|
141
|
+
const pending = useRef<ILoadedState>(EMPTY_LOADED);
|
|
142
|
+
const publish = useCallback(() => setLoaded(pending.current), []);
|
|
143
|
+
const stop = useCallback(() => {
|
|
144
|
+
controller.current?.abort();
|
|
145
|
+
controller.current = undefined;
|
|
146
|
+
setLoading(false);
|
|
147
|
+
}, []);
|
|
148
|
+
const start = useCallback(() => {
|
|
149
|
+
stop();
|
|
150
|
+
pending.current = EMPTY_LOADED;
|
|
151
|
+
setLoaded(EMPTY_LOADED);
|
|
152
|
+
setError(undefined);
|
|
153
|
+
if (surface === undefined) return;
|
|
154
|
+
const own = new AbortController();
|
|
155
|
+
controller.current = own;
|
|
156
|
+
setLoading(true);
|
|
157
|
+
void runLoader({
|
|
158
|
+
source: surface.source,
|
|
159
|
+
signal: own.signal,
|
|
160
|
+
pending,
|
|
161
|
+
publishEachBlock: !screenReader,
|
|
162
|
+
publish,
|
|
163
|
+
setError,
|
|
164
|
+
setLoading,
|
|
165
|
+
});
|
|
166
|
+
}, [publish, screenReader, stop, surface]);
|
|
167
|
+
useEffect(() => stop, [stop]);
|
|
168
|
+
return { loaded, loading, error, start, stop, publish };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface ISearchKeysDeps {
|
|
172
|
+
readonly open: boolean;
|
|
173
|
+
readonly matches: readonly IHistorySearchMatch[];
|
|
174
|
+
readonly selected: number;
|
|
175
|
+
readonly scope: THistorySearchScope;
|
|
176
|
+
readonly screenReader: boolean;
|
|
177
|
+
readonly loader: ILoader;
|
|
178
|
+
readonly close: () => void;
|
|
179
|
+
readonly setScope: (scope: THistorySearchScope) => void;
|
|
180
|
+
readonly setSelectedIndex: (index: number) => void;
|
|
181
|
+
readonly setQuery: (update: (current: string) => string) => void;
|
|
182
|
+
readonly onInsert: (text: string) => void;
|
|
183
|
+
readonly onExecute: (text: string) => void;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The overlay's keys: the bound actions, and otherwise plain typing into the query. */
|
|
187
|
+
function useHistorySearchKeys(deps: ISearchKeysDeps): void {
|
|
188
|
+
const editQuery = (update: (current: string) => string): void => {
|
|
189
|
+
deps.setQuery(update);
|
|
190
|
+
deps.setSelectedIndex(0);
|
|
191
|
+
if (deps.screenReader) deps.loader.publish();
|
|
192
|
+
};
|
|
193
|
+
const take = (action: 'insert' | 'execute'): void => {
|
|
194
|
+
const text = deps.matches[deps.selected]?.entry.text;
|
|
195
|
+
if (text === undefined) return;
|
|
196
|
+
deps.close();
|
|
197
|
+
(action === 'insert' ? deps.onInsert : deps.onExecute)(text);
|
|
198
|
+
};
|
|
199
|
+
const move = (step: number): void => {
|
|
200
|
+
if (deps.matches.length === 0) return;
|
|
201
|
+
deps.setSelectedIndex((deps.selected + step + deps.matches.length) % deps.matches.length);
|
|
202
|
+
};
|
|
203
|
+
useKeybindingActions(
|
|
204
|
+
'history-search',
|
|
205
|
+
(actions, input, key, consumed) => {
|
|
206
|
+
const action = actions[0];
|
|
207
|
+
if (action === 'cancel') deps.close();
|
|
208
|
+
else if (action === 'insert' || action === 'execute') take(action);
|
|
209
|
+
else if (action === 'previous') move(-1);
|
|
210
|
+
else if (action === 'next') move(1);
|
|
211
|
+
else if (action === 'cycle-scope') {
|
|
212
|
+
deps.setScope(cycleScope(deps.scope));
|
|
213
|
+
deps.setSelectedIndex(0);
|
|
214
|
+
} else if (!consumed && (key.backspace || key.delete)) editQuery((q) => q.slice(0, -1));
|
|
215
|
+
else if (!consumed && isPrintable(input, key)) editQuery((q) => q + input);
|
|
216
|
+
},
|
|
217
|
+
{ isActive: deps.open },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The candidate list for the scope: the live list for `session`, the collapsed file otherwise. */
|
|
222
|
+
function useCandidates(
|
|
223
|
+
open: boolean,
|
|
224
|
+
surface: IHistorySearchSurface | undefined,
|
|
225
|
+
scope: THistorySearchScope,
|
|
226
|
+
sessionPrompts: readonly string[],
|
|
227
|
+
entries: readonly IPromptHistoryEntry[],
|
|
228
|
+
): IPromptHistoryEntry[] {
|
|
229
|
+
// Keyed on the surface's values, not its identity: the app rebuilds the object every render.
|
|
230
|
+
const sessionId = surface?.sessionId;
|
|
231
|
+
const project = surface?.project;
|
|
232
|
+
return useMemo(() => {
|
|
233
|
+
if (!open || sessionId === undefined || project === undefined) return [];
|
|
234
|
+
const context = { sessionId, project };
|
|
235
|
+
const scoped =
|
|
236
|
+
scope === 'session'
|
|
237
|
+
? liveEntries(sessionPrompts, context)
|
|
238
|
+
: entries.filter((entry) => inScope(entry, scope, context));
|
|
239
|
+
return collapseToNewest(scoped);
|
|
240
|
+
}, [entries, open, project, scope, sessionId, sessionPrompts]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
interface IQueryState {
|
|
244
|
+
readonly query: string;
|
|
245
|
+
readonly scope: THistorySearchScope;
|
|
246
|
+
readonly selectedIndex: number;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const FRESH_QUERY: IQueryState = { query: '', scope: 'all', selectedIndex: 0 };
|
|
250
|
+
|
|
251
|
+
/** The query, scope and selection as one state, with the setters the key handler needs. */
|
|
252
|
+
function useQueryState(): {
|
|
253
|
+
readonly state: IQueryState;
|
|
254
|
+
readonly reset: () => void;
|
|
255
|
+
readonly setters: Pick<ISearchKeysDeps, 'setScope' | 'setSelectedIndex' | 'setQuery'>;
|
|
256
|
+
} {
|
|
257
|
+
const [state, setState] = useState<IQueryState>(FRESH_QUERY);
|
|
258
|
+
const reset = useCallback(() => setState(FRESH_QUERY), []);
|
|
259
|
+
const setters = useMemo(
|
|
260
|
+
() => ({
|
|
261
|
+
setScope: (scope: THistorySearchScope): void => setState((s) => ({ ...s, scope })),
|
|
262
|
+
setSelectedIndex: (selectedIndex: number): void => setState((s) => ({ ...s, selectedIndex })),
|
|
263
|
+
setQuery: (update: (current: string) => string): void =>
|
|
264
|
+
setState((s) => ({ ...s, query: update(s.query) })),
|
|
265
|
+
}),
|
|
266
|
+
[],
|
|
267
|
+
);
|
|
268
|
+
return { state, reset, setters };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Open and close: one loader start per open, one stop per close, the app told of both. */
|
|
272
|
+
function useOverlayVisibility(
|
|
273
|
+
loader: ILoader,
|
|
274
|
+
canOpen: boolean,
|
|
275
|
+
reset: () => void,
|
|
276
|
+
onOpenChange: ((open: boolean) => void) | undefined,
|
|
277
|
+
): { readonly open: boolean; readonly openSearch: () => void; readonly closeSearch: () => void } {
|
|
278
|
+
const [open, setOpen] = useState(false);
|
|
279
|
+
const openSearch = useCallback(() => {
|
|
280
|
+
if (!canOpen || open) return;
|
|
281
|
+
reset();
|
|
282
|
+
setOpen(true);
|
|
283
|
+
onOpenChange?.(true);
|
|
284
|
+
loader.start();
|
|
285
|
+
}, [canOpen, loader, onOpenChange, open, reset]);
|
|
286
|
+
const closeSearch = useCallback(() => {
|
|
287
|
+
loader.stop();
|
|
288
|
+
setOpen(false);
|
|
289
|
+
onOpenChange?.(false);
|
|
290
|
+
}, [loader, onOpenChange]);
|
|
291
|
+
return { open, openSearch, closeSearch };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function useHistorySearch(options: IUseHistorySearchOptions): {
|
|
295
|
+
readonly view: IHistorySearchView;
|
|
296
|
+
readonly openSearch: () => void;
|
|
297
|
+
readonly closeSearch: () => void;
|
|
298
|
+
} {
|
|
299
|
+
const screenReader = useScreenReader();
|
|
300
|
+
const loader = useHistoryLoader(options.surface, screenReader);
|
|
301
|
+
const { state, reset, setters } = useQueryState();
|
|
302
|
+
const { query, scope } = state;
|
|
303
|
+
const { surface, onInsert, onExecute } = options;
|
|
304
|
+
const { open, openSearch, closeSearch } = useOverlayVisibility(
|
|
305
|
+
loader,
|
|
306
|
+
surface !== undefined,
|
|
307
|
+
reset,
|
|
308
|
+
options.onOpenChange,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const candidates = useCandidates(
|
|
312
|
+
open,
|
|
313
|
+
surface,
|
|
314
|
+
scope,
|
|
315
|
+
options.sessionPrompts,
|
|
316
|
+
loader.loaded.entries,
|
|
317
|
+
);
|
|
318
|
+
const matches = useMemo(() => filterPrompts(candidates, query), [candidates, query]);
|
|
319
|
+
const selected = Math.min(state.selectedIndex, Math.max(0, matches.length - 1));
|
|
320
|
+
useHistorySearchKeys({
|
|
321
|
+
open,
|
|
322
|
+
matches,
|
|
323
|
+
selected,
|
|
324
|
+
scope,
|
|
325
|
+
screenReader,
|
|
326
|
+
loader,
|
|
327
|
+
close: closeSearch,
|
|
328
|
+
onInsert,
|
|
329
|
+
onExecute,
|
|
330
|
+
...setters,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const { loading, error, loaded } = loader;
|
|
334
|
+
const view = { ...state, open, selectedIndex: selected, matches, loading, error };
|
|
335
|
+
return { view: { ...view, skippedLines: loaded.skippedLines }, openSearch, closeSearch };
|
|
336
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import { cleanup, render } from 'ink-testing-library';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { useAppInputBindings } from '../useAppInputBindings.js';
|
|
7
|
+
|
|
8
|
+
type TOptions = Parameters<typeof useAppInputBindings>[0];
|
|
9
|
+
|
|
10
|
+
function Probe({ options }: { options: TOptions }): React.ReactElement {
|
|
11
|
+
useAppInputBindings(options);
|
|
12
|
+
return <Text>ready</Text>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function options(overrides: Partial<TOptions> = {}): TOptions {
|
|
16
|
+
return {
|
|
17
|
+
isThinking: false,
|
|
18
|
+
isShuttingDown: false,
|
|
19
|
+
permissionRequest: null,
|
|
20
|
+
pendingUserAction: null,
|
|
21
|
+
pluginVisible: false,
|
|
22
|
+
transportVisible: false,
|
|
23
|
+
sessionPickerVisible: false,
|
|
24
|
+
workspaceSwitcherVisible: false,
|
|
25
|
+
historySearchOpen: false,
|
|
26
|
+
themePickerVisible: false,
|
|
27
|
+
selectedEntry: undefined,
|
|
28
|
+
backgroundListFocused: false,
|
|
29
|
+
mainThreadEntryId: undefined,
|
|
30
|
+
activeTools: [],
|
|
31
|
+
abort: vi.fn(),
|
|
32
|
+
stopWaitingLoop: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
toggleWorkspaceSwitcher: vi.fn(),
|
|
34
|
+
selectWorkspaceEntry: vi.fn(),
|
|
35
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
recoveryError: undefined,
|
|
37
|
+
recoveryPending: false,
|
|
38
|
+
coordinationBlocked: false,
|
|
39
|
+
retryRecovery: vi.fn(),
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(cleanup);
|
|
45
|
+
|
|
46
|
+
describe('Esc for a waiting self-paced loop', () => {
|
|
47
|
+
it('routes idle Esc to targeted loop stop, but active-turn Esc to abort', async () => {
|
|
48
|
+
const idle = options();
|
|
49
|
+
const idleView = render(<Probe options={idle} />);
|
|
50
|
+
idleView.stdin.write('\u001b');
|
|
51
|
+
await vi.waitFor(() => expect(idle.stopWaitingLoop).toHaveBeenCalledOnce());
|
|
52
|
+
expect(idle.abort).not.toHaveBeenCalled();
|
|
53
|
+
idleView.unmount();
|
|
54
|
+
|
|
55
|
+
const thinking = options({ isThinking: true });
|
|
56
|
+
const thinkingView = render(<Probe options={thinking} />);
|
|
57
|
+
thinkingView.stdin.write('\u001b');
|
|
58
|
+
await vi.waitFor(() => expect(thinking.abort).toHaveBeenCalledOnce());
|
|
59
|
+
expect(thinking.stopWaitingLoop).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('keeps list-focus and overlay Esc with their owning surfaces', async () => {
|
|
63
|
+
for (const blocked of [
|
|
64
|
+
options({ backgroundListFocused: true }),
|
|
65
|
+
options({ historySearchOpen: true }),
|
|
66
|
+
]) {
|
|
67
|
+
const view = render(<Probe options={blocked} />);
|
|
68
|
+
view.stdin.write('\u001b');
|
|
69
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
70
|
+
expect(blocked.stopWaitingLoop).not.toHaveBeenCalled();
|
|
71
|
+
view.unmount();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { resolvePluginCallbacks } from '../usePluginCallbacks.js';
|
|
4
|
+
|
|
5
|
+
import type { ICommandPluginAdapter } from '@robota-sdk/agent-interface-command';
|
|
6
|
+
|
|
7
|
+
describe('resolvePluginCallbacks', () => {
|
|
8
|
+
it('preserves the host-provided plugin capability', () => {
|
|
9
|
+
const adapter = { listInstalled: vi.fn() } as unknown as ICommandPluginAdapter;
|
|
10
|
+
expect(resolvePluginCallbacks(adapter)).toBe(adapter);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('rejects explicitly when the host has no plugin capability', async () => {
|
|
14
|
+
await expect(resolvePluginCallbacks().listInstalled()).rejects.toThrow(
|
|
15
|
+
'Plugin management is unavailable in this host.',
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
});
|