@agi-fans/dsh-tui 0.20.0 → 0.22.0
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/lib/CHANGELOG.md +249 -3
- package/lib/chrome/box.d.ts +47 -16
- package/lib/chrome/box.js +172 -130
- package/lib/chrome/box.js.map +1 -1
- package/lib/chrome/code-highlight.d.ts +15 -0
- package/lib/chrome/code-highlight.js +60 -4
- package/lib/chrome/code-highlight.js.map +1 -1
- package/lib/chrome/command-output.d.ts +29 -2
- package/lib/chrome/command-output.js +100 -4
- package/lib/chrome/command-output.js.map +1 -1
- package/lib/chrome/main-screen-renderer.d.ts +18 -0
- package/lib/chrome/main-screen-renderer.js +58 -5
- package/lib/chrome/main-screen-renderer.js.map +1 -1
- package/lib/chrome/markdown.js +30 -10
- package/lib/chrome/markdown.js.map +1 -1
- package/lib/chrome/relative-time.d.ts +17 -0
- package/lib/chrome/relative-time.js +36 -0
- package/lib/chrome/relative-time.js.map +1 -1
- package/lib/chrome/renderer.d.ts +9 -0
- package/lib/chrome/renderer.js +1 -1
- package/lib/chrome/renderer.js.map +1 -1
- package/lib/chrome/secret-mask.d.ts +35 -0
- package/lib/chrome/secret-mask.js +180 -0
- package/lib/chrome/secret-mask.js.map +1 -0
- package/lib/chrome/status-config.d.ts +161 -51
- package/lib/chrome/status-config.js +237 -126
- package/lib/chrome/status-config.js.map +1 -1
- package/lib/chrome/status-line.d.ts +3 -2
- package/lib/chrome/status-line.js +105 -63
- package/lib/chrome/status-line.js.map +1 -1
- package/lib/chrome/terminal-colors.d.ts +85 -0
- package/lib/chrome/terminal-colors.js +230 -0
- package/lib/chrome/terminal-colors.js.map +1 -0
- package/lib/chrome/terminal-images.d.ts +9 -1
- package/lib/chrome/terminal-images.js +27 -4
- package/lib/chrome/terminal-images.js.map +1 -1
- package/lib/chrome/theme.d.ts +32 -9
- package/lib/chrome/theme.js +616 -520
- package/lib/chrome/theme.js.map +1 -1
- package/lib/chrome/tool-args.d.ts +1 -1
- package/lib/chrome/tool-args.js +3 -1
- package/lib/chrome/tool-args.js.map +1 -1
- package/lib/chrome/tool-renderers.d.ts +2 -0
- package/lib/chrome/tool-renderers.js +69 -3
- package/lib/chrome/tool-renderers.js.map +1 -1
- package/lib/chrome/tools-list.d.ts +1 -4
- package/lib/chrome/tools-list.js +1 -41
- package/lib/chrome/tools-list.js.map +1 -1
- package/lib/chrome/welcome-tips.d.ts +20 -7
- package/lib/chrome/welcome-tips.js +22 -41
- package/lib/chrome/welcome-tips.js.map +1 -1
- package/lib/commands/auth-ids.d.ts +25 -0
- package/lib/commands/auth-ids.js +27 -0
- package/lib/commands/auth-ids.js.map +1 -0
- package/lib/commands/auth-input.d.ts +34 -0
- package/lib/commands/auth-input.js +96 -0
- package/lib/commands/auth-input.js.map +1 -0
- package/lib/commands/auth-providers.d.ts +39 -0
- package/lib/commands/auth-providers.js +125 -0
- package/lib/commands/auth-providers.js.map +1 -0
- package/lib/commands/auth.d.ts +16 -22
- package/lib/commands/auth.js +346 -299
- package/lib/commands/auth.js.map +1 -1
- package/lib/commands/btw.d.ts +27 -0
- package/lib/commands/btw.js +59 -0
- package/lib/commands/btw.js.map +1 -0
- package/lib/commands/diff.js +30 -5
- package/lib/commands/diff.js.map +1 -1
- package/lib/commands/export.d.ts +1 -1
- package/lib/commands/export.js +5 -3
- package/lib/commands/export.js.map +1 -1
- package/lib/commands/files.d.ts +17 -1
- package/lib/commands/files.js +69 -61
- package/lib/commands/files.js.map +1 -1
- package/lib/commands/integrations.js +18 -6
- package/lib/commands/integrations.js.map +1 -1
- package/lib/commands/jobs.d.ts +19 -4
- package/lib/commands/jobs.js +50 -7
- package/lib/commands/jobs.js.map +1 -1
- package/lib/commands/model.js +109 -76
- package/lib/commands/model.js.map +1 -1
- package/lib/commands/permission.js +46 -13
- package/lib/commands/permission.js.map +1 -1
- package/lib/commands/plugins.js +196 -42
- package/lib/commands/plugins.js.map +1 -1
- package/lib/commands/schedule.d.ts +29 -0
- package/lib/commands/schedule.js +228 -0
- package/lib/commands/schedule.js.map +1 -0
- package/lib/commands/session-configuration.d.ts +1 -1
- package/lib/commands/session-configuration.js +29 -28
- package/lib/commands/session-configuration.js.map +1 -1
- package/lib/commands/session.js +75 -80
- package/lib/commands/session.js.map +1 -1
- package/lib/commands/terminal.d.ts +24 -4
- package/lib/commands/terminal.js +38 -94
- package/lib/commands/terminal.js.map +1 -1
- package/lib/commands/trajectory.js +4 -1
- package/lib/commands/trajectory.js.map +1 -1
- package/lib/definition.d.ts +150 -11
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/input/actions.d.ts +51 -0
- package/lib/input/actions.js +120 -0
- package/lib/input/actions.js.map +1 -0
- package/lib/input/editor-discovery.js +2 -2
- package/lib/input/editor-discovery.js.map +1 -1
- package/lib/input/editor.d.ts +0 -10
- package/lib/input/editor.js +11 -9
- package/lib/input/editor.js.map +1 -1
- package/lib/input/image-paste.d.ts +19 -1
- package/lib/input/image-paste.js +82 -1
- package/lib/input/image-paste.js.map +1 -1
- package/lib/input/keybindings-config.d.ts +15 -4
- package/lib/input/keybindings-config.js +57 -35
- package/lib/input/keybindings-config.js.map +1 -1
- package/lib/input/keys.d.ts +18 -0
- package/lib/input/keys.js +48 -3
- package/lib/input/keys.js.map +1 -1
- package/lib/input/wheel-scroll.d.ts +46 -0
- package/lib/input/wheel-scroll.js +69 -0
- package/lib/input/wheel-scroll.js.map +1 -0
- package/lib/prompt-intents.d.ts +182 -0
- package/lib/prompt-intents.js +127 -0
- package/lib/prompt-intents.js.map +1 -0
- package/lib/runtime/agent-profile.d.ts +5 -0
- package/lib/runtime/agent-profile.js +12 -0
- package/lib/runtime/agent-profile.js.map +1 -1
- package/lib/runtime/approval-rules.d.ts +59 -0
- package/lib/runtime/approval-rules.js +179 -0
- package/lib/runtime/approval-rules.js.map +1 -0
- package/lib/runtime/btw-policy.d.ts +7 -0
- package/lib/runtime/btw-policy.js +18 -0
- package/lib/runtime/btw-policy.js.map +1 -0
- package/lib/runtime/btw.d.ts +47 -0
- package/lib/runtime/btw.js +200 -0
- package/lib/runtime/btw.js.map +1 -0
- package/lib/runtime/cancellable.d.ts +75 -0
- package/lib/runtime/cancellable.js +76 -0
- package/lib/runtime/cancellable.js.map +1 -0
- package/lib/runtime/composer-images.js +5 -4
- package/lib/runtime/composer-images.js.map +1 -1
- package/lib/runtime/composer-shell.d.ts +30 -0
- package/lib/runtime/composer-shell.js +150 -0
- package/lib/runtime/composer-shell.js.map +1 -0
- package/lib/runtime/file-recovery-local.d.ts +73 -0
- package/lib/runtime/file-recovery-local.js +351 -0
- package/lib/runtime/file-recovery-local.js.map +1 -0
- package/lib/runtime/file-recovery-recording.d.ts +23 -0
- package/lib/runtime/file-recovery-recording.js +131 -0
- package/lib/runtime/file-recovery-recording.js.map +1 -0
- package/lib/runtime/file-recovery-review.d.ts +3 -0
- package/lib/runtime/file-recovery-review.js +130 -0
- package/lib/runtime/file-recovery-review.js.map +1 -0
- package/lib/runtime/file-recovery-store.d.ts +83 -0
- package/lib/runtime/file-recovery-store.js +329 -0
- package/lib/runtime/file-recovery-store.js.map +1 -0
- package/lib/runtime/file-recovery-worker.d.ts +22 -0
- package/lib/runtime/file-recovery-worker.js +153 -0
- package/lib/runtime/file-recovery-worker.js.map +1 -0
- package/lib/runtime/file-review.d.ts +6 -3
- package/lib/runtime/file-review.js +44 -7
- package/lib/runtime/file-review.js.map +1 -1
- package/lib/runtime/herdr-agent.d.ts +4 -13
- package/lib/runtime/herdr-agent.js +19 -63
- package/lib/runtime/herdr-agent.js.map +1 -1
- package/lib/runtime/plain-tui.d.ts +2 -0
- package/lib/runtime/plain-tui.js +7 -1
- package/lib/runtime/plain-tui.js.map +1 -1
- package/lib/runtime/plugin-settings.d.ts +25 -2
- package/lib/runtime/plugin-settings.js +329 -31
- package/lib/runtime/plugin-settings.js.map +1 -1
- package/lib/runtime/provider-local.d.ts +81 -24
- package/lib/runtime/provider-local.js +1807 -608
- package/lib/runtime/provider-local.js.map +1 -1
- package/lib/runtime/runner.js +52 -12
- package/lib/runtime/runner.js.map +1 -1
- package/lib/runtime/session-archive.d.ts +6 -0
- package/lib/runtime/session-archive.js +129 -3
- package/lib/runtime/session-archive.js.map +1 -1
- package/lib/runtime/session-runtime.js +1 -1
- package/lib/runtime/session-runtime.js.map +1 -1
- package/lib/runtime/settings-preferences.d.ts +5 -0
- package/lib/runtime/settings-preferences.js +34 -0
- package/lib/runtime/settings-preferences.js.map +1 -0
- package/lib/runtime/startup-notices.js +2 -0
- package/lib/runtime/startup-notices.js.map +1 -1
- package/lib/runtime/terminal-modes.d.ts +47 -0
- package/lib/runtime/terminal-modes.js +86 -0
- package/lib/runtime/terminal-modes.js.map +1 -0
- package/lib/runtime/terminal-notifications.d.ts +3 -5
- package/lib/runtime/terminal-notifications.js +4 -8
- package/lib/runtime/terminal-notifications.js.map +1 -1
- package/lib/runtime/terminal-probe.d.ts +36 -0
- package/lib/runtime/terminal-probe.js +37 -0
- package/lib/runtime/terminal-probe.js.map +1 -0
- package/lib/runtime/terminal-session.d.ts +57 -0
- package/lib/runtime/terminal-session.js +164 -0
- package/lib/runtime/terminal-session.js.map +1 -0
- package/lib/runtime/terminal-state.d.ts +29 -0
- package/lib/runtime/terminal-state.js +62 -0
- package/lib/runtime/terminal-state.js.map +1 -0
- package/lib/runtime/tool-presentation.js +50 -14
- package/lib/runtime/tool-presentation.js.map +1 -1
- package/lib/session/external-agents.d.ts +5 -0
- package/lib/session/external-agents.js +27 -0
- package/lib/session/external-agents.js.map +1 -0
- package/lib/session/feature-toggles.d.ts +1 -1
- package/lib/session/feature-toggles.js +1 -1
- package/lib/session/interaction-adapter.d.ts +1 -6
- package/lib/session/interaction-adapter.js +95 -18
- package/lib/session/interaction-adapter.js.map +1 -1
- package/lib/session/message-queue.js +1 -1
- package/lib/session/message-queue.js.map +1 -1
- package/lib/session/model-readiness.d.ts +16 -0
- package/lib/session/model-readiness.js +43 -0
- package/lib/session/model-readiness.js.map +1 -0
- package/lib/session/package-metadata.d.ts +8 -0
- package/lib/session/package-metadata.js +8 -0
- package/lib/session/package-metadata.js.map +1 -1
- package/lib/session/project-context.d.ts +5 -0
- package/lib/session/project-context.js +36 -0
- package/lib/session/project-context.js.map +1 -1
- package/lib/session/session-controller.d.ts +10 -13
- package/lib/session/session-controller.js +183 -98
- package/lib/session/session-controller.js.map +1 -1
- package/lib/session/session-library.js +2 -0
- package/lib/session/session-library.js.map +1 -1
- package/lib/session/session-tree.d.ts +3 -0
- package/lib/session/session-tree.js +3 -1
- package/lib/session/session-tree.js.map +1 -1
- package/lib/session/subagent-roster.d.ts +2 -1
- package/lib/session/subagent-roster.js +24 -3
- package/lib/session/subagent-roster.js.map +1 -1
- package/lib/session/subagent-tracker.d.ts +6 -2
- package/lib/session/subagent-tracker.js +49 -2
- package/lib/session/subagent-tracker.js.map +1 -1
- package/lib/session/tui-settings.d.ts +47 -13
- package/lib/session/tui-settings.js +76 -34
- package/lib/session/tui-settings.js.map +1 -1
- package/lib/session/working-directory.d.ts +4 -0
- package/lib/session/working-directory.js +4 -0
- package/lib/session/working-directory.js.map +1 -0
- package/lib/session/worktrees.d.ts +13 -0
- package/lib/session/worktrees.js +49 -0
- package/lib/session/worktrees.js.map +1 -0
- package/lib/views/approval-rule.d.ts +3 -0
- package/lib/views/approval-rule.js +4 -0
- package/lib/views/approval-rule.js.map +1 -0
- package/lib/views/at-complete.d.ts +8 -0
- package/lib/views/at-complete.js +64 -2
- package/lib/views/at-complete.js.map +1 -1
- package/lib/views/autocomplete.d.ts +22 -2
- package/lib/views/autocomplete.js +70 -40
- package/lib/views/autocomplete.js.map +1 -1
- package/lib/views/copy-targets.d.ts +13 -14
- package/lib/views/copy-targets.js +42 -54
- package/lib/views/copy-targets.js.map +1 -1
- package/lib/views/document-reader.js +18 -5
- package/lib/views/document-reader.js.map +1 -1
- package/lib/views/event-views.d.ts +1 -1
- package/lib/views/event-views.js +60 -6
- package/lib/views/event-views.js.map +1 -1
- package/lib/views/file-actions.d.ts +12 -0
- package/lib/views/file-actions.js +72 -0
- package/lib/views/file-actions.js.map +1 -0
- package/lib/views/history-search.js +15 -21
- package/lib/views/history-search.js.map +1 -1
- package/lib/views/hotkeys.d.ts +8 -1
- package/lib/views/hotkeys.js +53 -26
- package/lib/views/hotkeys.js.map +1 -1
- package/lib/views/list-navigation.d.ts +62 -0
- package/lib/views/list-navigation.js +73 -0
- package/lib/views/list-navigation.js.map +1 -0
- package/lib/views/list-view.d.ts +99 -0
- package/lib/views/list-view.js +165 -0
- package/lib/views/list-view.js.map +1 -0
- package/lib/views/prompt-selector.d.ts +34 -4
- package/lib/views/prompt-selector.js +270 -134
- package/lib/views/prompt-selector.js.map +1 -1
- package/lib/views/session-files.d.ts +1 -2
- package/lib/views/session-files.js +1 -11
- package/lib/views/session-files.js.map +1 -1
- package/lib/views/settings-list.d.ts +51 -10
- package/lib/views/settings-list.js +267 -315
- package/lib/views/settings-list.js.map +1 -1
- package/lib/views/trajectory-timeline.d.ts +72 -0
- package/lib/views/trajectory-timeline.js +133 -0
- package/lib/views/trajectory-timeline.js.map +1 -0
- package/lib/views/trajectory.d.ts +15 -4
- package/lib/views/trajectory.js +557 -123
- package/lib/views/trajectory.js.map +1 -1
- package/lib/views/transcript-export.js +9 -0
- package/lib/views/transcript-export.js.map +1 -1
- package/lib/views/transcript-render.d.ts +97 -25
- package/lib/views/transcript-render.js +542 -170
- package/lib/views/transcript-render.js.map +1 -1
- package/lib/views/transcript-search.d.ts +39 -6
- package/lib/views/transcript-search.js +46 -11
- package/lib/views/transcript-search.js.map +1 -1
- package/lib/views/transcript-types.d.ts +63 -5
- package/lib/views/transcript-types.js +23 -11
- package/lib/views/transcript-types.js.map +1 -1
- package/package.json +95 -65
- package/lib/commands/loop.d.ts +0 -52
- package/lib/commands/loop.js +0 -307
- package/lib/commands/loop.js.map +0 -1
- package/lib/commands/steer.d.ts +0 -5
- package/lib/commands/steer.js +0 -24
- package/lib/commands/steer.js.map +0 -1
- package/lib/runtime/terminal-reader.d.ts +0 -11
- package/lib/runtime/terminal-reader.js +0 -87
- package/lib/runtime/terminal-reader.js.map +0 -1
- package/lib/views/agent-hub.d.ts +0 -32
- package/lib/views/agent-hub.js +0 -206
- package/lib/views/agent-hub.js.map +0 -1
- package/lib/views/copy-selector.d.ts +0 -51
- package/lib/views/copy-selector.js +0 -139
- package/lib/views/copy-selector.js.map +0 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* TUI capability seam — local terminal provider.
|
|
3
3
|
|
|
4
4
|
* Owns the tty: raw-mode key input (editing, history, slash/tab
|
|
5
|
-
* autocomplete, /settings overlay,
|
|
5
|
+
* autocomplete, /settings overlay, Ctrl-R history search, PgUp/PgDn
|
|
6
6
|
* and Shift+Up/Down transcript scroll, Ctrl-O tool
|
|
7
7
|
* expand, bracketed paste, double-Escape conversation rewind, double Ctrl-C exit, Ctrl-D quit),
|
|
8
8
|
* SIGWINCH reflow, and the differential renderer. In non-tty mode
|
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
* printing of settled blocks.
|
|
11
11
|
* @module @agi-fans/dsh-tui
|
|
12
12
|
*/
|
|
13
|
-
import { TerminalNotificationController, TerminalNotificationQueue, terminalNotificationSequence,
|
|
13
|
+
import { TerminalNotificationController, TerminalNotificationQueue, terminalNotificationSequence, } from "./terminal-notifications.js";
|
|
14
|
+
import { PROGRAM_STATUS_QUERY, programStatusSequence, terminalTitle } from "./terminal-state.js";
|
|
15
|
+
import { TerminalSession } from "./terminal-session.js";
|
|
16
|
+
import { BRACKETED_PASTE, FOCUS_REPORTING, MOUSE_TRACKING, } from "./terminal-modes.js";
|
|
17
|
+
import { IDLE_WHEEL, terminalAcceleratesWheel, wheelStep } from "../input/wheel-scroll.js";
|
|
14
18
|
import { HerdrAgentReporter } from "./herdr-agent.js";
|
|
15
19
|
import { imageProtocol } from "../chrome/terminal-images.js";
|
|
16
20
|
import { previewImages } from "./image-preview.js";
|
|
@@ -31,43 +35,65 @@ import { TUI_SERVICE, } from "../definition.js";
|
|
|
31
35
|
import { applySlashCompletion, formatHelpText, parseSlashInput, resolveSlashCommand, slashSuggestions, BUILTIN_SLASH_COMMANDS, } from "../views/autocomplete.js";
|
|
32
36
|
import { applyPathCompletion, defaultPathSource, editorLineAt, findPathToken, parsePathPrefix, pathSuggestions, searchPathSuggestions, } from "../views/path-complete.js";
|
|
33
37
|
import { activeAtToken } from '@deepseek-ai/dsh-file-reference/grammar';
|
|
34
|
-
import { nextSelectableAutocompleteIndex, searchAtSuggestions, } from "../views/at-complete.js";
|
|
38
|
+
import { matchesAtShortcutQuery, nextSelectableAutocompleteIndex, searchAtSuggestions, } from "../views/at-complete.js";
|
|
35
39
|
import { readTmuxMouseEnabled } from "../input/mouse-policy.js";
|
|
36
40
|
import { TextSelection } from "../input/text-selection.js";
|
|
37
41
|
import { copyToClipboard, readFromClipboard } from "../input/clipboard.js";
|
|
38
|
-
import {
|
|
39
|
-
import { buildCopyTargets, extractCopyTarget, parseCopyKind } from "../views/copy-targets.js";
|
|
42
|
+
import { buildCopyTargets } from "../views/copy-targets.js";
|
|
40
43
|
import { applyHistorySearchEvent, createHistorySearch, } from "../views/history-search.js";
|
|
41
44
|
import { InputEditor, lineEnd, lineStart } from "../input/editor.js";
|
|
42
|
-
import { applySettingsEvent, createSettings, } from "../views/settings-list.js";
|
|
43
|
-
import {
|
|
45
|
+
import { applySettingsEvent, createSettings, refreshSettings, tuiSettingItems, } from "../views/settings-list.js";
|
|
46
|
+
import { editSettingsEntry, pluginSettingsEntries, resetSettingsControl, setSettingsEntry, showSettingsError } from "./plugin-settings.js";
|
|
47
|
+
import { preferenceChanges } from "./settings-preferences.js";
|
|
44
48
|
import { appendTrajectoryEvent, applyTrajectoryEvent, createTrajectory, trajectoryDetailMetrics, trajectoryListMetrics, } from "../views/trajectory.js";
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
import { applyTranscriptSearchEvent, createTranscriptSearch, searchBlockIndexes, } from "../views/transcript-search.js";
|
|
49
|
+
import { applyEvent, applyStreamChunk, renderedBlocks, formatAgentElapsed, subagentPreviewStart, initialTranscript, replayEvents, renderView, turnGroups, transcriptSeam, TRANSCRIPT_FAST_SCROLL, withWorkspaceSummary, } from "../views/event-views.js";
|
|
50
|
+
import { applyTranscriptSearchEvent, createTranscriptSearch, searchMatches, } from "../views/transcript-search.js";
|
|
48
51
|
import { nextRevealStep, revealStreamingAssistant, streamingAssistantKey, streamingAssistantUnits, } from "../views/streaming-reveal.js";
|
|
49
52
|
import { flushPending, parseKeys } from "../input/keys.js";
|
|
53
|
+
import { sanitizeDisplayLine } from "../chrome/renderer.js";
|
|
54
|
+
import { blockFileAction } from "../views/file-actions.js";
|
|
55
|
+
import { fileDocument, reviewFiles } from "./file-review.js";
|
|
56
|
+
import { stripAnsi } from "../chrome/width.js";
|
|
50
57
|
import { MainScreenRenderer } from "../chrome/main-screen-renderer.js";
|
|
51
|
-
import { colorDisabledByEnv, createTheme,
|
|
58
|
+
import { colorDisabledByEnv, createTheme, detectColorDepth } from "../chrome/theme.js";
|
|
59
|
+
import { formatToolsText } from "../chrome/tools-list.js";
|
|
52
60
|
import { renderTool } from "../chrome/tool-renderers.js";
|
|
53
|
-
import { TUI_SETTINGS_ENTRY, TUI_SETTINGS_FIELDS } from "../session/tui-settings.js";
|
|
54
|
-
import {
|
|
61
|
+
import { NOTIFICATION_THRESHOLD_MS, resolveNotificationMode, TUI_SETTINGS_ENTRY, TUI_SETTINGS_FIELDS, } from "../session/tui-settings.js";
|
|
62
|
+
import { DEFAULT_STATUS_DENSITY, resolveStatusBarConfig, statusBarDocument } from "../chrome/status-config.js";
|
|
55
63
|
import { HistoryStore } from "../session/history-store.js";
|
|
56
64
|
import { PasteBurst } from "../input/paste-burst.js";
|
|
57
|
-
import { loadKeybindings } from "../input/keybindings-config.js";
|
|
65
|
+
import { DEFAULT_KEYBINDINGS, loadKeybindings, saveKeybindings, TUI_ACTION_LABELS, TUI_ACTIONS } from "../input/keybindings-config.js";
|
|
58
66
|
import { discoverEditors } from "../input/editor-discovery.js";
|
|
59
67
|
import { editExternally, editFileExternally } from "../input/external-editor.js";
|
|
60
68
|
import z from '@deepseek-ai/schemastery';
|
|
61
|
-
import { movePromptSelection, movePromptTree, searchPromptSelection, filteredPromptOptions, refreshPromptOptions, promptTreePageSize, selectedPromptAnswer, selectedFilteredPromptAnswer, togglePromptSelection, } from "../views/prompt-selector.js";
|
|
62
|
-
import {
|
|
63
|
-
import {
|
|
69
|
+
import { movePromptSelection, movePromptTree, searchPromptSelection, filteredPromptOptions, refreshPromptOptions, promptTreePageSize, selectedPromptAnswer, selectedFilteredPromptAnswer, togglePromptSelection, documentRows, findInRows, } from "../views/prompt-selector.js";
|
|
70
|
+
import { edgeIndex, listNavigation, navigateIndex } from "../views/list-navigation.js";
|
|
71
|
+
import { refreshProjectContext, resolveGitStatusFiles, resolveProjectContext } from "../session/project-context.js";
|
|
72
|
+
import { modelRouteReady } from "../session/model-readiness.js";
|
|
64
73
|
import { formatEssentialHotkeysText, formatHotkeysText, hotkeyCount, keysForAction } from "../views/hotkeys.js";
|
|
65
|
-
import { imagePathCandidates, stripComposerImageMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
|
|
66
|
-
import { APP_NAME, APP_VERSION } from "../session/package-metadata.js";
|
|
74
|
+
import { imagePathCandidates, splitComposerImageMarkers, stripComposerImageMarkers, readImageFile, readImageFromClipboard, readMacClipboardFiles, } from "../input/image-paste.js";
|
|
75
|
+
import { APP_DISPLAY_NAME, APP_NAME, APP_VERSION } from "../session/package-metadata.js";
|
|
67
76
|
const DOUBLE_CTRL_C_MS = 500;
|
|
77
|
+
/**
|
|
78
|
+
* Whether an open completion menu still speaks for the `@` token being typed.
|
|
79
|
+
*
|
|
80
|
+
* A `@` menu is built for a prefix, and the reader extends that prefix one
|
|
81
|
+
* keystroke at a time while the search for the new one is still running. The
|
|
82
|
+
* rows on screen describe a token this one extends, so they stay. Anything else
|
|
83
|
+
* — a new `@`, a different path, a token that was never recorded — describes
|
|
84
|
+
* nothing on screen and must not be reused.
|
|
85
|
+
*/
|
|
86
|
+
export function completionDescribes(described, current) {
|
|
87
|
+
if (described === undefined)
|
|
88
|
+
return false;
|
|
89
|
+
return current.startsWith(described) || described.startsWith(current);
|
|
90
|
+
}
|
|
91
|
+
/** How long a confirmation stays on the composer's border before it fades. */
|
|
92
|
+
const TRANSIENT_STATUS_MS = 3_000;
|
|
93
|
+
/** Shortest interval between repaints while a Shell command writes. */
|
|
94
|
+
const SHELL_PAINT_INTERVAL_MS = 80;
|
|
68
95
|
const DOUBLE_ESCAPE_MS = 500;
|
|
69
96
|
const STREAMING_REVEAL_MS = 1000 / 30;
|
|
70
|
-
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1_000;
|
|
71
97
|
/**
|
|
72
98
|
* Durable settlement events that arrive in bursts while a turn runs. Renders
|
|
73
99
|
* for these are coalesced through the stream-render timer; rarer control
|
|
@@ -91,19 +117,6 @@ function shortenPath(cwd) {
|
|
|
91
117
|
return '~' + cwd.slice(home.length);
|
|
92
118
|
return cwd;
|
|
93
119
|
}
|
|
94
|
-
/** Maximum code points written into the OSC 2 window title. */
|
|
95
|
-
const WINDOW_TITLE_MAX = 120;
|
|
96
|
-
/**
|
|
97
|
-
* Make a session title safe for an OSC 2 write: control characters are
|
|
98
|
-
* replaced so a title cannot terminate or inject the escape sequence, and the
|
|
99
|
-
* payload is bounded without splitting a surrogate pair.
|
|
100
|
-
*/
|
|
101
|
-
function windowTitleText(title) {
|
|
102
|
-
if (title === undefined)
|
|
103
|
-
return '';
|
|
104
|
-
const cleaned = title.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
105
|
-
return [...cleaned].slice(0, WINDOW_TITLE_MAX).join('');
|
|
106
|
-
}
|
|
107
120
|
export const name = 'omdsh-tui';
|
|
108
121
|
/**
|
|
109
122
|
* Row schema. The durable preferences ride as volatile fields, so a settings
|
|
@@ -111,7 +124,7 @@ export const name = 'omdsh-tui';
|
|
|
111
124
|
* `loader/volatile-update` without remounting the terminal provider.
|
|
112
125
|
*/
|
|
113
126
|
export const Config = z.object({
|
|
114
|
-
model: z.string(),
|
|
127
|
+
model: z.string().default(''),
|
|
115
128
|
historyPath: z.string(),
|
|
116
129
|
keybindingsPath: z.string(),
|
|
117
130
|
dshHome: z.string(),
|
|
@@ -163,24 +176,19 @@ export class LocalTui {
|
|
|
163
176
|
if (!this.#tty)
|
|
164
177
|
throw new Error('Opening an editor requires an interactive terminal.');
|
|
165
178
|
try {
|
|
166
|
-
this.#
|
|
167
|
-
this.#term.output.write('\x1b[?1004l');
|
|
168
|
-
this.#term.input.setRawMode?.(false);
|
|
169
|
-
editFileExternally(path, this.#externalEditor);
|
|
179
|
+
this.#handTerminal(() => { editFileExternally(path, this.#externalEditor); });
|
|
170
180
|
}
|
|
171
181
|
finally {
|
|
172
|
-
if (!this.#disposed)
|
|
173
|
-
this.#term.input.setRawMode?.(true);
|
|
174
|
-
this.#term.output.write('\x1b[?1004h');
|
|
175
|
-
this.#renderer.reset();
|
|
182
|
+
if (!this.#disposed)
|
|
176
183
|
this.#render();
|
|
177
|
-
}
|
|
178
184
|
}
|
|
179
185
|
}
|
|
180
186
|
#deferInitialRender = false;
|
|
181
187
|
#imageProtocol;
|
|
182
188
|
#imageCellSize = { width: 9, height: 18 };
|
|
183
189
|
#imagePreviewAbort;
|
|
190
|
+
#fileReviewAbort;
|
|
191
|
+
#fileActionFrame;
|
|
184
192
|
#imageTemporary = new Set();
|
|
185
193
|
#terminalProfile;
|
|
186
194
|
#resizeDebounceMs;
|
|
@@ -189,6 +197,8 @@ export class LocalTui {
|
|
|
189
197
|
#streamRenderTimer = null;
|
|
190
198
|
#term;
|
|
191
199
|
#model;
|
|
200
|
+
/** Optimistic until the credential seams answer; false points at /login. */
|
|
201
|
+
#modelReady = true;
|
|
192
202
|
#reasoningEffort;
|
|
193
203
|
#colors;
|
|
194
204
|
#themeName;
|
|
@@ -198,6 +208,21 @@ export class LocalTui {
|
|
|
198
208
|
#editor = new InputEditor();
|
|
199
209
|
#files = new ComposerFiles({ text: () => this.#editor.text,
|
|
200
210
|
append: marker => { this.#editor.setCursor(this.#editor.text.length); this.#editor.handle({ type: 'text', value: (this.#editor.text === '' ? '' : ' ') + marker }); } });
|
|
211
|
+
insertText(text) {
|
|
212
|
+
if (text === '')
|
|
213
|
+
return;
|
|
214
|
+
const before = this.#editor.text.slice(0, this.#editor.cursor);
|
|
215
|
+
const after = this.#editor.text.slice(this.#editor.cursor);
|
|
216
|
+
const prefix = before !== '' && !/\s$/u.test(before) ? ' ' : '';
|
|
217
|
+
const suffix = (after !== '' && !/^\s/u.test(after)) || (text.startsWith('@') && after === '') ? ' ' : '';
|
|
218
|
+
this.#editor.handle({ type: 'text', value: prefix + text + suffix });
|
|
219
|
+
this.#refreshAutocomplete();
|
|
220
|
+
if (this.#tty)
|
|
221
|
+
this.#render();
|
|
222
|
+
}
|
|
223
|
+
async copyText(text, label = 'text') {
|
|
224
|
+
await this.#copyPicked(text, label);
|
|
225
|
+
}
|
|
201
226
|
stageFileAttachment(file) {
|
|
202
227
|
this.#files.add(file);
|
|
203
228
|
this.#refreshAutocomplete();
|
|
@@ -209,6 +234,8 @@ export class LocalTui {
|
|
|
209
234
|
#literalInput = false;
|
|
210
235
|
#draft = '';
|
|
211
236
|
#historyDraft;
|
|
237
|
+
#clearedDraft;
|
|
238
|
+
#stashedDrafts = new Map();
|
|
212
239
|
#draftStore;
|
|
213
240
|
#draftScope;
|
|
214
241
|
#draftFingerprint = '';
|
|
@@ -216,17 +243,19 @@ export class LocalTui {
|
|
|
216
243
|
#draftTimer;
|
|
217
244
|
#unsavedDrafts = new Map();
|
|
218
245
|
#draftError = false;
|
|
246
|
+
/**
|
|
247
|
+
* The open completion menu. `query` is the `@` prefix the rows were built for,
|
|
248
|
+
* so the next keystroke can tell "that token, extended" from "a different one".
|
|
249
|
+
*/
|
|
219
250
|
#ac = null;
|
|
220
251
|
#search = null;
|
|
221
252
|
#transcriptSearch = null;
|
|
222
253
|
#settings = null;
|
|
223
254
|
/** Optional-feature state read from the Profile patch this process booted with. */
|
|
224
255
|
#features;
|
|
225
|
-
/** Profile patch the
|
|
256
|
+
/** Profile patch the Tools feature controls write; absent hides those controls. */
|
|
226
257
|
#featurePatch;
|
|
227
|
-
#copySelector = null;
|
|
228
258
|
#trajectory = null;
|
|
229
|
-
#agentHub = null;
|
|
230
259
|
#pending = null;
|
|
231
260
|
#queuedSubmissions = [];
|
|
232
261
|
/** Newer queue entries temporarily detached while Up browses backward. */
|
|
@@ -289,16 +318,41 @@ export class LocalTui {
|
|
|
289
318
|
#focusBlockEdge;
|
|
290
319
|
#motion = 'full';
|
|
291
320
|
#externalEditor = 'auto';
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
#
|
|
321
|
+
#terminalTitle = 'status';
|
|
322
|
+
#terminalState = 'auto';
|
|
323
|
+
#programSupported = false;
|
|
324
|
+
#programQuerySent = false;
|
|
325
|
+
/** What the terminal reported about its own colors; empty until (and unless) it answers. */
|
|
326
|
+
get #terminalColors() { return this.#session.colors; }
|
|
327
|
+
/** Capability probe, reported colors, keyboard protocol, and every terminal mode this session turned on. */
|
|
328
|
+
#session = new TerminalSession({
|
|
329
|
+
write: bytes => { this.#term.output.write(bytes); },
|
|
330
|
+
alternateActive: () => this.#renderer?.alternateActive ?? false,
|
|
331
|
+
setAlternateScreenModes: modes => { this.#renderer?.setAlternateScreenModes(modes); },
|
|
332
|
+
deferUntilMainScreen: bytes => { this.#renderer?.deferUntilMainScreen(bytes); },
|
|
333
|
+
colorsChanged: () => { this.#render(); },
|
|
334
|
+
probeSettled: () => {
|
|
335
|
+
this.#probeGate = false;
|
|
336
|
+
if (!this.#disposed && !this.#deferInitialRender)
|
|
337
|
+
this.#render();
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
#offExit;
|
|
341
|
+
/** Accumulated wheel gesture, so fast spins move further than one notch. */
|
|
342
|
+
#wheelGesture = IDLE_WHEEL;
|
|
343
|
+
#wheelAccelerates;
|
|
344
|
+
/** When the pending provider retry fires, so the working row can count down. */
|
|
345
|
+
#retryDeadline;
|
|
346
|
+
/** When the running compaction started. */
|
|
347
|
+
#compactionStartedAt;
|
|
348
|
+
#readImageInput;
|
|
349
|
+
#writtenProgramState;
|
|
295
350
|
#checkUpdates = true;
|
|
296
351
|
#startupChangelog = 'summary';
|
|
297
352
|
#notifications = new TerminalNotificationController();
|
|
298
353
|
#notificationQueue = new TerminalNotificationQueue();
|
|
299
354
|
#notificationTimer;
|
|
300
355
|
#terminalFocused;
|
|
301
|
-
#notificationFocus = 'unfocused';
|
|
302
356
|
/**
|
|
303
357
|
* Herdr lifecycle authority for this pane. The plugin entry injects the
|
|
304
358
|
* environment-aware reporter; a directly constructed instance is inert so
|
|
@@ -306,9 +360,10 @@ export class LocalTui {
|
|
|
306
360
|
*/
|
|
307
361
|
#herdr;
|
|
308
362
|
#notificationPolicy = 'off';
|
|
309
|
-
#
|
|
310
|
-
#statusBar = defaultStatusBarConfig();
|
|
363
|
+
#statusBar = resolveStatusBarConfig({ density: DEFAULT_STATUS_DENSITY, hidden: [] });
|
|
311
364
|
#toolsExpanded = false;
|
|
365
|
+
/** Command outputs the reader opened; the rest fold after their preview. */
|
|
366
|
+
#expandedOutputs = new Set();
|
|
312
367
|
/**
|
|
313
368
|
* The reader opened something with Ctrl+O or Alt+O and is reading it.
|
|
314
369
|
*
|
|
@@ -327,11 +382,31 @@ export class LocalTui {
|
|
|
327
382
|
#copyOnSelect = true;
|
|
328
383
|
#mathMode = 'auto';
|
|
329
384
|
#mermaidMode = 'auto';
|
|
385
|
+
/** Transcript collapses every thought to one line; Ctrl+T flips it. */
|
|
386
|
+
#hideThinking = false;
|
|
330
387
|
#mouseInteraction = 'auto';
|
|
331
388
|
#tmuxMouseEnabled;
|
|
332
389
|
#jumpPress;
|
|
333
390
|
#jumpToLatest;
|
|
334
391
|
#openedGroups = new Set();
|
|
392
|
+
/** Side-conversation pane; owned here so Esc can close it from any state. */
|
|
393
|
+
#aside;
|
|
394
|
+
/** Set while the pane is open, so closing it stops the work it was showing. */
|
|
395
|
+
#asideAbort;
|
|
396
|
+
/** Thoughts a reader opened individually, addressed by reasoning identity. */
|
|
397
|
+
#expandedReasoning = new Set();
|
|
398
|
+
/**
|
|
399
|
+
* The last action's confirmation, drawn on the composer's bottom border.
|
|
400
|
+
*
|
|
401
|
+
* A status that reports what a key just did is not part of the conversation,
|
|
402
|
+
* so it never enters the block list: it replaces itself, and it is dropped by
|
|
403
|
+
* the next keystroke. Writing it into the transcript would put chatter into
|
|
404
|
+
* native scrollback, which cannot be taken back.
|
|
405
|
+
*/
|
|
406
|
+
#transientStatus;
|
|
407
|
+
#transientTimer = null;
|
|
408
|
+
/** Aborts the live Shell card, so Ctrl+C interrupts the command it belongs to. */
|
|
409
|
+
#shellAbort;
|
|
335
410
|
/** Turn scopes whose tools stay detailed as new calls arrive during inspection. */
|
|
336
411
|
#detailedTurns = new Set();
|
|
337
412
|
#tools = [];
|
|
@@ -347,8 +422,6 @@ export class LocalTui {
|
|
|
347
422
|
* when the prompt settles.
|
|
348
423
|
*/
|
|
349
424
|
#promptDisplaced = null;
|
|
350
|
-
#recentSessions = [];
|
|
351
|
-
#welcomeTips;
|
|
352
425
|
#sessionId;
|
|
353
426
|
#sessionTitle;
|
|
354
427
|
#writtenWindowTitle;
|
|
@@ -356,7 +429,13 @@ export class LocalTui {
|
|
|
356
429
|
#sessionControls;
|
|
357
430
|
#loopStatus;
|
|
358
431
|
#subagents;
|
|
359
|
-
|
|
432
|
+
/**
|
|
433
|
+
* The roster row the reader is pointing at, or `undefined` when the composer
|
|
434
|
+
* owns the keyboard. `↓` on an empty composer enters the bar; `Enter` opens the
|
|
435
|
+
* child. The bar is the roster now — the full-screen hub it used to open was a
|
|
436
|
+
* second stop on the way to the same transcript.
|
|
437
|
+
*/
|
|
438
|
+
#subagentSelection;
|
|
360
439
|
#inspected;
|
|
361
440
|
#promptDocument;
|
|
362
441
|
/**
|
|
@@ -364,7 +443,9 @@ export class LocalTui {
|
|
|
364
443
|
* preference changes so a session that starts colorless can still enable
|
|
365
444
|
* hexadecimal palettes without a restart.
|
|
366
445
|
*/
|
|
367
|
-
#
|
|
446
|
+
#colorDepth = 'ansi16';
|
|
447
|
+
/** Hold the first frame until the capability probe settles; scrollback rows are immutable, so the first real paint must use the reported colors. */
|
|
448
|
+
#probeGate = false;
|
|
368
449
|
#copy;
|
|
369
450
|
#copyTask = Promise.resolve();
|
|
370
451
|
#readClipboard;
|
|
@@ -372,6 +453,7 @@ export class LocalTui {
|
|
|
372
453
|
#readClipboardFiles;
|
|
373
454
|
#readImagePath;
|
|
374
455
|
#historyStore;
|
|
456
|
+
#keybindingsPath;
|
|
375
457
|
#keybindings;
|
|
376
458
|
#cwd;
|
|
377
459
|
#projectRoot;
|
|
@@ -382,6 +464,10 @@ export class LocalTui {
|
|
|
382
464
|
#searchSessions;
|
|
383
465
|
#autocompleteDebounceMs;
|
|
384
466
|
#persistPrefs = null;
|
|
467
|
+
#catalogVersion = 0;
|
|
468
|
+
#prefsSaveTask;
|
|
469
|
+
#disposeTask;
|
|
470
|
+
#prefsVersion = 0;
|
|
385
471
|
#agentBehavior;
|
|
386
472
|
#updateAgentBehavior;
|
|
387
473
|
#offAgentBehaviorWatch;
|
|
@@ -406,6 +492,7 @@ export class LocalTui {
|
|
|
406
492
|
this.#readClipboardFiles = paths.readClipboardFiles ?? readMacClipboardFiles;
|
|
407
493
|
this.#historyStore = paths.historyPath === undefined ? undefined : new HistoryStore(paths.historyPath);
|
|
408
494
|
this.#history = this.#historyStore?.load() ?? [];
|
|
495
|
+
this.#keybindingsPath = paths.keybindingsPath;
|
|
409
496
|
this.#keybindings = loadKeybindings(paths.keybindingsPath);
|
|
410
497
|
this.#featurePatch = paths.featurePatch;
|
|
411
498
|
if (paths.featurePatch !== undefined) {
|
|
@@ -429,25 +516,27 @@ export class LocalTui {
|
|
|
429
516
|
this.#searchFileMentions = paths.searchFileMentions;
|
|
430
517
|
this.#searchSessions = paths.searchSessions;
|
|
431
518
|
this.#autocompleteDebounceMs = Math.max(0, paths.autocompleteDebounceMs ?? 100);
|
|
432
|
-
this.#welcomeTips = pickWelcomeTips();
|
|
433
519
|
this.#deferInitialRender = paths.deferInitialRender === true;
|
|
434
520
|
this.#terminalProfile = paths.terminalProfile ?? detectTerminalProfile();
|
|
435
521
|
this.#imageProtocol = this.#terminalProfile !== 'direct' || paths.imageProtocol === 'none' ? undefined : paths.imageProtocol ?? imageProtocol(process.env);
|
|
522
|
+
this.#readImageInput = paths.readImageInput;
|
|
436
523
|
this.#resizeDebounceMs = Math.max(0, paths.resizeDebounceMs ?? 120);
|
|
437
524
|
this.#streamRenderMs = Math.max(0, paths.streamRenderMs ?? 8);
|
|
525
|
+
this.#wheelAccelerates = paths.wheelAcceleration ?? !terminalAcceleratesWheel();
|
|
438
526
|
this.#herdr = paths.herdrReporter ?? new HerdrAgentReporter({ env: {} });
|
|
439
|
-
this.#
|
|
440
|
-
this.#tty = term.input.isTTY === true;
|
|
527
|
+
this.#syncColorDepth();
|
|
528
|
+
this.#tty = term.input.isTTY === true && term.output.isTTY === true;
|
|
441
529
|
this.#draftStore = this.#tty && paths.draftsPath !== undefined ? new ComposerDraftStore(paths.draftsPath) : undefined;
|
|
442
530
|
this.#plain = new PlainTui({
|
|
443
531
|
term,
|
|
444
532
|
blocks: () => this.#state.blocks,
|
|
445
533
|
mathMode: () => this.#mathMode,
|
|
446
534
|
mermaidMode: () => this.#mermaidMode,
|
|
535
|
+
hideThinking: () => this.#hideThinking,
|
|
447
536
|
promptRequest: () => this.#prompt?.request,
|
|
448
537
|
finishPrompt: answer => { this.#finishPrompt(answer); },
|
|
449
538
|
});
|
|
450
|
-
this.#pwd = shortenPath(
|
|
539
|
+
this.#pwd = shortenPath(this.#cwd);
|
|
451
540
|
this.#branch = project.gitLabel;
|
|
452
541
|
this.#renderer = new MainScreenRenderer({ write: (chunk) => { this.#term.output.write(chunk); } }, {
|
|
453
542
|
width: this.#term.width(),
|
|
@@ -485,24 +574,54 @@ export class LocalTui {
|
|
|
485
574
|
if (this.#disposed)
|
|
486
575
|
return;
|
|
487
576
|
this.#terminalFocused = undefined;
|
|
488
|
-
this.#
|
|
577
|
+
this.#writtenWindowTitle = undefined;
|
|
578
|
+
this.#writtenProgramState = undefined;
|
|
579
|
+
this.#programQuerySent = false;
|
|
580
|
+
this.#programSupported = false;
|
|
581
|
+
this.#syncWindowTitle();
|
|
582
|
+
this.#session.takeBack();
|
|
489
583
|
this.#renderer.reset();
|
|
490
584
|
this.#render();
|
|
491
585
|
};
|
|
492
586
|
process.on('SIGCONT', resume);
|
|
493
587
|
this.#offResume = () => { process.off('SIGCONT', resume); };
|
|
494
588
|
}
|
|
495
|
-
|
|
589
|
+
this.#session.setMode('paste', BRACKETED_PASTE, true);
|
|
590
|
+
this.#session.setMode('focus', FOCUS_REPORTING, true);
|
|
591
|
+
this.#probeGate = this.#deferInitialRender;
|
|
592
|
+
// A crash or `process.exit` skips dispose; the shell must still get its
|
|
593
|
+
// terminal back rather than a stuck alternate screen or key encoding.
|
|
594
|
+
const onExit = () => { this.#restoreTerminalOnExit(); };
|
|
595
|
+
process.once('exit', onExit);
|
|
596
|
+
this.#offExit = () => { process.off('exit', onExit); };
|
|
597
|
+
this.#session.start();
|
|
496
598
|
}
|
|
497
599
|
this.#render();
|
|
498
600
|
this.#herdr.start();
|
|
601
|
+
if (paths.deferInitialRender !== true)
|
|
602
|
+
this.#syncWindowTitle();
|
|
603
|
+
}
|
|
604
|
+
/** Follow the viewed Session without replacing its transcript or native history. */
|
|
605
|
+
setWorkingDirectory(cwd) {
|
|
606
|
+
if (cwd === this.#cwd && cwd === this.#projectRoot)
|
|
607
|
+
return;
|
|
608
|
+
this.#cwd = cwd;
|
|
609
|
+
const project = resolveProjectContext(cwd);
|
|
610
|
+
this.#projectRoot = cwd;
|
|
611
|
+
this.#pwd = shortenPath(cwd);
|
|
612
|
+
this.#branch = project.gitLabel;
|
|
613
|
+
this.#refreshAutocomplete();
|
|
614
|
+
this.#syncWindowTitle();
|
|
615
|
+
if (this.#tty)
|
|
616
|
+
this.#render();
|
|
499
617
|
}
|
|
500
|
-
/** Refresh Git
|
|
618
|
+
/** Refresh Git metadata without changing the Session's effective directory. */
|
|
501
619
|
#refreshProjectContext() {
|
|
502
620
|
const branch = refreshProjectContext(this.#cwd).gitLabel;
|
|
503
621
|
if (branch === this.#branch)
|
|
504
622
|
return;
|
|
505
623
|
this.#branch = branch;
|
|
624
|
+
this.#syncWindowTitle();
|
|
506
625
|
if (this.#tty)
|
|
507
626
|
this.#render();
|
|
508
627
|
}
|
|
@@ -514,7 +633,20 @@ export class LocalTui {
|
|
|
514
633
|
this.#syncStreamingReveal(undefined);
|
|
515
634
|
this.#render();
|
|
516
635
|
}
|
|
636
|
+
if (event.type === 'llm/retry')
|
|
637
|
+
this.#retryDeadline = Date.now() + event.data.delayMs;
|
|
638
|
+
if (event.type === 'llm/retry-started' || event.type === 'turn/end')
|
|
639
|
+
this.#retryDeadline = undefined;
|
|
517
640
|
this.#state = applyEvent(this.#state, event, presentation, workspace);
|
|
641
|
+
const compacting = this.#state.status === 'compacting';
|
|
642
|
+
if (compacting && this.#compactionStartedAt === undefined)
|
|
643
|
+
this.#compactionStartedAt = Date.now();
|
|
644
|
+
else if (!compacting)
|
|
645
|
+
this.#compactionStartedAt = undefined;
|
|
646
|
+
if (event.type === 'turn/end' && this.#inspected === undefined) {
|
|
647
|
+
this.#herdr.settled(event.data.reason.kind);
|
|
648
|
+
this.#syncWindowTitle();
|
|
649
|
+
}
|
|
518
650
|
this.#emitNotification(this.#notifications.event({
|
|
519
651
|
type: event.type,
|
|
520
652
|
time: event.time,
|
|
@@ -574,8 +706,12 @@ export class LocalTui {
|
|
|
574
706
|
}
|
|
575
707
|
setStatus(status, options = {}) {
|
|
576
708
|
// Parent lifecycle remains authoritative while the child owns the visible view.
|
|
577
|
-
if (options.root === true || this.#inspected === undefined)
|
|
709
|
+
if (options.root === true || this.#inspected === undefined) {
|
|
578
710
|
this.#herdr.setRunning(status === 'running');
|
|
711
|
+
if (options.outcome !== undefined)
|
|
712
|
+
this.#herdr.settled(options.outcome);
|
|
713
|
+
this.#syncWindowTitle();
|
|
714
|
+
}
|
|
579
715
|
if (options.root === true && this.#inspected !== undefined)
|
|
580
716
|
return;
|
|
581
717
|
if (this.#state.status === 'compacting')
|
|
@@ -585,9 +721,19 @@ export class LocalTui {
|
|
|
585
721
|
if (this.#tty)
|
|
586
722
|
this.#render();
|
|
587
723
|
}
|
|
724
|
+
/** Update first-run model readiness without touching the model label. */
|
|
725
|
+
setModelReady(ready) {
|
|
726
|
+
if (this.#modelReady === ready)
|
|
727
|
+
return;
|
|
728
|
+
this.#modelReady = ready;
|
|
729
|
+
if (this.#tty)
|
|
730
|
+
this.#render();
|
|
731
|
+
}
|
|
588
732
|
setModel(model, reasoningEffort) {
|
|
589
733
|
this.#model = model;
|
|
590
734
|
this.#reasoningEffort = reasoningEffort;
|
|
735
|
+
this.#syncSettingsActions();
|
|
736
|
+
this.#refreshSettingsCatalog();
|
|
591
737
|
if (this.#tty)
|
|
592
738
|
this.#render();
|
|
593
739
|
}
|
|
@@ -609,13 +755,7 @@ export class LocalTui {
|
|
|
609
755
|
? undefined
|
|
610
756
|
: { agents: roster.agents.map(agent => ({ ...agent, activity: [...agent.activity] })) };
|
|
611
757
|
if ((this.#subagents?.agents.length ?? 0) === 0)
|
|
612
|
-
this.#
|
|
613
|
-
if (this.#agentHub !== null) {
|
|
614
|
-
if (this.#subagents === undefined)
|
|
615
|
-
this.#agentHub = null;
|
|
616
|
-
else
|
|
617
|
-
this.#agentHub = updateAgentHub(this.#agentHub, this.#subagents);
|
|
618
|
-
}
|
|
758
|
+
this.#subagentSelection = undefined;
|
|
619
759
|
const inspected = this.#inspected;
|
|
620
760
|
if (inspected !== undefined) {
|
|
621
761
|
const current = this.#subagents?.agents.find(agent => agent.id === inspected.id);
|
|
@@ -629,7 +769,7 @@ export class LocalTui {
|
|
|
629
769
|
};
|
|
630
770
|
}
|
|
631
771
|
}
|
|
632
|
-
if (!statusChanged
|
|
772
|
+
if (!statusChanged)
|
|
633
773
|
return;
|
|
634
774
|
this.#syncTick();
|
|
635
775
|
if (this.#tty)
|
|
@@ -644,7 +784,7 @@ export class LocalTui {
|
|
|
644
784
|
this.#inspected = inspected === undefined ? undefined : { ...inspected };
|
|
645
785
|
if (inspected !== undefined) {
|
|
646
786
|
this.#inspectEscapeFenceUntil = 0;
|
|
647
|
-
this.#
|
|
787
|
+
this.#subagentSelection = undefined;
|
|
648
788
|
}
|
|
649
789
|
this.#syncTick();
|
|
650
790
|
if (this.#tty)
|
|
@@ -659,6 +799,7 @@ export class LocalTui {
|
|
|
659
799
|
description: command.description,
|
|
660
800
|
...(command.inputHint === undefined ? {} : { inputHint: command.inputHint }),
|
|
661
801
|
}));
|
|
802
|
+
this.#syncSettingsActions();
|
|
662
803
|
this.#refreshAutocomplete();
|
|
663
804
|
if (this.#tty)
|
|
664
805
|
this.#render();
|
|
@@ -668,9 +809,7 @@ export class LocalTui {
|
|
|
668
809
|
return false;
|
|
669
810
|
this.#finishPrompt(null);
|
|
670
811
|
this.#trajectory = createTrajectory(events);
|
|
671
|
-
this.#agentHub = null;
|
|
672
812
|
this.#settings = null;
|
|
673
|
-
this.#copySelector = null;
|
|
674
813
|
this.#search = null;
|
|
675
814
|
this.#ac = null;
|
|
676
815
|
this.#render();
|
|
@@ -695,9 +834,7 @@ export class LocalTui {
|
|
|
695
834
|
const block = this.#state.blocks.findLast(candidate => candidate.kind === 'tool' && String(candidate.callId) === callId);
|
|
696
835
|
if (block?.kind !== 'tool')
|
|
697
836
|
return undefined;
|
|
698
|
-
//
|
|
699
|
-
// transcript already does. The approval prompt renders its detail on one
|
|
700
|
-
// line, so the parts are joined here rather than handed over as rows.
|
|
837
|
+
// Keep the provider-owned presentation and input intact in the approval reader.
|
|
701
838
|
const view = renderTool({
|
|
702
839
|
name: block.name,
|
|
703
840
|
arguments: block.args,
|
|
@@ -706,15 +843,9 @@ export class LocalTui {
|
|
|
706
843
|
expanded: false,
|
|
707
844
|
...(block.presentation === undefined ? {} : { presentation: block.presentation }),
|
|
708
845
|
});
|
|
709
|
-
|
|
710
|
-
? view.input.join(' ').replace(/\s+/gu, ' ').trim()
|
|
711
|
-
: '';
|
|
712
|
-
// A card with a summary already says what the call is. Without one the raw
|
|
713
|
-
// argument text is all there is, and its pretty-printed form would reduce to
|
|
714
|
-
// a lone brace on a single row, so the whole body is folded into one line.
|
|
715
|
-
return [view.title, view.summary, detail === '' ? undefined : detail]
|
|
846
|
+
return [view.title, view.summary, ...view.input]
|
|
716
847
|
.filter((part) => part !== undefined && part !== '')
|
|
717
|
-
.join('
|
|
848
|
+
.join('\n');
|
|
718
849
|
}
|
|
719
850
|
notice(text, options = {}) {
|
|
720
851
|
const merged = options.job === undefined ? undefined : mergeJobSettlement(this.#state, options.job);
|
|
@@ -757,7 +888,202 @@ export class LocalTui {
|
|
|
757
888
|
else
|
|
758
889
|
this.#plain.print();
|
|
759
890
|
}
|
|
891
|
+
/**
|
|
892
|
+
* Run a Shell line as a live card that streams in place.
|
|
893
|
+
*
|
|
894
|
+
* The card is appended before the command starts and updated as it writes, so
|
|
895
|
+
* the reader watches their own command rather than switching to another
|
|
896
|
+
* surface and coming back. It never waits for an exit gesture: when the
|
|
897
|
+
* command finishes the card is finished too, exactly like a tool call.
|
|
898
|
+
*/
|
|
899
|
+
async shellCommand(request) {
|
|
900
|
+
const block = { kind: 'shell', command: request.command, output: '', status: 'running' };
|
|
901
|
+
this.#insertShellBlock(block);
|
|
902
|
+
// Ctrl+C belongs to the running command while its card is alive, the same
|
|
903
|
+
// way it belongs to an active turn. The caller decides what to do with the
|
|
904
|
+
// abort; the provider only routes the key.
|
|
905
|
+
this.#shellAbort = request.abort;
|
|
906
|
+
let lastPaint = 0;
|
|
907
|
+
let pending = '';
|
|
908
|
+
let pendingPaint;
|
|
909
|
+
const cancelPending = () => { if (pendingPaint !== undefined) {
|
|
910
|
+
clearTimeout(pendingPaint);
|
|
911
|
+
pendingPaint = undefined;
|
|
912
|
+
} };
|
|
913
|
+
const paint = () => { if (this.#tty && !this.#disposed)
|
|
914
|
+
this.#render(); };
|
|
915
|
+
// Output can arrive faster than a frame is worth; coalescing keeps a chatty
|
|
916
|
+
// command from spending the whole turn in the renderer.
|
|
917
|
+
const append = (output) => {
|
|
918
|
+
pending = output;
|
|
919
|
+
const now = Date.now();
|
|
920
|
+
if (now - lastPaint < SHELL_PAINT_INTERVAL_MS) {
|
|
921
|
+
cancelPending();
|
|
922
|
+
pendingPaint = setTimeout(paint, SHELL_PAINT_INTERVAL_MS - (now - lastPaint));
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
lastPaint = now;
|
|
926
|
+
this.#updateShellBlock(request.command, current => ({ ...current, output: pending }));
|
|
927
|
+
paint();
|
|
928
|
+
};
|
|
929
|
+
let result;
|
|
930
|
+
try {
|
|
931
|
+
result = await request.run(append, request.signal);
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
this.#updateShellBlock(request.command, current => ({ ...current, status: 'error' }));
|
|
935
|
+
paint();
|
|
936
|
+
throw error;
|
|
937
|
+
}
|
|
938
|
+
finally {
|
|
939
|
+
cancelPending();
|
|
940
|
+
if (this.#shellAbort === request.abort)
|
|
941
|
+
this.#shellAbort = undefined;
|
|
942
|
+
}
|
|
943
|
+
const failed = result.interrupted === true || (result.code !== undefined && result.code !== 0);
|
|
944
|
+
this.#updateShellBlock(request.command, current => ({
|
|
945
|
+
...current,
|
|
946
|
+
output: result.output === '' ? current.output : result.output,
|
|
947
|
+
status: failed ? 'error' : 'ok',
|
|
948
|
+
...(result.code === undefined ? {} : { code: result.code }),
|
|
949
|
+
...(request.share === true ? { shared: true } : {}),
|
|
950
|
+
}));
|
|
951
|
+
paint();
|
|
952
|
+
return result;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* A side question, streamed into its own transcript block.
|
|
956
|
+
*
|
|
957
|
+
* Modelled on `shellCommand` rather than on `commandOutput`: the answer arrives
|
|
958
|
+
* over time, and the reader should watch it arrive where it will stay. Coalesced
|
|
959
|
+
* the same way, because a chatty stream would otherwise spend the turn in the
|
|
960
|
+
* renderer.
|
|
961
|
+
*/
|
|
962
|
+
/** Navigation belongs to the pane only when nothing else is claiming it. */
|
|
963
|
+
#asideOwnsKeys() {
|
|
964
|
+
return this.#editor.text === '' && this.#ac === null && this.#prompt === null;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Leaving the pane stops the consultation.
|
|
968
|
+
*
|
|
969
|
+
* Without this the request kept running invisibly and its answer was dropped,
|
|
970
|
+
* so the reader could neither read it nor stop paying for it.
|
|
971
|
+
*/
|
|
972
|
+
#closeAside() {
|
|
973
|
+
const aside = this.#aside;
|
|
974
|
+
this.#asideAbort?.abort();
|
|
975
|
+
this.#asideAbort = undefined;
|
|
976
|
+
this.#aside = undefined;
|
|
977
|
+
/**
|
|
978
|
+
* A finished answer collapses into one line rather than disappearing.
|
|
979
|
+
*
|
|
980
|
+
* The pane is above the composer, so closing it was the only way to get the
|
|
981
|
+
* screen height back — and that cost the text. Keeping the exchange as a
|
|
982
|
+
* folded block means the reader can always close the pane. A consultation
|
|
983
|
+
* still running records nothing: the reader left before there was an answer.
|
|
984
|
+
*/
|
|
985
|
+
if (aside !== undefined && aside.status !== 'running' && aside.text.trim() !== '') {
|
|
986
|
+
this.#state = { ...this.#state, blocks: [...this.#state.blocks, { kind: 'aside', question: aside.question, text: aside.text }] };
|
|
987
|
+
this.#followTail();
|
|
988
|
+
}
|
|
989
|
+
this.#render();
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* A side question, streamed into its own pane above the composer.
|
|
993
|
+
*
|
|
994
|
+
* Not a transcript block: the exchange belongs to a conversation that is not
|
|
995
|
+
* this one, so it gets its own pane rather than a row in the conversation the
|
|
996
|
+
* reader is working in. The pane is docked, `Esc` closes it, and the session
|
|
997
|
+
* looks exactly as it did. Coalesced the same way a Shell card is, because a
|
|
998
|
+
* chatty stream would otherwise spend the turn in the renderer.
|
|
999
|
+
*/
|
|
1000
|
+
async asideQuestion(request) {
|
|
1001
|
+
this.#asideAbort?.abort();
|
|
1002
|
+
const abort = new AbortController();
|
|
1003
|
+
this.#asideAbort = abort;
|
|
1004
|
+
// Leaving the pane and losing the session both stop the consultation.
|
|
1005
|
+
if (request.signal.aborted)
|
|
1006
|
+
abort.abort();
|
|
1007
|
+
else
|
|
1008
|
+
request.signal.addEventListener('abort', () => abort.abort(), { once: true });
|
|
1009
|
+
this.#aside = { question: request.question, text: '', status: 'running', scroll: 0 };
|
|
1010
|
+
this.#render();
|
|
1011
|
+
let lastPaint = 0;
|
|
1012
|
+
let pending = '';
|
|
1013
|
+
let pendingPaint;
|
|
1014
|
+
const paint = () => { if (this.#tty && !this.#disposed)
|
|
1015
|
+
this.#render(); };
|
|
1016
|
+
const show = (change) => {
|
|
1017
|
+
if (this.#aside !== undefined)
|
|
1018
|
+
this.#aside = { ...this.#aside, ...change };
|
|
1019
|
+
};
|
|
1020
|
+
/**
|
|
1021
|
+
* The pending text is applied by the paint, not merely followed by one: a
|
|
1022
|
+
* chunk that arrives inside the throttle window used to schedule a repaint of
|
|
1023
|
+
* the *old* text, so the last words before a pause never appeared.
|
|
1024
|
+
*/
|
|
1025
|
+
const flush = () => {
|
|
1026
|
+
if (pendingPaint !== undefined) {
|
|
1027
|
+
clearTimeout(pendingPaint);
|
|
1028
|
+
pendingPaint = undefined;
|
|
1029
|
+
}
|
|
1030
|
+
lastPaint = Date.now();
|
|
1031
|
+
show({ text: pending });
|
|
1032
|
+
paint();
|
|
1033
|
+
};
|
|
1034
|
+
const stream = (text) => {
|
|
1035
|
+
pending = text;
|
|
1036
|
+
const wait = SHELL_PAINT_INTERVAL_MS - (Date.now() - lastPaint);
|
|
1037
|
+
if (wait <= 0) {
|
|
1038
|
+
flush();
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (pendingPaint !== undefined)
|
|
1042
|
+
clearTimeout(pendingPaint);
|
|
1043
|
+
pendingPaint = setTimeout(flush, wait);
|
|
1044
|
+
};
|
|
1045
|
+
try {
|
|
1046
|
+
const result = await request.run(stream, abort.signal);
|
|
1047
|
+
show({
|
|
1048
|
+
text: result.text,
|
|
1049
|
+
status: result.failed === true ? 'error' : 'done',
|
|
1050
|
+
...(result.detail === undefined ? {} : { detail: result.detail }),
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
catch (error) {
|
|
1054
|
+
if (!abort.signal.aborted)
|
|
1055
|
+
show({ status: 'error', detail: error instanceof Error ? error.message : String(error) });
|
|
1056
|
+
}
|
|
1057
|
+
finally {
|
|
1058
|
+
if (pendingPaint !== undefined)
|
|
1059
|
+
clearTimeout(pendingPaint);
|
|
1060
|
+
if (this.#asideAbort === abort)
|
|
1061
|
+
this.#asideAbort = undefined;
|
|
1062
|
+
paint();
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
/** Append a Shell card and follow it, so the reader sees it start. */
|
|
1066
|
+
#insertShellBlock(block) {
|
|
1067
|
+
this.#state = { ...this.#state, blocks: [...this.#state.blocks, block] };
|
|
1068
|
+
this.#followTail();
|
|
1069
|
+
if (this.#tty)
|
|
1070
|
+
this.#render();
|
|
1071
|
+
else
|
|
1072
|
+
this.#plain.print();
|
|
1073
|
+
}
|
|
1074
|
+
/** Replace the newest Shell card for `command`; a card is identified by its position. */
|
|
1075
|
+
#updateShellBlock(command, update) {
|
|
1076
|
+
const at = this.#state.blocks.findLastIndex(block => block.kind === 'shell' && block.command === command);
|
|
1077
|
+
if (at < 0)
|
|
1078
|
+
return;
|
|
1079
|
+
const next = this.#state.blocks.slice();
|
|
1080
|
+
next[at] = update(next[at]);
|
|
1081
|
+
this.#state = { ...this.#state, blocks: next };
|
|
1082
|
+
}
|
|
760
1083
|
prompt(request) {
|
|
1084
|
+
// Background approvals take the terminal from a user-opened file reader.
|
|
1085
|
+
if (this.#fileReviewAbort !== undefined && request.signal !== this.#fileReviewAbort.signal)
|
|
1086
|
+
this.#fileReviewAbort.abort();
|
|
761
1087
|
if (this.#disposed || request.signal?.aborted === true)
|
|
762
1088
|
return Promise.resolve(null);
|
|
763
1089
|
if (this.#prompt !== null) {
|
|
@@ -775,18 +1101,21 @@ export class LocalTui {
|
|
|
775
1101
|
}
|
|
776
1102
|
this.#flushPasteBurst(true);
|
|
777
1103
|
this.#pasteBurst.clear();
|
|
778
|
-
if (request.notify !== false)
|
|
1104
|
+
if (request.notify !== false) {
|
|
779
1105
|
this.#emitNotification(this.#notifications.humanPrompt());
|
|
780
|
-
|
|
1106
|
+
this.#herdr.prompt(request.title);
|
|
1107
|
+
this.#syncWindowTitle();
|
|
1108
|
+
}
|
|
781
1109
|
if (this.#tty && request.documentImage !== undefined && this.#imageProtocol !== undefined)
|
|
782
1110
|
this.#term.output.write('\x1b[16t');
|
|
783
1111
|
const savedInput = this.#editor.snapshot();
|
|
784
|
-
this.#editor.setText(request.options?.length
|
|
1112
|
+
this.#editor.setText(request.allowCustom === false || request.filterable === true || (request.options?.length && request.initialInput === undefined) ? '' : request.initialInput ?? '');
|
|
785
1113
|
this.#ac = null;
|
|
786
1114
|
const displaced = this.#displaceSurface();
|
|
787
1115
|
return new Promise((resolve) => {
|
|
788
1116
|
const selected = Math.max(0, filteredPromptOptions(request, '').findIndex(option => (option.value ?? option.label) === request.initialValue) ?? 0);
|
|
789
|
-
const pending = { request, selected, checked: new Set(), resolve, savedInput,
|
|
1117
|
+
const pending = { request, selected, checked: new Set((request.options ?? []).flatMap((option, index) => request.initialValues?.includes(option.value ?? option.label) ? [index] : [])), resolve, savedInput,
|
|
1118
|
+
...(request.armMs !== undefined && request.armMs > 0 ? { armedUntil: Date.now() + request.armMs } : {}),
|
|
790
1119
|
...(request.documentPosition === undefined ? {} : { documentAnchor: request.documentPosition, documentQuery: request.documentPosition.query,
|
|
791
1120
|
documentWordWrap: request.documentPosition.wordWrap, documentColumn: request.documentPosition.column }),
|
|
792
1121
|
...(request.documentTail === true && request.documentPosition?.following !== false ? { documentScroll: Number.POSITIVE_INFINITY } : {}) };
|
|
@@ -864,11 +1193,13 @@ export class LocalTui {
|
|
|
864
1193
|
});
|
|
865
1194
|
}
|
|
866
1195
|
replaceSession(events, presentations, status = 'idle', reason = 'open') {
|
|
1196
|
+
if (reason !== 'refresh')
|
|
1197
|
+
this.#fileReviewAbort?.abort();
|
|
867
1198
|
this.#trajectory = null;
|
|
868
1199
|
const state = replayEvents(events, presentations);
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
const boundary = reason === 'open' || (reason === 'new' && this.#state.blocks.some(block => block.kind !== 'notice'
|
|
1200
|
+
// A notice does not turn a blank session into a document that /new needs
|
|
1201
|
+
// to separate. Refreshes are the same document.
|
|
1202
|
+
const boundary = reason === 'open' || (reason === 'new' && this.#state.blocks.some(block => block.kind !== 'notice'));
|
|
872
1203
|
const blocks = boundary ? [transcriptSeam('session-opened'), ...state.blocks] : state.blocks;
|
|
873
1204
|
this.#state = { ...state, status, compactCommandId: undefined, blocks };
|
|
874
1205
|
this.#plain.resetPrinted();
|
|
@@ -881,12 +1212,13 @@ export class LocalTui {
|
|
|
881
1212
|
this.#plain.print();
|
|
882
1213
|
}
|
|
883
1214
|
setSession(info) {
|
|
1215
|
+
if (info.id !== this.#sessionId)
|
|
1216
|
+
this.#fileReviewAbort?.abort();
|
|
884
1217
|
this.#switchDraft(this.#inspected?.id ?? info.id);
|
|
885
1218
|
this.#sessionId = info.id;
|
|
886
1219
|
this.#herdr.setSession(info.id);
|
|
887
1220
|
const title = info.title?.trim();
|
|
888
1221
|
this.#sessionTitle = title === undefined || title === '' ? undefined : title;
|
|
889
|
-
this.#recentSessions = info.recent.map((session) => ({ ...session }));
|
|
890
1222
|
this.#sessionStats = info.stats === undefined ? undefined : { ...info.stats };
|
|
891
1223
|
this.#sessionControls = info.controls === undefined
|
|
892
1224
|
? undefined
|
|
@@ -894,6 +1226,7 @@ export class LocalTui {
|
|
|
894
1226
|
...info.controls,
|
|
895
1227
|
...(info.controls.plan === undefined ? {} : { plan: { ...info.controls.plan } }),
|
|
896
1228
|
};
|
|
1229
|
+
this.#syncSettingsActions();
|
|
897
1230
|
if (!this.#tty)
|
|
898
1231
|
return;
|
|
899
1232
|
this.#syncWindowTitle();
|
|
@@ -904,30 +1237,50 @@ export class LocalTui {
|
|
|
904
1237
|
else
|
|
905
1238
|
this.#render();
|
|
906
1239
|
}
|
|
907
|
-
/**
|
|
908
|
-
* Mirror the folded session title into the terminal's own window/tab title.
|
|
909
|
-
* Control characters are stripped so a title can never inject an escape
|
|
910
|
-
* sequence, and the payload is bounded to keep tab strips readable.
|
|
911
|
-
*/
|
|
1240
|
+
/** Only the foreground terminal owner emits title and program-state sequences. */
|
|
912
1241
|
#syncWindowTitle() {
|
|
913
|
-
|
|
914
|
-
|
|
1242
|
+
if (!this.#tty || this.#disposed)
|
|
1243
|
+
return;
|
|
1244
|
+
const status = this.#herdr.programStatus;
|
|
1245
|
+
const title = terminalTitle(this.#terminalTitle, status, this.#sessionTitle, this.#cwd, this.#branch);
|
|
1246
|
+
if (title !== this.#writtenWindowTitle && !(this.#terminalTitle === 'off' && this.#writtenWindowTitle === undefined)) {
|
|
1247
|
+
this.#writtenWindowTitle = title;
|
|
1248
|
+
this.#term.output.write(`\x1b]2;${title}\x07`);
|
|
1249
|
+
}
|
|
1250
|
+
if (this.#terminalState === 'auto' && !this.#programQuerySent) {
|
|
1251
|
+
this.#programQuerySent = true;
|
|
1252
|
+
this.#term.output.write(PROGRAM_STATUS_QUERY);
|
|
1253
|
+
}
|
|
1254
|
+
const enabled = this.#terminalState === 'on' || this.#terminalState === 'auto' && this.#programSupported;
|
|
1255
|
+
if (!enabled) {
|
|
1256
|
+
if (this.#writtenProgramState !== undefined)
|
|
1257
|
+
this.#term.output.write(programStatusSequence('clear'));
|
|
1258
|
+
this.#writtenProgramState = undefined;
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
// Only the session name is exported as metadata; prompt bodies and errors stay in the TUI.
|
|
1262
|
+
const sequence = programStatusSequence({ state: status.state, ...(this.#sessionTitle === undefined ? {} : { message: this.#sessionTitle }) });
|
|
1263
|
+
if (sequence === this.#writtenProgramState)
|
|
915
1264
|
return;
|
|
916
|
-
this.#
|
|
917
|
-
this.#term.output.write(
|
|
1265
|
+
this.#writtenProgramState = sequence;
|
|
1266
|
+
this.#term.output.write(sequence);
|
|
918
1267
|
}
|
|
919
1268
|
/**
|
|
920
|
-
* Re-derive the
|
|
1269
|
+
* Re-derive the color depth from the active color preference. Every path
|
|
921
1270
|
* that changes `#colors` calls this so `/settings` takes effect immediately.
|
|
922
1271
|
*/
|
|
923
|
-
#
|
|
924
|
-
this.#
|
|
1272
|
+
#syncColorDepth() {
|
|
1273
|
+
this.#colorDepth = this.#colors ? detectColorDepth() : 'ansi16';
|
|
925
1274
|
}
|
|
926
1275
|
/** Apply prefs loaded from this entry's config (does not persist). */
|
|
927
1276
|
applyStoredPrefs(prefs) {
|
|
1277
|
+
if (this.#disposed)
|
|
1278
|
+
return;
|
|
1279
|
+
this.#prefsVersion++;
|
|
928
1280
|
this.#applyPrefs(prefs, { persist: false, forceToolsSync: true });
|
|
929
1281
|
if (this.#settings !== null)
|
|
930
1282
|
this.#settings = { ...this.#settings, prefs };
|
|
1283
|
+
this.#refreshSettingsCatalog();
|
|
931
1284
|
if (this.#tty)
|
|
932
1285
|
this.#render();
|
|
933
1286
|
}
|
|
@@ -939,32 +1292,27 @@ export class LocalTui {
|
|
|
939
1292
|
motion: this.#motion,
|
|
940
1293
|
mathMode: this.#mathMode,
|
|
941
1294
|
mermaidMode: this.#mermaidMode,
|
|
1295
|
+
hideThinking: this.#hideThinking,
|
|
942
1296
|
editor: this.#externalEditor,
|
|
943
|
-
|
|
1297
|
+
terminalTitle: this.#terminalTitle,
|
|
1298
|
+
terminalState: this.#terminalState,
|
|
944
1299
|
copyOnSelect: this.#copyOnSelect,
|
|
945
1300
|
mouseInteraction: this.#mouseInteraction,
|
|
946
1301
|
pasteProtection: this.#pasteProtection,
|
|
947
1302
|
checkUpdates: this.#checkUpdates,
|
|
948
1303
|
startupChangelog: this.#startupChangelog,
|
|
949
1304
|
notifications: this.#notificationPolicy,
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
groups: [...this.#statusBar.groups],
|
|
955
|
-
...(this.#statusBar.order === undefined ? {} : { order: [...this.#statusBar.order] }),
|
|
956
|
-
...(this.#statusBar.meta === undefined ? {} : { meta: [...this.#statusBar.meta] }),
|
|
957
|
-
...(this.#statusBar.metaOrder === undefined ? {} : { metaOrder: [...this.#statusBar.metaOrder] }),
|
|
958
|
-
...(this.#statusBar.colors === undefined ? {} : { colors: { ...this.#statusBar.colors } }),
|
|
959
|
-
...(this.#statusBar.sides === undefined ? {} : { sides: { ...this.#statusBar.sides } }),
|
|
960
|
-
},
|
|
1305
|
+
// The whole document, not the density alone: a projection that dropped the
|
|
1306
|
+
// narrowing made every later re-read of the preferences show the parts the
|
|
1307
|
+
// reader had just hidden.
|
|
1308
|
+
statusBar: statusBarDocument(this.#statusBar),
|
|
961
1309
|
};
|
|
962
1310
|
}
|
|
963
1311
|
/** Called after a live `/settings` change. */
|
|
964
1312
|
setPrefsPersist(persist) {
|
|
965
1313
|
this.#persistPrefs = persist;
|
|
966
1314
|
}
|
|
967
|
-
/** Bind
|
|
1315
|
+
/** Bind product configuration and Advanced forms; unbinding cancels and drains its active form. */
|
|
968
1316
|
bindPluginSettings(binding) {
|
|
969
1317
|
if (this.#pluginSettings !== undefined)
|
|
970
1318
|
throw new Error('omdsh-tui: Plugin settings are already bound');
|
|
@@ -975,8 +1323,7 @@ export class LocalTui {
|
|
|
975
1323
|
this.#pluginSettings = undefined;
|
|
976
1324
|
this.#pluginSettingsAbort?.abort();
|
|
977
1325
|
await this.#pluginSettingsTask;
|
|
978
|
-
|
|
979
|
-
this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, undefined, discoverEditors(this.#externalEditor));
|
|
1326
|
+
this.#rebuildSettings([]);
|
|
980
1327
|
if (this.#tty && !this.#disposed)
|
|
981
1328
|
this.#render();
|
|
982
1329
|
};
|
|
@@ -998,8 +1345,7 @@ export class LocalTui {
|
|
|
998
1345
|
this.#updateAgentBehavior = undefined;
|
|
999
1346
|
this.#agentBehavior = undefined;
|
|
1000
1347
|
this.#agentBehaviorPending = false;
|
|
1001
|
-
|
|
1002
|
-
this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, this.#pluginSettings?.entries(), discoverEditors(this.#externalEditor));
|
|
1348
|
+
this.#rebuildSettings(this.#pluginSettings?.entries() ?? []);
|
|
1003
1349
|
if (this.#tty && !this.#disposed)
|
|
1004
1350
|
this.#render();
|
|
1005
1351
|
};
|
|
@@ -1163,14 +1509,31 @@ export class LocalTui {
|
|
|
1163
1509
|
return () => { if (this.#steerHandler === handler)
|
|
1164
1510
|
this.#steerHandler = undefined; };
|
|
1165
1511
|
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Last-resort restore for a process that exits without disposing: leave the
|
|
1514
|
+
* alternate screen, give every mode back and show the cursor. Writes to a TTY
|
|
1515
|
+
* are synchronous, so this completes before the process ends.
|
|
1516
|
+
*/
|
|
1517
|
+
#restoreTerminalOnExit() {
|
|
1518
|
+
if (this.#disposed || !this.#tty)
|
|
1519
|
+
return;
|
|
1520
|
+
try {
|
|
1521
|
+
this.#renderer.finish();
|
|
1522
|
+
}
|
|
1523
|
+
catch { /* best effort while exiting */ }
|
|
1524
|
+
this.#session.release();
|
|
1525
|
+
this.#term.output.write('\x1b[?25h');
|
|
1526
|
+
}
|
|
1166
1527
|
dispose() {
|
|
1167
1528
|
if (this.#disposed)
|
|
1168
|
-
return this.#herdr.release();
|
|
1529
|
+
return this.#disposeTask ?? this.#herdr.release();
|
|
1169
1530
|
this.#flushPasteBurst(true);
|
|
1170
1531
|
this.#flushDraft();
|
|
1171
1532
|
this.#disposed = true;
|
|
1172
1533
|
this.#steerHandler = undefined;
|
|
1173
1534
|
this.#imagePreviewAbort?.abort();
|
|
1535
|
+
this.#fileReviewAbort?.abort();
|
|
1536
|
+
this.#asideAbort?.abort();
|
|
1174
1537
|
for (const directory of this.#imageTemporary) {
|
|
1175
1538
|
try {
|
|
1176
1539
|
rmSync(directory, { recursive: true, force: true });
|
|
@@ -1200,7 +1563,6 @@ export class LocalTui {
|
|
|
1200
1563
|
this.#tick = null;
|
|
1201
1564
|
}
|
|
1202
1565
|
this.#stopRevealTick();
|
|
1203
|
-
this.#setTerminalProgress(false);
|
|
1204
1566
|
if (this.#loopTick !== null) {
|
|
1205
1567
|
clearInterval(this.#loopTick);
|
|
1206
1568
|
this.#loopTick = null;
|
|
@@ -1220,7 +1582,13 @@ export class LocalTui {
|
|
|
1220
1582
|
this.#term.output.write('\x1b]2;\x07');
|
|
1221
1583
|
this.#writtenWindowTitle = undefined;
|
|
1222
1584
|
}
|
|
1223
|
-
this.#
|
|
1585
|
+
if (this.#writtenProgramState !== undefined)
|
|
1586
|
+
this.#term.output.write(programStatusSequence('clear'));
|
|
1587
|
+
this.#writtenProgramState = undefined;
|
|
1588
|
+
this.#offExit?.();
|
|
1589
|
+
this.#offExit = undefined;
|
|
1590
|
+
this.#session.dispose();
|
|
1591
|
+
this.#term.output.write('\x1b[?25h\r\n');
|
|
1224
1592
|
if (this.#resumeHintRequested && this.#sessionId !== undefined) {
|
|
1225
1593
|
this.#term.output.write(`\r\nResume this session with ${APP_NAME} --resume ${this.#sessionId}\r\n`);
|
|
1226
1594
|
}
|
|
@@ -1230,6 +1598,10 @@ export class LocalTui {
|
|
|
1230
1598
|
}
|
|
1231
1599
|
if (this.#escapeTimer !== null)
|
|
1232
1600
|
clearTimeout(this.#escapeTimer);
|
|
1601
|
+
if (this.#transientTimer !== null) {
|
|
1602
|
+
clearTimeout(this.#transientTimer);
|
|
1603
|
+
this.#transientTimer = null;
|
|
1604
|
+
}
|
|
1233
1605
|
if (this.#autocompleteTimer !== null)
|
|
1234
1606
|
clearTimeout(this.#autocompleteTimer);
|
|
1235
1607
|
this.#autocompleteAbort?.abort();
|
|
@@ -1240,7 +1612,8 @@ export class LocalTui {
|
|
|
1240
1612
|
this.#offAgentBehaviorWatch = undefined;
|
|
1241
1613
|
this.#settlePending(null);
|
|
1242
1614
|
this.#finishPrompt(null);
|
|
1243
|
-
|
|
1615
|
+
this.#disposeTask = Promise.all([this.#herdr.release(), this.#prefsSaveTask]).then(() => { });
|
|
1616
|
+
return this.#disposeTask;
|
|
1244
1617
|
}
|
|
1245
1618
|
/** Settle one pending read, detaching its abort listener. */
|
|
1246
1619
|
#settlePending(submission) {
|
|
@@ -1262,11 +1635,30 @@ export class LocalTui {
|
|
|
1262
1635
|
return true;
|
|
1263
1636
|
return this.#subagents?.agents.some(agent => agent.phase === 'running' || agent.phase === 'starting') === true;
|
|
1264
1637
|
}
|
|
1638
|
+
/** Countdown shown on the working row while a retry or a compaction is pending. */
|
|
1639
|
+
#workingLabel() {
|
|
1640
|
+
if (this.#retryDeadline !== undefined) {
|
|
1641
|
+
return `Retrying in ${Math.max(0, Math.ceil((this.#retryDeadline - Date.now()) / 1000))}s`;
|
|
1642
|
+
}
|
|
1643
|
+
if (this.#state.status === 'compacting' && this.#compactionStartedAt !== undefined) {
|
|
1644
|
+
return `Compacting ${Math.max(0, Math.floor((Date.now() - this.#compactionStartedAt) / 1000))}s`;
|
|
1645
|
+
}
|
|
1646
|
+
return undefined;
|
|
1647
|
+
}
|
|
1648
|
+
/** The newest image the transcript carries, whichever side produced it. */
|
|
1649
|
+
#latestImageRef() {
|
|
1650
|
+
for (let index = this.#state.blocks.length - 1; index >= 0; index -= 1) {
|
|
1651
|
+
const block = this.#state.blocks[index];
|
|
1652
|
+
const image = block.kind === 'tool' || block.kind === 'user' ? block.image : undefined;
|
|
1653
|
+
if (image !== undefined)
|
|
1654
|
+
return image;
|
|
1655
|
+
}
|
|
1656
|
+
return undefined;
|
|
1657
|
+
}
|
|
1265
1658
|
#syncTick() {
|
|
1266
1659
|
if (!this.#tty)
|
|
1267
1660
|
return;
|
|
1268
1661
|
const busy = this.#busy();
|
|
1269
|
-
this.#setTerminalProgress(this.#terminalProgress && busy);
|
|
1270
1662
|
if (busy && this.#motion !== 'off') {
|
|
1271
1663
|
if (this.#tick === null) {
|
|
1272
1664
|
this.#tick = setInterval(() => {
|
|
@@ -1323,31 +1715,6 @@ export class LocalTui {
|
|
|
1323
1715
|
clearInterval(this.#revealTick);
|
|
1324
1716
|
this.#revealTick = null;
|
|
1325
1717
|
}
|
|
1326
|
-
#setTerminalProgress(active) {
|
|
1327
|
-
if (!this.#tty)
|
|
1328
|
-
return;
|
|
1329
|
-
if (active) {
|
|
1330
|
-
if (this.#terminalProgressActive)
|
|
1331
|
-
return;
|
|
1332
|
-
this.#terminalProgressActive = true;
|
|
1333
|
-
this.#term.output.write(terminalProgressSequence(true));
|
|
1334
|
-
this.#terminalProgressTick = setInterval(() => {
|
|
1335
|
-
if (!this.#disposed && this.#terminalProgressActive) {
|
|
1336
|
-
this.#term.output.write(terminalProgressSequence(true));
|
|
1337
|
-
}
|
|
1338
|
-
}, TERMINAL_PROGRESS_KEEPALIVE_MS);
|
|
1339
|
-
this.#terminalProgressTick.unref?.();
|
|
1340
|
-
return;
|
|
1341
|
-
}
|
|
1342
|
-
if (this.#terminalProgressTick !== null) {
|
|
1343
|
-
clearInterval(this.#terminalProgressTick);
|
|
1344
|
-
this.#terminalProgressTick = null;
|
|
1345
|
-
}
|
|
1346
|
-
if (!this.#terminalProgressActive)
|
|
1347
|
-
return;
|
|
1348
|
-
this.#terminalProgressActive = false;
|
|
1349
|
-
this.#term.output.write(terminalProgressSequence(false));
|
|
1350
|
-
}
|
|
1351
1718
|
#syncLoopTick() {
|
|
1352
1719
|
if (!this.#tty)
|
|
1353
1720
|
return;
|
|
@@ -1466,10 +1833,14 @@ export class LocalTui {
|
|
|
1466
1833
|
}
|
|
1467
1834
|
if (this.#deferInitialRender)
|
|
1468
1835
|
return;
|
|
1836
|
+
// Rows committed to scrollback are immutable; the first real paint waits for the probe so it already uses the reported colors.
|
|
1837
|
+
if (this.#probeGate)
|
|
1838
|
+
return;
|
|
1469
1839
|
const width = this.#term.width();
|
|
1470
1840
|
const renderState = this.#reveal === undefined
|
|
1471
1841
|
? this.#state
|
|
1472
1842
|
: revealStreamingAssistant(this.#state, this.#reveal.revealed);
|
|
1843
|
+
const workingLabel = this.#workingLabel();
|
|
1473
1844
|
const frame = this.#tty
|
|
1474
1845
|
? renderView(renderState, {
|
|
1475
1846
|
width,
|
|
@@ -1477,6 +1848,7 @@ export class LocalTui {
|
|
|
1477
1848
|
imageCellSize: this.#imageCellSize,
|
|
1478
1849
|
mathMode: this.#mathMode,
|
|
1479
1850
|
mermaidMode: this.#mermaidMode,
|
|
1851
|
+
hideThinking: this.#hideThinking,
|
|
1480
1852
|
height: this.#term.height(),
|
|
1481
1853
|
model: this.#model,
|
|
1482
1854
|
...(this.#reasoningEffort === undefined ? {} : { reasoningEffort: this.#reasoningEffort }),
|
|
@@ -1492,46 +1864,47 @@ export class LocalTui {
|
|
|
1492
1864
|
...(this.#sessionTitle === undefined ? {} : { sessionTitle: this.#sessionTitle }),
|
|
1493
1865
|
version: APP_VERSION,
|
|
1494
1866
|
appName: APP_NAME,
|
|
1867
|
+
displayName: APP_DISPLAY_NAME,
|
|
1495
1868
|
spinnerFrame: this.#spinner,
|
|
1496
1869
|
motion: this.#motion,
|
|
1497
|
-
|
|
1870
|
+
colorDepth: this.#colorDepth,
|
|
1498
1871
|
themeName: this.#themeName,
|
|
1872
|
+
terminal: this.#terminalColors,
|
|
1873
|
+
...(workingLabel === undefined ? {} : { workingLabel }),
|
|
1874
|
+
...(this.#transientStatus === undefined ? {} : { transientStatus: this.#transientStatus }),
|
|
1499
1875
|
scrollStart: this.#follow ? Number.POSITIVE_INFINITY : this.#scrollStart,
|
|
1500
1876
|
...(this.#focusBlock === undefined ? {} : { focusBlock: this.#focusBlock, focusBlockEdge: this.#focusBlockEdge ?? 'start' }),
|
|
1501
1877
|
...(this.#transcriptSearch === null ? {} : {
|
|
1502
1878
|
transcriptSearch: {
|
|
1503
1879
|
query: this.#transcriptSearch.query,
|
|
1504
|
-
matches: searchBlockIndexes(this.#state.blocks.map(blockSearchText), this.#transcriptSearch.query),
|
|
1505
1880
|
focus: this.#transcriptSearch.focus,
|
|
1506
1881
|
editing: this.#transcriptSearch.editing,
|
|
1507
1882
|
},
|
|
1508
1883
|
}),
|
|
1509
1884
|
toolsExpanded: this.#toolsExpanded,
|
|
1885
|
+
expandedOutputs: this.#expandedOutputs,
|
|
1510
1886
|
expandedTools: this.#detailedToolIds(),
|
|
1511
1887
|
toolDetailsKey: keysForAction(this.#keybindings, 'toggle-tool-details'),
|
|
1512
1888
|
turnDetailsKey: keysForAction(this.#keybindings, 'toggle-tools'),
|
|
1513
1889
|
...(this.#steerHandler === undefined ? {} : { steerKey: keysForAction(this.#keybindings, 'steer-turn') }),
|
|
1514
1890
|
openedGroups: this.#openedGroups,
|
|
1515
1891
|
commands: this.#commands(),
|
|
1516
|
-
|
|
1517
|
-
welcomeTips: this.#welcomeTips,
|
|
1892
|
+
modelReady: this.#modelReady,
|
|
1518
1893
|
...(this.#sessionStats === undefined ? {} : { sessionStats: this.#sessionStats }),
|
|
1519
1894
|
...(this.#sessionControls === undefined ? {} : { sessionControls: this.#sessionControls }),
|
|
1520
1895
|
...(this.#loopStatus === undefined ? {} : { loopStatus: this.#loopStatus }),
|
|
1521
1896
|
...(this.#subagents === undefined ? {} : { subagents: this.#subagents }),
|
|
1522
|
-
|
|
1897
|
+
...(this.#subagentSelection === undefined ? {} : { subagentSelection: this.#subagentSelection }),
|
|
1523
1898
|
...(this.#inspected === undefined ? {} : { inspected: this.#inspected }),
|
|
1524
1899
|
...(this.#trajectory === null ? {} : { trajectory: this.#trajectory }),
|
|
1525
|
-
...(this.#
|
|
1900
|
+
...(this.#aside === undefined ? {} : { aside: this.#aside }),
|
|
1526
1901
|
statusBar: this.#statusBar,
|
|
1527
1902
|
...(this.#prompt === null ? {} : { promptSelector: this.#prompt }),
|
|
1528
1903
|
...(this.#settings !== null
|
|
1529
1904
|
? { settings: this.#settings }
|
|
1530
|
-
: this.#
|
|
1531
|
-
? {
|
|
1532
|
-
: this.#
|
|
1533
|
-
? { historySearch: this.#search }
|
|
1534
|
-
: this.#ac !== null ? { autocomplete: this.#ac } : {}),
|
|
1905
|
+
: this.#search !== null
|
|
1906
|
+
? { historySearch: this.#search }
|
|
1907
|
+
: this.#ac !== null ? { autocomplete: this.#ac } : {}),
|
|
1535
1908
|
})
|
|
1536
1909
|
: { lines: [] };
|
|
1537
1910
|
this.#focusBlock = undefined;
|
|
@@ -1542,8 +1915,9 @@ export class LocalTui {
|
|
|
1542
1915
|
}
|
|
1543
1916
|
this.#syncScroll(frame.transcript);
|
|
1544
1917
|
this.#jumpToLatest = frame.jumpToLatest;
|
|
1918
|
+
this.#fileActionFrame = frame.transcript === undefined || frame.transientSurface === 'overlay' ? undefined : frame;
|
|
1545
1919
|
this.#setMouseTracking(frame.transcript !== undefined && frame.transientSurface !== 'overlay'
|
|
1546
|
-
&& (this.#inspecting || this.#maxStart > 0));
|
|
1920
|
+
&& (this.#inspecting || this.#maxStart > 0 || (frame.transcript.fileRegions?.length ?? 0) > 0));
|
|
1547
1921
|
// A run opening or folding reshapes rows above the screen in place; the
|
|
1548
1922
|
// renderer's frozen boundary is a row index and has to be told, or a fold
|
|
1549
1923
|
// blanks the screen and a large one replays the transcript into history.
|
|
@@ -1634,7 +2008,27 @@ export class LocalTui {
|
|
|
1634
2008
|
if (!this.#tty || enabled === this.#mouseTracking)
|
|
1635
2009
|
return;
|
|
1636
2010
|
this.#mouseTracking = enabled;
|
|
1637
|
-
this.#
|
|
2011
|
+
this.#session.setMode('mouse', MOUSE_TRACKING, enabled);
|
|
2012
|
+
}
|
|
2013
|
+
/** Give every terminal mode back before the terminal is lent out (Ctrl+Z, external editor). */
|
|
2014
|
+
#handOffTerminal() {
|
|
2015
|
+
this.#setMouseTracking(false);
|
|
2016
|
+
this.#session.handOff();
|
|
2017
|
+
}
|
|
2018
|
+
/** Run `run` with the terminal handed off; take it back and repaint afterwards. */
|
|
2019
|
+
#handTerminal(run) {
|
|
2020
|
+
this.#handOffTerminal();
|
|
2021
|
+
this.#term.input.setRawMode?.(false);
|
|
2022
|
+
try {
|
|
2023
|
+
return run();
|
|
2024
|
+
}
|
|
2025
|
+
finally {
|
|
2026
|
+
if (!this.#disposed) {
|
|
2027
|
+
this.#term.input.setRawMode?.(true);
|
|
2028
|
+
this.#session.takeBack();
|
|
2029
|
+
this.#renderer.reset();
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
1638
2032
|
}
|
|
1639
2033
|
/**
|
|
1640
2034
|
* After a Ctrl+O toggle: read what is open in a browsing frame at `target`,
|
|
@@ -1680,14 +2074,14 @@ export class LocalTui {
|
|
|
1680
2074
|
this.#routeInput(event);
|
|
1681
2075
|
}, 80);
|
|
1682
2076
|
}
|
|
1683
|
-
else if (rest.length > 32) {
|
|
2077
|
+
else if (rest.length > 32 && !rest.startsWith('\x1b]')) {
|
|
1684
2078
|
this.#pendingKeys = '';
|
|
1685
2079
|
}
|
|
1686
2080
|
}
|
|
1687
2081
|
#routeInputBatch(events) {
|
|
1688
2082
|
if (this.#pasteProtection && this.#state.status !== 'compacting' && !this.#paste && this.#pasteInFlight === 0
|
|
1689
|
-
&& this.#prompt === null && this.#settings === null
|
|
1690
|
-
&& this.#search === null && this.#trajectory === null && this.#
|
|
2083
|
+
&& this.#prompt === null && this.#settings === null
|
|
2084
|
+
&& this.#search === null && this.#trajectory === null && this.#transcriptSearch === null
|
|
1691
2085
|
&& events.every(event => event.type === 'text' || (event.type === 'key' && ['enter', 'ctrl+j', 'tab'].includes(event.id)))) {
|
|
1692
2086
|
if (this.#pasteBurstTimer !== undefined)
|
|
1693
2087
|
clearTimeout(this.#pasteBurstTimer);
|
|
@@ -1710,8 +2104,8 @@ export class LocalTui {
|
|
|
1710
2104
|
this.#dispatch(event);
|
|
1711
2105
|
return;
|
|
1712
2106
|
}
|
|
1713
|
-
const composer = this.#state.status !== 'compacting' && this.#prompt === null && this.#settings === null
|
|
1714
|
-
&& this.#search === null && this.#trajectory === null && this.#
|
|
2107
|
+
const composer = this.#state.status !== 'compacting' && this.#prompt === null && this.#settings === null
|
|
2108
|
+
&& this.#search === null && this.#trajectory === null && this.#transcriptSearch === null;
|
|
1715
2109
|
if (this.#paste || this.#pasteInFlight > 0 || !this.#pasteProtection || !composer
|
|
1716
2110
|
|| event.type === 'paste-start' || event.type === 'paste-end') {
|
|
1717
2111
|
this.#flushPasteBurst(true);
|
|
@@ -1728,6 +2122,7 @@ export class LocalTui {
|
|
|
1728
2122
|
}
|
|
1729
2123
|
#deliverPasteEmission(event) {
|
|
1730
2124
|
if (event.type === 'paste') {
|
|
2125
|
+
this.#literalInput ||= this.#editor.cursor === 0 && event.value.startsWith('!');
|
|
1731
2126
|
this.#editor.paste(event.value.replaceAll('\r\n', '\n').replaceAll('\r', '\n'));
|
|
1732
2127
|
this.#refreshAutocomplete();
|
|
1733
2128
|
this.#render();
|
|
@@ -1763,7 +2158,7 @@ export class LocalTui {
|
|
|
1763
2158
|
async #acceptPastedText(text) {
|
|
1764
2159
|
if (this.#prompt !== null) {
|
|
1765
2160
|
const prompt = this.#prompt;
|
|
1766
|
-
if (prompt.
|
|
2161
|
+
if (prompt.documentInput !== undefined) {
|
|
1767
2162
|
this.#handleDocumentReader({ type: 'text', value: text }, prompt);
|
|
1768
2163
|
return;
|
|
1769
2164
|
}
|
|
@@ -1776,7 +2171,7 @@ export class LocalTui {
|
|
|
1776
2171
|
this.#render();
|
|
1777
2172
|
return;
|
|
1778
2173
|
}
|
|
1779
|
-
if (this.#settings !== null
|
|
2174
|
+
if (this.#settings !== null)
|
|
1780
2175
|
return;
|
|
1781
2176
|
if (this.#search !== null) {
|
|
1782
2177
|
this.#applySearch({ type: 'text', value: text });
|
|
@@ -1810,12 +2205,13 @@ export class LocalTui {
|
|
|
1810
2205
|
return;
|
|
1811
2206
|
}
|
|
1812
2207
|
}
|
|
2208
|
+
this.#literalInput ||= this.#editor.cursor === 0 && text.startsWith('!');
|
|
1813
2209
|
this.#editor.paste(text);
|
|
1814
2210
|
this.#refreshAutocomplete();
|
|
1815
2211
|
this.#render();
|
|
1816
2212
|
}
|
|
1817
2213
|
async #pasteClipboard() {
|
|
1818
|
-
if (this.#search === null && this.#settings === null
|
|
2214
|
+
if (this.#search === null && this.#settings === null) {
|
|
1819
2215
|
const image = await this.#readClipboardImage();
|
|
1820
2216
|
if (image !== null) {
|
|
1821
2217
|
if (await this.#images.admit(image)) {
|
|
@@ -1863,8 +2259,27 @@ export class LocalTui {
|
|
|
1863
2259
|
return;
|
|
1864
2260
|
const inside = (target) => event.row === target.row && event.column >= target.column && event.column < target.column + target.width;
|
|
1865
2261
|
if (event.button === 'right') {
|
|
1866
|
-
if (event.action === 'press')
|
|
1867
|
-
|
|
2262
|
+
if (event.action === 'press') {
|
|
2263
|
+
if (this.#selection.active)
|
|
2264
|
+
void this.#copySelection();
|
|
2265
|
+
else {
|
|
2266
|
+
const document = this.#renderer.documentRowAt(event.row);
|
|
2267
|
+
const frame = this.#fileActionFrame;
|
|
2268
|
+
const source = frame?.documentRows;
|
|
2269
|
+
const region = document === undefined ? undefined : frame?.transcript?.fileRegions?.find(region => document >= region.start && document < region.end);
|
|
2270
|
+
const expected = document === undefined || source === undefined || source === null ? undefined
|
|
2271
|
+
: frame?.lines[source.frameStart + document - source.documentStart];
|
|
2272
|
+
const actual = this.#renderer.viewport()[event.row];
|
|
2273
|
+
// Native-history snapshots may differ from the current document. Do
|
|
2274
|
+
// not reinterpret their old text as another file after a reflow.
|
|
2275
|
+
if (region !== undefined && expected !== undefined && actual !== undefined && stripAnsi(sanitizeDisplayLine(expected)).trimEnd() === stripAnsi(actual).trimEnd()) {
|
|
2276
|
+
const block = this.#state.blocks[region.block];
|
|
2277
|
+
const action = block === undefined ? undefined : blockFileAction(block);
|
|
2278
|
+
if (action !== undefined)
|
|
2279
|
+
this.#openFileAction(action, region.file);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
1868
2283
|
return;
|
|
1869
2284
|
}
|
|
1870
2285
|
if (event.action === 'press') {
|
|
@@ -1921,16 +2336,45 @@ export class LocalTui {
|
|
|
1921
2336
|
this.notice('Could not copy selection to the clipboard.', { level: 'error' });
|
|
1922
2337
|
}
|
|
1923
2338
|
}
|
|
2339
|
+
#openFileAction(action, initial) {
|
|
2340
|
+
if (!this.#tty || this.#disposed || this.#prompt !== null || this.#fileReviewAbort !== undefined)
|
|
2341
|
+
return;
|
|
2342
|
+
const abort = new AbortController();
|
|
2343
|
+
this.#fileReviewAbort = abort;
|
|
2344
|
+
this.#selection.clear();
|
|
2345
|
+
const surface = {
|
|
2346
|
+
prompt: (request) => this.prompt({ ...request, onSuperseded: () => { abort.abort(); } }),
|
|
2347
|
+
notice: (text, options) => { this.notice(text, options); },
|
|
2348
|
+
openFileInEditor: (path) => { this.openFileInEditor(path); },
|
|
2349
|
+
insertText: (text) => { this.insertText(text); },
|
|
2350
|
+
};
|
|
2351
|
+
void reviewFiles(surface, action.title, action.cwd, action.files, index => fileDocument(action.files[index].path, abort.signal), abort.signal, initial ?? (action.files.length === 1 ? 0 : undefined), false)
|
|
2352
|
+
.catch(error => { if (!abort.signal.aborted && !this.#disposed)
|
|
2353
|
+
this.notice(`Could not preview file: ${error instanceof Error ? error.message : String(error)}`, { level: 'error' }); })
|
|
2354
|
+
.finally(() => { if (this.#fileReviewAbort === abort)
|
|
2355
|
+
this.#fileReviewAbort = undefined; });
|
|
2356
|
+
}
|
|
1924
2357
|
#dispatch(event) {
|
|
2358
|
+
// Any input makes the last confirmation stale; a note that survives typing
|
|
2359
|
+
// starts competing with the command the reader is writing.
|
|
2360
|
+
if (event.type === 'key' || event.type === 'text')
|
|
2361
|
+
this.#clearTransientStatus();
|
|
1925
2362
|
if (event.type === 'cell-size') {
|
|
1926
2363
|
this.#imageCellSize = { width: event.width, height: event.height };
|
|
1927
2364
|
if (this.#prompt?.request.documentImage !== undefined)
|
|
1928
2365
|
this.#render();
|
|
1929
2366
|
return;
|
|
1930
2367
|
}
|
|
2368
|
+
if (event.type === 'program-status-support') {
|
|
2369
|
+
this.#programSupported = true;
|
|
2370
|
+
this.#syncWindowTitle();
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
if (this.#session.handle(event))
|
|
2374
|
+
return;
|
|
1931
2375
|
if (event.type === 'focus') {
|
|
1932
2376
|
this.#terminalFocused = event.focused;
|
|
1933
|
-
if (event.focused && this.#
|
|
2377
|
+
if (event.focused && this.#notificationPolicy === 'unfocused')
|
|
1934
2378
|
this.#notificationQueue.clear();
|
|
1935
2379
|
return;
|
|
1936
2380
|
}
|
|
@@ -1954,7 +2398,10 @@ export class LocalTui {
|
|
|
1954
2398
|
if (event.type === 'wheel') {
|
|
1955
2399
|
this.#jumpPress = undefined;
|
|
1956
2400
|
if (this.#mouseTracking && !this.#paste && this.#pasteInFlight === 0) {
|
|
1957
|
-
this.#
|
|
2401
|
+
const step = wheelStep(this.#wheelGesture, Date.now(), event.direction === 'up' ? -1 : 1, !this.#wheelAccelerates);
|
|
2402
|
+
this.#wheelGesture = step.gesture;
|
|
2403
|
+
if (step.lines !== 0)
|
|
2404
|
+
this.#scrollBy(step.lines);
|
|
1958
2405
|
}
|
|
1959
2406
|
return;
|
|
1960
2407
|
}
|
|
@@ -2020,7 +2467,7 @@ export class LocalTui {
|
|
|
2020
2467
|
if (this.#handlePrompt(event))
|
|
2021
2468
|
return;
|
|
2022
2469
|
if (this.#trajectory !== null) {
|
|
2023
|
-
const { pageSize } = trajectoryListMetrics(this.#trajectory, this.#term.height());
|
|
2470
|
+
const { pageSize } = trajectoryListMetrics(this.#trajectory, this.#term.height(), this.#term.width());
|
|
2024
2471
|
const { pageLines } = trajectoryDetailMetrics(this.#trajectory, this.#term.height());
|
|
2025
2472
|
const command = applyTrajectoryEvent(this.#trajectory, event, { pageSize, detailPageLines: pageLines });
|
|
2026
2473
|
if ('close' in command)
|
|
@@ -2030,25 +2477,12 @@ export class LocalTui {
|
|
|
2030
2477
|
this.#render();
|
|
2031
2478
|
return;
|
|
2032
2479
|
}
|
|
2033
|
-
if (this.#agentHub !== null) {
|
|
2034
|
-
const command = applyAgentHubEvent(this.#agentHub, event);
|
|
2035
|
-
if ('close' in command)
|
|
2036
|
-
this.#agentHub = null;
|
|
2037
|
-
else if ('open' in command) {
|
|
2038
|
-
this.#agentHub = null;
|
|
2039
|
-
this.#openInspect(command.open);
|
|
2040
|
-
}
|
|
2041
|
-
else
|
|
2042
|
-
this.#agentHub = command.state;
|
|
2043
|
-
this.#render();
|
|
2044
|
-
return;
|
|
2045
|
-
}
|
|
2046
2480
|
if (this.#transcriptSearch !== null) {
|
|
2047
2481
|
this.#applyTranscriptSearch(event);
|
|
2048
2482
|
return;
|
|
2049
2483
|
}
|
|
2050
2484
|
if (event.type === 'key' && event.id === 'end' && this.#jumpToLatest !== undefined
|
|
2051
|
-
&& this.#settings === null && this.#
|
|
2485
|
+
&& this.#settings === null && this.#search === null) {
|
|
2052
2486
|
this.#followTail();
|
|
2053
2487
|
this.#render();
|
|
2054
2488
|
return;
|
|
@@ -2059,60 +2493,31 @@ export class LocalTui {
|
|
|
2059
2493
|
return;
|
|
2060
2494
|
}
|
|
2061
2495
|
if (this.#inspected !== undefined && this.#inspected.writable !== true && event.type === 'text'
|
|
2062
|
-
&& this.#prompt === null && this.#settings === null && this.#
|
|
2063
|
-
return;
|
|
2064
|
-
}
|
|
2065
|
-
if (event.type === 'key' && event.id === 'ctrl+c') {
|
|
2066
|
-
if (this.#prompt !== null) {
|
|
2067
|
-
this.#finishPrompt(null);
|
|
2068
|
-
this.#editor.setText('');
|
|
2069
|
-
this.#render();
|
|
2070
|
-
return;
|
|
2071
|
-
}
|
|
2072
|
-
if (this.#settings !== null) {
|
|
2073
|
-
this.#settings = null;
|
|
2074
|
-
this.#render();
|
|
2075
|
-
return;
|
|
2076
|
-
}
|
|
2077
|
-
if (this.#copySelector !== null) {
|
|
2078
|
-
this.#copySelector = null;
|
|
2079
|
-
this.#render();
|
|
2080
|
-
return;
|
|
2081
|
-
}
|
|
2082
|
-
if (this.#search !== null) {
|
|
2083
|
-
this.#search = null;
|
|
2084
|
-
this.#render();
|
|
2085
|
-
return;
|
|
2086
|
-
}
|
|
2087
|
-
const now = Date.now();
|
|
2088
|
-
if (now - this.#lastSigintTime < DOUBLE_CTRL_C_MS) {
|
|
2089
|
-
this.#lastSigintTime = 0;
|
|
2090
|
-
this.#quit();
|
|
2091
|
-
return;
|
|
2092
|
-
}
|
|
2093
|
-
this.#lastSigintTime = now;
|
|
2094
|
-
if (this.#state.status !== 'idle') {
|
|
2095
|
-
for (const listener of this.#interrupts)
|
|
2096
|
-
listener();
|
|
2097
|
-
}
|
|
2098
|
-
else {
|
|
2099
|
-
this.#literalInput = false;
|
|
2100
|
-
this.#editor.clear();
|
|
2101
|
-
this.#images.clear();
|
|
2102
|
-
this.#files.clear();
|
|
2103
|
-
this.#historyIndex = 0;
|
|
2104
|
-
this.#clearDraft();
|
|
2105
|
-
this.#ac = null;
|
|
2106
|
-
this.#render();
|
|
2107
|
-
}
|
|
2496
|
+
&& this.#prompt === null && this.#settings === null && this.#search === null) {
|
|
2108
2497
|
return;
|
|
2109
2498
|
}
|
|
2110
2499
|
if (this.#settings !== null) {
|
|
2111
2500
|
this.#applySettings(event);
|
|
2112
2501
|
return;
|
|
2113
2502
|
}
|
|
2114
|
-
|
|
2115
|
-
|
|
2503
|
+
/**
|
|
2504
|
+
* The side pane is a companion, not a mode: it answers where the reader is
|
|
2505
|
+
* already working, so it must not take keys the composer is using. It reads
|
|
2506
|
+
* navigation only while the composer is empty and nothing else is offering a
|
|
2507
|
+
* choice — a draft, an autocomplete list, or historical recall all keep their
|
|
2508
|
+
* arrows, and Escape keeps closing whatever is actually on top.
|
|
2509
|
+
*/
|
|
2510
|
+
if (this.#aside !== undefined && this.#asideOwnsKeys() && event.type === 'key'
|
|
2511
|
+
&& (event.id === 'up' || event.id === 'down' || event.id === 'pageUp' || event.id === 'pageDown')) {
|
|
2512
|
+
const step = event.id === 'pageUp' || event.id === 'pageDown' ? 6 : 1;
|
|
2513
|
+
const delta = event.id === 'up' || event.id === 'pageUp' ? -step : step;
|
|
2514
|
+
this.#aside = { ...this.#aside, scroll: Math.max(0, this.#aside.scroll + delta) };
|
|
2515
|
+
this.#render();
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
if (this.#aside !== undefined && event.type === 'key' && event.id === 'escape'
|
|
2519
|
+
&& this.#editor.text === '' && this.#ac === null) {
|
|
2520
|
+
this.#closeAside();
|
|
2116
2521
|
return;
|
|
2117
2522
|
}
|
|
2118
2523
|
if (this.#search !== null) {
|
|
@@ -2126,92 +2531,210 @@ export class LocalTui {
|
|
|
2126
2531
|
}
|
|
2127
2532
|
if (this.#handleSubagentLauncher(event))
|
|
2128
2533
|
return;
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
this.#files.clear();
|
|
2135
|
-
this.#historyIndex = 0;
|
|
2136
|
-
this.#literalInput = false;
|
|
2137
|
-
this.#clearDraft();
|
|
2138
|
-
this.#ac = null;
|
|
2139
|
-
this.#render();
|
|
2140
|
-
return;
|
|
2141
|
-
}
|
|
2142
|
-
this.#closeInspect();
|
|
2143
|
-
return;
|
|
2144
|
-
}
|
|
2145
|
-
if (this.#state.status !== 'idle' || this.#pending === null
|
|
2146
|
-
|| this.#editor.text !== '' || this.#images.count > 0) {
|
|
2147
|
-
this.#lastEscapeTime = 0;
|
|
2148
|
-
}
|
|
2149
|
-
else {
|
|
2150
|
-
const now = Date.now();
|
|
2151
|
-
if (now - this.#lastEscapeTime < DOUBLE_ESCAPE_MS) {
|
|
2152
|
-
this.#lastEscapeTime = 0;
|
|
2153
|
-
for (const listener of this.#rewinds)
|
|
2154
|
-
listener();
|
|
2155
|
-
}
|
|
2156
|
-
else {
|
|
2157
|
-
this.#lastEscapeTime = now;
|
|
2158
|
-
}
|
|
2159
|
-
return;
|
|
2160
|
-
}
|
|
2161
|
-
}
|
|
2534
|
+
// Escape is the one key that gives back exactly one layer, so it is resolved
|
|
2535
|
+
// here — after the overlays and the autocomplete have had their turn — and
|
|
2536
|
+
// not with the other actions above.
|
|
2537
|
+
if (event.type === 'key' && this.#boundAction(event.id) === 'interrupt' && this.#runInterrupt())
|
|
2538
|
+
return;
|
|
2162
2539
|
if (event.type === 'key' && (event.id === 'backspace' || event.id === 'delete')
|
|
2163
2540
|
&& this.#images.removeAtCursor(event.id)) {
|
|
2164
2541
|
this.#refreshAutocomplete();
|
|
2165
2542
|
this.#render();
|
|
2166
2543
|
return;
|
|
2167
2544
|
}
|
|
2545
|
+
if (event.type === 'key' && (event.id === 'ctrl+-' || event.id === 'ctrl+_')
|
|
2546
|
+
&& this.#clearedDraft !== undefined && this.#clearedDraft.revision === this.#editor.revision
|
|
2547
|
+
&& !this.#hasComposerContent()) {
|
|
2548
|
+
const cleared = this.#clearedDraft.draft;
|
|
2549
|
+
this.#clearedDraft = undefined;
|
|
2550
|
+
this.#restoreDraft(cleared);
|
|
2551
|
+
this.#historyIndex = 0;
|
|
2552
|
+
this.#refreshAutocomplete();
|
|
2553
|
+
this.#render();
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2168
2556
|
this.#applyCommand(this.#editor.handle(event));
|
|
2169
2557
|
}
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2558
|
+
/**
|
|
2559
|
+
* Resolve `interrupt` against whatever currently owns the screen.
|
|
2560
|
+
*
|
|
2561
|
+
* One action, one meaning — cancel what is happening — but the thing that is
|
|
2562
|
+
* happening depends on how deep the reader is. The order runs from the most
|
|
2563
|
+
* transient surface to the least, so Escape always gives back exactly one
|
|
2564
|
+
* layer instead of dropping the reader somewhere unrelated.
|
|
2565
|
+
*/
|
|
2566
|
+
#runInterrupt() {
|
|
2567
|
+
if (this.#prompt !== null) {
|
|
2568
|
+
if (this.#prompt.request.presentation === 'progress' || this.#prompt.request.interrupt === undefined) {
|
|
2569
|
+
this.#finishPrompt(null);
|
|
2570
|
+
return true;
|
|
2571
|
+
}
|
|
2572
|
+
const handled = this.#prompt.request.interrupt();
|
|
2177
2573
|
this.#finishPrompt(null);
|
|
2178
|
-
if (this.#state.status !== 'idle')
|
|
2574
|
+
if (handled !== true && this.#state.status !== 'idle')
|
|
2179
2575
|
for (const listener of this.#interrupts)
|
|
2180
2576
|
listener();
|
|
2181
|
-
this.#render();
|
|
2182
2577
|
return true;
|
|
2183
2578
|
}
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2579
|
+
// Escaping a child transcript returns to the parent, and a writable child
|
|
2580
|
+
// with a draft clears it first — the same order the reader expects from
|
|
2581
|
+
// any other depth.
|
|
2582
|
+
if (this.#inspected !== undefined) {
|
|
2583
|
+
if (this.#inspected.writable === true && this.#hasComposerContent()) {
|
|
2584
|
+
this.#clearComposerWithUndo();
|
|
2585
|
+
return true;
|
|
2586
|
+
}
|
|
2587
|
+
this.#closeInspect();
|
|
2188
2588
|
return true;
|
|
2189
2589
|
}
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
this.#prompt = prompt;
|
|
2198
|
-
this.#render();
|
|
2199
|
-
}
|
|
2200
|
-
if (takeTime && prompt.request.wait !== undefined)
|
|
2590
|
+
if (this.#state.status !== 'idle') {
|
|
2591
|
+
// Aborting a turn would strand whatever the user queued behind it. Pi
|
|
2592
|
+
// hands those drafts back first, so nothing typed during the turn is lost
|
|
2593
|
+
// to the abort that ends it.
|
|
2594
|
+
this.#restoreQueuedBeforeAbort();
|
|
2595
|
+
for (const listener of this.#interrupts)
|
|
2596
|
+
listener();
|
|
2201
2597
|
return true;
|
|
2202
|
-
|
|
2203
|
-
|
|
2598
|
+
}
|
|
2599
|
+
if (!this.#hasComposerContent()) {
|
|
2600
|
+
// Nothing to cancel and nothing typed: the second Escape opens the session
|
|
2601
|
+
// tree, which is where a reader goes when they want to leave this point.
|
|
2602
|
+
const now = Date.now();
|
|
2603
|
+
if (now - this.#lastEscapeTime < DOUBLE_ESCAPE_MS) {
|
|
2604
|
+
this.#lastEscapeTime = 0;
|
|
2605
|
+
for (const listener of this.#rewinds)
|
|
2606
|
+
listener();
|
|
2607
|
+
}
|
|
2608
|
+
else {
|
|
2609
|
+
this.#lastEscapeTime = now;
|
|
2610
|
+
}
|
|
2611
|
+
return true;
|
|
2612
|
+
}
|
|
2613
|
+
// Something is typed: Escape is not a no-op, but it also has nothing to
|
|
2614
|
+
// cancel, so the editor keeps the keystroke.
|
|
2615
|
+
return false;
|
|
2616
|
+
}
|
|
2617
|
+
/**
|
|
2618
|
+
* Empty the composer, or exit on a second press.
|
|
2619
|
+
*
|
|
2620
|
+
* Ctrl+C is a composer gesture everywhere else on a terminal; keeping
|
|
2621
|
+
* interrupt on Escape leaves this to do exactly one job.
|
|
2622
|
+
*/
|
|
2623
|
+
#runClearComposer() {
|
|
2624
|
+
if (this.#prompt !== null) {
|
|
2625
|
+
this.#finishPrompt(null);
|
|
2626
|
+
this.#editor.setText('');
|
|
2627
|
+
this.#render();
|
|
2628
|
+
return true;
|
|
2629
|
+
}
|
|
2630
|
+
// A running Shell line owns Ctrl+C before the composer does: the reader is
|
|
2631
|
+
// watching a command, and the key they press to stop it is the same one.
|
|
2632
|
+
if (this.#shellAbort !== undefined && !this.#hasComposerContent()) {
|
|
2633
|
+
this.#shellAbort();
|
|
2634
|
+
return true;
|
|
2635
|
+
}
|
|
2636
|
+
if (this.#settings !== null) {
|
|
2637
|
+
this.#settings = null;
|
|
2638
|
+
this.#render();
|
|
2639
|
+
return true;
|
|
2640
|
+
}
|
|
2641
|
+
if (this.#search !== null) {
|
|
2642
|
+
this.#search = null;
|
|
2643
|
+
this.#render();
|
|
2644
|
+
return true;
|
|
2645
|
+
}
|
|
2646
|
+
if (this.#transcriptSearch !== null) {
|
|
2647
|
+
this.#transcriptSearch = null;
|
|
2648
|
+
this.#render();
|
|
2649
|
+
return true;
|
|
2650
|
+
}
|
|
2651
|
+
const now = Date.now();
|
|
2652
|
+
if (now - this.#lastSigintTime < DOUBLE_CTRL_C_MS) {
|
|
2653
|
+
this.#lastSigintTime = 0;
|
|
2654
|
+
this.#quit();
|
|
2655
|
+
return true;
|
|
2656
|
+
}
|
|
2657
|
+
this.#lastSigintTime = now;
|
|
2658
|
+
// A running turn keeps Ctrl+C as the familiar abort while the composer is
|
|
2659
|
+
// empty; Escape already covers it, and refusing the reflex would be worse.
|
|
2660
|
+
if (this.#state.status !== 'idle' && !this.#hasComposerContent()) {
|
|
2661
|
+
this.#restoreQueuedBeforeAbort();
|
|
2662
|
+
for (const listener of this.#interrupts)
|
|
2663
|
+
listener();
|
|
2664
|
+
return true;
|
|
2665
|
+
}
|
|
2666
|
+
this.#clearComposerWithUndo();
|
|
2667
|
+
return true;
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Hand durable follow-ups back to the composer before a turn is abandoned.
|
|
2671
|
+
*
|
|
2672
|
+
* The queued messages would otherwise be delivered to a turn that will never
|
|
2673
|
+
* produce the step they were aimed at, which silently loses them.
|
|
2674
|
+
*/
|
|
2675
|
+
#restoreQueuedBeforeAbort() {
|
|
2676
|
+
if (!this.#state.nextTurnInbox.some(message => message.source.kind === 'user'))
|
|
2677
|
+
return;
|
|
2678
|
+
if (this.#hasComposerContent())
|
|
2679
|
+
return;
|
|
2680
|
+
for (const listener of this.#queueEdits)
|
|
2681
|
+
listener();
|
|
2682
|
+
}
|
|
2683
|
+
#handlePrompt(event) {
|
|
2684
|
+
let prompt = this.#prompt;
|
|
2685
|
+
if (prompt === null)
|
|
2686
|
+
return false;
|
|
2687
|
+
const disarmed = prompt.armedUntil !== undefined && Date.now() < prompt.armedUntil;
|
|
2688
|
+
if (event.type === 'key' && event.id === 'ctrl+c' && prompt.request.interrupt !== undefined) {
|
|
2689
|
+
this.#lastSigintTime = Date.now();
|
|
2690
|
+
const handled = prompt.request.interrupt();
|
|
2691
|
+
this.#finishPrompt(null);
|
|
2692
|
+
if (handled !== true && this.#state.status !== 'idle')
|
|
2693
|
+
for (const listener of this.#interrupts)
|
|
2694
|
+
listener();
|
|
2695
|
+
this.#render();
|
|
2696
|
+
return true;
|
|
2697
|
+
}
|
|
2698
|
+
if (event.type === 'key' && event.id === 'ctrl+s' && prompt.request.skippable === true) {
|
|
2699
|
+
this.#editor.setText('');
|
|
2700
|
+
this.#finishPrompt('');
|
|
2701
|
+
this.#render();
|
|
2702
|
+
return true;
|
|
2703
|
+
}
|
|
2704
|
+
if (prompt.request.presentation === 'progress') {
|
|
2705
|
+
if (event.type === 'key' && (event.id === 'escape' || event.id === 'ctrl+c')) {
|
|
2706
|
+
this.#finishPrompt(null);
|
|
2707
|
+
this.#render();
|
|
2708
|
+
}
|
|
2709
|
+
return true;
|
|
2710
|
+
}
|
|
2711
|
+
const takeTime = event.type === 'key' && event.id === 'ctrl+t';
|
|
2712
|
+
const editing = event.type === 'text' || (event.type === 'key'
|
|
2713
|
+
&& ['backspace', 'delete', 'up', 'down', 'tab', 'shift+tab'].includes(event.id));
|
|
2714
|
+
if (prompt.request.wait !== undefined && prompt.waitHeld !== true && (takeTime || editing)) {
|
|
2715
|
+
prompt.request.wait.hold();
|
|
2716
|
+
prompt.offTick?.();
|
|
2717
|
+
prompt = { ...prompt, waitHeld: true };
|
|
2718
|
+
this.#prompt = prompt;
|
|
2719
|
+
this.#render();
|
|
2720
|
+
}
|
|
2721
|
+
if (takeTime && prompt.request.wait !== undefined)
|
|
2722
|
+
return true;
|
|
2723
|
+
if (prompt.request.presentation === 'plan-review' || prompt.request.presentation === 'document')
|
|
2724
|
+
return this.#handlePlanReview(event, prompt);
|
|
2204
2725
|
if (event.type === 'text' && this.#editor.text === '') {
|
|
2205
2726
|
const action = prompt.request.actions?.find(item => item.key === event.value);
|
|
2206
2727
|
const selected = filteredPromptOptions(prompt.request, '', prompt.collapsed)[prompt.selected];
|
|
2207
2728
|
if (action !== undefined && (action.scope === 'list' || selected !== undefined)) {
|
|
2208
|
-
|
|
2209
|
-
|
|
2729
|
+
if (!disarmed) {
|
|
2730
|
+
this.#finishPrompt(action.valuePrefix + (action.scope === 'list' ? '' : selected.value ?? selected.label));
|
|
2731
|
+
this.#render();
|
|
2732
|
+
}
|
|
2210
2733
|
return true;
|
|
2211
2734
|
}
|
|
2212
2735
|
}
|
|
2213
|
-
if (event.type === 'text' && event.value === ' ' && prompt.request.multiSelect === true
|
|
2214
|
-
this.#prompt = togglePromptSelection(prompt);
|
|
2736
|
+
if (event.type === 'text' && event.value === ' ' && prompt.request.multiSelect === true) {
|
|
2737
|
+
this.#prompt = togglePromptSelection(prompt, this.#editor.text);
|
|
2215
2738
|
this.#render();
|
|
2216
2739
|
return true;
|
|
2217
2740
|
}
|
|
@@ -2227,38 +2750,56 @@ export class LocalTui {
|
|
|
2227
2750
|
return true;
|
|
2228
2751
|
if (event.type !== 'key')
|
|
2229
2752
|
return false;
|
|
2753
|
+
if (disarmed && (event.id === 'enter' || event.id === 'ctrl+j'))
|
|
2754
|
+
return true;
|
|
2230
2755
|
const filtered = filteredPromptOptions(prompt.request, this.#editor.text, prompt.collapsed);
|
|
2231
2756
|
const count = filtered.length;
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2757
|
+
const keyAction = prompt.request.actions?.find(item => item.key.toLowerCase() === event.id.toLowerCase());
|
|
2758
|
+
if (keyAction !== undefined && (keyAction.scope === 'list'
|
|
2759
|
+
|| prompt.request.presentation === 'tree'
|
|
2760
|
+
|| prompt.request.presentation === 'card')) {
|
|
2761
|
+
if (!disarmed) {
|
|
2762
|
+
const selected = filtered[prompt.selected];
|
|
2763
|
+
if (keyAction.scope === 'list' || selected !== undefined) {
|
|
2764
|
+
this.#editor.setText('');
|
|
2765
|
+
this.#finishPrompt(keyAction.valuePrefix + (keyAction.scope === 'list' ? '' : selected.value ?? selected.label));
|
|
2238
2766
|
this.#render();
|
|
2239
2767
|
}
|
|
2240
|
-
return true;
|
|
2241
2768
|
}
|
|
2769
|
+
return true;
|
|
2242
2770
|
}
|
|
2243
2771
|
if (event.id === 'escape' || event.id === 'ctrl+c') {
|
|
2244
|
-
|
|
2245
|
-
|
|
2772
|
+
/**
|
|
2773
|
+
* Escape gives back one layer: an active filter clears before the card
|
|
2774
|
+
* closes.
|
|
2775
|
+
*
|
|
2776
|
+
* Only the tree used to do this, so a filtered `/model` or `/copy` closed
|
|
2777
|
+
* outright and a reader who wanted their list back had to reopen it.
|
|
2778
|
+
*/
|
|
2779
|
+
if (event.id === 'escape' && prompt.request.filterable === true && this.#editor.text !== '') {
|
|
2780
|
+
const selected = filtered[prompt.selected]?.value ?? filtered[prompt.selected]?.label;
|
|
2246
2781
|
this.#editor.setText('');
|
|
2247
|
-
|
|
2782
|
+
const restored = filteredPromptOptions(prompt.request, '', prompt.collapsed);
|
|
2783
|
+
const at = restored.findIndex(option => (option.value ?? option.label) === selected);
|
|
2784
|
+
this.#prompt = {
|
|
2785
|
+
...prompt,
|
|
2786
|
+
...(prompt.request.presentation === 'tree' ? { collapsed: new Set() } : {}),
|
|
2787
|
+
selected: Math.max(0, at),
|
|
2788
|
+
previewScroll: 0,
|
|
2789
|
+
};
|
|
2248
2790
|
this.#render();
|
|
2249
2791
|
return true;
|
|
2250
2792
|
}
|
|
2251
|
-
this.#editor.setText('');
|
|
2252
2793
|
this.#finishPrompt(null);
|
|
2253
2794
|
this.#render();
|
|
2254
2795
|
return true;
|
|
2255
2796
|
}
|
|
2256
|
-
if (prompt.request.presentation === '
|
|
2797
|
+
if (prompt.request.presentation === 'tree' && (event.id === 'left' || event.id === 'right')) {
|
|
2257
2798
|
this.#prompt = movePromptTree(prompt, event.id, this.#editor.text);
|
|
2258
2799
|
this.#render();
|
|
2259
2800
|
return true;
|
|
2260
2801
|
}
|
|
2261
|
-
if (prompt.request.presentation === '
|
|
2802
|
+
if (prompt.request.presentation === 'tree' && (event.id === 'ctrl+up' || event.id === 'ctrl+down')) {
|
|
2262
2803
|
const start = this.#promptDocument?.start ?? prompt.previewScroll ?? 0;
|
|
2263
2804
|
this.#prompt = { ...prompt, previewScroll: Math.max(0, Math.min(start + (event.id === 'ctrl+up' ? -1 : 1), this.#promptDocument?.maxStart ?? 0)) };
|
|
2264
2805
|
this.#render();
|
|
@@ -2276,29 +2817,30 @@ export class LocalTui {
|
|
|
2276
2817
|
}
|
|
2277
2818
|
return true;
|
|
2278
2819
|
}
|
|
2279
|
-
|
|
2280
|
-
|
|
2820
|
+
// Movement comes from the shared reading, so this selector cannot drift from
|
|
2821
|
+
// the other lists about what `tab` or `pageUp` means. `enter` stays below
|
|
2822
|
+
// because what it submits depends on the editor and on multi-select.
|
|
2823
|
+
const page = prompt.request.presentation === 'tree'
|
|
2281
2824
|
? promptTreePageSize(prompt.request, this.#term.width(), this.#term.height()) : 10;
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
next = count - 1;
|
|
2294
|
-
if (next !== undefined) {
|
|
2295
|
-
this.#prompt = movePromptSelection(prompt, next, count);
|
|
2825
|
+
const navigation = listNavigation(event);
|
|
2826
|
+
if (navigation.kind === 'move' || navigation.kind === 'page') {
|
|
2827
|
+
// The tree clamps: its rows are a structure, and wrapping past the last
|
|
2828
|
+
// branch lands the reader somewhere unrelated to the one they left.
|
|
2829
|
+
const wrap = prompt.request.presentation !== 'tree';
|
|
2830
|
+
this.#prompt = movePromptSelection(prompt, navigateIndex(navigation, prompt.selected, count, page, { wrap }), count);
|
|
2831
|
+
this.#render();
|
|
2832
|
+
return true;
|
|
2833
|
+
}
|
|
2834
|
+
if (navigation.kind === 'edge') {
|
|
2835
|
+
this.#prompt = movePromptSelection(prompt, edgeIndex(navigation, count), count);
|
|
2296
2836
|
this.#render();
|
|
2297
2837
|
return true;
|
|
2298
2838
|
}
|
|
2299
|
-
if (event.id === 'enter' && this.#editor.text === '') {
|
|
2839
|
+
if (event.id === 'enter' && (this.#editor.text === '' || prompt.request.multiSelect === true)) {
|
|
2300
2840
|
const answer = selectedPromptAnswer(prompt);
|
|
2301
|
-
|
|
2841
|
+
// A disabled row and an empty multi-select both swallow Enter: nothing is
|
|
2842
|
+
// being answered, so the surface stays where the reader is.
|
|
2843
|
+
if (answer === null)
|
|
2302
2844
|
return true;
|
|
2303
2845
|
this.#finishPrompt(answer);
|
|
2304
2846
|
this.#render();
|
|
@@ -2306,6 +2848,10 @@ export class LocalTui {
|
|
|
2306
2848
|
}
|
|
2307
2849
|
if (event.id === 'enter' && prompt.request.filterable === true) {
|
|
2308
2850
|
const answer = selectedFilteredPromptAnswer(prompt, this.#editor.text);
|
|
2851
|
+
// A heading under a filter is still a heading: clearing the query and
|
|
2852
|
+
// closing on it would lose both the list and the filter.
|
|
2853
|
+
if (answer === null)
|
|
2854
|
+
return true;
|
|
2309
2855
|
this.#editor.setText('');
|
|
2310
2856
|
this.#finishPrompt(answer);
|
|
2311
2857
|
this.#render();
|
|
@@ -2323,19 +2869,25 @@ export class LocalTui {
|
|
|
2323
2869
|
}
|
|
2324
2870
|
#handlePlanReview(event, prompt) {
|
|
2325
2871
|
const document = prompt.request.presentation === 'document';
|
|
2872
|
+
const disarmed = prompt.armedUntil !== undefined && Date.now() < prompt.armedUntil;
|
|
2326
2873
|
if (document && prompt.request.documentSource !== undefined && this.#handleDocumentReader(event, prompt))
|
|
2327
2874
|
return true;
|
|
2875
|
+
if (document && prompt.request.documentSource === undefined && this.#handleRenderedDocument(event, prompt))
|
|
2876
|
+
return true;
|
|
2328
2877
|
if (document && event.type === 'text') {
|
|
2329
2878
|
const action = prompt.request.actions?.find(item => item.key.toLowerCase() === event.value.toLowerCase());
|
|
2330
|
-
if (action !== undefined) {
|
|
2879
|
+
if (action !== undefined && !disarmed) {
|
|
2331
2880
|
this.#finishPrompt(action.valuePrefix);
|
|
2332
2881
|
this.#render();
|
|
2333
2882
|
}
|
|
2334
2883
|
return true;
|
|
2335
2884
|
}
|
|
2885
|
+
if (!document && event.type === 'key' && (event.id === 'ctrl+x' || this.#boundAction(event.id) === 'external-editor')) {
|
|
2886
|
+
this.#openPlanFeedbackEditor(prompt);
|
|
2887
|
+
return true;
|
|
2888
|
+
}
|
|
2336
2889
|
if (prompt.feedback === true) {
|
|
2337
2890
|
if (event.type === 'key' && event.id === 'ctrl+c') {
|
|
2338
|
-
this.#editor.setText('');
|
|
2339
2891
|
this.#finishPrompt(null);
|
|
2340
2892
|
this.#render();
|
|
2341
2893
|
return true;
|
|
@@ -2347,6 +2899,8 @@ export class LocalTui {
|
|
|
2347
2899
|
return true;
|
|
2348
2900
|
}
|
|
2349
2901
|
if (event.type === 'key' && (event.id === 'enter' || event.id === 'ctrl+j')) {
|
|
2902
|
+
if (disarmed)
|
|
2903
|
+
return true;
|
|
2350
2904
|
const feedback = this.#editor.text.trim();
|
|
2351
2905
|
this.#editor.setText('');
|
|
2352
2906
|
this.#finishPrompt(feedback === '' ? selectedPromptAnswer(prompt) : feedback);
|
|
@@ -2362,8 +2916,16 @@ export class LocalTui {
|
|
|
2362
2916
|
return true;
|
|
2363
2917
|
if (event.type !== 'key')
|
|
2364
2918
|
return false;
|
|
2919
|
+
const keyAction = prompt.request.actions?.find(item => item.key.toLowerCase() === event.id.toLowerCase() && item.scope === 'list');
|
|
2920
|
+
if (keyAction !== undefined) {
|
|
2921
|
+
if (!disarmed) {
|
|
2922
|
+
this.#editor.setText('');
|
|
2923
|
+
this.#finishPrompt(keyAction.valuePrefix);
|
|
2924
|
+
this.#render();
|
|
2925
|
+
}
|
|
2926
|
+
return true;
|
|
2927
|
+
}
|
|
2365
2928
|
if (event.id === 'escape' || event.id === 'ctrl+c') {
|
|
2366
|
-
this.#editor.setText('');
|
|
2367
2929
|
this.#finishPrompt(null);
|
|
2368
2930
|
this.#render();
|
|
2369
2931
|
return true;
|
|
@@ -2403,6 +2965,8 @@ export class LocalTui {
|
|
|
2403
2965
|
return true;
|
|
2404
2966
|
}
|
|
2405
2967
|
if (event.id === 'enter') {
|
|
2968
|
+
if (disarmed)
|
|
2969
|
+
return true;
|
|
2406
2970
|
const answer = selectedPromptAnswer(prompt);
|
|
2407
2971
|
if (answer === null)
|
|
2408
2972
|
return true;
|
|
@@ -2420,9 +2984,78 @@ export class LocalTui {
|
|
|
2420
2984
|
}
|
|
2421
2985
|
return true;
|
|
2422
2986
|
}
|
|
2987
|
+
/**
|
|
2988
|
+
* Find in a rendered document.
|
|
2989
|
+
*
|
|
2990
|
+
* The source reader searches its model; a rendered page is its rows, so the
|
|
2991
|
+
* search runs over exactly what the reader is looking at. Without this, `/help`
|
|
2992
|
+
* — sixty rows of grouped commands — was the one surface with no way to look
|
|
2993
|
+
* something up in it.
|
|
2994
|
+
*/
|
|
2995
|
+
#handleRenderedDocument(event, prompt) {
|
|
2996
|
+
const rows = () => documentRows(prompt.request, createTheme(this.#colors, this.#colorDepth, this.#themeName, this.#terminalColors), Math.max(1, this.#term.width() - 6));
|
|
2997
|
+
// Always build on the newest state: typing updates the query while the find
|
|
2998
|
+
// is open, and jumping from a stale copy would drop it again.
|
|
2999
|
+
const jumpTo = (row) => {
|
|
3000
|
+
this.#prompt = { ...(this.#prompt ?? prompt), documentScroll: Math.max(0, row - 1), documentTarget: row, documentAnchor: undefined };
|
|
3001
|
+
};
|
|
3002
|
+
if (prompt.documentInput !== undefined) {
|
|
3003
|
+
if (event.type === 'key' && (event.id === 'escape' || event.id === 'ctrl+c')) {
|
|
3004
|
+
this.#editor.setText('');
|
|
3005
|
+
this.#prompt = { ...prompt, documentInput: undefined, documentQuery: prompt.documentOriginalQuery ?? '', documentError: undefined };
|
|
3006
|
+
this.#render();
|
|
3007
|
+
return true;
|
|
3008
|
+
}
|
|
3009
|
+
if (event.type === 'key' && (event.id === 'enter' || event.id === 'ctrl+j')) {
|
|
3010
|
+
const query = this.#editor.text.trim();
|
|
3011
|
+
this.#prompt = { ...(this.#prompt ?? prompt), documentInput: undefined, documentQuery: query, documentError: undefined };
|
|
3012
|
+
const matches = findInRows(rows(), query);
|
|
3013
|
+
if (matches.length > 0)
|
|
3014
|
+
jumpTo(matches[0].row);
|
|
3015
|
+
else
|
|
3016
|
+
this.#prompt = { ...(this.#prompt ?? prompt), documentTarget: undefined };
|
|
3017
|
+
this.#render();
|
|
3018
|
+
return true;
|
|
3019
|
+
}
|
|
3020
|
+
// Typing narrows the query as it is entered, so the count is live.
|
|
3021
|
+
if (event.type === 'text') {
|
|
3022
|
+
this.#editor.handle(event);
|
|
3023
|
+
this.#prompt = { ...(this.#prompt ?? prompt), documentQuery: this.#editor.text };
|
|
3024
|
+
this.#render();
|
|
3025
|
+
return true;
|
|
3026
|
+
}
|
|
3027
|
+
if (event.type === 'key') {
|
|
3028
|
+
this.#editor.handle(event);
|
|
3029
|
+
this.#prompt = { ...(this.#prompt ?? prompt), documentQuery: this.#editor.text };
|
|
3030
|
+
this.#render();
|
|
3031
|
+
return true;
|
|
3032
|
+
}
|
|
3033
|
+
return false;
|
|
3034
|
+
}
|
|
3035
|
+
const search = event.type === 'key' && event.id === 'ctrl+f' || event.type === 'text' && event.value === '/';
|
|
3036
|
+
if (search) {
|
|
3037
|
+
this.#editor.setText(prompt.documentQuery ?? '');
|
|
3038
|
+
this.#prompt = { ...prompt, documentInput: 'search', documentOriginalQuery: prompt.documentQuery ?? '', documentError: undefined };
|
|
3039
|
+
this.#render();
|
|
3040
|
+
return true;
|
|
3041
|
+
}
|
|
3042
|
+
if (event.type === 'key' && (event.id === 'ctrl+n' || event.id === 'ctrl+p')) {
|
|
3043
|
+
const matches = findInRows(rows(), prompt.documentQuery ?? '');
|
|
3044
|
+
if (matches.length === 0)
|
|
3045
|
+
return true;
|
|
3046
|
+
const current = prompt.documentTarget ?? prompt.documentScroll ?? 0;
|
|
3047
|
+
const from = event.id === 'ctrl+n'
|
|
3048
|
+
? (matches.findIndex(match => match.row > current) + matches.length) % matches.length
|
|
3049
|
+
: (matches.findLastIndex(match => match.row < current) + matches.length) % matches.length;
|
|
3050
|
+
jumpTo(matches[from].row);
|
|
3051
|
+
this.#render();
|
|
3052
|
+
return true;
|
|
3053
|
+
}
|
|
3054
|
+
return false;
|
|
3055
|
+
}
|
|
2423
3056
|
#handleDocumentReader(event, prompt) {
|
|
2424
3057
|
const source = prompt.request.documentSource;
|
|
2425
|
-
const layout = documentLayout(source, createTheme(this.#colors, this.#
|
|
3058
|
+
const layout = documentLayout(source, createTheme(this.#colors, this.#colorDepth, this.#themeName, this.#terminalColors), Math.max(1, this.#term.width() - 6), {
|
|
2426
3059
|
wordWrap: prompt.documentWordWrap ?? true, column: prompt.documentColumn ?? 0,
|
|
2427
3060
|
});
|
|
2428
3061
|
const start = this.#promptDocument?.start ?? 0;
|
|
@@ -2504,37 +3137,72 @@ export class LocalTui {
|
|
|
2504
3137
|
listener(id);
|
|
2505
3138
|
}
|
|
2506
3139
|
#handleSubagentLauncher(event) {
|
|
2507
|
-
const
|
|
2508
|
-
if (this.#
|
|
2509
|
-
if (
|
|
2510
|
-
this.#
|
|
3140
|
+
const agents = this.#subagents?.agents ?? [];
|
|
3141
|
+
if (this.#subagentSelection !== undefined) {
|
|
3142
|
+
if (agents.length === 0) {
|
|
3143
|
+
this.#subagentSelection = undefined;
|
|
2511
3144
|
return false;
|
|
2512
3145
|
}
|
|
3146
|
+
const at = Math.min(this.#subagentSelection, agents.length - 1);
|
|
2513
3147
|
if (event.type === 'key' && event.id === 'enter') {
|
|
2514
|
-
this.#
|
|
2515
|
-
|
|
3148
|
+
this.#subagentSelection = undefined;
|
|
3149
|
+
this.#openSelectedSubagent(agents[at]);
|
|
2516
3150
|
return true;
|
|
2517
3151
|
}
|
|
2518
|
-
if (event.type === 'key' &&
|
|
2519
|
-
this.#
|
|
3152
|
+
if (event.type === 'key' && event.id === 'escape') {
|
|
3153
|
+
this.#subagentSelection = undefined;
|
|
2520
3154
|
this.#render();
|
|
2521
3155
|
return true;
|
|
2522
3156
|
}
|
|
2523
|
-
if (event.type === 'key' && event.id === 'down')
|
|
3157
|
+
if (event.type === 'key' && (event.id === 'up' || event.id === 'down')) {
|
|
3158
|
+
const delta = event.id === 'up' ? -1 : 1;
|
|
3159
|
+
// `up` from the first row leaves the bar; `down` stops at the last one.
|
|
3160
|
+
if (at + delta < 0)
|
|
3161
|
+
this.#subagentSelection = undefined;
|
|
3162
|
+
else
|
|
3163
|
+
this.#subagentSelection = Math.min(agents.length - 1, at + delta);
|
|
3164
|
+
this.#render();
|
|
2524
3165
|
return true;
|
|
2525
|
-
|
|
2526
|
-
return
|
|
3166
|
+
}
|
|
3167
|
+
return true;
|
|
2527
3168
|
}
|
|
2528
|
-
if (
|
|
3169
|
+
if (agents.length === 0 || event.type !== 'key' || event.id !== 'down')
|
|
2529
3170
|
return false;
|
|
2530
3171
|
if (this.#editor.text !== '' || this.#images.count > 0 || this.#historyIndex !== 0 || this.#ac !== null)
|
|
2531
3172
|
return false;
|
|
2532
3173
|
if (this.#queuedSubmissions.length > 0 || this.#queueEditNewer !== null || this.#queueEditPending)
|
|
2533
3174
|
return false;
|
|
2534
|
-
this.#
|
|
3175
|
+
this.#subagentSelection = Math.min(agents.length - 1, Math.max(0, subagentPreviewStart(agents)));
|
|
2535
3176
|
this.#render();
|
|
2536
3177
|
return true;
|
|
2537
3178
|
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Open what the selected row has.
|
|
3181
|
+
*
|
|
3182
|
+
* A local child has a transcript to read, so `Enter` swaps the view. An external
|
|
3183
|
+
* child has none by construction — its results land in the parent session — so
|
|
3184
|
+
* `Enter` prints its status and recent activity as a fold-able transcript
|
|
3185
|
+
* result instead of opening a surface for it.
|
|
3186
|
+
*/
|
|
3187
|
+
#openSelectedSubagent(agent) {
|
|
3188
|
+
if (agent.mode === 'external') {
|
|
3189
|
+
this.commandOutput(`Agent · ${agent.label}`, [
|
|
3190
|
+
`${agent.phase === 'completed' ? 'Completed' : agent.phase === 'error' ? 'Failed' : 'Running'} · ${formatAgentElapsed(agent)}`,
|
|
3191
|
+
'',
|
|
3192
|
+
`Mode: ${agent.mode}`,
|
|
3193
|
+
`Session: ${agent.id}`,
|
|
3194
|
+
`Parent: ${agent.parentId ?? 'root session'}`,
|
|
3195
|
+
`Depth: ${agent.depth}`,
|
|
3196
|
+
...(agent.workflow === undefined ? [] : [`Workflow: ${agent.workflow.name}`, `Phase: ${agent.workflow.phase ?? '—'}`]),
|
|
3197
|
+
...(agent.activity.length === 0 ? [] : ['', 'Recent activity', ...agent.activity.map(item => `${item.status.padEnd(8)} ${item.text}`)]),
|
|
3198
|
+
'',
|
|
3199
|
+
'An external agent keeps no local transcript; its results arrive in this session.',
|
|
3200
|
+
].join('\n'));
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
this.#openInspect(agent.id);
|
|
3204
|
+
this.#render();
|
|
3205
|
+
}
|
|
2538
3206
|
#closeInspect() {
|
|
2539
3207
|
if (this.#inspected === undefined)
|
|
2540
3208
|
return;
|
|
@@ -2544,41 +3212,28 @@ export class LocalTui {
|
|
|
2544
3212
|
for (const listener of this.#inspectCloses)
|
|
2545
3213
|
listener();
|
|
2546
3214
|
}
|
|
2547
|
-
#pickSubagent() {
|
|
2548
|
-
this.#subagentLauncherFocused = false;
|
|
2549
|
-
const agents = this.#subagents?.agents ?? [];
|
|
2550
|
-
if (agents.length === 0 && !this.#subagents?.workflows?.length) {
|
|
2551
|
-
this.notice('No subagents are available in this session.');
|
|
2552
|
-
return;
|
|
2553
|
-
}
|
|
2554
|
-
this.#prompt = null;
|
|
2555
|
-
this.#settings = null;
|
|
2556
|
-
this.#copySelector = null;
|
|
2557
|
-
this.#search = null;
|
|
2558
|
-
this.#trajectory = null;
|
|
2559
|
-
this.#ac = null;
|
|
2560
|
-
this.#agentHub = createAgentHub(this.#subagents ?? { agents });
|
|
2561
|
-
this.#render();
|
|
2562
|
-
}
|
|
2563
3215
|
#applyPrefs(prefs, options) {
|
|
3216
|
+
const previous = options.persist ? this.prefs() : undefined;
|
|
3217
|
+
const version = this.#prefsVersion;
|
|
2564
3218
|
const previousMotion = this.#motion;
|
|
2565
3219
|
this.#themeName = prefs.theme;
|
|
2566
3220
|
this.#colors = prefs.colors;
|
|
2567
|
-
this.#
|
|
3221
|
+
this.#syncColorDepth();
|
|
2568
3222
|
this.#motion = prefs.motion ?? 'full';
|
|
2569
3223
|
this.#flushPasteBurst(true);
|
|
2570
3224
|
this.#pasteProtection = prefs.pasteProtection !== false;
|
|
2571
3225
|
this.#externalEditor = prefs.editor ?? 'auto';
|
|
2572
|
-
this.#
|
|
3226
|
+
this.#terminalTitle = prefs.terminalTitle ?? 'status';
|
|
3227
|
+
this.#terminalState = prefs.terminalState ?? 'auto';
|
|
3228
|
+
this.#syncWindowTitle();
|
|
2573
3229
|
this.#copyOnSelect = prefs.copyOnSelect ?? true;
|
|
2574
3230
|
this.#mathMode = prefs.mathMode ?? 'auto';
|
|
2575
3231
|
this.#mermaidMode = prefs.mermaidMode ?? 'auto';
|
|
3232
|
+
this.#hideThinking = prefs.hideThinking === true;
|
|
2576
3233
|
this.#mouseInteraction = prefs.mouseInteraction ?? 'auto';
|
|
2577
3234
|
this.#checkUpdates = prefs.checkUpdates ?? true;
|
|
2578
3235
|
this.#startupChangelog = prefs.startupChangelog ?? 'summary';
|
|
2579
|
-
this.#notificationPolicy = prefs.notifications
|
|
2580
|
-
this.#notificationThreshold = prefs.notificationThreshold ?? '30s';
|
|
2581
|
-
this.#notificationFocus = prefs.notificationFocus ?? 'unfocused';
|
|
3236
|
+
this.#notificationPolicy = resolveNotificationMode(prefs.notifications, prefs.notificationFocus);
|
|
2582
3237
|
if (this.#notificationPolicy === 'off')
|
|
2583
3238
|
this.#notificationQueue.clear();
|
|
2584
3239
|
this.#configureNotifications();
|
|
@@ -2590,8 +3245,33 @@ export class LocalTui {
|
|
|
2590
3245
|
this.#reveal = undefined;
|
|
2591
3246
|
}
|
|
2592
3247
|
this.#syncTick();
|
|
2593
|
-
if (options.persist)
|
|
2594
|
-
|
|
3248
|
+
if (options.persist && this.#persistPrefs !== null) {
|
|
3249
|
+
const failed = async (error) => {
|
|
3250
|
+
if (this.#disposed)
|
|
3251
|
+
return;
|
|
3252
|
+
if (this.#prefsVersion === version && previous !== undefined)
|
|
3253
|
+
this.applyStoredPrefs(previous);
|
|
3254
|
+
await this.#settingsError(`Settings were not saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
3255
|
+
};
|
|
3256
|
+
try {
|
|
3257
|
+
const changes = previous === undefined ? [] : preferenceChanges(previous, prefs);
|
|
3258
|
+
const result = changes.length === 0 ? undefined : this.#persistPrefs(prefs, changes);
|
|
3259
|
+
if (result !== undefined && result !== null && typeof result === 'object' && typeof result.then === 'function') {
|
|
3260
|
+
this.#prefsSaveTask = Promise.resolve(result).catch(failed).finally(() => { this.#prefsSaveTask = undefined; });
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
catch (error) {
|
|
3264
|
+
this.#prefsSaveTask = failed(error).finally(() => { this.#prefsSaveTask = undefined; });
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
async #settingsError(detail) {
|
|
3269
|
+
if (this.#disposed)
|
|
3270
|
+
return;
|
|
3271
|
+
if (this.#settings === null)
|
|
3272
|
+
this.notice(detail, { level: 'error' });
|
|
3273
|
+
else
|
|
3274
|
+
await showSettingsError(this, detail);
|
|
2595
3275
|
}
|
|
2596
3276
|
#applyStoredAgentBehavior(next) {
|
|
2597
3277
|
this.#agentBehavior = { language: next.language };
|
|
@@ -2613,7 +3293,7 @@ export class LocalTui {
|
|
|
2613
3293
|
if (this.#settings !== null && this.#agentBehavior !== undefined) {
|
|
2614
3294
|
this.#settings = { ...this.#settings, agent: { ...this.#agentBehavior } };
|
|
2615
3295
|
}
|
|
2616
|
-
this
|
|
3296
|
+
await this.#settingsError(`Could not save Agent language: ${error instanceof Error ? error.message : String(error)}`);
|
|
2617
3297
|
}
|
|
2618
3298
|
finally {
|
|
2619
3299
|
this.#agentBehaviorPending = false;
|
|
@@ -2622,34 +3302,28 @@ export class LocalTui {
|
|
|
2622
3302
|
}
|
|
2623
3303
|
}
|
|
2624
3304
|
/**
|
|
2625
|
-
* Write the
|
|
3305
|
+
* Write the Tools feature controls' state into the Profile patch.
|
|
2626
3306
|
*
|
|
2627
3307
|
* `disabled` is a Loader option rather than plugin config, so the settings
|
|
2628
3308
|
* service cannot express it; the Profile patch is the documented surface
|
|
2629
3309
|
* that can, and the Harness reads it while composing the tree. Only the
|
|
2630
3310
|
* managed block is rewritten, so hand-authored rows and comments survive.
|
|
2631
|
-
*
|
|
3311
|
+
* The displayed feature state changes only after the patch is written.
|
|
2632
3312
|
*/
|
|
2633
|
-
#writeFeaturePatch() {
|
|
3313
|
+
#writeFeaturePatch(features) {
|
|
2634
3314
|
const path = this.#featurePatch;
|
|
2635
|
-
|
|
2636
|
-
if (path === undefined || features === undefined)
|
|
3315
|
+
if (path === undefined)
|
|
2637
3316
|
return;
|
|
3317
|
+
let current = '';
|
|
2638
3318
|
try {
|
|
2639
|
-
|
|
2640
|
-
try {
|
|
2641
|
-
current = readFileSync(path, 'utf8');
|
|
2642
|
-
}
|
|
2643
|
-
catch {
|
|
2644
|
-
current = '';
|
|
2645
|
-
}
|
|
2646
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
2647
|
-
writeFileSync(path, replaceFeatureBlock(current, renderFeatureBlock(features)), 'utf8');
|
|
3319
|
+
current = readFileSync(path, 'utf8');
|
|
2648
3320
|
}
|
|
2649
3321
|
catch (error) {
|
|
2650
|
-
|
|
2651
|
-
|
|
3322
|
+
if (error.code !== 'ENOENT')
|
|
3323
|
+
throw error;
|
|
2652
3324
|
}
|
|
3325
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
3326
|
+
writeFileSync(path, replaceFeatureBlock(current, renderFeatureBlock(features)), 'utf8');
|
|
2653
3327
|
}
|
|
2654
3328
|
#applySettings(event) {
|
|
2655
3329
|
if (this.#settings === null)
|
|
@@ -2657,8 +3331,36 @@ export class LocalTui {
|
|
|
2657
3331
|
if (this.#pluginSettingsTask !== undefined)
|
|
2658
3332
|
return;
|
|
2659
3333
|
const command = applySettingsEvent(this.#settings, event);
|
|
3334
|
+
if (command.kind === 'open-command') {
|
|
3335
|
+
if (this.#prefsSaveTask !== undefined)
|
|
3336
|
+
return;
|
|
3337
|
+
if (command.name === 'keybindings') {
|
|
3338
|
+
void this.#editKeybindings();
|
|
3339
|
+
return;
|
|
3340
|
+
}
|
|
3341
|
+
this.#runSlash(command.name);
|
|
3342
|
+
return;
|
|
3343
|
+
}
|
|
3344
|
+
if (command.kind === 'reset') {
|
|
3345
|
+
if (this.#prefsSaveTask !== undefined)
|
|
3346
|
+
return;
|
|
3347
|
+
if (this.#pluginSettings?.reset === undefined) {
|
|
3348
|
+
void this.#settingsError('Restoring inheritance requires a writable Profile settings service.');
|
|
3349
|
+
return;
|
|
3350
|
+
}
|
|
3351
|
+
this.#runPluginSettings((binding, signal) => binding.reset(command.id, command.entry, signal, command.label));
|
|
3352
|
+
return;
|
|
3353
|
+
}
|
|
2660
3354
|
if (command.kind === 'open-plugin') {
|
|
2661
|
-
this.#
|
|
3355
|
+
if (this.#prefsSaveTask !== undefined)
|
|
3356
|
+
return;
|
|
3357
|
+
this.#runPluginSettings((binding, signal) => binding.edit(command.id, signal));
|
|
3358
|
+
return;
|
|
3359
|
+
}
|
|
3360
|
+
if (command.kind === 'set-plugin') {
|
|
3361
|
+
if (this.#prefsSaveTask !== undefined)
|
|
3362
|
+
return;
|
|
3363
|
+
this.#runPluginSettings(async (binding, signal) => { await binding.set?.(command.entry, command.value, signal); });
|
|
2662
3364
|
return;
|
|
2663
3365
|
}
|
|
2664
3366
|
if (command.kind === 'update') {
|
|
@@ -2667,13 +3369,21 @@ export class LocalTui {
|
|
|
2667
3369
|
return;
|
|
2668
3370
|
}
|
|
2669
3371
|
if (command.kind === 'apply') {
|
|
3372
|
+
if (this.#prefsSaveTask !== undefined)
|
|
3373
|
+
return;
|
|
2670
3374
|
if (command.domain === 'features') {
|
|
2671
3375
|
if (command.state.features === undefined)
|
|
2672
3376
|
return;
|
|
3377
|
+
try {
|
|
3378
|
+
this.#writeFeaturePatch(command.state.features);
|
|
3379
|
+
}
|
|
3380
|
+
catch (error) {
|
|
3381
|
+
void this.#settingsError(`Feature toggles were not saved: ${error instanceof Error ? error.message : String(error)}`);
|
|
3382
|
+
return;
|
|
3383
|
+
}
|
|
2673
3384
|
this.#settings = command.state;
|
|
2674
3385
|
this.#features = command.state.features;
|
|
2675
3386
|
this.#render();
|
|
2676
|
-
this.#writeFeaturePatch();
|
|
2677
3387
|
return;
|
|
2678
3388
|
}
|
|
2679
3389
|
if (command.domain === 'agent') {
|
|
@@ -2695,56 +3405,40 @@ export class LocalTui {
|
|
|
2695
3405
|
this.#render();
|
|
2696
3406
|
}
|
|
2697
3407
|
}
|
|
2698
|
-
#
|
|
3408
|
+
#runPluginSettings(operation) {
|
|
2699
3409
|
const binding = this.#pluginSettings;
|
|
2700
3410
|
if (binding === undefined)
|
|
2701
3411
|
return;
|
|
2702
3412
|
const closing = new AbortController();
|
|
3413
|
+
const selected = this.#settings === null ? undefined : tuiSettingItems(this.#settings.prefs, this.#settings.agent, this.#settings.features, this.#settings.plugins, this.#settings.editors, this.#settings.actions)[this.#settings.selected]?.id;
|
|
3414
|
+
this.#catalogVersion++;
|
|
2703
3415
|
this.#pluginSettingsAbort = closing;
|
|
2704
|
-
this.#pluginSettingsTask = Promise.resolve().then(() => binding
|
|
2705
|
-
if (
|
|
2706
|
-
|
|
2707
|
-
|
|
3416
|
+
this.#pluginSettingsTask = Promise.resolve().then(() => operation(binding, closing.signal)).catch(async (error) => {
|
|
3417
|
+
if (closing.signal.aborted || this.#disposed)
|
|
3418
|
+
return;
|
|
3419
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
3420
|
+
await showSettingsError(this, detail, closing.signal);
|
|
3421
|
+
}).finally(async () => {
|
|
3422
|
+
const entries = await this.#pluginSettings?.refresh?.().catch(() => this.#pluginSettings?.entries() ?? []) ?? this.#pluginSettings?.entries() ?? [];
|
|
2708
3423
|
this.#pluginSettingsAbort = undefined;
|
|
2709
3424
|
this.#pluginSettingsTask = undefined;
|
|
2710
|
-
if (this.#
|
|
2711
|
-
this.#
|
|
2712
|
-
...(this.#agentBehavior === undefined ? {} : { agent: this.#agentBehavior }),
|
|
2713
|
-
...(this.#features === undefined ? {} : { features: this.#features }), plugins: this.#pluginSettings?.entries() ?? [] };
|
|
3425
|
+
if (!this.#disposed)
|
|
3426
|
+
this.#rebuildSettings(entries, selected);
|
|
2714
3427
|
if (this.#tty && !this.#disposed)
|
|
2715
3428
|
this.#render();
|
|
2716
3429
|
});
|
|
2717
3430
|
}
|
|
2718
|
-
#applyCopySelector(event) {
|
|
2719
|
-
if (this.#copySelector === null)
|
|
2720
|
-
return;
|
|
2721
|
-
const command = applyCopySelectorEvent(this.#copySelector, event);
|
|
2722
|
-
if (command.kind === 'update') {
|
|
2723
|
-
this.#copySelector = command.state;
|
|
2724
|
-
this.#render();
|
|
2725
|
-
return;
|
|
2726
|
-
}
|
|
2727
|
-
if (command.kind === 'pick') {
|
|
2728
|
-
this.#copySelector = null;
|
|
2729
|
-
void this.#copyPicked(command.item.text, command.item.copyMessage);
|
|
2730
|
-
return;
|
|
2731
|
-
}
|
|
2732
|
-
if (command.kind === 'close') {
|
|
2733
|
-
this.#copySelector = null;
|
|
2734
|
-
this.#render();
|
|
2735
|
-
}
|
|
2736
|
-
}
|
|
2737
3431
|
async #copyPicked(text, label) {
|
|
2738
3432
|
try {
|
|
2739
3433
|
const delivery = await this.#deliverCopy(text);
|
|
2740
3434
|
if (this.#disposed)
|
|
2741
3435
|
return;
|
|
2742
|
-
this.#
|
|
3436
|
+
this.#showTransientStatus(delivery === 'requested' ? 'Sent ' + label + ' to the terminal clipboard' : 'Copied ' + label);
|
|
2743
3437
|
}
|
|
2744
3438
|
catch {
|
|
2745
3439
|
if (this.#disposed)
|
|
2746
3440
|
return;
|
|
2747
|
-
this.#
|
|
3441
|
+
this.#showTransientStatus('Copy failed');
|
|
2748
3442
|
}
|
|
2749
3443
|
this.#render();
|
|
2750
3444
|
}
|
|
@@ -2838,13 +3532,26 @@ export class LocalTui {
|
|
|
2838
3532
|
const atPrefix = at?.query.replaceAll('\\', '/')
|
|
2839
3533
|
?? (token?.kind === 'at' ? parsePathPrefix(token.prefix).raw.replaceAll('\\', '/') : '');
|
|
2840
3534
|
const fuzzyAt = (at !== undefined || token?.kind === 'at') && atPrefix !== '' && !atPrefix.endsWith('/');
|
|
3535
|
+
const shortcutAt = at !== undefined && matchesAtShortcutQuery(at.query, at.quoted);
|
|
2841
3536
|
const harnessAt = at !== undefined
|
|
2842
3537
|
&& (this.#searchFileMentions !== undefined || this.#searchSessions !== undefined);
|
|
2843
|
-
if (fuzzyAt || harnessAt) {
|
|
2844
|
-
|
|
2845
|
-
|
|
3538
|
+
if (fuzzyAt || harnessAt || shortcutAt) {
|
|
3539
|
+
// A `@` query is answered by a debounced search, so almost every keystroke
|
|
3540
|
+
// lands before an answer exists. Clearing the menu here emptied the screen
|
|
3541
|
+
// for the whole debounce-plus-search interval and refilled it afterwards —
|
|
3542
|
+
// one flash per character, and the same again on every delete.
|
|
3543
|
+
//
|
|
3544
|
+
// The rows already on screen still describe the token this keystroke is
|
|
3545
|
+
// extending, so they stay until a real result replaces them. A genuinely
|
|
3546
|
+
// different token closes the menu, because those rows no longer apply.
|
|
3547
|
+
if ((harnessAt || shortcutAt) && !fuzzyAt) {
|
|
3548
|
+
const listing = pathSuggestions(this.#editor.text, this.#editor.cursor, pathOptions, commands);
|
|
3549
|
+
if (listing !== null)
|
|
3550
|
+
this.#setAutocomplete(listing, atPrefix);
|
|
3551
|
+
else if (!this.#acDescribes(atPrefix))
|
|
3552
|
+
this.#ac = null;
|
|
2846
3553
|
}
|
|
2847
|
-
else {
|
|
3554
|
+
else if (!this.#acDescribes(atPrefix)) {
|
|
2848
3555
|
this.#ac = null;
|
|
2849
3556
|
}
|
|
2850
3557
|
const text = this.#editor.text;
|
|
@@ -2855,7 +3562,7 @@ export class LocalTui {
|
|
|
2855
3562
|
return;
|
|
2856
3563
|
const controller = new AbortController();
|
|
2857
3564
|
this.#autocompleteAbort = controller;
|
|
2858
|
-
const search = this.#searchFileMentions === undefined && this.#searchSessions === undefined
|
|
3565
|
+
const search = this.#searchFileMentions === undefined && this.#searchSessions === undefined && !shortcutAt
|
|
2859
3566
|
? searchPathSuggestions(text, cursor, {
|
|
2860
3567
|
...pathOptions,
|
|
2861
3568
|
searchFiles: this.#searchFiles,
|
|
@@ -2866,13 +3573,20 @@ export class LocalTui {
|
|
|
2866
3573
|
searchFiles: this.#searchFiles,
|
|
2867
3574
|
...(this.#searchFileMentions === undefined ? {} : { searchFileMentions: this.#searchFileMentions }),
|
|
2868
3575
|
...(this.#searchSessions === undefined ? {} : { searchSessions: this.#searchSessions }),
|
|
3576
|
+
...(shortcutAt ? { gitContext: resolveGitStatusFiles(this.#cwd) } : {}),
|
|
2869
3577
|
signal: controller.signal,
|
|
2870
3578
|
}, commands);
|
|
2871
3579
|
void search.then((result) => {
|
|
2872
3580
|
if (this.#disposed || controller.signal.aborted || requestId !== this.#autocompleteRequestId)
|
|
2873
3581
|
return;
|
|
2874
3582
|
this.#autocompleteAbort = null;
|
|
2875
|
-
|
|
3583
|
+
// An empty answer for a `@` query means "nothing matched yet", not
|
|
3584
|
+
// "close the menu": keeping the live list is what stops the flash when
|
|
3585
|
+
// a prefix has no rows of its own.
|
|
3586
|
+
if (result !== null)
|
|
3587
|
+
this.#setAutocomplete(result, atPrefix);
|
|
3588
|
+
else if (!this.#acDescribes(atPrefix))
|
|
3589
|
+
this.#setAutocomplete(null);
|
|
2876
3590
|
this.#render();
|
|
2877
3591
|
}).catch((error) => {
|
|
2878
3592
|
if (controller.signal.aborted || requestId !== this.#autocompleteRequestId)
|
|
@@ -2888,7 +3602,16 @@ export class LocalTui {
|
|
|
2888
3602
|
const result = pathSuggestions(this.#editor.text, this.#editor.cursor, pathOptions, commands);
|
|
2889
3603
|
this.#setAutocomplete(result);
|
|
2890
3604
|
}
|
|
2891
|
-
|
|
3605
|
+
/**
|
|
3606
|
+
* Whether the open menu still speaks for the `@` token being typed.
|
|
3607
|
+
*
|
|
3608
|
+
* A menu with no token recorded belongs to a slash or path completion and is
|
|
3609
|
+
* never reused across an `@` keystroke.
|
|
3610
|
+
*/
|
|
3611
|
+
#acDescribes(atPrefix) {
|
|
3612
|
+
return completionDescribes(this.#ac?.query, atPrefix);
|
|
3613
|
+
}
|
|
3614
|
+
#setAutocomplete(result, query) {
|
|
2892
3615
|
if (result === null) {
|
|
2893
3616
|
this.#ac = null;
|
|
2894
3617
|
return;
|
|
@@ -2900,7 +3623,7 @@ export class LocalTui {
|
|
|
2900
3623
|
if (idx >= 0)
|
|
2901
3624
|
selected = idx;
|
|
2902
3625
|
}
|
|
2903
|
-
this.#ac = { items: result.items, selected };
|
|
3626
|
+
this.#ac = { items: result.items, selected, ...(query === undefined ? {} : { query }) };
|
|
2904
3627
|
}
|
|
2905
3628
|
#moveAutocomplete(dir) {
|
|
2906
3629
|
if (this.#ac === null || this.#ac.items.length === 0)
|
|
@@ -2948,50 +3671,69 @@ export class LocalTui {
|
|
|
2948
3671
|
this.#historyNext();
|
|
2949
3672
|
return;
|
|
2950
3673
|
}
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
this.#
|
|
2962
|
-
|
|
2963
|
-
this.#
|
|
2964
|
-
this.#
|
|
2965
|
-
|
|
2966
|
-
this.#
|
|
2967
|
-
this.#
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
3674
|
+
}
|
|
3675
|
+
/**
|
|
3676
|
+
* Show a one-line confirmation that goes away on its own.
|
|
3677
|
+
*
|
|
3678
|
+
* Replaced, never appended: two presses in a row leave one line saying what
|
|
3679
|
+
* the second one did, which is what the reader is looking for.
|
|
3680
|
+
*/
|
|
3681
|
+
#showTransientStatus(text, ttlMs = TRANSIENT_STATUS_MS) {
|
|
3682
|
+
this.#transientStatus = text;
|
|
3683
|
+
if (this.#transientTimer !== null)
|
|
3684
|
+
clearTimeout(this.#transientTimer);
|
|
3685
|
+
this.#transientTimer = setTimeout(() => {
|
|
3686
|
+
this.#transientTimer = null;
|
|
3687
|
+
if (this.#transientStatus !== text)
|
|
3688
|
+
return;
|
|
3689
|
+
this.#transientStatus = undefined;
|
|
3690
|
+
if (!this.#disposed && this.#tty)
|
|
3691
|
+
this.#render();
|
|
3692
|
+
}, ttlMs);
|
|
3693
|
+
this.#transientTimer.unref?.();
|
|
3694
|
+
this.#render();
|
|
3695
|
+
}
|
|
3696
|
+
/** Any keystroke makes the last confirmation stale. */
|
|
3697
|
+
#clearTransientStatus() {
|
|
3698
|
+
if (this.#transientStatus === undefined)
|
|
2974
3699
|
return;
|
|
3700
|
+
this.#transientStatus = undefined;
|
|
3701
|
+
if (this.#transientTimer !== null) {
|
|
3702
|
+
clearTimeout(this.#transientTimer);
|
|
3703
|
+
this.#transientTimer = null;
|
|
2975
3704
|
}
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
3705
|
+
}
|
|
3706
|
+
/**
|
|
3707
|
+
* Show or hide thinking across the whole transcript, and remember the choice.
|
|
3708
|
+
*
|
|
3709
|
+
* A revealed transcript is one where no thought is folded, so the per-thought
|
|
3710
|
+
* openings a reader made are dropped at the same time; keeping them would
|
|
3711
|
+
* leave a thought open inside a transcript that claims to show everything.
|
|
3712
|
+
*/
|
|
3713
|
+
#toggleThinking() {
|
|
3714
|
+
const next = !this.#hideThinking;
|
|
3715
|
+
this.#hideThinking = next;
|
|
3716
|
+
if (!next)
|
|
3717
|
+
this.#expandedReasoning.clear();
|
|
3718
|
+
const persist = this.#persistPrefs;
|
|
3719
|
+
if (persist !== null)
|
|
3720
|
+
void persist(this.prefs(), [{ op: 'set', path: ['hideThinking'], value: next }]);
|
|
3721
|
+
this.#showTransientStatus(next ? 'Thinking: hidden' : 'Thinking: shown');
|
|
3722
|
+
}
|
|
3723
|
+
/** Hand the terminal to the shell and stop until SIGCONT brings it back. */
|
|
3724
|
+
#suspend() {
|
|
3725
|
+
if (process.platform === 'win32')
|
|
2989
3726
|
return;
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
this.#renderer.reset();
|
|
3727
|
+
if (this.#inspecting) {
|
|
3728
|
+
this.#followTail();
|
|
2993
3729
|
this.#render();
|
|
2994
3730
|
}
|
|
3731
|
+
// The shell that takes over gets every mode back; SIGCONT takes them again.
|
|
3732
|
+
this.#handOffTerminal();
|
|
3733
|
+
try {
|
|
3734
|
+
process.kill(process.pid, 'SIGTSTP');
|
|
3735
|
+
}
|
|
3736
|
+
catch { /* no controlling tty */ }
|
|
2995
3737
|
}
|
|
2996
3738
|
#historyPrev() {
|
|
2997
3739
|
if (this.#images.count > 0)
|
|
@@ -3007,7 +3749,7 @@ export class LocalTui {
|
|
|
3007
3749
|
this.#refreshAutocomplete();
|
|
3008
3750
|
this.#render();
|
|
3009
3751
|
}
|
|
3010
|
-
#restoreLatestQueuedSubmission() {
|
|
3752
|
+
#restoreLatestQueuedSubmission(allowNonEmpty = false) {
|
|
3011
3753
|
if (this.#queueEditPending)
|
|
3012
3754
|
return true;
|
|
3013
3755
|
if (this.#queueEditNewer !== null) {
|
|
@@ -3025,11 +3767,14 @@ export class LocalTui {
|
|
|
3025
3767
|
listener();
|
|
3026
3768
|
return true;
|
|
3027
3769
|
}
|
|
3028
|
-
if (this.#
|
|
3770
|
+
if (this.#historyIndex !== 0)
|
|
3771
|
+
return false;
|
|
3772
|
+
const hasDraft = this.#hasComposerContent();
|
|
3773
|
+
if (!allowNonEmpty && hasDraft)
|
|
3029
3774
|
return false;
|
|
3030
3775
|
const submission = this.#queuedSubmissions.pop();
|
|
3031
3776
|
if (submission !== undefined) {
|
|
3032
|
-
this.#queueEditNewer = [];
|
|
3777
|
+
this.#queueEditNewer = hasDraft ? [this.#currentSubmission()] : [];
|
|
3033
3778
|
this.#replaceInput(submission);
|
|
3034
3779
|
return true;
|
|
3035
3780
|
}
|
|
@@ -3069,23 +3814,47 @@ export class LocalTui {
|
|
|
3069
3814
|
this.#quitRequested = true;
|
|
3070
3815
|
}
|
|
3071
3816
|
}
|
|
3817
|
+
/**
|
|
3818
|
+
* Turn composer text into a submission, shared by the main and inspected
|
|
3819
|
+
* composers.
|
|
3820
|
+
*
|
|
3821
|
+
* The composer marker is a draft affordance, not something the reader said,
|
|
3822
|
+
* so it leaves the text; it still records where the picture sat, so the
|
|
3823
|
+
* segments keep that order.
|
|
3824
|
+
*/
|
|
3825
|
+
#buildSubmission(text) {
|
|
3826
|
+
const images = this.#images.copies();
|
|
3827
|
+
const files = this.#files.copies();
|
|
3828
|
+
const typedText = this.#files.text(images.length > 0 ? text.trim() : text);
|
|
3829
|
+
const segments = images.length === 0 ? [] : splitComposerImageMarkers(typedText, images);
|
|
3830
|
+
// Stripping a marker that sat on its own line leaves that line break behind.
|
|
3831
|
+
const submittedText = images.length > 0 ? stripComposerImageMarkers(typedText, images).trim() : typedText;
|
|
3832
|
+
const literal = text === this.#editor.expandedText && (this.#literalInput || this.#editor.startsWithPaste);
|
|
3833
|
+
const historyText = submittedText.replace(/[ \t]{2,}/gu, ' ').trim();
|
|
3834
|
+
return {
|
|
3835
|
+
submission: {
|
|
3836
|
+
text: submittedText,
|
|
3837
|
+
images,
|
|
3838
|
+
...(segments.length === 0 ? {} : { segments }),
|
|
3839
|
+
...(files.length === 0 ? {} : { files }),
|
|
3840
|
+
...(literal ? { literal: true } : {}),
|
|
3841
|
+
},
|
|
3842
|
+
historyText,
|
|
3843
|
+
};
|
|
3844
|
+
}
|
|
3072
3845
|
#submit(text) {
|
|
3073
3846
|
if (this.#prompt !== null) {
|
|
3847
|
+
if (this.#prompt.request.presentation === 'progress')
|
|
3848
|
+
return;
|
|
3074
3849
|
this.#editor.setText('');
|
|
3075
3850
|
const answer = this.#prompt.request.preserveWhitespace === true ? text : text.trim();
|
|
3076
3851
|
this.#finishPrompt(answer.trim() === '' ? null : answer);
|
|
3077
3852
|
this.#render();
|
|
3078
3853
|
return;
|
|
3079
3854
|
}
|
|
3080
|
-
const
|
|
3081
|
-
const files =
|
|
3082
|
-
const submittedText = this.#files.text(images.length > 0 ? text.trim() : text);
|
|
3083
|
-
const literal = text === this.#editor.expandedText && (this.#literalInput || this.#editor.startsWithPaste);
|
|
3084
|
-
const submission = { text: submittedText, images, ...(files.length === 0 ? {} : { files }), ...(literal ? { literal: true } : {}) };
|
|
3855
|
+
const { submission, historyText } = this.#buildSubmission(text);
|
|
3856
|
+
const { images, files = [], literal = false, text: submittedText } = submission;
|
|
3085
3857
|
const queueEditNewer = this.#queueEditNewer;
|
|
3086
|
-
const historyText = stripComposerImageMarkers(submittedText, images)
|
|
3087
|
-
.replace(/[ \t]{2,}/gu, ' ')
|
|
3088
|
-
.trim();
|
|
3089
3858
|
if (historyText !== '' && this.#history[this.#history.length - 1] !== historyText) {
|
|
3090
3859
|
this.#history.push(historyText);
|
|
3091
3860
|
this.#historyStore?.add(historyText);
|
|
@@ -3102,9 +3871,7 @@ export class LocalTui {
|
|
|
3102
3871
|
this.#ac = null;
|
|
3103
3872
|
this.#search = null;
|
|
3104
3873
|
this.#settings = null;
|
|
3105
|
-
this.#copySelector = null;
|
|
3106
3874
|
this.#trajectory = null;
|
|
3107
|
-
this.#agentHub = null;
|
|
3108
3875
|
// Image placeholders are TUI-owned. Mixed image+slash drafts stay a
|
|
3109
3876
|
// submission so restore can keep the original markers; the runner strips
|
|
3110
3877
|
// them only to detect and execute Harness commands.
|
|
@@ -3131,16 +3898,14 @@ export class LocalTui {
|
|
|
3131
3898
|
if (queueEditNewer !== null)
|
|
3132
3899
|
this.#queuedSubmissions.push(...queueEditNewer);
|
|
3133
3900
|
}
|
|
3901
|
+
if (submittedText !== '' || images.length > 0 || files.length > 0) {
|
|
3902
|
+
this.#popStashedDraft();
|
|
3903
|
+
}
|
|
3134
3904
|
this.#render();
|
|
3135
3905
|
}
|
|
3136
3906
|
#submitInspect(text) {
|
|
3137
|
-
const
|
|
3138
|
-
const images =
|
|
3139
|
-
const files = this.#files.copies();
|
|
3140
|
-
const submittedText = this.#files.text(images.length > 0 ? text.trim() : text);
|
|
3141
|
-
const historyText = stripComposerImageMarkers(submittedText, images)
|
|
3142
|
-
.replace(/[ \t]{2,}/gu, ' ')
|
|
3143
|
-
.trim();
|
|
3907
|
+
const { submission, historyText } = this.#buildSubmission(text);
|
|
3908
|
+
const { images, files = [], literal = false, text: submittedText } = submission;
|
|
3144
3909
|
if (historyText !== '' && this.#history[this.#history.length - 1] !== historyText) {
|
|
3145
3910
|
this.#history.push(historyText);
|
|
3146
3911
|
this.#historyStore?.add(historyText);
|
|
@@ -3164,8 +3929,10 @@ export class LocalTui {
|
|
|
3164
3929
|
this.#render();
|
|
3165
3930
|
return;
|
|
3166
3931
|
}
|
|
3932
|
+
const { literal: _literal, ...inspectSubmission } = submission;
|
|
3167
3933
|
for (const listener of this.#inspectSubmits)
|
|
3168
|
-
listener(
|
|
3934
|
+
listener(inspectSubmission);
|
|
3935
|
+
this.#popStashedDraft();
|
|
3169
3936
|
this.#render();
|
|
3170
3937
|
}
|
|
3171
3938
|
#runSlash(name, args = '') {
|
|
@@ -3228,8 +3995,7 @@ export class LocalTui {
|
|
|
3228
3995
|
return;
|
|
3229
3996
|
}
|
|
3230
3997
|
if (command.name === 'tools') {
|
|
3231
|
-
this.#
|
|
3232
|
-
this.#render();
|
|
3998
|
+
this.#runTools();
|
|
3233
3999
|
return;
|
|
3234
4000
|
}
|
|
3235
4001
|
if (args !== '' && args !== 'full') {
|
|
@@ -3237,28 +4003,7 @@ export class LocalTui {
|
|
|
3237
4003
|
this.#render();
|
|
3238
4004
|
return;
|
|
3239
4005
|
}
|
|
3240
|
-
|
|
3241
|
-
this.#state = {
|
|
3242
|
-
...this.#state,
|
|
3243
|
-
blocks: [...this.#state.blocks, {
|
|
3244
|
-
kind: 'commandOutput',
|
|
3245
|
-
command: 'help',
|
|
3246
|
-
text: [
|
|
3247
|
-
formatHelpText(this.#commands()),
|
|
3248
|
-
'',
|
|
3249
|
-
full
|
|
3250
|
-
? `**Keyboard Shortcuts · ${hotkeyCount(this.#keybindings)} shortcuts**`
|
|
3251
|
-
: '**Essential Shortcuts**',
|
|
3252
|
-
'',
|
|
3253
|
-
full
|
|
3254
|
-
? formatHotkeysText(this.#keybindings)
|
|
3255
|
-
: formatEssentialHotkeysText(this.#keybindings),
|
|
3256
|
-
...(full ? [] : ['', 'Use `/help full` to view every keyboard shortcut.']),
|
|
3257
|
-
].join('\n'),
|
|
3258
|
-
}],
|
|
3259
|
-
};
|
|
3260
|
-
this.#focusLatestBlock();
|
|
3261
|
-
this.#render();
|
|
4006
|
+
this.#runHelp(args === 'full');
|
|
3262
4007
|
}
|
|
3263
4008
|
#commands() {
|
|
3264
4009
|
const localNames = new Set(BUILTIN_SLASH_COMMANDS.flatMap((command) => [command.name, ...(command.aliases ?? [])]));
|
|
@@ -3268,6 +4013,7 @@ export class LocalTui {
|
|
|
3268
4013
|
name: command.name,
|
|
3269
4014
|
description: command.description,
|
|
3270
4015
|
...(command.inputHint === undefined ? {} : { inputHint: command.inputHint }),
|
|
4016
|
+
...(command.complete === undefined ? {} : { complete: command.complete }),
|
|
3271
4017
|
}));
|
|
3272
4018
|
return [...BUILTIN_SLASH_COMMANDS, ...runtime];
|
|
3273
4019
|
}
|
|
@@ -3276,10 +4022,24 @@ export class LocalTui {
|
|
|
3276
4022
|
if (pending === null)
|
|
3277
4023
|
return;
|
|
3278
4024
|
this.#prompt = null;
|
|
3279
|
-
|
|
4025
|
+
if (pending.request.notify !== false) {
|
|
4026
|
+
this.#herdr.promptResolved();
|
|
4027
|
+
this.#syncWindowTitle();
|
|
4028
|
+
}
|
|
3280
4029
|
pending.offAbort?.();
|
|
3281
4030
|
pending.offTick?.();
|
|
4031
|
+
const canceledDraft = answer === null && pending.request.onCancelDraft !== undefined
|
|
4032
|
+
? this.#editor.expandedText
|
|
4033
|
+
: '';
|
|
3282
4034
|
this.#editor.restore(pending.savedInput);
|
|
4035
|
+
if (canceledDraft.trim() !== '') {
|
|
4036
|
+
try {
|
|
4037
|
+
pending.request.onCancelDraft?.(canceledDraft);
|
|
4038
|
+
}
|
|
4039
|
+
catch (error) {
|
|
4040
|
+
this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
3283
4043
|
this.#draftFingerprint = this.#draftKey();
|
|
3284
4044
|
pending.resolve(answer);
|
|
3285
4045
|
this.#restoreDisplacedSurface();
|
|
@@ -3296,17 +4056,11 @@ export class LocalTui {
|
|
|
3296
4056
|
#displaceSurface() {
|
|
3297
4057
|
const displaced = this.#trajectory !== null
|
|
3298
4058
|
? { kind: 'trajectory', state: this.#trajectory }
|
|
3299
|
-
: this.#
|
|
3300
|
-
? { kind: '
|
|
3301
|
-
: this.#
|
|
3302
|
-
? { kind: 'settings', state: this.#settings }
|
|
3303
|
-
: this.#copySelector !== null
|
|
3304
|
-
? { kind: 'copySelector', state: this.#copySelector }
|
|
3305
|
-
: this.#search !== null ? { kind: 'search', state: this.#search } : null;
|
|
4059
|
+
: this.#settings !== null
|
|
4060
|
+
? { kind: 'settings', state: this.#settings }
|
|
4061
|
+
: this.#search !== null ? { kind: 'search', state: this.#search } : null;
|
|
3306
4062
|
this.#trajectory = null;
|
|
3307
|
-
this.#agentHub = null;
|
|
3308
4063
|
this.#settings = null;
|
|
3309
|
-
this.#copySelector = null;
|
|
3310
4064
|
this.#search = null;
|
|
3311
4065
|
return displaced;
|
|
3312
4066
|
}
|
|
@@ -3318,43 +4072,71 @@ export class LocalTui {
|
|
|
3318
4072
|
return;
|
|
3319
4073
|
if (displaced.kind === 'trajectory')
|
|
3320
4074
|
this.#trajectory = displaced.state;
|
|
3321
|
-
else if (displaced.kind === '
|
|
3322
|
-
this.#agentHub = displaced.state;
|
|
3323
|
-
else if (displaced.kind === 'settings')
|
|
4075
|
+
else if (displaced.kind === 'settings') {
|
|
3324
4076
|
this.#settings = displaced.state;
|
|
3325
|
-
|
|
3326
|
-
this.#
|
|
4077
|
+
this.#rebuildSettings(this.#pluginSettings?.entries() ?? []);
|
|
4078
|
+
this.#refreshSettingsCatalog();
|
|
4079
|
+
}
|
|
3327
4080
|
else
|
|
3328
4081
|
this.#search = displaced.state;
|
|
3329
4082
|
}
|
|
4083
|
+
/** The pane's answer, offered to `/copy` while it is on screen and finished. */
|
|
4084
|
+
#copyableAside() {
|
|
4085
|
+
if (this.#aside === undefined || this.#aside.status === 'running' || this.#aside.text.trim() === '')
|
|
4086
|
+
return undefined;
|
|
4087
|
+
return { question: this.#aside.question, text: this.#aside.text };
|
|
4088
|
+
}
|
|
4089
|
+
/**
|
|
4090
|
+
* Copy something out of the transcript.
|
|
4091
|
+
*
|
|
4092
|
+
* There used to be four kinds — `text`, `code`, `cmd`, `output` — and each one
|
|
4093
|
+
* asked the reader to remember a word to skip a keystroke in the picker. A kind
|
|
4094
|
+
* that means "the default" is a no-op, and the other three name a row the picker
|
|
4095
|
+
* already shows with its content, its language and its size. One command, one
|
|
4096
|
+
* list: the choice is made by looking, not by recalling.
|
|
4097
|
+
*/
|
|
3330
4098
|
async #runCopy(args) {
|
|
3331
|
-
if (args.trim()
|
|
3332
|
-
|
|
3333
|
-
if (items.length === 0) {
|
|
3334
|
-
this.#notice('Nothing to copy.');
|
|
3335
|
-
this.#render();
|
|
3336
|
-
return;
|
|
3337
|
-
}
|
|
3338
|
-
this.#search = null;
|
|
3339
|
-
this.#ac = null;
|
|
3340
|
-
this.#settings = null;
|
|
3341
|
-
this.#copySelector = createCopySelector(items);
|
|
4099
|
+
if (args.trim() !== '') {
|
|
4100
|
+
this.#notice('Usage: /copy');
|
|
3342
4101
|
this.#render();
|
|
3343
4102
|
return;
|
|
3344
4103
|
}
|
|
3345
|
-
const
|
|
3346
|
-
if (
|
|
3347
|
-
this.#notice('
|
|
4104
|
+
const items = buildCopyTargets(this.#state.blocks, undefined, this.#copyableAside());
|
|
4105
|
+
if (items.length === 0) {
|
|
4106
|
+
this.#notice('Nothing to copy.');
|
|
3348
4107
|
this.#render();
|
|
3349
4108
|
return;
|
|
3350
4109
|
}
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
4110
|
+
/**
|
|
4111
|
+
* The same card as every other list.
|
|
4112
|
+
*
|
|
4113
|
+
* This used to be a bespoke overlay with its own key model: a filter that
|
|
4114
|
+
* stayed inactive until `/` — so typing a letter did nothing and Space
|
|
4115
|
+
* copied — and a nine-row hotkey table of its own. It was the last surface
|
|
4116
|
+
* that did not answer to the shared list vocabulary, which is exactly the
|
|
4117
|
+
* inconsistency the reader noticed.
|
|
4118
|
+
*/
|
|
4119
|
+
const answer = await this.prompt({
|
|
4120
|
+
title: 'Copy',
|
|
4121
|
+
question: items.length === 1 ? 'One thing to copy' : 'Choose what to copy',
|
|
4122
|
+
filterable: true,
|
|
4123
|
+
allowCustom: false,
|
|
4124
|
+
notify: false,
|
|
4125
|
+
options: items.map(item => ({
|
|
4126
|
+
label: item.label,
|
|
4127
|
+
value: item.id,
|
|
4128
|
+
...(item.hint === '' ? {} : { description: item.hint }),
|
|
4129
|
+
// The preview is the payload: choosing blind is what the row labels
|
|
4130
|
+
// cannot prevent when two replies open with the same words.
|
|
4131
|
+
preview: item.text,
|
|
4132
|
+
})),
|
|
4133
|
+
});
|
|
4134
|
+
if (answer === null)
|
|
3355
4135
|
return;
|
|
3356
|
-
|
|
3357
|
-
|
|
4136
|
+
const picked = items.find(item => item.id === answer);
|
|
4137
|
+
if (picked === undefined)
|
|
4138
|
+
return;
|
|
4139
|
+
void this.#copyPicked(picked.text, picked.copyMessage);
|
|
3358
4140
|
}
|
|
3359
4141
|
#runSettings(args) {
|
|
3360
4142
|
if (args.trim() !== '') {
|
|
@@ -3364,9 +4146,60 @@ export class LocalTui {
|
|
|
3364
4146
|
}
|
|
3365
4147
|
this.#search = null;
|
|
3366
4148
|
this.#ac = null;
|
|
3367
|
-
this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, this.#pluginSettings?.entries(), discoverEditors(this.#externalEditor));
|
|
4149
|
+
this.#settings = createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, this.#pluginSettings?.entries(), discoverEditors(this.#externalEditor), this.#settingsActions());
|
|
4150
|
+
this.#refreshSettingsCatalog();
|
|
3368
4151
|
this.#render();
|
|
3369
4152
|
}
|
|
4153
|
+
#settingsActions() {
|
|
4154
|
+
const runtimeActions = [
|
|
4155
|
+
{ id: 'command:model', label: 'Model and reasoning', description: 'Open /model; changes the current selection and saves the default route.', value: [this.#model, this.#reasoningEffort].filter(Boolean).join(' · '), values: [], action: 'model' },
|
|
4156
|
+
{ id: 'command:permission', label: 'Access', description: 'Open /permission to choose access for this session.', value: this.#sessionControls?.permission ?? 'Choose', values: [], action: 'permission' },
|
|
4157
|
+
{ id: 'command:agent', label: 'Agent preset', description: 'Open /agent to choose the session Agent preset.', value: this.#sessionControls?.agentPreset ?? 'Choose', values: [], action: 'agent' },
|
|
4158
|
+
].filter(item => this.#runtimeCommands.some(command => command.name === item.action));
|
|
4159
|
+
const customBindings = Object.entries(this.#keybindings).filter(([key, action]) => DEFAULT_KEYBINDINGS[key] !== action).length;
|
|
4160
|
+
return [
|
|
4161
|
+
...runtimeActions,
|
|
4162
|
+
...(this.#keybindingsPath === undefined ? [] : [{
|
|
4163
|
+
id: 'action:keybindings',
|
|
4164
|
+
label: 'Keybindings',
|
|
4165
|
+
description: 'Remap keyboard shortcuts and save to keybindings.json.',
|
|
4166
|
+
value: customBindings === 0 ? 'Default' : `${customBindings} custom`,
|
|
4167
|
+
values: [],
|
|
4168
|
+
action: 'keybindings',
|
|
4169
|
+
group: 'Input',
|
|
4170
|
+
}]),
|
|
4171
|
+
];
|
|
4172
|
+
}
|
|
4173
|
+
#refreshSettingsCatalog() {
|
|
4174
|
+
const binding = this.#pluginSettings;
|
|
4175
|
+
if (this.#settings === null || binding?.refresh === undefined || this.#disposed)
|
|
4176
|
+
return;
|
|
4177
|
+
const version = ++this.#catalogVersion;
|
|
4178
|
+
void binding.refresh().then(entries => {
|
|
4179
|
+
if (version !== this.#catalogVersion || binding !== this.#pluginSettings || this.#settings === null || this.#disposed)
|
|
4180
|
+
return;
|
|
4181
|
+
this.#rebuildSettings(entries);
|
|
4182
|
+
this.#render();
|
|
4183
|
+
}).catch(() => { });
|
|
4184
|
+
}
|
|
4185
|
+
#rebuildSettings(entries, focus) {
|
|
4186
|
+
if (this.#settings === null || this.#disposed)
|
|
4187
|
+
return;
|
|
4188
|
+
this.#settings = refreshSettings(this.#settings, createSettings(this.prefs(), focus, this.#agentBehavior, this.#features, entries, discoverEditors(this.#externalEditor), this.#settingsActions()));
|
|
4189
|
+
}
|
|
4190
|
+
#syncSettingsActions() {
|
|
4191
|
+
if (this.#disposed)
|
|
4192
|
+
return;
|
|
4193
|
+
const entries = this.#pluginSettings?.entries() ?? [];
|
|
4194
|
+
if (this.#settings !== null)
|
|
4195
|
+
this.#rebuildSettings(entries);
|
|
4196
|
+
if (this.#promptDisplaced?.kind === 'settings') {
|
|
4197
|
+
this.#promptDisplaced = {
|
|
4198
|
+
kind: 'settings',
|
|
4199
|
+
state: refreshSettings(this.#promptDisplaced.state, createSettings(this.prefs(), undefined, this.#agentBehavior, this.#features, entries, discoverEditors(this.#externalEditor), this.#settingsActions())),
|
|
4200
|
+
};
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
3370
4203
|
#notice(text) {
|
|
3371
4204
|
this.#state = {
|
|
3372
4205
|
...this.#state,
|
|
@@ -3374,40 +4207,59 @@ export class LocalTui {
|
|
|
3374
4207
|
};
|
|
3375
4208
|
}
|
|
3376
4209
|
#configureNotifications() {
|
|
3377
|
-
const thresholds = { '15s': 15_000, '30s': 30_000, '1m': 60_000, '2m': 120_000 };
|
|
3378
4210
|
this.#notifications.configure({
|
|
3379
4211
|
policy: this.#notificationPolicy,
|
|
3380
|
-
thresholdMs:
|
|
4212
|
+
thresholdMs: NOTIFICATION_THRESHOLD_MS,
|
|
3381
4213
|
});
|
|
3382
4214
|
}
|
|
3383
4215
|
#emitNotification(notification) {
|
|
3384
4216
|
if (notification === undefined || !this.#tty || this.#disposed)
|
|
3385
4217
|
return;
|
|
3386
|
-
if (this.#
|
|
4218
|
+
if (this.#notificationPolicy === 'unfocused' && this.#terminalFocused === true)
|
|
3387
4219
|
return;
|
|
3388
4220
|
this.#notificationQueue.offer(notification);
|
|
3389
4221
|
if (this.#notificationTimer !== undefined)
|
|
3390
4222
|
return;
|
|
3391
4223
|
this.#notificationTimer = setTimeout(() => {
|
|
3392
4224
|
this.#notificationTimer = undefined;
|
|
3393
|
-
const pending = this.#notificationQueue.take(
|
|
4225
|
+
const pending = this.#notificationQueue.take();
|
|
3394
4226
|
if (pending === undefined || this.#disposed || this.#notificationPolicy === 'off')
|
|
3395
4227
|
return;
|
|
4228
|
+
// Re-read focus at delivery time: the reader may have come back inside the
|
|
4229
|
+
// coalescing window, and a turn finishing while they watch is not news.
|
|
4230
|
+
if (this.#notificationPolicy === 'unfocused' && this.#terminalFocused === true)
|
|
4231
|
+
return;
|
|
3396
4232
|
const sequence = terminalNotificationSequence(pending);
|
|
3397
4233
|
if (sequence !== '')
|
|
3398
4234
|
this.#term.output.write(sequence);
|
|
3399
4235
|
}, 25);
|
|
3400
4236
|
}
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
4237
|
+
/**
|
|
4238
|
+
* Help is reference, not conversation: 60 rows of it pushed into immutable
|
|
4239
|
+
* scrollback and vanished on resume, so the reader paid for it twice. It opens
|
|
4240
|
+
* in the reading surface instead, where it can be searched and left with Esc.
|
|
4241
|
+
*/
|
|
4242
|
+
#runHelp(full) {
|
|
4243
|
+
const text = [
|
|
4244
|
+
formatHelpText(this.#commands()),
|
|
4245
|
+
'',
|
|
4246
|
+
full
|
|
4247
|
+
? `**Keyboard Shortcuts · ${hotkeyCount(this.#keybindings)} shortcuts**`
|
|
4248
|
+
: '**Essential Shortcuts**',
|
|
4249
|
+
'',
|
|
4250
|
+
full ? formatHotkeysText(this.#keybindings) : formatEssentialHotkeysText(this.#keybindings),
|
|
4251
|
+
...(full ? [] : ['', 'Use `/help full` to view every keyboard shortcut.']),
|
|
4252
|
+
].join('\n');
|
|
4253
|
+
this.commandOutput('help', text, { focus: true });
|
|
3407
4254
|
}
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
4255
|
+
/**
|
|
4256
|
+
* Tools are reference, like the command list: a table of names and
|
|
4257
|
+
* descriptions the reader consults and dismisses, not a step of the work.
|
|
4258
|
+
*/
|
|
4259
|
+
#runTools() {
|
|
4260
|
+
// The first row of a command result is its heading, so a table needs one.
|
|
4261
|
+
const heading = `Tools · ${this.#tools.length} active`;
|
|
4262
|
+
this.commandOutput('tools', `${heading}\n\n${formatToolsText(this.#tools)}`, { focus: true });
|
|
3411
4263
|
}
|
|
3412
4264
|
/** Resolve a configured action for one key id, tolerating camelCase ids. */
|
|
3413
4265
|
#boundAction(id) {
|
|
@@ -3415,13 +4267,75 @@ export class LocalTui {
|
|
|
3415
4267
|
}
|
|
3416
4268
|
/** Run one configured action; returns true when the event was consumed. */
|
|
3417
4269
|
#runAction(action) {
|
|
3418
|
-
|
|
4270
|
+
// `interrupt` is resolved further down, after every surface that wants to
|
|
4271
|
+
// interpret Escape itself — a settings search clears before the overlay
|
|
4272
|
+
// closes. Claiming it here would skip that layering.
|
|
4273
|
+
if (action === 'interrupt')
|
|
4274
|
+
return false;
|
|
4275
|
+
if (action === 'clear-composer')
|
|
4276
|
+
return this.#runClearComposer();
|
|
4277
|
+
if (action === 'quit') {
|
|
4278
|
+
if (this.#hasComposerContent() || this.#editor.text !== '')
|
|
4279
|
+
return false;
|
|
4280
|
+
this.#quit();
|
|
4281
|
+
return true;
|
|
4282
|
+
}
|
|
4283
|
+
if (action === 'suspend') {
|
|
4284
|
+
this.#suspend();
|
|
4285
|
+
return true;
|
|
4286
|
+
}
|
|
4287
|
+
if (action === 'reset-display') {
|
|
4288
|
+
this.#renderer.reset();
|
|
4289
|
+
this.#render();
|
|
4290
|
+
return true;
|
|
4291
|
+
}
|
|
4292
|
+
if (this.#prompt !== null || this.#settings !== null)
|
|
3419
4293
|
return false;
|
|
4294
|
+
if (action === 'select-model') {
|
|
4295
|
+
this.#submit('/model');
|
|
4296
|
+
return true;
|
|
4297
|
+
}
|
|
4298
|
+
if (action === 'cycle-thinking') {
|
|
4299
|
+
this.#submit('/model reasoning');
|
|
4300
|
+
return true;
|
|
4301
|
+
}
|
|
4302
|
+
if (action === 'toggle-thinking') {
|
|
4303
|
+
this.#toggleThinking();
|
|
4304
|
+
return true;
|
|
4305
|
+
}
|
|
4306
|
+
if (action === 'dequeue')
|
|
4307
|
+
return this.#restoreLatestQueuedSubmission(true);
|
|
4308
|
+
if (action === 'stash-draft') {
|
|
4309
|
+
if (this.#search !== null)
|
|
4310
|
+
return false;
|
|
4311
|
+
this.#stashOrRestoreDraft();
|
|
4312
|
+
return true;
|
|
4313
|
+
}
|
|
3420
4314
|
if (action === 'preview-images') {
|
|
3421
4315
|
if (this.#search !== null || this.#inspected !== undefined)
|
|
3422
4316
|
return false;
|
|
3423
|
-
if (this.#images.count === 0)
|
|
3424
|
-
|
|
4317
|
+
if (this.#images.count === 0) {
|
|
4318
|
+
// No draft image: preview the newest image the conversation carries,
|
|
4319
|
+
// which is the only way a settled card can still show its picture.
|
|
4320
|
+
const ref = this.#latestImageRef();
|
|
4321
|
+
if (ref === undefined)
|
|
4322
|
+
this.notice('Paste an image before opening its preview.');
|
|
4323
|
+
else if (this.#readImageInput === undefined)
|
|
4324
|
+
this.notice('This deployment cannot reopen that image.');
|
|
4325
|
+
else if (this.#imagePreviewAbort === undefined) {
|
|
4326
|
+
const abort = new AbortController();
|
|
4327
|
+
this.#imagePreviewAbort = abort;
|
|
4328
|
+
void this.#readImageInput(ref, abort.signal)
|
|
4329
|
+
.then(image => image === undefined
|
|
4330
|
+
? this.notice('That image could not be read back for a preview.', { level: 'error' })
|
|
4331
|
+
: previewImages(this, [image], abort.signal))
|
|
4332
|
+
.catch((error) => { if (!abort.signal.aborted && !this.#disposed)
|
|
4333
|
+
this.notice(error instanceof Error ? error.message : String(error), { level: 'error' }); })
|
|
4334
|
+
.finally(() => { if (this.#imagePreviewAbort === abort)
|
|
4335
|
+
this.#imagePreviewAbort = undefined; });
|
|
4336
|
+
}
|
|
4337
|
+
return true;
|
|
4338
|
+
}
|
|
3425
4339
|
else if (this.#imagePreviewAbort === undefined) {
|
|
3426
4340
|
const abort = new AbortController();
|
|
3427
4341
|
const revision = this.#images.revision;
|
|
@@ -3496,10 +4410,27 @@ export class LocalTui {
|
|
|
3496
4410
|
return true;
|
|
3497
4411
|
}
|
|
3498
4412
|
if (action === 'review-changes') {
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
4413
|
+
// The keyboard twin of right-clicking a tool's path: open the files the
|
|
4414
|
+
// tool under the viewport declared. It shares the mouse path's region
|
|
4415
|
+
// lookup, so the two can never disagree about which call is under the
|
|
4416
|
+
// reader's eye.
|
|
4417
|
+
//
|
|
4418
|
+
// This used to also derive the turn under the viewport and forward
|
|
4419
|
+
// `/diff turn N` when no tool was there — twenty lines of anchor
|
|
4420
|
+
// backtracking to reach what `/diff` already asks for on its own. A
|
|
4421
|
+
// reader who wants this turn's changes types `/diff`.
|
|
4422
|
+
const rows = this.#renderer.viewport().map((_, row) => this.#renderer.documentRowAt(row)).filter((row) => row !== undefined);
|
|
4423
|
+
const top = this.#follow ? undefined : rows[0];
|
|
4424
|
+
const regions = this.#fileActionFrame?.transcript?.fileRegions ?? [];
|
|
4425
|
+
const region = (top === undefined ? undefined : regions.find(region => top >= region.start && top < region.end))
|
|
4426
|
+
?? (this.#inspecting ? regions.find(region => this.#state.blocks[region.block]?.kind === 'tool' && rows.some(row => row >= region.start && row < region.end)) : undefined);
|
|
4427
|
+
const block = this.#state.blocks[region?.block ?? (this.#viewportBlock() ?? this.#state.blocks.length - 1)];
|
|
4428
|
+
const files = block?.kind === 'tool' ? blockFileAction(block) : undefined;
|
|
4429
|
+
if (files === undefined) {
|
|
4430
|
+
this.#showTransientStatus('No tool files here · use /diff', 4_000);
|
|
4431
|
+
return true;
|
|
4432
|
+
}
|
|
4433
|
+
this.#openFileAction(files);
|
|
3503
4434
|
return true;
|
|
3504
4435
|
}
|
|
3505
4436
|
if (action === 'scroll-page-up') {
|
|
@@ -3558,10 +4489,6 @@ export class LocalTui {
|
|
|
3558
4489
|
this.#render();
|
|
3559
4490
|
return true;
|
|
3560
4491
|
}
|
|
3561
|
-
if (action === 'inspect-subagent') {
|
|
3562
|
-
void this.#pickSubagent();
|
|
3563
|
-
return true;
|
|
3564
|
-
}
|
|
3565
4492
|
if (action === 'retry') {
|
|
3566
4493
|
this.#submit('/retry');
|
|
3567
4494
|
return true;
|
|
@@ -3574,10 +4501,6 @@ export class LocalTui {
|
|
|
3574
4501
|
this.#submit('/model previous');
|
|
3575
4502
|
return true;
|
|
3576
4503
|
}
|
|
3577
|
-
if (action === 'cycle-reasoning') {
|
|
3578
|
-
this.#submit('/model reasoning');
|
|
3579
|
-
return true;
|
|
3580
|
-
}
|
|
3581
4504
|
if (action === 'copy-prompt') {
|
|
3582
4505
|
void this.#copyPicked(this.#editor.expandedText, 'current prompt');
|
|
3583
4506
|
return true;
|
|
@@ -3591,29 +4514,212 @@ export class LocalTui {
|
|
|
3591
4514
|
this.#startAsyncPaste(this.#pasteClipboard());
|
|
3592
4515
|
return true;
|
|
3593
4516
|
}
|
|
4517
|
+
// A queued follow-up is the one action that must still work while the turn
|
|
4518
|
+
// it belongs to is running; Enter is which-turn, this is after-this-turn.
|
|
4519
|
+
if (action === 'follow-up') {
|
|
4520
|
+
if (this.#editor.expandedText.trim() === '')
|
|
4521
|
+
return false;
|
|
4522
|
+
this.#submit(this.#editor.expandedText);
|
|
4523
|
+
return true;
|
|
4524
|
+
}
|
|
4525
|
+
if (action !== 'external-editor')
|
|
4526
|
+
return false;
|
|
3594
4527
|
try {
|
|
3595
|
-
this.#
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
this.#images.reconcile();
|
|
4528
|
+
this.#handTerminal(() => {
|
|
4529
|
+
this.#literalInput ||= this.#editor.startsWithPaste;
|
|
4530
|
+
const text = editExternally(this.#editor.expandedText, this.#externalEditor);
|
|
4531
|
+
this.#editor.setText(text);
|
|
4532
|
+
this.#images.reconcile();
|
|
4533
|
+
});
|
|
3602
4534
|
}
|
|
3603
4535
|
catch (error) {
|
|
3604
4536
|
this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
|
|
3605
4537
|
}
|
|
3606
4538
|
finally {
|
|
3607
4539
|
if (!this.#disposed) {
|
|
3608
|
-
this.#term.input.setRawMode?.(true);
|
|
3609
|
-
this.#term.output.write('\x1b[?1004h');
|
|
3610
|
-
this.#renderer.reset();
|
|
3611
4540
|
this.#refreshAutocomplete();
|
|
3612
4541
|
this.#render();
|
|
3613
4542
|
}
|
|
3614
4543
|
}
|
|
3615
4544
|
return true;
|
|
3616
4545
|
}
|
|
4546
|
+
#openPlanFeedbackEditor(prompt) {
|
|
4547
|
+
try {
|
|
4548
|
+
this.#handTerminal(() => {
|
|
4549
|
+
const initial = prompt.feedback === true ? this.#editor.expandedText : '';
|
|
4550
|
+
const text = editExternally(initial, this.#externalEditor);
|
|
4551
|
+
this.#editor.setText(text);
|
|
4552
|
+
this.#prompt = { ...prompt, feedback: true };
|
|
4553
|
+
});
|
|
4554
|
+
}
|
|
4555
|
+
catch (error) {
|
|
4556
|
+
this.notice(error instanceof Error ? error.message : String(error), { level: 'error' });
|
|
4557
|
+
}
|
|
4558
|
+
finally {
|
|
4559
|
+
if (!this.#disposed)
|
|
4560
|
+
this.#render();
|
|
4561
|
+
}
|
|
4562
|
+
}
|
|
4563
|
+
#hasComposerContent() {
|
|
4564
|
+
return this.#editor.text !== '' || this.#images.count > 0 || this.#files.copies().length > 0;
|
|
4565
|
+
}
|
|
4566
|
+
#stashScope() {
|
|
4567
|
+
return this.#inspected?.id ?? this.#sessionId ?? '__default__';
|
|
4568
|
+
}
|
|
4569
|
+
#clearComposerWithUndo(options = {}) {
|
|
4570
|
+
const hadContent = this.#hasComposerContent();
|
|
4571
|
+
const snapshot = hadContent ? this.#captureDraft() : undefined;
|
|
4572
|
+
this.#literalInput = false;
|
|
4573
|
+
this.#editor.clear();
|
|
4574
|
+
this.#images.clear();
|
|
4575
|
+
this.#files.clear();
|
|
4576
|
+
if (options.clearQueueEdit) {
|
|
4577
|
+
this.#queueEditNewer = null;
|
|
4578
|
+
this.#queueEditPending = false;
|
|
4579
|
+
}
|
|
4580
|
+
if (options.closeSearch)
|
|
4581
|
+
this.#search = null;
|
|
4582
|
+
this.#historyIndex = 0;
|
|
4583
|
+
this.#clearDraft();
|
|
4584
|
+
this.#ac = null;
|
|
4585
|
+
if (snapshot !== undefined) {
|
|
4586
|
+
this.#clearedDraft = { draft: snapshot, revision: this.#editor.revision };
|
|
4587
|
+
}
|
|
4588
|
+
this.#render();
|
|
4589
|
+
}
|
|
4590
|
+
#stashOrRestoreDraft() {
|
|
4591
|
+
const scope = this.#stashScope();
|
|
4592
|
+
const stashed = this.#stashedDrafts.get(scope);
|
|
4593
|
+
if (this.#hasComposerContent()) {
|
|
4594
|
+
const current = this.#captureDraft();
|
|
4595
|
+
this.#stashedDrafts.set(scope, current);
|
|
4596
|
+
if (stashed !== undefined) {
|
|
4597
|
+
this.#restoreDraft(stashed);
|
|
4598
|
+
this.notice('Swapped composer draft with stashed draft.');
|
|
4599
|
+
}
|
|
4600
|
+
else {
|
|
4601
|
+
this.#literalInput = false;
|
|
4602
|
+
this.#editor.setText('');
|
|
4603
|
+
this.#images.clear();
|
|
4604
|
+
this.#files.clear();
|
|
4605
|
+
this.#clearDraft();
|
|
4606
|
+
this.notice('Stashed composer draft. Send a message or press Ctrl+S again to restore.');
|
|
4607
|
+
}
|
|
4608
|
+
this.#historyIndex = 0;
|
|
4609
|
+
this.#ac = null;
|
|
4610
|
+
this.#render();
|
|
4611
|
+
return;
|
|
4612
|
+
}
|
|
4613
|
+
if (stashed !== undefined) {
|
|
4614
|
+
this.#stashedDrafts.delete(scope);
|
|
4615
|
+
this.#restoreDraft(stashed);
|
|
4616
|
+
this.#historyIndex = 0;
|
|
4617
|
+
this.#refreshAutocomplete();
|
|
4618
|
+
this.notice('Restored stashed draft.');
|
|
4619
|
+
this.#render();
|
|
4620
|
+
return;
|
|
4621
|
+
}
|
|
4622
|
+
this.notice('Nothing to stash.');
|
|
4623
|
+
}
|
|
4624
|
+
#popStashedDraft() {
|
|
4625
|
+
if (this.#hasComposerContent())
|
|
4626
|
+
return;
|
|
4627
|
+
const scope = this.#stashScope();
|
|
4628
|
+
const stashed = this.#stashedDrafts.get(scope);
|
|
4629
|
+
if (stashed === undefined)
|
|
4630
|
+
return;
|
|
4631
|
+
this.#stashedDrafts.delete(scope);
|
|
4632
|
+
this.#restoreDraft(stashed);
|
|
4633
|
+
this.#historyIndex = 0;
|
|
4634
|
+
this.#refreshAutocomplete();
|
|
4635
|
+
}
|
|
4636
|
+
async #editKeybindings() {
|
|
4637
|
+
if (this.#keybindingsPath === undefined)
|
|
4638
|
+
return;
|
|
4639
|
+
while (!this.#disposed) {
|
|
4640
|
+
const options = [
|
|
4641
|
+
...TUI_ACTIONS.map(action => {
|
|
4642
|
+
const keys = keysForAction(this.#keybindings, action);
|
|
4643
|
+
const defaultKeys = keysForAction(DEFAULT_KEYBINDINGS, action);
|
|
4644
|
+
return {
|
|
4645
|
+
label: `${TUI_ACTION_LABELS[action]} · ${keys}`,
|
|
4646
|
+
value: action,
|
|
4647
|
+
description: keys === defaultKeys ? `Action: ${action}` : `Action: ${action} (default: ${defaultKeys})`,
|
|
4648
|
+
};
|
|
4649
|
+
}),
|
|
4650
|
+
{
|
|
4651
|
+
label: 'Reset all keybindings to defaults',
|
|
4652
|
+
value: '__reset_all__',
|
|
4653
|
+
description: 'Restore default shortcuts in keybindings.json',
|
|
4654
|
+
},
|
|
4655
|
+
];
|
|
4656
|
+
const choice = await this.prompt({
|
|
4657
|
+
title: 'Keybindings',
|
|
4658
|
+
question: 'Choose an action to remap, or Esc to return to /settings.',
|
|
4659
|
+
options,
|
|
4660
|
+
filterable: true,
|
|
4661
|
+
allowCustom: false,
|
|
4662
|
+
notify: false,
|
|
4663
|
+
});
|
|
4664
|
+
if (choice === null || this.#disposed)
|
|
4665
|
+
break;
|
|
4666
|
+
if (choice === '__reset_all__') {
|
|
4667
|
+
try {
|
|
4668
|
+
saveKeybindings(this.#keybindingsPath, DEFAULT_KEYBINDINGS);
|
|
4669
|
+
this.#keybindings = loadKeybindings(this.#keybindingsPath);
|
|
4670
|
+
if (this.#settings !== null)
|
|
4671
|
+
this.#rebuildSettings(this.#pluginSettings?.entries() ?? [], 'action:keybindings');
|
|
4672
|
+
this.notice('Reset keybindings to defaults.');
|
|
4673
|
+
}
|
|
4674
|
+
catch (error) {
|
|
4675
|
+
this.notice(`Could not save keybindings: ${error instanceof Error ? error.message : String(error)}`, { level: 'error' });
|
|
4676
|
+
}
|
|
4677
|
+
continue;
|
|
4678
|
+
}
|
|
4679
|
+
const action = choice;
|
|
4680
|
+
if (!TUI_ACTIONS.includes(action))
|
|
4681
|
+
continue;
|
|
4682
|
+
const currentRawKeys = Object.entries(this.#keybindings).filter(([, bound]) => bound === action).map(([key]) => key);
|
|
4683
|
+
const defaultKeys = keysForAction(DEFAULT_KEYBINDINGS, action);
|
|
4684
|
+
const entered = await this.prompt({
|
|
4685
|
+
title: `Remap ${TUI_ACTION_LABELS[action]}`,
|
|
4686
|
+
question: `Enter key chord (e.g. ctrl+s, alt+k), "default" (${defaultKeys}), or "none" to unbind.`,
|
|
4687
|
+
initialInput: currentRawKeys[0] ?? '',
|
|
4688
|
+
allowCustom: true,
|
|
4689
|
+
notify: false,
|
|
4690
|
+
});
|
|
4691
|
+
if (entered === null || this.#disposed)
|
|
4692
|
+
continue;
|
|
4693
|
+
const normalized = entered.trim().toLowerCase();
|
|
4694
|
+
if (normalized === '')
|
|
4695
|
+
continue;
|
|
4696
|
+
const next = { ...this.#keybindings };
|
|
4697
|
+
for (const [key, bound] of Object.entries(next)) {
|
|
4698
|
+
if (bound === action)
|
|
4699
|
+
delete next[key];
|
|
4700
|
+
}
|
|
4701
|
+
if (normalized === 'default') {
|
|
4702
|
+
for (const [key, bound] of Object.entries(DEFAULT_KEYBINDINGS)) {
|
|
4703
|
+
if (bound === action)
|
|
4704
|
+
next[key] = action;
|
|
4705
|
+
}
|
|
4706
|
+
}
|
|
4707
|
+
else if (normalized !== 'none' && normalized !== 'unbind') {
|
|
4708
|
+
next[normalized] = action;
|
|
4709
|
+
}
|
|
4710
|
+
try {
|
|
4711
|
+
saveKeybindings(this.#keybindingsPath, next);
|
|
4712
|
+
this.#keybindings = loadKeybindings(this.#keybindingsPath);
|
|
4713
|
+
if (this.#settings !== null)
|
|
4714
|
+
this.#rebuildSettings(this.#pluginSettings?.entries() ?? [], 'action:keybindings');
|
|
4715
|
+
}
|
|
4716
|
+
catch (error) {
|
|
4717
|
+
this.notice(`Could not save keybindings: ${error instanceof Error ? error.message : String(error)}`, { level: 'error' });
|
|
4718
|
+
}
|
|
4719
|
+
}
|
|
4720
|
+
if (this.#tty && !this.#disposed)
|
|
4721
|
+
this.#render();
|
|
4722
|
+
}
|
|
3617
4723
|
/**
|
|
3618
4724
|
* Block index the reader is looking at: the first one that starts at or below
|
|
3619
4725
|
* the top of the transcript window. A collapsed row has to stay reachable
|
|
@@ -3643,6 +4749,22 @@ export class LocalTui {
|
|
|
3643
4749
|
return;
|
|
3644
4750
|
}
|
|
3645
4751
|
const anchor = this.#viewportBlock();
|
|
4752
|
+
/**
|
|
4753
|
+
* A long command result and a collapsed side answer fold the same way, and
|
|
4754
|
+
* this is the key that opens either. It is answered before the run lookup
|
|
4755
|
+
* because neither is a run: the reader aimed at those rows, not at the run
|
|
4756
|
+
* above them.
|
|
4757
|
+
*/
|
|
4758
|
+
const anchored = anchor === undefined ? undefined : this.#state.blocks[anchor];
|
|
4759
|
+
if (anchor !== undefined && (anchored?.kind === 'commandOutput' || anchored?.kind === 'aside')) {
|
|
4760
|
+
if (this.#expandedOutputs.has(anchored))
|
|
4761
|
+
this.#expandedOutputs.delete(anchored);
|
|
4762
|
+
else
|
|
4763
|
+
this.#expandedOutputs.add(anchored);
|
|
4764
|
+
this.#inspect(anchor);
|
|
4765
|
+
this.#render();
|
|
4766
|
+
return;
|
|
4767
|
+
}
|
|
3646
4768
|
const groups = turnGroups(this.#state).filter(item => !item.live);
|
|
3647
4769
|
// The answer, changed-file record, and following notices still address
|
|
3648
4770
|
// their turn when the reader is following the tail.
|
|
@@ -3671,11 +4793,6 @@ export class LocalTui {
|
|
|
3671
4793
|
this.#render();
|
|
3672
4794
|
return;
|
|
3673
4795
|
}
|
|
3674
|
-
if (anchor !== undefined && this.#state.blocks[anchor]?.kind === 'toolCatalog') {
|
|
3675
|
-
this.#toolsExpanded = !this.#toolsExpanded;
|
|
3676
|
-
this.#inspect(anchor);
|
|
3677
|
-
this.#render();
|
|
3678
|
-
}
|
|
3679
4796
|
// A live turn already has the process presentation; Alt+O owns tool details.
|
|
3680
4797
|
}
|
|
3681
4798
|
#detailScope(group) {
|
|
@@ -3738,20 +4855,47 @@ export class LocalTui {
|
|
|
3738
4855
|
else
|
|
3739
4856
|
set.add(key);
|
|
3740
4857
|
}
|
|
4858
|
+
/**
|
|
4859
|
+
* Every occurrence of the active query, in reading order.
|
|
4860
|
+
*
|
|
4861
|
+
* Stepping needs the same list the painter uses, so it asks the renderer rather
|
|
4862
|
+
* than scanning source text: a hit inside a folded row or a wrapped Markdown
|
|
4863
|
+
* table exists only in the rendered rows.
|
|
4864
|
+
*/
|
|
4865
|
+
#searchOccurrences() {
|
|
4866
|
+
const search = this.#transcriptSearch;
|
|
4867
|
+
if (search === null || search.query.trim() === '')
|
|
4868
|
+
return [];
|
|
4869
|
+
const options = {
|
|
4870
|
+
width: this.#term.width(),
|
|
4871
|
+
colors: this.#colors,
|
|
4872
|
+
themeName: this.#themeName,
|
|
4873
|
+
...(this.#colorDepth === undefined ? {} : { colorDepth: this.#colorDepth }),
|
|
4874
|
+
mathMode: this.#mathMode,
|
|
4875
|
+
toolsExpanded: this.#toolsExpanded,
|
|
4876
|
+
expandedOutputs: this.#expandedOutputs,
|
|
4877
|
+
hideThinking: this.#hideThinking,
|
|
4878
|
+
expandedTools: this.#detailedToolIds(),
|
|
4879
|
+
...(this.#openedGroups === undefined ? {} : { openedGroups: this.#openedGroups }),
|
|
4880
|
+
};
|
|
4881
|
+
return searchMatches(renderedBlocks(this.#state, options), search.query);
|
|
4882
|
+
}
|
|
3741
4883
|
/** Fold one event into the active transcript search. */
|
|
3742
4884
|
#applyTranscriptSearch(event) {
|
|
3743
4885
|
const state = this.#transcriptSearch;
|
|
3744
4886
|
if (state === null)
|
|
3745
4887
|
return;
|
|
3746
|
-
const command = applyTranscriptSearchEvent(state, event, this.#
|
|
4888
|
+
const command = applyTranscriptSearchEvent(state, event, this.#searchOccurrences());
|
|
3747
4889
|
if (command.kind === 'close') {
|
|
3748
4890
|
this.#transcriptSearch = null;
|
|
3749
4891
|
this.#render();
|
|
3750
4892
|
return;
|
|
3751
4893
|
}
|
|
3752
4894
|
this.#transcriptSearch = command.state;
|
|
3753
|
-
if (command.kind === 'focus')
|
|
3754
|
-
this.#focusBlock = command.block;
|
|
4895
|
+
if (command.kind === 'focus') {
|
|
4896
|
+
this.#focusBlock = command.match.block;
|
|
4897
|
+
this.#focusBlockEdge = 'start';
|
|
4898
|
+
}
|
|
3755
4899
|
this.#render();
|
|
3756
4900
|
}
|
|
3757
4901
|
}
|
|
@@ -3775,6 +4919,21 @@ export function apply(ctx, config) {
|
|
|
3775
4919
|
};
|
|
3776
4920
|
const tui = new LocalTui(term, config.model, config.colors.get(), config.theme.get(), copyToClipboard, {
|
|
3777
4921
|
deferInitialRender: true,
|
|
4922
|
+
readImageInput: async (ref, signal) => {
|
|
4923
|
+
const attachments = ctx.get('attachments');
|
|
4924
|
+
if (attachments === undefined)
|
|
4925
|
+
return undefined;
|
|
4926
|
+
const stored = await attachments.readImage(ref, signal);
|
|
4927
|
+
if (stored === undefined)
|
|
4928
|
+
return undefined;
|
|
4929
|
+
return {
|
|
4930
|
+
data: stored.data,
|
|
4931
|
+
mediaType: stored.ref.mediaType,
|
|
4932
|
+
...(stored.ref.name === undefined ? {} : { name: stored.ref.name }),
|
|
4933
|
+
width: stored.ref.width,
|
|
4934
|
+
height: stored.ref.height,
|
|
4935
|
+
};
|
|
4936
|
+
},
|
|
3778
4937
|
terminalProfile,
|
|
3779
4938
|
alternateScreenOverlays: terminalProfile === 'direct',
|
|
3780
4939
|
herdrReporter: new HerdrAgentReporter({ interactive: term.input.isTTY === true && term.output.isTTY === true }),
|
|
@@ -3788,6 +4947,31 @@ export function apply(ctx, config) {
|
|
|
3788
4947
|
});
|
|
3789
4948
|
ctx.provide(TUI_SERVICE, tui);
|
|
3790
4949
|
ctx.effect(() => () => tui.dispose());
|
|
4950
|
+
// A missing route must be visible without turning the startup header into a
|
|
4951
|
+
// credential check: the seams answer optimistically when they cannot tell.
|
|
4952
|
+
ctx.inject(['settings', 'credentials'], readyCtx => {
|
|
4953
|
+
const refreshModelReady = () => {
|
|
4954
|
+
void modelRouteReady(readyCtx).then(ready => tui.setModelReady(ready)).catch(() => { });
|
|
4955
|
+
};
|
|
4956
|
+
refreshModelReady();
|
|
4957
|
+
readyCtx.effect(() => {
|
|
4958
|
+
const disposers = [
|
|
4959
|
+
readyCtx.on('credentials/reference-updated', refreshModelReady),
|
|
4960
|
+
readyCtx.on('credentials/record-updated', refreshModelReady),
|
|
4961
|
+
readyCtx.on('authorization/settled', refreshModelReady),
|
|
4962
|
+
];
|
|
4963
|
+
// The account service and the credential backend may activate just after
|
|
4964
|
+
// this injection; re-read once the composition has settled so an existing
|
|
4965
|
+
// grant is reflected in the live placeholder without a credential event.
|
|
4966
|
+
const timers = [setTimeout(refreshModelReady, 250), setTimeout(refreshModelReady, 1_500)];
|
|
4967
|
+
return () => {
|
|
4968
|
+
for (const dispose of disposers)
|
|
4969
|
+
dispose();
|
|
4970
|
+
for (const timer of timers)
|
|
4971
|
+
clearTimeout(timer);
|
|
4972
|
+
};
|
|
4973
|
+
}, 'omdsh model readiness');
|
|
4974
|
+
});
|
|
3791
4975
|
// Preferences are this entry's own volatile config: the initial read applies
|
|
3792
4976
|
// them without persisting, a committed form edit dispatches a volatile update
|
|
3793
4977
|
// to this fiber only, and an in-app `/settings` change writes back through the
|
|
@@ -3800,14 +4984,14 @@ export function apply(ctx, config) {
|
|
|
3800
4984
|
mathMode: config.mathMode.get(),
|
|
3801
4985
|
mermaidMode: config.mermaidMode.get(),
|
|
3802
4986
|
editor: config.editor.get(),
|
|
3803
|
-
|
|
4987
|
+
terminalTitle: config.terminalTitle.get(),
|
|
4988
|
+
terminalState: config.terminalState.get(),
|
|
3804
4989
|
copyOnSelect: config.copyOnSelect.get(),
|
|
3805
4990
|
mouseInteraction: config.mouseInteraction.get(),
|
|
3806
4991
|
pasteProtection: config.pasteProtection.get(),
|
|
3807
4992
|
checkUpdates: config.checkUpdates.get(),
|
|
3808
4993
|
startupChangelog: config.startupChangelog.get(),
|
|
3809
4994
|
notifications: config.notifications.get(),
|
|
3810
|
-
notificationThreshold: config.notificationThreshold.get(),
|
|
3811
4995
|
notificationFocus: config.notificationFocus.get(),
|
|
3812
4996
|
};
|
|
3813
4997
|
const statusBar = config.statusBar.get();
|
|
@@ -3825,17 +5009,32 @@ export function apply(ctx, config) {
|
|
|
3825
5009
|
// Persistence needs the settings service; without one the provider still runs
|
|
3826
5010
|
// and an in-app change stays session-local, as it did before.
|
|
3827
5011
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
3828
|
-
tui.setPrefsPersist((
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
.catch((error) => { settingsCtx.logger.warn(error); });
|
|
5012
|
+
tui.setPrefsPersist((_prefs, changes) => {
|
|
5013
|
+
return settingsCtx.settings.mutate(TUI_SETTINGS_ENTRY, [...changes ?? []])
|
|
5014
|
+
.catch((error) => { tui.applyStoredPrefs(storedPrefs()); throw error; });
|
|
3832
5015
|
});
|
|
3833
5016
|
});
|
|
3834
5017
|
ctx.inject(['settings', 'credentials'], (settingsCtx) => {
|
|
3835
|
-
settingsCtx.effect(() =>
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
5018
|
+
settingsCtx.effect(() => {
|
|
5019
|
+
let bundles;
|
|
5020
|
+
let inventoryVersion = 0;
|
|
5021
|
+
return tui.bindPluginSettings({
|
|
5022
|
+
entries: () => pluginSettingsEntries(settingsCtx, bundles),
|
|
5023
|
+
refresh: async () => {
|
|
5024
|
+
const version = ++inventoryVersion;
|
|
5025
|
+
const fresh = await settingsCtx.get('pluginManager')?.listBundles();
|
|
5026
|
+
if (version === inventoryVersion)
|
|
5027
|
+
bundles = fresh;
|
|
5028
|
+
return pluginSettingsEntries(settingsCtx, fresh);
|
|
5029
|
+
},
|
|
5030
|
+
edit: (id, signal) => editSettingsEntry(settingsCtx, id, signal),
|
|
5031
|
+
set: (entry, value, signal) => setSettingsEntry(settingsCtx, entry, value, signal),
|
|
5032
|
+
reset: async (id, entry, signal, label) => {
|
|
5033
|
+
await resetSettingsControl(settingsCtx, id, entry, signal, label);
|
|
5034
|
+
tui.applyStoredPrefs(storedPrefs());
|
|
5035
|
+
},
|
|
5036
|
+
});
|
|
5037
|
+
});
|
|
3839
5038
|
});
|
|
3840
5039
|
}
|
|
3841
5040
|
//# sourceMappingURL=provider-local.js.map
|