@robota-sdk/agent-ui-terminal 3.0.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1204 -0
- package/LICENSE +661 -0
- package/README.md +50 -0
- package/dist/node/index.d.ts +1224 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +33 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +88 -0
- package/src/App.tsx +157 -0
- package/src/AppPresentation.tsx +190 -0
- package/src/AppView.tsx +26 -0
- package/src/BackgroundTaskPanel.tsx +73 -0
- package/src/CjkTextInput.tsx +336 -0
- package/src/ConfirmPrompt.tsx +140 -0
- package/src/ContextWarningBanner.tsx +45 -0
- package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
- package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
- package/src/HistorySearchOverlay.tsx +150 -0
- package/src/InputArea.tsx +338 -0
- package/src/ListPicker.tsx +158 -0
- package/src/MenuSelect.tsx +159 -0
- package/src/MessageList.tsx +308 -0
- package/src/MultiSelectList.tsx +294 -0
- package/src/PendingActionPrompt.tsx +121 -0
- package/src/PermissionPrompt.tsx +236 -0
- package/src/PluginTUI.tsx +258 -0
- package/src/RoleLabel.tsx +72 -0
- package/src/SafeText.tsx +66 -0
- package/src/SessionEventNotices.tsx +27 -0
- package/src/SessionPicker.tsx +92 -0
- package/src/SessionStatusBar.tsx +80 -0
- package/src/SlashAutocomplete.tsx +154 -0
- package/src/StatusBar.tsx +308 -0
- package/src/StreamingIndicator.tsx +145 -0
- package/src/SupervisedSessionView.tsx +872 -0
- package/src/TextPrompt.tsx +112 -0
- package/src/ThemePicker.tsx +366 -0
- package/src/ToolCommandOutput.tsx +48 -0
- package/src/ToolDiffBlock.tsx +44 -0
- package/src/TransportTUI.tsx +166 -0
- package/src/TuiInteractionChannel.ts +530 -0
- package/src/UpdateNotice.tsx +18 -0
- package/src/UsageSummaryEntry.tsx +49 -0
- package/src/WaveText.tsx +70 -0
- package/src/__tests__/ListPicker.test.tsx +188 -0
- package/src/__tests__/MenuSelect.test.tsx +127 -0
- package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
- package/src/__tests__/PluginTUI.test.tsx +167 -0
- package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
- package/src/__tests__/SupervisedSessionView-render.test.tsx +52 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +1112 -0
- package/src/__tests__/TextPrompt.test.tsx +112 -0
- package/src/__tests__/TransportTUI.test.tsx +124 -0
- package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +294 -0
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +650 -0
- package/src/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/__tests__/__fixtures__/korean-text.json +17 -0
- package/src/__tests__/abort-after-permission.test.tsx +160 -0
- package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
- package/src/__tests__/attention-bell-and-marks.test.ts +144 -0
- package/src/__tests__/autocomplete-editing-keys.test.tsx +127 -0
- package/src/__tests__/background-task-panel-countdown.test.tsx +75 -0
- package/src/__tests__/background-task-panel.test.tsx +98 -0
- package/src/__tests__/background-task-row-format.test.ts +152 -0
- package/src/__tests__/channel-factory-integration.test.ts +150 -0
- package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
- package/src/__tests__/cjk-defer-submit.test.tsx +68 -0
- package/src/__tests__/cjk-fallback-render.test.tsx +89 -0
- package/src/__tests__/cjk-text-input-flow.test.ts +187 -0
- package/src/__tests__/cjk-text-input.test.ts +196 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
- package/src/__tests__/command-output-summary.test.ts +95 -0
- package/src/__tests__/command-result-handler.test.ts +171 -0
- package/src/__tests__/compact-event-bridge.test.ts +20 -0
- package/src/__tests__/confirm-permission-flow.test.ts +149 -0
- package/src/__tests__/confirm-prompt.test.tsx +103 -0
- package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +47 -0
- package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
- package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
- package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
- package/src/__tests__/external-prompt-notice.test.tsx +100 -0
- package/src/__tests__/fixtures/command-handoff-driver.tsx +114 -0
- package/src/__tests__/fixtures/fake-editor.sh +7 -0
- package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
- package/src/__tests__/fork-attach.test.tsx +235 -0
- package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
- package/src/__tests__/helpers/terminal-profiles.ts +152 -0
- package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
- package/src/__tests__/history-clear-broadcast.test.ts +66 -0
- package/src/__tests__/history-search-overlay.test.tsx +329 -0
- package/src/__tests__/humanize-tool-name.test.ts +30 -0
- package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
- package/src/__tests__/input-area-flow.test.ts +199 -0
- package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
- package/src/__tests__/interaction-channel-charter.test.ts +13 -0
- package/src/__tests__/key-hint-consistency.test.tsx +249 -0
- package/src/__tests__/key-hint-footer.test.tsx +69 -0
- package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
- package/src/__tests__/message-list-rendering.test.tsx +474 -0
- package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
- package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
- package/src/__tests__/output-style-reaches-session.test.ts +35 -0
- package/src/__tests__/palette-consistency.test.ts +329 -0
- package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
- package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
- package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
- package/src/__tests__/permission-prompt-requester.test.tsx +34 -0
- package/src/__tests__/product-display-name.test.tsx +79 -0
- package/src/__tests__/prompt-queue.test.tsx +260 -0
- package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
- package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
- package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
- package/src/__tests__/pty/behavior-2437-git.ptytest.ts +350 -0
- package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
- package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
- package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
- package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
- package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
- package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +320 -0
- package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +211 -0
- package/src/__tests__/pty/ime-cursor.ptytest.ts +187 -0
- package/src/__tests__/pty/isolated-home.ts +97 -0
- package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
- package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
- package/src/__tests__/pty/pty-driver.ts +155 -0
- package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +135 -0
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +140 -0
- package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
- package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
- package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
- package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
- package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
- package/src/__tests__/pty/session-attach.ptytest.ts +182 -0
- package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
- package/src/__tests__/pty/spawn-pty.test.ts +120 -0
- package/src/__tests__/pty/spawn-pty.ts +229 -0
- package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
- package/src/__tests__/pty/trust-prompt.ptytest.ts +134 -0
- package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
- package/src/__tests__/pty-driver-dispose.test.ts +43 -0
- package/src/__tests__/real-cursor-positioning.test.tsx +264 -0
- package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
- package/src/__tests__/render-channel-options.test.ts +201 -0
- package/src/__tests__/render-lifecycle.test.ts +36 -0
- package/src/__tests__/render-markdown.test.ts +164 -0
- package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
- package/src/__tests__/safe-text-boundary.test.tsx +75 -0
- package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
- package/src/__tests__/screen-reader-input.test.tsx +106 -0
- package/src/__tests__/screen-reader-labels.test.ts +91 -0
- package/src/__tests__/screen-reader-menus.test.tsx +200 -0
- package/src/__tests__/screen-reader-pacing.test.ts +230 -0
- package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
- package/src/__tests__/screen-reader-render-options.test.ts +262 -0
- package/src/__tests__/screen-reader-stdout.test.ts +322 -0
- package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
- package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
- package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
- package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
- package/src/__tests__/selection-flow.test.ts +61 -0
- package/src/__tests__/session-event-notices.test.tsx +31 -0
- package/src/__tests__/session-init-poller.test.ts +102 -0
- package/src/__tests__/session-picker.test.tsx +93 -0
- package/src/__tests__/session-switch-channel.test.tsx +470 -0
- package/src/__tests__/shutdown-signal.test.ts +34 -0
- package/src/__tests__/status-activity.test.ts +71 -0
- package/src/__tests__/status-bar.test.tsx +290 -0
- package/src/__tests__/status-glyph.test.ts +30 -0
- package/src/__tests__/streaming-indicator.test.tsx +178 -0
- package/src/__tests__/terminal-capabilities.test.ts +153 -0
- package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
- package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
- package/src/__tests__/text-prompt-flow.test.ts +77 -0
- package/src/__tests__/theme-picker.test.tsx +345 -0
- package/src/__tests__/transport-boundary.test.ts +11 -0
- package/src/__tests__/tui-app-channel-port.test.ts +31 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +70 -0
- package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
- package/src/__tests__/tui-interaction-queues.test.ts +149 -0
- package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
- package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
- package/src/__tests__/tui-state-manager.test.ts +482 -0
- package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
- package/src/__tests__/wave-text.test.tsx +133 -0
- package/src/__tests__/wire-tui-channel.test.ts +1457 -0
- package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
- package/src/app-banner.tsx +42 -0
- package/src/app-static-items.ts +35 -0
- package/src/app-view-model.ts +140 -0
- package/src/attached-session-connection.ts +14 -0
- package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
- package/src/attention/__tests__/attention-tracker.test.ts +103 -0
- package/src/attention/__tests__/countdown.test.ts +15 -0
- package/src/attention/__tests__/focus-input-filter.test.ts +93 -0
- package/src/attention/__tests__/interval-recap.test.ts +49 -0
- package/src/attention/attention-coordinator.ts +71 -0
- package/src/attention/attention-tracker.ts +124 -0
- package/src/attention/countdown.ts +24 -0
- package/src/attention/focus-input-filter.ts +131 -0
- package/src/attention/interval-recap.ts +148 -0
- package/src/attention/time-units.ts +6 -0
- package/src/attention-bell.ts +79 -0
- package/src/attributed-user-echo.ts +30 -0
- package/src/background-task-row-format.ts +99 -0
- package/src/command-interaction.ts +8 -0
- package/src/command-output-summary.ts +129 -0
- package/src/create-default-tui-cli-adapter.ts +45 -0
- package/src/execution-workspace-switcher-selection.ts +118 -0
- package/src/execution-workspace-view-model.ts +139 -0
- package/src/external-prompt-notice.tsx +38 -0
- package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
- package/src/flows/__tests__/defer-submit.test.ts +88 -0
- package/src/flows/__tests__/real-cursor-flow.test.ts +139 -0
- package/src/flows/background-focus-flow.ts +44 -0
- package/src/flows/cjk-cursor-motion.ts +75 -0
- package/src/flows/cjk-text-deletion.ts +53 -0
- package/src/flows/cjk-text-input-flow.ts +248 -0
- package/src/flows/cjk-text-input-types.ts +24 -0
- package/src/flows/confirm-prompt-flow.ts +90 -0
- package/src/flows/defer-submit.ts +67 -0
- package/src/flows/fork-attach-flow.ts +62 -0
- package/src/flows/input-area-flow.ts +188 -0
- package/src/flows/permission-prompt-flow.ts +108 -0
- package/src/flows/real-cursor-flow.ts +83 -0
- package/src/flows/selection-flow.ts +205 -0
- package/src/flows/session-init-poller.ts +76 -0
- package/src/flows/text-prompt-flow.ts +93 -0
- package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
- package/src/history-search/history-search-flow.ts +94 -0
- package/src/history-search/useHistorySearch.ts +336 -0
- package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +87 -0
- package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
- package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
- package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
- package/src/hooks/command-result-handler.ts +44 -0
- package/src/hooks/side-effects-types.ts +45 -0
- package/src/hooks/useAppController.ts +271 -0
- package/src/hooks/useAppInputBindings.ts +163 -0
- package/src/hooks/useAppInteractionState.ts +84 -0
- package/src/hooks/useAppLifecycleState.ts +81 -0
- package/src/hooks/useAppOverlays.ts +90 -0
- package/src/hooks/useAppScreenState.ts +81 -0
- package/src/hooks/useAppSubmissionState.ts +45 -0
- package/src/hooks/useAppThemeState.ts +157 -0
- package/src/hooks/useAppWorkspaceState.ts +149 -0
- package/src/hooks/useAppearanceSettings.ts +30 -0
- package/src/hooks/useAutocomplete.ts +101 -0
- package/src/hooks/useBackgroundPanel.ts +58 -0
- package/src/hooks/useCountdownTick.ts +24 -0
- package/src/hooks/useExecutionDetailPage.ts +69 -0
- package/src/hooks/useForkAttach.ts +60 -0
- package/src/hooks/useInputAreaHistorySearch.ts +72 -0
- package/src/hooks/useInputAreaKeys.ts +114 -0
- package/src/hooks/useNumberedSelection.ts +93 -0
- package/src/hooks/usePluginCallbacks.ts +38 -0
- package/src/hooks/usePluginScreenData.ts +85 -0
- package/src/hooks/useRealCursorPosition.ts +125 -0
- package/src/hooks/useRenderMarkdown.ts +38 -0
- package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
- package/src/hooks/useSideEffects.ts +144 -0
- package/src/hooks/useStatusLineSettings.ts +50 -0
- package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
- package/src/hooks/useTuiChannel.ts +141 -0
- package/src/humanize-tool-name.ts +51 -0
- package/src/index.ts +72 -0
- package/src/input-area-rules.tsx +71 -0
- package/src/json-value.ts +18 -0
- package/src/key-hint-footer.tsx +54 -0
- package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
- package/src/keybindings/__tests__/node-keybindings-source.test.ts +118 -0
- package/src/keybindings/keybinding-catalogue.ts +171 -0
- package/src/keybindings/keybinding-registry.ts +303 -0
- package/src/keybindings/keybinding-syntax.ts +155 -0
- package/src/keybindings/keybindings-context.tsx +124 -0
- package/src/keybindings/node-keybindings-source.ts +189 -0
- package/src/numbered-list.tsx +113 -0
- package/src/plugin-tui-handlers.ts +163 -0
- package/src/product-display-name-context.tsx +30 -0
- package/src/render-markdown.ts +209 -0
- package/src/render.tsx +746 -0
- package/src/sanitize-terminal-text.ts +171 -0
- package/src/screen-reader-announcement.ts +52 -0
- package/src/screen-reader-context.tsx +32 -0
- package/src/screen-reader-labels.ts +64 -0
- package/src/screen-reader-pacing-context.tsx +47 -0
- package/src/screen-reader-pacing.ts +178 -0
- package/src/screen-reader-stdout.ts +328 -0
- package/src/short-session-id.ts +9 -0
- package/src/shutdown-signal.ts +34 -0
- package/src/slash-command-input.ts +11 -0
- package/src/status-activity.ts +70 -0
- package/src/status-glyph.ts +78 -0
- package/src/terminal-capabilities-context.tsx +27 -0
- package/src/terminal-capabilities.ts +77 -0
- package/src/terminal-focus-reporting.ts +50 -0
- package/src/terminal-handoff-controller.ts +98 -0
- package/src/terminal-marks.ts +63 -0
- package/src/theme/__tests__/built-in-themes.test.ts +117 -0
- package/src/theme/__tests__/color-vision.test.ts +82 -0
- package/src/theme/__tests__/theme-context.test.tsx +146 -0
- package/src/theme/__tests__/theme-document.test.ts +222 -0
- package/src/theme/__tests__/theme-registry.test.ts +138 -0
- package/src/theme/__tests__/theme-styles.test.ts +233 -0
- package/src/theme/built-in-themes.ts +342 -0
- package/src/theme/color-vision.ts +171 -0
- package/src/theme/index.ts +45 -0
- package/src/theme/theme-context.tsx +81 -0
- package/src/theme/theme-contracts.ts +139 -0
- package/src/theme/theme-document.ts +278 -0
- package/src/theme/theme-registry.ts +127 -0
- package/src/theme/theme-styles.ts +177 -0
- package/src/tool-summary-status.ts +59 -0
- package/src/tui-app-channel-port.ts +111 -0
- package/src/tui-channel-lifecycle-coordinator.ts +149 -0
- package/src/tui-channel-options.ts +184 -0
- package/src/tui-cli-adapter-context.tsx +13 -0
- package/src/tui-cli-adapter.ts +24 -0
- package/src/tui-interaction-queues.ts +171 -0
- package/src/tui-session-binding.ts +52 -0
- package/src/tui-session-event-projector.ts +149 -0
- package/src/tui-session-events.ts +89 -0
- package/src/tui-session-notice-store.ts +43 -0
- package/src/tui-session-options.ts +134 -0
- package/src/tui-state-manager.ts +347 -0
- package/src/types.ts +21 -0
- package/src/use-terminal-title.ts +29 -0
- package/src/utils/__tests__/edit-diff.test.ts +426 -0
- package/src/utils/__tests__/paste-detection.test.ts +116 -0
- package/src/utils/__tests__/paste-labels.test.ts +46 -0
- package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
- package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
- package/src/utils/edit-diff.ts +151 -0
- package/src/utils/input-top-border.ts +19 -0
- package/src/utils/paste-labels.ts +9 -0
- package/src/utils/tool-call-extractor.ts +92 -0
- package/src/utils/tool-diff-summary.ts +75 -0
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +963 -0
- package/src/wire-tui-client-commands.ts +142 -0
- package/src/wire-tui-projection.ts +136 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
parseKeybindingsDocument,
|
|
7
|
+
type IKeybindingDiagnostic,
|
|
8
|
+
type IKeybindingSnapshot,
|
|
9
|
+
} from './keybinding-registry.js';
|
|
10
|
+
|
|
11
|
+
const RELOAD_DEBOUNCE_MS = 10;
|
|
12
|
+
|
|
13
|
+
export interface IKeybindingsFilePort {
|
|
14
|
+
ensureFile(): Promise<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IKeybindingsSource extends IKeybindingsFilePort {
|
|
18
|
+
readonly filePath: string;
|
|
19
|
+
start(): Promise<void>;
|
|
20
|
+
isStarted(): boolean;
|
|
21
|
+
getSnapshot(): IKeybindingSnapshot;
|
|
22
|
+
subscribe(listener: (snapshot: IKeybindingSnapshot) => void): () => void;
|
|
23
|
+
dispose(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface INodeKeybindingsSourceOptions {
|
|
27
|
+
readonly filePath: string;
|
|
28
|
+
readonly schemaUrl?: string;
|
|
29
|
+
readonly onDiagnostic?: (diagnostic: IKeybindingDiagnostic) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultDocument(schemaUrl: string | undefined) {
|
|
33
|
+
return { ...(schemaUrl === undefined ? {} : { $schema: schemaUrl }), version: 1 as const, bindings: {} };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function defaultSnapshot(filePath: string, schemaUrl: string | undefined): IKeybindingSnapshot {
|
|
37
|
+
const parsed = parseKeybindingsDocument(JSON.stringify(defaultDocument(schemaUrl)), filePath);
|
|
38
|
+
if (!parsed.ok) throw new Error(parsed.diagnostic.message);
|
|
39
|
+
return parsed.snapshot;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class NodeKeybindingsSource implements IKeybindingsSource {
|
|
43
|
+
readonly filePath: string;
|
|
44
|
+
readonly #schemaUrl: string | undefined;
|
|
45
|
+
readonly #onDiagnostic: ((diagnostic: IKeybindingDiagnostic) => void) | undefined;
|
|
46
|
+
readonly #listeners = new Set<(snapshot: IKeybindingSnapshot) => void>();
|
|
47
|
+
#snapshot: IKeybindingSnapshot;
|
|
48
|
+
#watcher: FSWatcher | undefined;
|
|
49
|
+
#reloadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
50
|
+
#startPromise: Promise<void> | undefined;
|
|
51
|
+
#disposed = false;
|
|
52
|
+
|
|
53
|
+
constructor(options: INodeKeybindingsSourceOptions) {
|
|
54
|
+
if (options.filePath.trim() === '') throw new Error('Keybindings file path must not be empty.');
|
|
55
|
+
if (options.schemaUrl?.trim() === '') throw new Error('Keybindings schema URL must not be empty.');
|
|
56
|
+
this.filePath = options.filePath;
|
|
57
|
+
this.#schemaUrl = options.schemaUrl;
|
|
58
|
+
this.#onDiagnostic = options.onDiagnostic;
|
|
59
|
+
this.#snapshot = defaultSnapshot(this.filePath, this.#schemaUrl);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
start(): Promise<void> {
|
|
63
|
+
if (this.#disposed) return Promise.reject(new Error('Keybindings source is disposed.'));
|
|
64
|
+
if (this.#startPromise !== undefined) return this.#startPromise;
|
|
65
|
+
this.#startPromise = this.#start().catch((cause) => {
|
|
66
|
+
this.#watcher?.close();
|
|
67
|
+
this.#watcher = undefined;
|
|
68
|
+
this.#startPromise = undefined;
|
|
69
|
+
throw cause;
|
|
70
|
+
});
|
|
71
|
+
return this.#startPromise;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async #start(): Promise<void> {
|
|
75
|
+
const directory = dirname(this.filePath);
|
|
76
|
+
await mkdir(directory, { recursive: true });
|
|
77
|
+
await this.#load();
|
|
78
|
+
if (this.#disposed) throw new Error('Keybindings source is disposed.');
|
|
79
|
+
this.#watcher = watch(directory, { persistent: false }, (_event, filename) => {
|
|
80
|
+
if (filename !== null && filename.toString() !== basename(this.filePath)) return;
|
|
81
|
+
this.#scheduleReload();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
isStarted(): boolean {
|
|
86
|
+
return this.#watcher !== undefined && !this.#disposed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getSnapshot(): IKeybindingSnapshot {
|
|
90
|
+
return this.#snapshot;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
subscribe(listener: (snapshot: IKeybindingSnapshot) => void): () => void {
|
|
94
|
+
this.#listeners.add(listener);
|
|
95
|
+
return () => this.#listeners.delete(listener);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async ensureFile(): Promise<string> {
|
|
99
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
100
|
+
try {
|
|
101
|
+
await writeFile(this.filePath, `${JSON.stringify(defaultDocument(this.#schemaUrl), null, 2)}\n`, {
|
|
102
|
+
encoding: 'utf8',
|
|
103
|
+
flag: 'wx',
|
|
104
|
+
mode: 0o600,
|
|
105
|
+
});
|
|
106
|
+
} catch (cause) {
|
|
107
|
+
if (!isAlreadyExists(cause as object)) throw cause;
|
|
108
|
+
}
|
|
109
|
+
return this.filePath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
dispose(): void {
|
|
113
|
+
this.#disposed = true;
|
|
114
|
+
if (this.#reloadTimer !== undefined) clearTimeout(this.#reloadTimer);
|
|
115
|
+
this.#reloadTimer = undefined;
|
|
116
|
+
this.#watcher?.close();
|
|
117
|
+
this.#watcher = undefined;
|
|
118
|
+
this.#listeners.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#scheduleReload(): void {
|
|
122
|
+
if (this.#disposed) return;
|
|
123
|
+
if (this.#reloadTimer !== undefined) clearTimeout(this.#reloadTimer);
|
|
124
|
+
this.#reloadTimer = setTimeout(() => {
|
|
125
|
+
this.#reloadTimer = undefined;
|
|
126
|
+
void this.#load();
|
|
127
|
+
}, RELOAD_DEBOUNCE_MS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async #load(): Promise<void> {
|
|
131
|
+
let text: string;
|
|
132
|
+
try {
|
|
133
|
+
text = await readFile(this.filePath, 'utf8');
|
|
134
|
+
} catch (cause) {
|
|
135
|
+
if (isNotFound(cause as object)) return;
|
|
136
|
+
this.#publishInvalid({
|
|
137
|
+
file: this.filePath,
|
|
138
|
+
path: '$',
|
|
139
|
+
message: errorMessage(cause as object),
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const parsed = parseKeybindingsDocument(text, this.filePath);
|
|
144
|
+
if (!parsed.ok) {
|
|
145
|
+
this.#publishInvalid(parsed.diagnostic);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
this.#publish(Object.freeze({ ...parsed.snapshot, generation: this.#snapshot.generation + 1 }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#publishInvalid(diagnostic: IKeybindingDiagnostic): void {
|
|
152
|
+
this.#onDiagnostic?.(diagnostic);
|
|
153
|
+
this.#publish(
|
|
154
|
+
Object.freeze({
|
|
155
|
+
...this.#snapshot,
|
|
156
|
+
generation: this.#snapshot.generation + 1,
|
|
157
|
+
diagnostic,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#publish(snapshot: IKeybindingSnapshot): void {
|
|
163
|
+
if (this.#disposed) return;
|
|
164
|
+
this.#snapshot = snapshot;
|
|
165
|
+
for (const listener of this.#listeners) listener(snapshot);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isNotFound(cause: object): boolean {
|
|
170
|
+
return isNodeError(cause) && cause.code === 'ENOENT';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isAlreadyExists(cause: object): boolean {
|
|
174
|
+
return isNodeError(cause) && cause.code === 'EEXIST';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isNodeError(cause: object): cause is NodeJS.ErrnoException {
|
|
178
|
+
return cause instanceof Error && 'code' in cause;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function errorMessage(cause: object): string {
|
|
182
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function createNodeKeybindingsSource(
|
|
186
|
+
options: INodeKeybindingsSourceOptions,
|
|
187
|
+
): IKeybindingsSource {
|
|
188
|
+
return new NodeKeybindingsSource(options);
|
|
189
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-2004 — the numbered-list menu (SSOT for its grammar).
|
|
3
|
+
*
|
|
4
|
+
* Every arrow-key menu becomes this in screen-reader mode: `N. <option>` lines followed by one
|
|
5
|
+
* prompt naming the valid range. Arrow navigation and the `> ` cursor are position cues a reader
|
|
6
|
+
* cannot follow — a spoken "3" is unambiguous, and the range in the prompt is the affordance.
|
|
7
|
+
*
|
|
8
|
+
* The prompt LITERAL is authored here, not copied from any reference: the surveyed documentation
|
|
9
|
+
* publishes the prompt's NAME and the cancel suffix, never the composed string. This module is the
|
|
10
|
+
* one place it exists, so the menus cannot drift into per-component dialects — the same reason
|
|
11
|
+
* `key-hint-footer.tsx` owns the footer grammar.
|
|
12
|
+
*
|
|
13
|
+
* No colour props: the mode drops colour rather than substituting values, so the palette floor stays
|
|
14
|
+
* green and nothing here depends on a cue a reader cannot hear.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Box } from 'ink';
|
|
18
|
+
import React from 'react';
|
|
19
|
+
|
|
20
|
+
import { Text } from './SafeText.js';
|
|
21
|
+
|
|
22
|
+
/** Appended to the prompt when Escape actually cancels the menu. */
|
|
23
|
+
export const NUMBERED_LIST_CANCEL_SUFFIX = ' or Escape to cancel';
|
|
24
|
+
|
|
25
|
+
/** The prompt literal. `Enter selection (1-3)`, plus the cancel suffix where it applies. */
|
|
26
|
+
export function formatNumberedSelectionPrompt(itemCount: number, cancellable: boolean): string {
|
|
27
|
+
const base = `Enter selection (1-${itemCount})`;
|
|
28
|
+
return cancellable ? `${base}${NUMBERED_LIST_CANCEL_SUFFIX}` : base;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One row's number prefix: `1. `, `2. `, … */
|
|
32
|
+
export function numberedRowPrefix(index: number): string {
|
|
33
|
+
return `${index + 1}. `;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface INumberedSelectionPromptProps {
|
|
37
|
+
/** How many rows the prompt names. Zero ⇒ nothing is rendered: `(1-0)` names no answer. */
|
|
38
|
+
itemCount: number;
|
|
39
|
+
cancellable?: boolean;
|
|
40
|
+
/** Digits typed so far, echoed after the prompt. */
|
|
41
|
+
buffer?: string;
|
|
42
|
+
/** The last entry was out of range ⇒ the same literal is re-printed. */
|
|
43
|
+
invalid?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The prompt line, separated from `NumberedList` so a menu that renders its own rows — the
|
|
48
|
+
* checklist, whose rows carry a checkbox — asks its question in the same words rather than
|
|
49
|
+
* inventing a dialect.
|
|
50
|
+
*/
|
|
51
|
+
export function NumberedSelectionPrompt({
|
|
52
|
+
itemCount,
|
|
53
|
+
cancellable = false,
|
|
54
|
+
buffer = '',
|
|
55
|
+
invalid = false,
|
|
56
|
+
}: INumberedSelectionPromptProps): React.ReactElement | null {
|
|
57
|
+
if (itemCount === 0) return null;
|
|
58
|
+
const prompt = formatNumberedSelectionPrompt(itemCount, cancellable);
|
|
59
|
+
return (
|
|
60
|
+
<>
|
|
61
|
+
<Text>
|
|
62
|
+
{prompt}
|
|
63
|
+
{buffer.length > 0 ? ` ${buffer}` : ''}
|
|
64
|
+
</Text>
|
|
65
|
+
{/* An out-of-range answer re-prints the SAME literal: the range is the correction. */}
|
|
66
|
+
{invalid && <Text>{prompt}</Text>}
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface INumberedListProps {
|
|
72
|
+
/** Heading line, e.g. the menu title or the permission question. */
|
|
73
|
+
title?: string;
|
|
74
|
+
/** Extra context line under the title. */
|
|
75
|
+
description?: string;
|
|
76
|
+
/** The selectable options, in order. */
|
|
77
|
+
options: readonly string[];
|
|
78
|
+
/** Escape cancels ⇒ the prompt says so. */
|
|
79
|
+
cancellable?: boolean;
|
|
80
|
+
/** Digits typed so far, echoed after the prompt. */
|
|
81
|
+
buffer?: string;
|
|
82
|
+
/** The last entry was out of range ⇒ the same literal is re-printed. */
|
|
83
|
+
invalid?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Render the numbered list and its prompt. */
|
|
87
|
+
export function NumberedList({
|
|
88
|
+
title,
|
|
89
|
+
description,
|
|
90
|
+
options,
|
|
91
|
+
cancellable = false,
|
|
92
|
+
buffer = '',
|
|
93
|
+
invalid = false,
|
|
94
|
+
}: INumberedListProps): React.ReactElement {
|
|
95
|
+
return (
|
|
96
|
+
<Box flexDirection="column">
|
|
97
|
+
{title !== undefined && title.length > 0 && <Text>{title}</Text>}
|
|
98
|
+
{description !== undefined && description.length > 0 && <Text>{description}</Text>}
|
|
99
|
+
{options.map((option, index) => (
|
|
100
|
+
<Text key={`${index}-${option}`}>
|
|
101
|
+
{numberedRowPrefix(index)}
|
|
102
|
+
{option}
|
|
103
|
+
</Text>
|
|
104
|
+
))}
|
|
105
|
+
<NumberedSelectionPrompt
|
|
106
|
+
itemCount={options.length}
|
|
107
|
+
cancellable={cancellable}
|
|
108
|
+
buffer={buffer}
|
|
109
|
+
invalid={invalid}
|
|
110
|
+
/>
|
|
111
|
+
</Box>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen-specific selection handlers for PluginTUI.
|
|
3
|
+
* Extracted to keep PluginTUI.tsx under 300 lines.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { IMenuSelectItem } from './MenuSelect.js';
|
|
7
|
+
import type { ICommandPluginAdapter } from '@robota-sdk/agent-interface-command';
|
|
8
|
+
|
|
9
|
+
interface IConfirmState {
|
|
10
|
+
message: string;
|
|
11
|
+
onConfirm: () => void;
|
|
12
|
+
onCancel: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface IMenuContext {
|
|
16
|
+
marketplace?: string;
|
|
17
|
+
pluginId?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface INavActions {
|
|
21
|
+
push: (state: { screen: string; context?: IMenuContext }) => void;
|
|
22
|
+
pop: () => void;
|
|
23
|
+
popN: (n: number) => void;
|
|
24
|
+
notify: (content: string) => void;
|
|
25
|
+
setConfirm: (state: IConfirmState | undefined) => void;
|
|
26
|
+
refresh: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function handleMainSelect(value: string, nav: Pick<INavActions, 'push'>): void {
|
|
30
|
+
if (value === 'marketplace') {
|
|
31
|
+
nav.push({ screen: 'marketplace-list' });
|
|
32
|
+
} else if (value === 'installed') {
|
|
33
|
+
nav.push({ screen: 'installed-list' });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function handleMarketplaceListSelect(value: string, nav: Pick<INavActions, 'push'>): void {
|
|
38
|
+
if (value === '__add__') {
|
|
39
|
+
nav.push({ screen: 'marketplace-add' });
|
|
40
|
+
} else {
|
|
41
|
+
nav.push({ screen: 'marketplace-action', context: { marketplace: value } });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function handleMarketplaceActionSelect(
|
|
46
|
+
value: string,
|
|
47
|
+
marketplace: string,
|
|
48
|
+
callbacks: ICommandPluginAdapter,
|
|
49
|
+
nav: Pick<INavActions, 'push' | 'pop' | 'popN' | 'notify' | 'setConfirm'>,
|
|
50
|
+
): void {
|
|
51
|
+
if (value === 'browse') {
|
|
52
|
+
nav.push({ screen: 'marketplace-browse', context: { marketplace } });
|
|
53
|
+
} else if (value === 'update') {
|
|
54
|
+
callbacks
|
|
55
|
+
.marketplaceUpdate(marketplace)
|
|
56
|
+
.then(() => {
|
|
57
|
+
nav.notify(`Updated marketplace "${marketplace}".`);
|
|
58
|
+
nav.pop();
|
|
59
|
+
})
|
|
60
|
+
.catch((err) => {
|
|
61
|
+
nav.notify(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
62
|
+
});
|
|
63
|
+
} else if (value === 'remove') {
|
|
64
|
+
nav.setConfirm({
|
|
65
|
+
message: `Remove marketplace "${marketplace}" and all its plugins?`,
|
|
66
|
+
onConfirm: () => {
|
|
67
|
+
nav.setConfirm(undefined);
|
|
68
|
+
callbacks
|
|
69
|
+
.marketplaceRemove(marketplace)
|
|
70
|
+
.then(() => {
|
|
71
|
+
nav.notify(`Removed marketplace "${marketplace}".`);
|
|
72
|
+
nav.popN(2);
|
|
73
|
+
})
|
|
74
|
+
.catch((err) => {
|
|
75
|
+
nav.notify(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
onCancel: () => nav.setConfirm(undefined),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function handleMarketplaceBrowseSelect(
|
|
84
|
+
value: string,
|
|
85
|
+
marketplace: string,
|
|
86
|
+
items: IMenuSelectItem[],
|
|
87
|
+
nav: Pick<INavActions, 'push'>,
|
|
88
|
+
): void {
|
|
89
|
+
const fullId = `${value}@${marketplace}`;
|
|
90
|
+
const item = items.find((i) => i.value === value);
|
|
91
|
+
if (item?.hint === 'installed') {
|
|
92
|
+
nav.push({ screen: 'installed-action', context: { pluginId: fullId } });
|
|
93
|
+
} else {
|
|
94
|
+
nav.push({ screen: 'marketplace-install-scope', context: { marketplace, pluginId: fullId } });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function handleInstallScopeSelect(
|
|
99
|
+
value: string,
|
|
100
|
+
pluginId: string,
|
|
101
|
+
callbacks: ICommandPluginAdapter,
|
|
102
|
+
nav: Pick<INavActions, 'popN' | 'notify'>,
|
|
103
|
+
): void {
|
|
104
|
+
const scope = value as 'user' | 'project';
|
|
105
|
+
callbacks
|
|
106
|
+
.install(pluginId, scope)
|
|
107
|
+
.then(() => {
|
|
108
|
+
nav.notify(`Installed plugin "${pluginId}" (${scope} scope).`);
|
|
109
|
+
nav.popN(2);
|
|
110
|
+
})
|
|
111
|
+
.catch((err) => {
|
|
112
|
+
nav.notify(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function handleInstalledListSelect(
|
|
117
|
+
value: string,
|
|
118
|
+
callbacks: ICommandPluginAdapter,
|
|
119
|
+
nav: Pick<INavActions, 'notify' | 'setConfirm' | 'refresh'>,
|
|
120
|
+
): void {
|
|
121
|
+
nav.setConfirm({
|
|
122
|
+
message: `Uninstall plugin "${value}"?`,
|
|
123
|
+
onConfirm: () => {
|
|
124
|
+
nav.setConfirm(undefined);
|
|
125
|
+
callbacks
|
|
126
|
+
.uninstall(value)
|
|
127
|
+
.then(() => {
|
|
128
|
+
nav.notify(`Uninstalled plugin "${value}".`);
|
|
129
|
+
nav.refresh();
|
|
130
|
+
})
|
|
131
|
+
.catch((err) => {
|
|
132
|
+
nav.notify(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
onCancel: () => nav.setConfirm(undefined),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function handleInstalledActionSelect(
|
|
140
|
+
value: string,
|
|
141
|
+
pluginId: string,
|
|
142
|
+
callbacks: ICommandPluginAdapter,
|
|
143
|
+
nav: Pick<INavActions, 'popN' | 'notify' | 'setConfirm'>,
|
|
144
|
+
): void {
|
|
145
|
+
if (value === 'uninstall') {
|
|
146
|
+
nav.setConfirm({
|
|
147
|
+
message: `Uninstall plugin "${pluginId}"?`,
|
|
148
|
+
onConfirm: () => {
|
|
149
|
+
nav.setConfirm(undefined);
|
|
150
|
+
callbacks
|
|
151
|
+
.uninstall(pluginId)
|
|
152
|
+
.then(() => {
|
|
153
|
+
nav.notify(`Uninstalled plugin "${pluginId}".`);
|
|
154
|
+
nav.popN(2);
|
|
155
|
+
})
|
|
156
|
+
.catch((err) => {
|
|
157
|
+
nav.notify(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
onCancel: () => nav.setConfirm(undefined),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
const ProductDisplayNameContext = createContext('Assistant');
|
|
4
|
+
const ModelCommandToolPrefixContext = createContext<string | undefined>(undefined);
|
|
5
|
+
|
|
6
|
+
export function ProductDisplayNameProvider({
|
|
7
|
+
name,
|
|
8
|
+
modelCommandToolPrefix,
|
|
9
|
+
children,
|
|
10
|
+
}: {
|
|
11
|
+
name?: string;
|
|
12
|
+
modelCommandToolPrefix?: string;
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
}): React.ReactElement {
|
|
15
|
+
return (
|
|
16
|
+
<ProductDisplayNameContext.Provider value={name?.trim() || 'Assistant'}>
|
|
17
|
+
<ModelCommandToolPrefixContext.Provider value={modelCommandToolPrefix}>
|
|
18
|
+
{children}
|
|
19
|
+
</ModelCommandToolPrefixContext.Provider>
|
|
20
|
+
</ProductDisplayNameContext.Provider>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useProductDisplayName(): string {
|
|
25
|
+
return useContext(ProductDisplayNameContext);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useModelCommandToolPrefix(): string | undefined {
|
|
29
|
+
return useContext(ModelCommandToolPrefixContext);
|
|
30
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
// @ts-expect-error — marked-terminal has no type declarations
|
|
3
|
+
import TerminalRenderer from 'marked-terminal';
|
|
4
|
+
|
|
5
|
+
import { sanitizeTerminalText } from './sanitize-terminal-text.js';
|
|
6
|
+
import { isInteractiveColorTerminal } from './terminal-capabilities.js';
|
|
7
|
+
import {
|
|
8
|
+
diffRowStyles,
|
|
9
|
+
markdownRendererOptions,
|
|
10
|
+
resolveTheme,
|
|
11
|
+
syntaxHighlightTheme,
|
|
12
|
+
} from './theme/index.js';
|
|
13
|
+
|
|
14
|
+
import type { IDiffRowStyles, ITuiTheme } from './theme/index.js';
|
|
15
|
+
import type { Renderer } from 'marked';
|
|
16
|
+
|
|
17
|
+
const CODE_BLOCK_INDENT = ' ';
|
|
18
|
+
|
|
19
|
+
export interface IRenderMarkdownOptions {
|
|
20
|
+
color?: boolean;
|
|
21
|
+
codeBlockWidth?: number;
|
|
22
|
+
/**
|
|
23
|
+
* SCREEN-2002: the resolved theme. Absent ⇒ the `dark` built-in, which is byte-identical to what
|
|
24
|
+
* this renderer produced before themes existed.
|
|
25
|
+
*/
|
|
26
|
+
theme?: ITuiTheme;
|
|
27
|
+
/** SCREEN-2002: `false` renders code blocks as plain indented text, with no highlight SGR. */
|
|
28
|
+
syntaxHighlighting?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* CLI-2004: flatten tables to `Header: value` sentences. A box-drawn grid is unreadable aloud —
|
|
31
|
+
* a reader announces the rules, not the relationship between a heading and its cell.
|
|
32
|
+
*/
|
|
33
|
+
screenReader?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface IHighlightOptions {
|
|
37
|
+
ignoreIllegals?: boolean;
|
|
38
|
+
/** The cli-highlight theme marked-terminal forwards; absent ⇒ its red/green defaults. */
|
|
39
|
+
theme?: Record<string, (text: string) => string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type TTerminalRendererConstructor = new (
|
|
43
|
+
/** `marked-terminal`'s style options: one chalk instance per token it colours. */
|
|
44
|
+
options?: Record<string, (text: string) => string>,
|
|
45
|
+
highlightOptions?: IHighlightOptions,
|
|
46
|
+
) => Renderer;
|
|
47
|
+
|
|
48
|
+
const TerminalRendererConstructor = TerminalRenderer as TTerminalRendererConstructor;
|
|
49
|
+
|
|
50
|
+
function shouldUseColor(option: boolean | undefined): boolean {
|
|
51
|
+
if (option !== undefined) {
|
|
52
|
+
return option;
|
|
53
|
+
}
|
|
54
|
+
return isInteractiveColorTerminal();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isDiffLanguage(language: string | undefined): boolean {
|
|
58
|
+
return language?.trim().toLowerCase() === 'diff';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function styleAddedOrRemovedDiffRow(
|
|
62
|
+
line: string,
|
|
63
|
+
rowWidth: number,
|
|
64
|
+
color: boolean,
|
|
65
|
+
styles: IDiffRowStyles,
|
|
66
|
+
): string {
|
|
67
|
+
const row = `${CODE_BLOCK_INDENT}${line}`.padEnd(rowWidth);
|
|
68
|
+
if (!color) {
|
|
69
|
+
return row.trimEnd();
|
|
70
|
+
}
|
|
71
|
+
if (line.startsWith('+')) return styles.added(row);
|
|
72
|
+
if (line.startsWith('-')) return styles.removed(row);
|
|
73
|
+
return row.trimEnd();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function colorizeDiffLine(
|
|
77
|
+
line: string,
|
|
78
|
+
color: boolean,
|
|
79
|
+
rowWidth: number,
|
|
80
|
+
styles: IDiffRowStyles,
|
|
81
|
+
): string {
|
|
82
|
+
if (line.startsWith('+') || line.startsWith('-')) {
|
|
83
|
+
return styleAddedOrRemovedDiffRow(line, rowWidth, color, styles);
|
|
84
|
+
}
|
|
85
|
+
const row = `${CODE_BLOCK_INDENT}${line}`;
|
|
86
|
+
if (!color) {
|
|
87
|
+
return row;
|
|
88
|
+
}
|
|
89
|
+
if (line.startsWith('@@')) return styles.hunk(row);
|
|
90
|
+
if (line.startsWith('diff ') || line.startsWith('index ')) return styles.meta(row);
|
|
91
|
+
return row;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveDiffRowWidth(lines: readonly string[], requestedWidth: number | undefined): number {
|
|
95
|
+
const minimumWidth = lines.reduce(
|
|
96
|
+
(maxWidth, line) => Math.max(maxWidth, CODE_BLOCK_INDENT.length + line.length),
|
|
97
|
+
0,
|
|
98
|
+
);
|
|
99
|
+
if (requestedWidth === undefined) {
|
|
100
|
+
return minimumWidth;
|
|
101
|
+
}
|
|
102
|
+
return Math.max(minimumWidth, requestedWidth);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function renderDiffCodeBlock(
|
|
106
|
+
code: string,
|
|
107
|
+
color: boolean,
|
|
108
|
+
codeBlockWidth: number | undefined,
|
|
109
|
+
styles: IDiffRowStyles,
|
|
110
|
+
): string {
|
|
111
|
+
const lines = code.split('\n');
|
|
112
|
+
const rowWidth = resolveDiffRowWidth(lines, codeBlockWidth);
|
|
113
|
+
const body = lines.map((line) => colorizeDiffLine(line, color, rowWidth, styles)).join('\n');
|
|
114
|
+
return `${body}\n\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* CLI-2004 — flatten a table to `Header: value` lines.
|
|
119
|
+
*
|
|
120
|
+
* The cell and row hooks tag their output with separators marked's own renderers never emit, so the
|
|
121
|
+
* table hook can recover the grid it was given. Reconstructing it from the RENDERED strings is the
|
|
122
|
+
* alternative, and those already carry the SGR the inline renderers added.
|
|
123
|
+
*/
|
|
124
|
+
const CELL_MARK = '\u0000';
|
|
125
|
+
const ROW_MARK = '\u0001';
|
|
126
|
+
|
|
127
|
+
function splitTableCells(row: string): string[] {
|
|
128
|
+
const cells = row.split(ROW_MARK).join('').split(CELL_MARK);
|
|
129
|
+
// The trailing separator leaves one empty tail entry; the cells themselves may legitimately be ''.
|
|
130
|
+
return cells.slice(0, -1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function installTableFlattening(renderer: Renderer): void {
|
|
134
|
+
renderer.tablecell = (content: string): string => `${content}${CELL_MARK}`;
|
|
135
|
+
renderer.tablerow = (content: string): string => `${content}${ROW_MARK}`;
|
|
136
|
+
renderer.table = (header: string, body: string): string => {
|
|
137
|
+
const headers = splitTableCells(header);
|
|
138
|
+
const rows = body
|
|
139
|
+
.split(ROW_MARK)
|
|
140
|
+
.filter((row) => row.length > 0)
|
|
141
|
+
.map((row) => splitTableCells(`${row}${ROW_MARK}`));
|
|
142
|
+
if (headers.length === 0 || rows.length === 0) return '';
|
|
143
|
+
const blocks = rows.map((cells) =>
|
|
144
|
+
headers.map((head, index) => `${head}: ${cells[index] ?? ''}`).join('\n'),
|
|
145
|
+
);
|
|
146
|
+
// A blank line between rows: the row boundary is the only structure left to hear.
|
|
147
|
+
return `${blocks.join('\n\n')}\n\n`;
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createTerminalRenderer(
|
|
152
|
+
color: boolean,
|
|
153
|
+
codeBlockWidth: number | undefined,
|
|
154
|
+
screenReader: boolean,
|
|
155
|
+
theme: ITuiTheme,
|
|
156
|
+
syntaxHighlighting: boolean,
|
|
157
|
+
): Renderer {
|
|
158
|
+
// SCREEN-2002: both dependencies are told what to colour with. Left to their own defaults, a
|
|
159
|
+
// daltonized theme would still get green/red code blocks from cli-highlight's own theme.
|
|
160
|
+
const renderer = new TerminalRendererConstructor(markdownRendererOptions(theme.markdown), {
|
|
161
|
+
ignoreIllegals: true,
|
|
162
|
+
theme: syntaxHighlightTheme(theme.syntax),
|
|
163
|
+
});
|
|
164
|
+
const renderCode = renderer.code.bind(renderer);
|
|
165
|
+
const styles = diffRowStyles(theme);
|
|
166
|
+
|
|
167
|
+
renderer.code = (code: string, language: string | undefined, escaped: boolean): string => {
|
|
168
|
+
if (isDiffLanguage(language)) {
|
|
169
|
+
return renderDiffCodeBlock(code, color, codeBlockWidth, styles);
|
|
170
|
+
}
|
|
171
|
+
// Highlighting off: the block renders as plain indented text, the shape the diff path uses.
|
|
172
|
+
if (!syntaxHighlighting) {
|
|
173
|
+
return `${code
|
|
174
|
+
.split('\n')
|
|
175
|
+
.map((line) => `${CODE_BLOCK_INDENT}${line}`)
|
|
176
|
+
.join('\n')}\n\n`;
|
|
177
|
+
}
|
|
178
|
+
return renderCode(code, language, escaped);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (screenReader) installTableFlattening(renderer);
|
|
182
|
+
|
|
183
|
+
return renderer;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Render markdown to a terminal-formatted string with colors, bold, etc.
|
|
188
|
+
* Returns the rendered string (may include ANSI escape codes).
|
|
189
|
+
*/
|
|
190
|
+
export function renderMarkdown(md: string, options: IRenderMarkdownOptions = {}): string {
|
|
191
|
+
// SEC-019 (issue #2022): the untrusted string is sanitized BEFORE parsing, never after. The
|
|
192
|
+
// renderer ADDS ANSI — colours, bold, code-block framing — so filtering its output would strip the
|
|
193
|
+
// repository's own presentation along with the attacker's. Filtering its input removes what
|
|
194
|
+
// arrived from outside and leaves what is generated after.
|
|
195
|
+
//
|
|
196
|
+
// This is the choke point every render path shares (MessageList, StreamingIndicator,
|
|
197
|
+
// ToolDiffBlock), which is why it is here rather than at each of the three.
|
|
198
|
+
const safe = sanitizeTerminalText(md);
|
|
199
|
+
const result = marked.parse(safe, {
|
|
200
|
+
renderer: createTerminalRenderer(
|
|
201
|
+
shouldUseColor(options.color),
|
|
202
|
+
options.codeBlockWidth,
|
|
203
|
+
options.screenReader === true,
|
|
204
|
+
resolveTheme(options.theme),
|
|
205
|
+
options.syntaxHighlighting !== false,
|
|
206
|
+
),
|
|
207
|
+
});
|
|
208
|
+
return typeof result === 'string' ? result.trimEnd() : safe;
|
|
209
|
+
}
|