@visual-z/xeno 0.2.1 → 0.3.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/CHANGELOG.md +9 -0
- package/README.md +106 -26
- package/docs/install.md +51 -13
- package/docs/providers.md +11 -0
- package/package.json +5 -4
- package/src/apps/approve.ts +3 -3
- package/src/apps/commands.ts +38 -5
- package/src/apps/completions.ts +73 -19
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/project-trust.ts +2 -42
- package/src/apps/provider-connect.ts +346 -0
- package/src/apps/render.ts +22 -47
- package/src/apps/resources.ts +5 -2
- package/src/apps/shell.ts +298 -1526
- package/src/apps/startup-input.ts +45 -9
- package/src/apps/startup.ts +128 -86
- package/src/apps/ui/app.ts +1383 -0
- package/src/apps/ui/extension-host.ts +194 -0
- package/src/apps/ui/image-cache.ts +78 -0
- package/src/apps/ui/palette.ts +88 -0
- package/src/apps/ui/project.ts +127 -0
- package/src/apps/ui/standalone.ts +146 -0
- package/src/apps/ui/status-model.ts +68 -0
- package/src/apps/ui/surface.ts +84 -0
- package/src/apps/ui/theme.ts +86 -0
- package/src/core/acceptance.ts +107 -0
- package/src/core/action-defaults.ts +47 -295
- package/src/core/benchmark.ts +2 -2
- package/src/core/branch-receipt.ts +24 -0
- package/src/core/checkpoints.ts +64 -0
- package/src/core/churn.ts +2 -0
- package/src/core/command-artifacts.ts +94 -0
- package/src/core/command-contract.ts +35 -0
- package/src/core/completion-review.ts +21 -5
- package/src/core/context-detail.ts +37 -0
- package/src/core/context-policy.ts +74 -1
- package/src/core/continuation.ts +17 -2
- package/src/core/derive.ts +29 -3
- package/src/core/draft-images.ts +56 -0
- package/src/core/environment.ts +145 -0
- package/src/core/evaluation-coverage.ts +74 -0
- package/src/core/event-validation.ts +80 -5
- package/src/core/events.ts +97 -3
- package/src/core/evidence-links.ts +7 -0
- package/src/core/evidence.ts +13 -3
- package/src/core/execution.ts +8 -2
- package/src/core/experiment.ts +133 -0
- package/src/core/export.ts +9 -0
- package/src/core/failure-recovery.ts +173 -0
- package/src/core/file-precondition.ts +72 -0
- package/src/core/handoff.ts +65 -4
- package/src/core/history-query.ts +38 -2
- package/src/core/history.ts +38 -1
- package/src/core/integration-health.ts +34 -0
- package/src/core/keymap.ts +8 -20
- package/src/core/model-picker.ts +64 -0
- package/src/core/native-eval.ts +25 -0
- package/src/core/note-guidance.ts +34 -0
- package/src/core/observation-view.ts +49 -8
- package/src/core/permissions.ts +24 -2
- package/src/core/process-receipt.ts +105 -0
- package/src/core/process-state.ts +37 -0
- package/src/core/project.ts +45 -18
- package/src/core/prompt-policy.ts +37 -0
- package/src/core/query-eval-contract.ts +14 -0
- package/src/core/read-observation.ts +82 -0
- package/src/core/recovery-corpus.ts +17 -0
- package/src/core/recovery-evaluation.ts +60 -0
- package/src/core/recovery-sources.ts +74 -0
- package/src/core/runtime-target.ts +94 -0
- package/src/core/search-observation.ts +97 -0
- package/src/core/session-tree.ts +147 -0
- package/src/core/settings.ts +26 -10
- package/src/core/skills.ts +44 -0
- package/src/core/sql-query.ts +211 -0
- package/src/core/theme.ts +36 -58
- package/src/core/tool-contract.ts +51 -2
- package/src/core/tool-discovery.ts +87 -0
- package/src/core/tool-failure.ts +60 -0
- package/src/core/tool-mode-protocol.ts +25 -0
- package/src/core/transcript.ts +19 -4
- package/src/core/trial-lifecycle.ts +14 -0
- package/src/core/tui-mode.ts +29 -0
- package/src/core/update-check.ts +97 -0
- package/src/core/version.ts +55 -0
- package/src/core/work-state.ts +49 -3
- package/src/core/working-set.ts +91 -0
- package/src/runtime/auth.ts +2 -2
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/environment-tools.ts +60 -0
- package/src/runtime/evidence-notes.ts +4 -2
- package/src/runtime/exec-sessions.ts +206 -61
- package/src/runtime/execution-control.ts +1 -0
- package/src/runtime/extension-ui.ts +22 -30
- package/src/runtime/extensions.ts +95 -16
- package/src/runtime/harness-comparison.ts +27 -7
- package/src/runtime/harness-eval.ts +460 -0
- package/src/runtime/history-cache.ts +57 -0
- package/src/runtime/history-tools.ts +62 -12
- package/src/runtime/integrations.ts +210 -0
- package/src/runtime/loop.ts +1021 -268
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +143 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/query-eval-verifier.ts +28 -0
- package/src/runtime/recovery-coverage.ts +71 -0
- package/src/runtime/recovery-evidence.ts +76 -0
- package/src/runtime/recovery-fixtures.ts +116 -0
- package/src/runtime/runtime-target.ts +149 -0
- package/src/runtime/tool-broker.ts +137 -0
- package/src/runtime/tool-discovery.ts +57 -0
- package/src/runtime/tool-mode-eval.ts +146 -0
- package/src/runtime/tool-queue.ts +77 -0
- package/src/runtime/tools.ts +628 -183
- package/src/runtime/trial-control.ts +45 -0
- package/src/runtime/update-check.ts +47 -0
- package/src/runtime/window-trial.ts +2 -1
- package/src/runtime/working-notes.ts +33 -3
- package/src/runtime/workspace-lifecycle.ts +97 -0
- package/src/store/acceptance.ts +15 -0
- package/src/store/checkpoint-history.ts +43 -0
- package/src/store/command-artifacts.ts +96 -0
- package/src/store/command.ts +65 -37
- package/src/store/file-checkpoints.ts +104 -0
- package/src/store/file-index.ts +13 -1
- package/src/store/git-checkpoints.ts +67 -0
- package/src/store/observations.ts +61 -20
- package/src/store/process-receipts.ts +34 -0
- package/src/store/query-source.ts +13 -8
- package/src/store/recovery.ts +2 -3
- package/src/store/replay.ts +16 -4
- package/src/store/runtime-target.ts +64 -0
- package/src/store/search.ts +171 -132
- package/src/store/session-archive.ts +4 -0
- package/src/store/source-cancellation.ts +6 -0
- package/src/store/update-cache.ts +36 -0
- package/src/store/workspace-observation.ts +113 -0
- package/src/tui/ansi-spans.ts +126 -0
- package/src/tui/style.ts +19 -16
- package/src/tui/ui/atomic-draft.ts +121 -0
- package/src/tui/ui/canvas.ts +96 -0
- package/src/tui/ui/colors.ts +70 -0
- package/src/tui/ui/composer.ts +199 -0
- package/src/tui/ui/dialog.ts +379 -0
- package/src/tui/ui/draft.ts +23 -0
- package/src/tui/ui/edge.ts +186 -0
- package/src/tui/ui/image-size.ts +40 -0
- package/src/tui/ui/ink.ts +210 -0
- package/src/tui/ui/items.ts +40 -0
- package/src/tui/ui/keys.ts +40 -0
- package/src/tui/ui/layout.ts +74 -0
- package/src/tui/ui/list-model.ts +86 -0
- package/src/tui/ui/list-view.ts +172 -0
- package/src/tui/ui/mascot.ts +67 -0
- package/src/tui/ui/messages.ts +518 -0
- package/src/tui/ui/motion.ts +28 -0
- package/src/tui/ui/paths.ts +10 -0
- package/src/tui/ui/pictures.ts +78 -0
- package/src/tui/ui/screen.ts +421 -0
- package/src/tui/ui/status.ts +271 -0
- package/src/tui/ui/styled.ts +85 -0
- package/src/tui/ui/testing.ts +5 -0
- package/src/tui/ui/tool-card.ts +150 -0
- package/src/apps/extension-ui.ts +0 -330
- package/src/apps/resource-browser.ts +0 -75
- package/src/apps/session-browser.ts +0 -113
- package/src/apps/session-picker.ts +0 -55
- package/src/tui/ansi.ts +0 -67
- package/src/tui/autocomplete.ts +0 -29
- package/src/tui/chat-viewport.ts +0 -47
- package/src/tui/complete.ts +0 -87
- package/src/tui/editor.ts +0 -940
- package/src/tui/host.ts +0 -264
- package/src/tui/input.ts +0 -75
- package/src/tui/keys.ts +0 -378
- package/src/tui/markdown.ts +0 -17
- package/src/tui/render.ts +0 -153
- package/src/tui/select.ts +0 -258
- package/src/tui/settings.ts +0 -44
- package/src/tui/statusline.ts +0 -162
- package/src/tui/system-theme.ts +0 -700
- package/src/tui/terminal.ts +0 -30
- package/src/tui/text.ts +0 -52
- package/src/tui/toolview.ts +0 -386
- package/src/tui/transcript.ts +0 -123
- package/src/tui/tree.ts +0 -471
- package/src/vendor/pi-tui/LICENSE.upstream +0 -21
- package/src/vendor/pi-tui/UPSTREAM.json +0 -50
- package/src/vendor/pi-tui/VENDOR.md +0 -25
- package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
- package/src/vendor/pi-tui/autocomplete.ts +0 -860
- package/src/vendor/pi-tui/colors.ts +0 -367
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
- package/src/vendor/pi-tui/components/box.ts +0 -166
- package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
- package/src/vendor/pi-tui/components/editor.ts +0 -2472
- package/src/vendor/pi-tui/components/h-stack.ts +0 -44
- package/src/vendor/pi-tui/components/image.ts +0 -127
- package/src/vendor/pi-tui/components/input.ts +0 -494
- package/src/vendor/pi-tui/components/loader.ts +0 -101
- package/src/vendor/pi-tui/components/markdown.ts +0 -1021
- package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
- package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
- package/src/vendor/pi-tui/components/select-list.ts +0 -273
- package/src/vendor/pi-tui/components/settings-list.ts +0 -328
- package/src/vendor/pi-tui/components/spacer.ts +0 -28
- package/src/vendor/pi-tui/components/stack.ts +0 -154
- package/src/vendor/pi-tui/components/text.ts +0 -107
- package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
- package/src/vendor/pi-tui/components/v-stack.ts +0 -33
- package/src/vendor/pi-tui/editor-component.ts +0 -74
- package/src/vendor/pi-tui/fuzzy.ts +0 -138
- package/src/vendor/pi-tui/index.ts +0 -186
- package/src/vendor/pi-tui/keybindings.ts +0 -320
- package/src/vendor/pi-tui/keys.ts +0 -1401
- package/src/vendor/pi-tui/kill-ring.ts +0 -46
- package/src/vendor/pi-tui/latex.ts +0 -1506
- package/src/vendor/pi-tui/layout-node.ts +0 -51
- package/src/vendor/pi-tui/layout.ts +0 -449
- package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
- package/src/vendor/pi-tui/native/clipboard.h +0 -208
- package/src/vendor/pi-tui/native/darwin/README.md +0 -22
- package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
- package/src/vendor/pi-tui/native/linux/README.md +0 -23
- package/src/vendor/pi-tui/native/linux/build.sh +0 -65
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
- package/src/vendor/pi-tui/native/napi.h +0 -91
- package/src/vendor/pi-tui/native/win32/README.md +0 -31
- package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
- package/src/vendor/pi-tui/native-modifiers.ts +0 -13
- package/src/vendor/pi-tui/native-module-path.ts +0 -31
- package/src/vendor/pi-tui/native-platform.ts +0 -65
- package/src/vendor/pi-tui/oklab.ts +0 -233
- package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
- package/src/vendor/pi-tui/terminal-colors.ts +0 -91
- package/src/vendor/pi-tui/terminal-image.ts +0 -731
- package/src/vendor/pi-tui/terminal.ts +0 -554
- package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
- package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
- package/src/vendor/pi-tui/tui.ts +0 -1493
- package/src/vendor/pi-tui/undo-stack.ts +0 -28
- package/src/vendor/pi-tui/utils.ts +0 -1385
- package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
- package/src/vendor/pi-tui/word-navigation.ts +0 -117
package/src/apps/shell.ts
CHANGED
|
@@ -1,34 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Interactive loop and one-shot runner.
|
|
3
3
|
*
|
|
4
|
-
* The live UI is
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The live UI is OpenTUI (D20), driven through the same business hooks as
|
|
5
|
+
* line mode. A pipe or CI run never constructs it: `-p` output is parsed
|
|
6
|
+
* by scripts, while the interactive app projects the durable transcript.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { basename, dirname, join, resolve } from "node:path";
|
|
10
|
-
import {
|
|
11
|
-
import { homedir, tmpdir } from "node:os";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
12
11
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
13
12
|
import { assertNever } from "../core/exhaustive.ts";
|
|
14
13
|
import type { LogEvent } from "../core/events.ts";
|
|
14
|
+
import type { Skill } from "../core/skills.ts";
|
|
15
|
+
import { workReadiness } from "../core/work-state.ts";
|
|
15
16
|
import { userInputData, type ImageRef, type UserInput } from "../core/images.ts";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { readClipboard, writeClipboard, type ClipboardContent } from "../runtime/clipboard.ts";
|
|
17
|
+
import { attachImageFile } from "../runtime/images.ts";
|
|
18
|
+
import { writeClipboard } from "../runtime/clipboard.ts";
|
|
19
19
|
import type { ExtensionContext, ExtensionRegistry } from "../runtime/extensions.ts";
|
|
20
20
|
import { headlessUI } from "../runtime/extension-ui.ts";
|
|
21
|
-
import { ExtensionUiController } from "./extension-ui.ts";
|
|
22
|
-
import { highlightCode, transformMermaid, warmSyntaxLanguages } from "../runtime/rich-text.ts";
|
|
23
|
-
import { projectTranscript, type TranscriptItem } from "../core/transcript.ts";
|
|
24
21
|
import { fileReferences } from "../core/file-references.ts";
|
|
25
22
|
import { readNodeHistory } from "../store/restore.ts";
|
|
26
|
-
import { Transcript } from "../tui/transcript.ts";
|
|
27
23
|
import type { Handoff } from "../core/handoff.ts";
|
|
28
24
|
import { projectContext, type FrozenPrefix } from "../core/project.ts";
|
|
29
|
-
import {
|
|
25
|
+
import { contextDetail } from "../core/context-detail.ts";
|
|
26
|
+
import type { BoundaryConfig } from "../core/boundary.ts";
|
|
27
|
+
import {
|
|
28
|
+
contextLimits, decideContext, DEFAULT_CONTEXT_POLICY, gaugeLevel, nextBoundary,
|
|
29
|
+
type ContextDecision, type ContextLimits, type ContextPolicy, type GaugeLevel,
|
|
30
|
+
} from "../core/context-policy.ts";
|
|
30
31
|
import { anchoredCount, estimateTokens, type Anchor, type TokenCounter } from "../core/tokens.ts";
|
|
31
|
-
import type { RestorePoint, RewindMode
|
|
32
|
+
import type { RestorePoint, RewindMode } from "../runtime/loop.ts";
|
|
32
33
|
import { createProvider, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
|
|
33
34
|
import { outputTokenBudget, type Effort, type ModelSpec } from "../runtime/model.ts";
|
|
34
35
|
import { scopedModels } from "../runtime/model-scope.ts";
|
|
@@ -46,81 +47,57 @@ import {
|
|
|
46
47
|
import { writeAuthFile } from "../store/auth-store.ts";
|
|
47
48
|
import { authPath, configDir } from "../store/config.ts";
|
|
48
49
|
import { catalogCachePath, readModelSources, writeCatalogCache } from "../store/models.ts";
|
|
49
|
-
import {
|
|
50
|
-
buildFileIndex, indexTruncationNote, type FileIndex,
|
|
51
|
-
} from "../store/file-index.ts";
|
|
52
|
-
import { currentBranch } from "../store/git-branch.ts";
|
|
50
|
+
import { buildFileIndex, indexTruncationNote, type FileIndex } from "../store/file-index.ts";
|
|
53
51
|
import { projectTrust, trustPath } from "../store/trust.ts";
|
|
54
52
|
import { discoverResources, themeResourcePath } from "../store/resource-index.ts";
|
|
55
|
-
import {
|
|
56
|
-
import { TrustSelector, applyTrustChoice, type TrustChoice } from "./project-trust.ts";
|
|
53
|
+
import { applyTrustChoice, type TrustChoice } from "./project-trust.ts";
|
|
57
54
|
import { reloadAppResources } from "./resources.ts";
|
|
58
55
|
import { publishModelCatalog } from "./model-catalog.ts";
|
|
59
|
-
import type { PermissionRequest } from "../core/permissions.ts";
|
|
60
|
-
import type { ApprovalChoice } from "../runtime/tools.ts";
|
|
61
|
-
import { approvalHeading, approvalItems, choiceFromValue } from "./approve.ts";
|
|
62
56
|
import { readCursor, writeCursor, listNodes, readNode, readManifest, setSessionLabel } from "../store/log.ts";
|
|
63
57
|
import { registerSessionRoot } from "../store/session-browser.ts";
|
|
64
|
-
import {
|
|
65
|
-
createSession, findSessionByPrefix, listSessions, truncationNote,
|
|
66
|
-
sessionsDir,
|
|
67
|
-
} from "../store/sessions.ts";
|
|
58
|
+
import { createSession, findSessionByPrefix, listSessions, truncationNote, sessionsDir } from "../store/sessions.ts";
|
|
68
59
|
import { exportSessionArchive, importSessionArchive } from "../store/session-archive.ts";
|
|
69
|
-
import {
|
|
70
|
-
import
|
|
71
|
-
import {
|
|
72
|
-
import { interactiveAutocomplete } from "./completions.ts";
|
|
73
|
-
import { Container, PaddedDocument, createInteractiveTui, replaceInteractiveTui, Lines, PiText, MarkdownBlock, thinkingBorderColor } from "../tui/host.ts";
|
|
74
|
-
import type { InteractiveScreen, MarkdownFeatures } from "../tui/host.ts";
|
|
75
|
-
import { createChatViewport, type ChatViewport } from "../tui/chat-viewport.ts";
|
|
76
|
-
import { generateSystemThemeColors } from "../tui/system-theme.ts";
|
|
77
|
-
import { Settings } from "../tui/settings.ts";
|
|
78
|
-
import { SelectList, type SelectItem } from "../tui/select.ts";
|
|
79
|
-
import { TreeList, renderTree, treeFromPoints, type TreeNode, type NodeParent, type TreePoint } from "../tui/tree.ts";
|
|
80
|
-
import { renderStatus, type StatusState } from "../tui/statusline.ts";
|
|
81
|
-
import { deliveryBatch, type Phase, type QueuedMessage, type QueueKind } from "../core/queue.ts";
|
|
60
|
+
import { resolveTuiMode } from "../core/tui-mode.ts";
|
|
61
|
+
import { recordRecent } from "../core/model-picker.ts";
|
|
62
|
+
import { flattenTree, treeFromPoints, LEFT_BRANCH_MARK, EMPTY_TREE_MESSAGE, type TreeNode, type NodeParent, type TreePoint } from "../core/session-tree.ts";
|
|
82
63
|
import { cycle } from "../core/cycle.ts";
|
|
83
64
|
import { THINKING_LEVELS, asThinkingLevel as asEffort, supportedThinkingLevels, clampThinkingLevel } from "../core/thinking.ts";
|
|
84
|
-
import { changeModelScope, scopeReference, type ScopeChange } from "../core/model-scope.ts";
|
|
85
65
|
import { modelKey } from "../core/session-position.ts";
|
|
86
|
-
import {
|
|
87
|
-
import {
|
|
88
|
-
import {
|
|
89
|
-
import { builtinTheme, parseTheme, THEME_TOKENS, type Theme } from "../core/theme.ts";
|
|
90
|
-
import { DISPLAY_SETTINGS, settingValues, settingValue, settingPatch, mergeSettings } from "../core/settings.ts";
|
|
91
|
-
import { writeUserSettings, removeModelThinkingOverride, loadUiResources, resolveTheme, watchTheme, discoverThemes, selectedThemePath } from "../store/resources.ts";
|
|
66
|
+
import { defaultKeymap, listBindings } from "../core/keymap.ts";
|
|
67
|
+
import { settingPatch, mergeSettings } from "../core/settings.ts";
|
|
68
|
+
import { writeUserSettings, resolveTheme } from "../store/resources.ts";
|
|
92
69
|
import { colorEnabled } from "./render.ts";
|
|
93
|
-
import {
|
|
70
|
+
import { createStyle, IDENTITY } from "../tui/style.ts";
|
|
94
71
|
import { lastAssistantText, toHtml } from "../core/export.ts";
|
|
95
|
-
import { visibleWidth } from "../vendor/pi-tui/utils.ts";
|
|
96
72
|
import { toJsonEvent } from "../core/json-event.ts";
|
|
97
|
-
import { COMMANDS,
|
|
98
|
-
import {
|
|
99
|
-
expandInput,
|
|
100
|
-
} from "./input.ts";
|
|
73
|
+
import { COMMANDS, parseCommand, type Command } from "./commands.ts";
|
|
74
|
+
import { expandInput } from "./input.ts";
|
|
101
75
|
import {
|
|
102
76
|
describeError, firstLine, formatEvent, indent, stdinIsTty, stdoutIsTty, wrap,
|
|
103
77
|
} from "./render.ts";
|
|
104
78
|
import {
|
|
105
|
-
openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh,
|
|
79
|
+
openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, sessionToolCatalog, type App,
|
|
106
80
|
} from "./startup.ts";
|
|
107
|
-
import {
|
|
81
|
+
import type { TurnSurface, Ui } from "./ui/surface.ts";
|
|
108
82
|
|
|
109
|
-
|
|
83
|
+
/**
|
|
84
|
+
* One line. Listing all ~40 commands here wrapped into a two-line wall that
|
|
85
|
+
* nobody reads; typing `/` already opens the searchable command menu.
|
|
86
|
+
*/
|
|
87
|
+
const HINT = `type a prompt · / for ${COMMANDS.length} commands · /login connects a provider · /model switches · /help`;
|
|
110
88
|
const OAUTH_PASTE = "\0oauth";
|
|
111
|
-
const OAUTH_DEVICE = "\0oauth-device";
|
|
112
89
|
|
|
113
90
|
const initializedExtensions = new WeakSet<ExtensionRegistry>();
|
|
114
91
|
const extensionHistories = new WeakMap<App, LogEvent[]>();
|
|
115
|
-
function extensionContext(app: App, tui?:
|
|
92
|
+
function extensionContext(app: App, tui?: Ui): ExtensionContext {
|
|
116
93
|
const entries = () => structuredClone([...(extensionHistories.get(app) ?? []).filter(event => !app.session.state.events.some(current => current.id === event.id)), ...app.session.state.events]);
|
|
117
94
|
return {
|
|
118
|
-
ui: tui?.extensionUi
|
|
95
|
+
ui: tui?.extensionUi ?? headlessUI((text, type) => app.io.say(type === "error" ? app.io.palette.red(text) : text)),
|
|
119
96
|
cwd: app.workspace, mode: tui ? "tui" : app.args.mode === "json" ? "json" : "print", hasUI: !!tui,
|
|
120
97
|
model: app.providerConfig.spec, thinkingLevel: app.providerConfig.effort, signal: tui?.signal,
|
|
121
98
|
isIdle: () => !app.session.running, isProjectTrusted: () => app.projectTrusted === true,
|
|
122
|
-
hasPendingMessages: () =>
|
|
123
|
-
abort: () => { tui?.
|
|
99
|
+
hasPendingMessages: () => (tui?.queueLength ?? 0) > 0,
|
|
100
|
+
abort: () => { tui?.interrupt(); app.session.cancel(); }, shutdown: () => { app.shutdownRequested = true; app.session.cancel(); tui?.requestShutdown(); },
|
|
124
101
|
getSystemPrompt: () => app.session.config.frozen.system,
|
|
125
102
|
getContextUsage: () => ({ tokens: app.session.state.anchor?.promptTokens ?? null, contextWindow: app.providerConfig.spec.contextWindow }),
|
|
126
103
|
waitForIdle: () => app.session.whenIdle(),
|
|
@@ -133,8 +110,8 @@ function extensionContext(app: App, tui?: Tui): ExtensionContext {
|
|
|
133
110
|
};
|
|
134
111
|
}
|
|
135
112
|
|
|
136
|
-
async function bindExtensions(app: App, tui?:
|
|
137
|
-
|
|
113
|
+
async function bindExtensions(app: App, tui?: Ui): Promise<void> {
|
|
114
|
+
tui?.bindExtensionRegistry();
|
|
138
115
|
const registry = app.extensions; if (!registry) return;
|
|
139
116
|
const history = await readNodeHistory(app.logDir, app.session.state.node);
|
|
140
117
|
if (history.ok) extensionHistories.set(app, history.nodes.flatMap(node => node.events));
|
|
@@ -148,29 +125,28 @@ async function bindExtensions(app: App, tui?: Tui): Promise<void> {
|
|
|
148
125
|
user: async (text, options) => { if (tui) tui.deliverExtension(text, options?.deliverAs === "steer" ? "steering" : "followup"); else await run(app, text); },
|
|
149
126
|
setName: name => nameSession(app, name, tui),
|
|
150
127
|
setLabel: async (id, label) => { await setSessionLabel(app.logDir, id, label ?? "", app.session.clock.now()); },
|
|
151
|
-
getTools: () =>
|
|
128
|
+
getTools: () => app.session.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" &&
|
|
129
|
+
"name" in tool && typeof tool.name === "string" && "description" in tool && typeof tool.description === "string" && "input_schema" in tool
|
|
130
|
+
? [{ name: tool.name, description: tool.description, parameters: JSON.parse(JSON.stringify(tool.input_schema)) as unknown }] : []),
|
|
131
|
+
getAllTools: () => {
|
|
132
|
+
const { schemas, deferred } = sessionToolCatalog(app.args, registry, app.session.config.frozen.tools);
|
|
133
|
+
return [...new Map([...schemas, ...deferred].map(tool => [tool.name, tool])).values()]
|
|
134
|
+
.map(tool => ({ name: tool.name, description: tool.description, parameters: tool.input_schema }));
|
|
135
|
+
},
|
|
152
136
|
setTools: async names => {
|
|
153
137
|
if (app.session.running) throw new Error("Active tools can only change at an idle checkpoint");
|
|
154
138
|
const args = { ...app.args, tools: names, noTools: false };
|
|
155
|
-
const tools =
|
|
139
|
+
const { schemas: tools, deferred } = sessionToolCatalog(args, registry);
|
|
156
140
|
if (names.some(name => !tools.some(tool => tool.name === name))) throw new Error("Unknown active tool");
|
|
157
|
-
await app.session.reloadResources({ ...app.session.config.frozen, tools });
|
|
141
|
+
await app.session.reloadResources({ ...app.session.config.frozen, tools });
|
|
142
|
+
app.session.config.tools.deferredTools = deferred; app.args = args;
|
|
158
143
|
},
|
|
159
144
|
setModel: async model => {
|
|
160
145
|
if (typeof model !== "object" || !model || !("provider" in model) || !("id" in model)) return false;
|
|
161
146
|
await switchByPattern(app, String(model.provider) + "/" + String(model.id)); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
|
|
162
147
|
},
|
|
163
148
|
setThinking: async level => { await applyThinkingCommand(app, level, tui ?? null); },
|
|
164
|
-
exec:
|
|
165
|
-
const process = Bun.spawn([command, ...args], { cwd: options?.cwd ?? app.workspace, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
166
|
-
let killed = false;
|
|
167
|
-
const kill = () => { killed = true; process.kill(); };
|
|
168
|
-
options?.signal?.addEventListener("abort", kill, { once: true });
|
|
169
|
-
if (options?.signal?.aborted) kill();
|
|
170
|
-
const timer = options?.timeout ? setTimeout(kill, options.timeout) : undefined;
|
|
171
|
-
try { const [stdout, stderr, code] = await Promise.all([new Response(process.stdout).text(), new Response(process.stderr).text(), process.exited]); return { stdout, stderr, code, killed }; }
|
|
172
|
-
finally { clearTimeout(timer); options?.signal?.removeEventListener("abort", kill); }
|
|
173
|
-
},
|
|
149
|
+
exec: (command, args, options) => app.session.runExtensionCommand(command, args, options),
|
|
174
150
|
});
|
|
175
151
|
if (!initializedExtensions.has(registry)) {
|
|
176
152
|
initializedExtensions.add(registry);
|
|
@@ -187,31 +163,40 @@ function parseLine(app: App, text: string): Command {
|
|
|
187
163
|
|
|
188
164
|
/**
|
|
189
165
|
* Inputs maybeCut reads when it decides whether to cut. The gauge must use
|
|
190
|
-
* this same set -- a second estimate would disagree with the actual cut.
|
|
166
|
+
* this same set -- a second estimate would disagree with the actual cut. Policy,
|
|
167
|
+
* window and reserve have no defaults: a default is how the footer came to
|
|
168
|
+
* measure against a boundary the active policy never consults.
|
|
191
169
|
*/
|
|
192
170
|
export interface BudgetInput {
|
|
171
|
+
readonly logHint?: { manifestPath: string; segmentPath: string };
|
|
193
172
|
readonly frozen: FrozenPrefix;
|
|
194
173
|
readonly handoff: Handoff | null;
|
|
195
174
|
readonly events: readonly LogEvent[];
|
|
196
175
|
readonly anchor: Anchor | null;
|
|
197
176
|
readonly counter?: TokenCounter;
|
|
198
177
|
readonly boundary?: Partial<BoundaryConfig>;
|
|
199
|
-
|
|
200
|
-
readonly
|
|
178
|
+
readonly policy: ContextPolicy;
|
|
179
|
+
readonly contextWindow: number | undefined;
|
|
180
|
+
readonly outputReserve: number;
|
|
201
181
|
}
|
|
202
182
|
|
|
203
183
|
export interface ContextBudget {
|
|
204
184
|
readonly used: number;
|
|
205
|
-
|
|
185
|
+
/** Nothing the provider reported backs the count: before the first response, after a cut. */
|
|
186
|
+
readonly estimated: boolean;
|
|
187
|
+
/** Where the active policy next acts; null when it names no boundary. */
|
|
188
|
+
readonly trigger: number | null;
|
|
206
189
|
readonly inherited: number;
|
|
207
|
-
readonly
|
|
190
|
+
readonly limits: ContextLimits;
|
|
191
|
+
readonly decision: ContextDecision;
|
|
192
|
+
readonly level: GaugeLevel;
|
|
208
193
|
}
|
|
209
194
|
|
|
210
195
|
/**
|
|
211
196
|
* The numbers the status gauge shows, computed the same way maybeCut does:
|
|
212
197
|
* anchoredCount for the current projection, inheritedTokens from the empty
|
|
213
|
-
* node,
|
|
214
|
-
* recompute the work floor.
|
|
198
|
+
* node, contextLimits and decideContext for what the next turn boundary does.
|
|
199
|
+
* The TUI must not recompute the work floor.
|
|
215
200
|
*/
|
|
216
201
|
export function contextBudget(input: BudgetInput): ContextBudget {
|
|
217
202
|
const counter = input.counter ?? estimateTokens;
|
|
@@ -219,8 +204,9 @@ export function contextBudget(input: BudgetInput): ContextBudget {
|
|
|
219
204
|
frozen: input.frozen,
|
|
220
205
|
handoff: input.handoff,
|
|
221
206
|
events: input.events,
|
|
207
|
+
...(input.logHint ? { logHint: input.logHint } : {}),
|
|
222
208
|
});
|
|
223
|
-
const { tokens } = anchoredCount(
|
|
209
|
+
const { tokens, exactUpTo } = anchoredCount(
|
|
224
210
|
projected.messages,
|
|
225
211
|
projected.system,
|
|
226
212
|
input.anchor,
|
|
@@ -231,16 +217,38 @@ export function contextBudget(input: BudgetInput): ContextBudget {
|
|
|
231
217
|
frozen: input.frozen,
|
|
232
218
|
handoff: input.handoff,
|
|
233
219
|
events: [],
|
|
220
|
+
...(input.logHint ? { logHint: input.logHint } : {}),
|
|
234
221
|
});
|
|
235
222
|
const inherited = counter.count(floor.messages, floor.system, input.frozen.tools);
|
|
236
|
-
const
|
|
237
|
-
|
|
223
|
+
const limits = contextLimits({
|
|
224
|
+
policy: input.policy,
|
|
225
|
+
contextWindow: input.contextWindow,
|
|
226
|
+
outputReserve: input.outputReserve,
|
|
227
|
+
...(input.boundary ? { boundary: input.boundary } : {}),
|
|
228
|
+
});
|
|
229
|
+
// The footer asks what the next turn boundary does, even mid-turn. Readiness
|
|
230
|
+
// and price are not known here, so adaptive reads as deferrable at its soft
|
|
231
|
+
// trigger and certain only at the hard limit.
|
|
232
|
+
const decision = decideContext({
|
|
233
|
+
policy: input.policy,
|
|
234
|
+
continuationReady: false,
|
|
235
|
+
cost: null,
|
|
238
236
|
tokens,
|
|
239
237
|
inheritedTokens: inherited,
|
|
240
|
-
atTurnBoundary:
|
|
238
|
+
atTurnBoundary: true,
|
|
239
|
+
...(input.contextWindow === undefined ? {} : { contextWindow: input.contextWindow }),
|
|
240
|
+
outputReserve: input.outputReserve,
|
|
241
241
|
...(input.boundary ? { config: input.boundary } : {}),
|
|
242
242
|
});
|
|
243
|
-
return {
|
|
243
|
+
return {
|
|
244
|
+
used: tokens,
|
|
245
|
+
estimated: exactUpTo === 0,
|
|
246
|
+
trigger: nextBoundary(limits),
|
|
247
|
+
inherited,
|
|
248
|
+
limits,
|
|
249
|
+
decision,
|
|
250
|
+
level: gaugeLevel(tokens, limits, decision),
|
|
251
|
+
};
|
|
244
252
|
}
|
|
245
253
|
|
|
246
254
|
export function resolveEditorCommand(
|
|
@@ -315,9 +323,10 @@ async function fileIndexFor(app: App, refresh = false): Promise<FileIndex> {
|
|
|
315
323
|
|
|
316
324
|
export async function runPrompt(app: App, prompt: string): Promise<void> {
|
|
317
325
|
await bindExtensions(app);
|
|
318
|
-
if (app.shutdownRequested) { await app.extensions?.dispose(); return; }
|
|
319
|
-
|
|
320
|
-
await app
|
|
326
|
+
if (app.shutdownRequested) { await app.session.dispose(); await app.extensions?.dispose(); return; }
|
|
327
|
+
let ok: boolean;
|
|
328
|
+
try { ok = await run(app, prompt); }
|
|
329
|
+
finally { await app.session.dispose(); await app.extensions?.dispose(); }
|
|
321
330
|
printUsage(app);
|
|
322
331
|
// One-shot mode is scriptable, so a failed turn has to be a failed exit.
|
|
323
332
|
if (!ok) process.exit(1);
|
|
@@ -366,72 +375,54 @@ async function runLines(app: App): Promise<void> {
|
|
|
366
375
|
if (typeof outcome === "object") awaitingKeyFor = outcome.login;
|
|
367
376
|
}
|
|
368
377
|
await cancelPendingOAuth(app);
|
|
378
|
+
await app.session.dispose();
|
|
369
379
|
await app.extensions?.dispose();
|
|
370
380
|
printUsage(app);
|
|
371
381
|
}
|
|
372
382
|
|
|
373
383
|
async function runTui(app: App, releaseEarlyTty?: () => void): Promise<void> {
|
|
374
|
-
const C = app.io.palette;
|
|
375
384
|
if (!app.args.noSession) await registerSessionRoot({ path: dirname(app.logDir), workspace: app.workspace }).catch(error => app.io.say(String(error)));
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
// Transfer ownership synchronously: Pi must remember the shell's cooked mode, not startup's temporary raw mode.
|
|
382
|
-
releaseEarlyTty?.();
|
|
383
|
-
tui.attach();
|
|
384
|
-
await bindExtensions(app, tui);
|
|
385
|
-
app.askSlot.current = (request) => tui.ask(request);
|
|
386
|
-
tui.paint();
|
|
387
|
-
let shuttingDown = false;
|
|
388
|
-
const stop = (code: number) => {
|
|
389
|
-
if (shuttingDown) return;
|
|
390
|
-
shuttingDown = true;
|
|
391
|
-
process.exitCode = code;
|
|
392
|
-
tui.requestShutdown();
|
|
393
|
-
};
|
|
394
|
-
const terminate = () => stop(143);
|
|
395
|
-
const hangup = () => stop(129);
|
|
396
|
-
const crash = (error: unknown) => { stop(1); try { app.io.err(describeError(error)); } catch { /* dead terminal */ } };
|
|
397
|
-
const lastResort = () => tui.detach();
|
|
398
|
-
process.on("SIGTERM", terminate);
|
|
399
|
-
process.on("SIGHUP", hangup);
|
|
400
|
-
process.on("uncaughtException", crash);
|
|
401
|
-
process.on("unhandledRejection", crash);
|
|
402
|
-
process.on("exit", lastResort);
|
|
403
|
-
try {
|
|
404
|
-
while (true) {
|
|
405
|
-
const data = await tui.nextInput();
|
|
406
|
-
if (shuttingDown || tui.shutdownRequested) { await tui.settle(); break; }
|
|
407
|
-
const result = await tui.applyInput(data, true).catch((error: unknown) => {
|
|
408
|
-
tui.writeLine(app.io.palette.red(describeError(error)));
|
|
409
|
-
return "continue" as const;
|
|
410
|
-
});
|
|
411
|
-
if (result === "quit") { await tui.settle(); break; }
|
|
412
|
-
}
|
|
413
|
-
} finally {
|
|
414
|
-
try {
|
|
415
|
-
await app.extensions?.dispose();
|
|
416
|
-
await cancelPendingOAuth(app);
|
|
417
|
-
await tui.screen.terminal.drainInput();
|
|
418
|
-
} finally {
|
|
419
|
-
tui.detach();
|
|
420
|
-
delete app.askSlot.current;
|
|
421
|
-
app.io.say = previousSay;
|
|
422
|
-
}
|
|
423
|
-
process.off("SIGTERM", terminate);
|
|
424
|
-
process.off("SIGHUP", hangup);
|
|
425
|
-
process.off("uncaughtException", crash);
|
|
426
|
-
process.off("unhandledRejection", crash);
|
|
427
|
-
process.off("exit", lastResort);
|
|
428
|
-
printUsage(app);
|
|
429
|
-
}
|
|
385
|
+
// Process start is the one place `auto` meets the real terminal (core/tui-mode.ts).
|
|
386
|
+
if (app.ui) app.ui.settings = { ...app.ui.settings, tuiMode: resolveTuiMode({ flag: app.args.tuiMode, setting: app.ui.settings.tuiMode, env: process.env }).mode };
|
|
387
|
+
const { runInteractive } = await import("./ui/app.ts");
|
|
388
|
+
await runInteractive(app, interactiveHooks, releaseEarlyTty);
|
|
389
|
+
printUsage(app);
|
|
430
390
|
}
|
|
431
391
|
|
|
392
|
+
/**
|
|
393
|
+
* The business operations the interactive controller drives. Exported as one
|
|
394
|
+
* object so apps/ui/app.ts depends on shell.ts in one direction only and the
|
|
395
|
+
* set of things the UI can do is visible in one place.
|
|
396
|
+
*/
|
|
397
|
+
export const interactiveHooks = {
|
|
398
|
+
handleLine: (app: App, text: string, ui: Ui) => handleLine(app, text, ui),
|
|
399
|
+
run: (app: App, prompt: string, signal: AbortSignal | undefined, ui: Ui | undefined, images: readonly ImageRef[] = [], skill?: Skill) => run(app, prompt, signal, ui, images, skill),
|
|
400
|
+
parseLine: (app: App, text: string) => parseLine(app, text),
|
|
401
|
+
bindExtensions: (app: App, ui?: Ui) => bindExtensions(app, ui),
|
|
402
|
+
extensionContext: (app: App, ui?: Ui) => extensionContext(app, ui),
|
|
403
|
+
cancelPendingOAuth: (app: App) => cancelPendingOAuth(app),
|
|
404
|
+
switchToModel: (app: App, spec: ModelSpec) => switchToModel(app, spec),
|
|
405
|
+
applyThinking: (app: App, effort: Effort, ui: Ui | null) => applyThinking(app, effort, ui),
|
|
406
|
+
cycleThinking: (app: App, ui: Ui | null) => cycleThinking(app, ui),
|
|
407
|
+
cycleScopedModel: (app: App, direction: 1 | -1) => cycleScopedModel(app, direction),
|
|
408
|
+
commitSetting: (app: App, field: string, value: string, ui: Ui | null) => commitSetting(app, field, value, ui),
|
|
409
|
+
startSubscriptionLogin: (app: App, id: string, ui: Ui | null) => startSubscriptionLogin(app, id, ui),
|
|
410
|
+
login: (app: App, provider: string, key: string) => login(app, provider, key),
|
|
411
|
+
logout: (app: App, provider: string) => logout(app, provider),
|
|
412
|
+
resumeSession: (app: App, path: string, ui: Ui) => resumeSession(app, path, ui),
|
|
413
|
+
openNewSession: (app: App, ui: Ui) => openNewSession(app, ui),
|
|
414
|
+
forkSession: (app: App, arg: string, ui: Ui) => forkSession(app, arg, ui),
|
|
415
|
+
trustProject: (app: App, choice: TrustChoice, ui: Ui) => trustProject(app, choice, ui),
|
|
416
|
+
treeNodesFor: (app: App) => treeNodesFor(app),
|
|
417
|
+
copyLastReply: (app: App) => copyLastReply(app),
|
|
418
|
+
effortSupported: (spec: ModelSpec, level: Effort) => effortSupported(spec, level),
|
|
419
|
+
fileIndexFor: (app: App, refresh?: boolean) => fileIndexFor(app, refresh),
|
|
420
|
+
dropFileIndex: (workspace: string) => { fileIndexes.delete(workspace); },
|
|
421
|
+
};
|
|
422
|
+
|
|
432
423
|
type Outcome = "continue" | "quit" | { login: string };
|
|
433
424
|
|
|
434
|
-
async function execute(app: App, command: Command, tui:
|
|
425
|
+
async function execute(app: App, command: Command, tui: Ui | null): Promise<Outcome> {
|
|
435
426
|
switch (command.kind) {
|
|
436
427
|
case "extension":
|
|
437
428
|
if (tui) tui.runExtensionCommand(command.name, command.args);
|
|
@@ -449,6 +440,35 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
449
440
|
case "state":
|
|
450
441
|
printState(app);
|
|
451
442
|
return "continue";
|
|
443
|
+
case "context":
|
|
444
|
+
printContext(app);
|
|
445
|
+
return "continue";
|
|
446
|
+
case "acceptance":
|
|
447
|
+
await printAcceptance(app);
|
|
448
|
+
return "continue";
|
|
449
|
+
case "tasks": {
|
|
450
|
+
const items = await app.session.workItems();
|
|
451
|
+
app.io.say(`\nwork tasks: ${items.length}`);
|
|
452
|
+
for (const item of items) {
|
|
453
|
+
const ready = workReadiness(items, item.id);
|
|
454
|
+
app.io.say(` ${item.id}: ${ready.reason} · ${item.what} · verification=${item.verification}${item.owner ? ` · owner=${item.owner}` : ""}`);
|
|
455
|
+
if (ready.waiting_for.length) app.io.say(` waiting for: ${ready.waiting_for.join(", ")}`);
|
|
456
|
+
}
|
|
457
|
+
return "continue";
|
|
458
|
+
}
|
|
459
|
+
case "branches": {
|
|
460
|
+
if (command.action !== undefined && command.node !== undefined) app.io.say((await app.session.resolveBranch(command.node, command.action)).content);
|
|
461
|
+
for (const item of await app.session.branchStatuses()) app.io.say(` branch ${item.node}: ${item.status} · ${item.purpose}${item.receipt_ref ? ` · receipt ${item.receipt_ref}` : ""}`);
|
|
462
|
+
return "continue";
|
|
463
|
+
}
|
|
464
|
+
case "processes": {
|
|
465
|
+
for (const item of await app.session.processStatuses()) app.io.say(` process ${item.session}: ${item.status} · last observation=${item.observed}${item.exit !== undefined ? ` · exit=${item.exit}` : ""}${item.output ? ` · output ${item.output.sha256}` : ""}`);
|
|
466
|
+
return "continue";
|
|
467
|
+
}
|
|
468
|
+
case "accept":
|
|
469
|
+
await app.session.acceptCriterion(command.criterion);
|
|
470
|
+
await printAcceptance(app);
|
|
471
|
+
return "continue";
|
|
452
472
|
case "help":
|
|
453
473
|
printHelp(app);
|
|
454
474
|
return "continue";
|
|
@@ -464,7 +484,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
464
484
|
return "continue";
|
|
465
485
|
}
|
|
466
486
|
case "config":
|
|
467
|
-
if (tui) await tui.
|
|
487
|
+
if (tui) await tui.openResources();
|
|
468
488
|
else {
|
|
469
489
|
const resources = await discoverResources({ ...app.args, workspace: app.workspace, trusted: (await projectTrust(app.workspace, app.projectTrusted ?? app.args.approve)).trusted });
|
|
470
490
|
for (const entry of resources.entries) app.io.say(`${entry.enabled ? "[x]" : "[ ]"} ${entry.kind} ${entry.scope} ${entry.path}`);
|
|
@@ -479,7 +499,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
479
499
|
return "continue";
|
|
480
500
|
}
|
|
481
501
|
if (tui) {
|
|
482
|
-
tui.
|
|
502
|
+
tui.openModel();
|
|
483
503
|
return "continue";
|
|
484
504
|
}
|
|
485
505
|
printModels(app);
|
|
@@ -492,7 +512,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
492
512
|
return applyThinkingCommand(app, command.level, tui);
|
|
493
513
|
case "login": {
|
|
494
514
|
if (command.provider === undefined) {
|
|
495
|
-
if (tui) { tui.
|
|
515
|
+
if (tui) { tui.openLogin(); return "continue"; }
|
|
496
516
|
const C = app.io.palette;
|
|
497
517
|
const providers = [...new Set(app.catalog.map((m) => m.provider))];
|
|
498
518
|
app.io.say(C.bold("\nlogin"));
|
|
@@ -511,16 +531,22 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
511
531
|
if (isSubscriptionLogin(command.provider)) {
|
|
512
532
|
return startSubscriptionLogin(app, command.provider, tui);
|
|
513
533
|
}
|
|
534
|
+
if (tui) {
|
|
535
|
+
const provider = command.provider;
|
|
536
|
+
const key = await tui.askSecret(`API key · ${provider}`, "Key", "paste the key · $VAR reads the environment · !cmd runs a command · enter save · esc cancel");
|
|
537
|
+
if (key) await login(app, provider, key); else if (key !== undefined) tui.notice(" cancelled");
|
|
538
|
+
return "continue";
|
|
539
|
+
}
|
|
514
540
|
const C = app.io.palette;
|
|
515
|
-
//
|
|
516
|
-
// "it will be visible here" is how a secret ends up in a screenshot.
|
|
541
|
+
// Line mode echoes nothing for this line; the sentence must say so,
|
|
542
|
+
// because "it will be visible here" is how a secret ends up in a screenshot.
|
|
517
543
|
app.io.say(C.yellow(` paste the ${command.provider} key and press enter. It is not shown.`));
|
|
518
544
|
app.io.say(C.dim(" a value starting with ! runs as a command; $VAR reads the environment."));
|
|
519
545
|
app.io.say(C.dim(" empty line cancels."));
|
|
520
546
|
return { login: command.provider };
|
|
521
547
|
}
|
|
522
548
|
case "logout": {
|
|
523
|
-
if (tui && command.provider === undefined) { tui.
|
|
549
|
+
if (tui && command.provider === undefined) { tui.openLogout(); return "continue"; }
|
|
524
550
|
await logout(app, command.provider);
|
|
525
551
|
return "continue";
|
|
526
552
|
}
|
|
@@ -539,7 +565,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
539
565
|
}
|
|
540
566
|
case "tree":
|
|
541
567
|
if (tui) {
|
|
542
|
-
await tui.
|
|
568
|
+
await tui.openTree();
|
|
543
569
|
return "continue";
|
|
544
570
|
}
|
|
545
571
|
await printTree(app);
|
|
@@ -553,7 +579,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
553
579
|
return "continue";
|
|
554
580
|
}
|
|
555
581
|
if (tui) {
|
|
556
|
-
void tui.
|
|
582
|
+
void tui.openResume();
|
|
557
583
|
return "continue";
|
|
558
584
|
}
|
|
559
585
|
await printSessionList(app);
|
|
@@ -567,7 +593,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
567
593
|
case "import": {
|
|
568
594
|
if (!command.file) { app.io.say(" Usage: /import <session.jsonl>"); return "continue"; }
|
|
569
595
|
const importing = async () => {
|
|
570
|
-
if (tui && !await tui.
|
|
596
|
+
if (tui && !await tui.confirm("Import session", "Open the archive as a separate session? The current session stays saved.")) { app.io.say(" Import cancelled"); return; }
|
|
571
597
|
try {
|
|
572
598
|
const path = await importSessionArchive(resolve(app.workspace, command.file!), sessionsDir(app.workspace, sessionDirFor(app.args, app.workspace)), app.workspace);
|
|
573
599
|
if (await adoptLogDir(app, path)) { await bindExtensions(app, tui ?? undefined); await tui?.refreshResources(); await tui?.syncTranscript(); app.io.say(` Imported session: ${path}`); }
|
|
@@ -578,25 +604,25 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
578
604
|
}
|
|
579
605
|
case "debug": {
|
|
580
606
|
const file = resolve(app.workspace, command.file ?? join(configDir(), "debug.log"));
|
|
581
|
-
const
|
|
582
|
-
const lines =
|
|
607
|
+
const frame = tui?.snapshot() ?? { width: 80, height: 0, lines: [] };
|
|
608
|
+
const { width, height, lines } = frame;
|
|
583
609
|
const messages = projectContext({ frozen: app.session.config.frozen, handoff: app.session.state.handoff, events: app.session.state.events });
|
|
584
610
|
const body = [`xeno diagnostics ${app.session.clock.now()}`, `Terminal: ${width}x${height}`, `Total lines: ${lines.length}`,
|
|
585
|
-
...lines.map((line, i) => `[${i}]
|
|
611
|
+
...lines.map((line, i) => `[${i}] ${JSON.stringify(line)}`), "", "Agent messages (JSONL)", ...messages.messages.map(message => JSON.stringify(message)), ""].join("\n");
|
|
586
612
|
try { await mkdir(dirname(file), { recursive: true }); await writeFile(file, body, { mode: 0o600 }); app.io.say(` Debug log written: ${file}`); }
|
|
587
613
|
catch (error) { app.io.say(` Cannot write debug log: ${describeError(error)}`); }
|
|
588
614
|
return "continue";
|
|
589
615
|
}
|
|
590
616
|
case "changelog": {
|
|
591
617
|
const text = await readFile(new URL("../../CHANGELOG.md", import.meta.url), "utf8");
|
|
592
|
-
if (tui) tui.showDocument(text); else app.io.say(text);
|
|
618
|
+
if (tui) tui.showDocument("Changelog", text); else app.io.say(text);
|
|
593
619
|
return "continue";
|
|
594
620
|
}
|
|
595
621
|
case "copy":
|
|
596
622
|
await copyLastReply(app, tui ?? undefined);
|
|
597
623
|
return "continue";
|
|
598
624
|
case "fork":
|
|
599
|
-
if (tui && command.arg === undefined) { await tui.
|
|
625
|
+
if (tui && command.arg === undefined) { await tui.openFork(); return "continue"; }
|
|
600
626
|
await forkSession(app, command.arg, tui ?? undefined);
|
|
601
627
|
return "continue";
|
|
602
628
|
case "clone":
|
|
@@ -605,7 +631,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
605
631
|
return "continue";
|
|
606
632
|
case "trust":
|
|
607
633
|
if (command.target) await trustProject(app, command.target, tui ?? undefined);
|
|
608
|
-
else if (tui) await tui.
|
|
634
|
+
else if (tui) await tui.openTrust();
|
|
609
635
|
else app.io.say(" /trust once|here|parent|never controls project resources; tool permissions are separate.");
|
|
610
636
|
return "continue";
|
|
611
637
|
case "models-refresh":
|
|
@@ -615,7 +641,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
615
641
|
app.io.say(app.io.palette.yellow(` unknown command: ${command.input}. /help lists them.`));
|
|
616
642
|
return "continue";
|
|
617
643
|
case "prompt":
|
|
618
|
-
await run(app, command.text, tui?.signal, tui ?? undefined);
|
|
644
|
+
await run(app, command.text, tui?.signal, tui ?? undefined, [], command.skill);
|
|
619
645
|
app.io.say("");
|
|
620
646
|
return "continue";
|
|
621
647
|
default:
|
|
@@ -623,33 +649,8 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
623
649
|
}
|
|
624
650
|
}
|
|
625
651
|
|
|
626
|
-
async function handleLine(app: App, text: string, tui:
|
|
627
|
-
|
|
628
|
-
const provider = tui.awaitingKeyFor;
|
|
629
|
-
if (provider === OAUTH_DEVICE) {
|
|
630
|
-
if (!text) await cancelPendingOAuth(app);
|
|
631
|
-
else app.io.say(" Waiting for account login. Escape cancels.");
|
|
632
|
-
return "continue";
|
|
633
|
-
}
|
|
634
|
-
if (text.length === 0) {
|
|
635
|
-
if (provider === OAUTH_PASTE) await cancelPendingOAuth(app);
|
|
636
|
-
tui.endCredentialInput();
|
|
637
|
-
app.io.say(app.io.palette.dim(" cancelled\n"));
|
|
638
|
-
return "continue";
|
|
639
|
-
}
|
|
640
|
-
if (provider === OAUTH_PASTE) {
|
|
641
|
-
tui.editor.applyExternal("");
|
|
642
|
-
tui.runExtensionAction(() => completeOAuthPaste(app, text));
|
|
643
|
-
return "continue";
|
|
644
|
-
}
|
|
645
|
-
try { await login(app, provider, text); } finally { tui.endCredentialInput(); }
|
|
646
|
-
return "continue";
|
|
647
|
-
}
|
|
648
|
-
const outcome = await execute(app, parseLine(app, text), tui);
|
|
649
|
-
if (typeof outcome === "object") {
|
|
650
|
-
tui.beginCredentialInput(outcome.login);
|
|
651
|
-
}
|
|
652
|
-
return outcome;
|
|
652
|
+
async function handleLine(app: App, text: string, tui: Ui): Promise<Outcome> {
|
|
653
|
+
return execute(app, parseLine(app, text), tui);
|
|
653
654
|
}
|
|
654
655
|
|
|
655
656
|
function printUsage(app: App): void {
|
|
@@ -724,6 +725,43 @@ function printState(app: App): void {
|
|
|
724
725
|
app.io.say("");
|
|
725
726
|
}
|
|
726
727
|
|
|
728
|
+
function printContext(app: App): void {
|
|
729
|
+
const session = app.session;
|
|
730
|
+
const detail = contextDetail({ ...session.contextInput(), anchor: session.state.anchor,
|
|
731
|
+
policy: session.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
|
|
732
|
+
contextWindow: session.provider.config.spec.contextWindow, outputReserve: session.provider.config.maxTokens,
|
|
733
|
+
...(session.config.counter ? { counter: session.config.counter } : {}),
|
|
734
|
+
...(session.config.boundary ? { boundary: session.config.boundary } : {}),
|
|
735
|
+
});
|
|
736
|
+
app.io.say(app.io.palette.bold(`\ncontext v${detail.version} · node ${session.state.node} · ${detail.policy}`));
|
|
737
|
+
for (const [name, tokens] of Object.entries(detail.components)) app.io.say(` ${name.padEnd(18)} ~${tokens} tokens`);
|
|
738
|
+
app.io.say(` total ${detail.used} (${detail.countKind}; anchor correction ${detail.anchorCorrection}; measured through ${detail.measuredThrough} messages)`);
|
|
739
|
+
app.io.say(` limits ${JSON.stringify(detail.limits)}`);
|
|
740
|
+
const sources = detail.sources.flat();
|
|
741
|
+
app.io.say(` sources ${sources.length} references: ${sources.slice(-12).join(", ") || "none"}`);
|
|
742
|
+
if (sources.length > 12) app.io.say(` earlier sources ${sources.length - 12}; use history(recent=true) or history(event=ID)`);
|
|
743
|
+
for (const section of detail.sections.filter(s => s.omitted > 0)) {
|
|
744
|
+
app.io.say(` ${section.section} retained ${section.retained}/${section.total}; omitted ${section.omitted}; history(${JSON.stringify(section.recovery)})`);
|
|
745
|
+
}
|
|
746
|
+
app.io.say(` metadata ${detail.omittedEvents.length} events do not replay as messages; available in the log`);
|
|
747
|
+
if (detail.violations.length) app.io.say(` violations ${JSON.stringify(detail.violations)}`);
|
|
748
|
+
app.io.say("");
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function printAcceptance(app: App): Promise<void> {
|
|
752
|
+
const state = await app.session.acceptanceState();
|
|
753
|
+
app.io.say(state ? `\nacceptance ${state.contract_id}: ${state.status}` : "\nacceptance: no declared task contract");
|
|
754
|
+
for (const target of state?.targets ?? []) {
|
|
755
|
+
app.io.say(` target ${target.id}: ${target.status} · entry ${target.entry ?? "unobserved"} · pid ${target.pid ?? "unknown"} · session ${target.session ?? "none"} · port ${target.port ?? "not declared"}`);
|
|
756
|
+
app.io.say(` build ${target.build_event ?? "none"}; instance ${target.binding ?? "unknown"}; observation ${target.observation ?? "none"}; ${target.reason ?? "start the declared target after its build"}`);
|
|
757
|
+
}
|
|
758
|
+
for (const item of state?.criteria ?? []) {
|
|
759
|
+
app.io.say(` ${item.id}: ${item.status} · ${item.description}`);
|
|
760
|
+
if (item.kind === "command") app.io.say(` command: ${item.command}; evidence: ${item.evidence?.event_id ?? "none"}`);
|
|
761
|
+
else if (item.status !== "accepted") app.io.say(` /accept ${item.id} records your acceptance${item.target ? ` of the observed target ${item.target}` : ""}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
727
765
|
function printHotkeys(app: App): void {
|
|
728
766
|
const C = app.io.palette;
|
|
729
767
|
const map = app.ui?.keymap ?? defaultKeymap();
|
|
@@ -740,7 +778,7 @@ async function applySettingsCommand(
|
|
|
740
778
|
app: App,
|
|
741
779
|
field: string | undefined,
|
|
742
780
|
value: string | undefined,
|
|
743
|
-
tui:
|
|
781
|
+
tui: Ui | null,
|
|
744
782
|
): Promise<Outcome> {
|
|
745
783
|
if (field !== undefined && value !== undefined) {
|
|
746
784
|
await commitSetting(app, field, value, tui);
|
|
@@ -759,7 +797,7 @@ async function applySettingsCommand(
|
|
|
759
797
|
return "continue";
|
|
760
798
|
}
|
|
761
799
|
|
|
762
|
-
async function commitSetting(app: App, field: string, value: string, tui:
|
|
800
|
+
async function commitSetting(app: App, field: string, value: string, tui: Ui | null): Promise<void> {
|
|
763
801
|
const C = app.io.palette;
|
|
764
802
|
const key = field.toLowerCase();
|
|
765
803
|
if (app.ui === undefined) {
|
|
@@ -783,10 +821,11 @@ async function commitSetting(app: App, field: string, value: string, tui: Tui |
|
|
|
783
821
|
app.ui.settings = mergeSettings({ builtin: app.ui.settings, user: parsed.settings });
|
|
784
822
|
app.ui.userOverrides = mergeSettings({ builtin: app.ui.userOverrides, user: parsed.settings });
|
|
785
823
|
if (key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
|
|
786
|
-
|
|
824
|
+
if (tui) tui.toast(`${field} · ${value}`, "success");
|
|
825
|
+
else app.io.say(C.dim(` ${field} ${value}`));
|
|
787
826
|
tui?.applyConfiguration();
|
|
788
827
|
if (key === "theme") tui?.followTheme();
|
|
789
|
-
tui?.
|
|
828
|
+
tui?.redraw();
|
|
790
829
|
}
|
|
791
830
|
|
|
792
831
|
const EFFORTS = THINKING_LEVELS;
|
|
@@ -801,7 +840,7 @@ function effortSupported(spec: ModelSpec, level: Effort): boolean {
|
|
|
801
840
|
* must not cut: a cut drops the prefix cache, and thinking blocks stay
|
|
802
841
|
* valid because the model did not change.
|
|
803
842
|
*/
|
|
804
|
-
async function applyThinking(app: App, level: Effort, tui:
|
|
843
|
+
async function applyThinking(app: App, level: Effort, tui: Ui | null): Promise<boolean> {
|
|
805
844
|
const C = app.io.palette;
|
|
806
845
|
if (!effortSupported(app.providerConfig.spec, level)) {
|
|
807
846
|
app.io.say(
|
|
@@ -820,11 +859,11 @@ async function applyThinking(app: App, level: Effort, tui: Tui | null): Promise<
|
|
|
820
859
|
app.ui.settings = { ...app.ui.settings, thinking: level };
|
|
821
860
|
}
|
|
822
861
|
app.io.say(C.dim(` thinking ${level}`));
|
|
823
|
-
tui?.
|
|
862
|
+
tui?.redraw();
|
|
824
863
|
return true;
|
|
825
864
|
}
|
|
826
865
|
|
|
827
|
-
async function applyThinkingCommand(app: App, level: string | undefined, tui:
|
|
866
|
+
async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null): Promise<Outcome> {
|
|
828
867
|
if (level !== undefined) {
|
|
829
868
|
const effort = asEffort(level);
|
|
830
869
|
if (effort === undefined) {
|
|
@@ -835,7 +874,7 @@ async function applyThinkingCommand(app: App, level: string | undefined, tui: Tu
|
|
|
835
874
|
return "continue";
|
|
836
875
|
}
|
|
837
876
|
if (tui) {
|
|
838
|
-
tui.
|
|
877
|
+
tui.openThinking();
|
|
839
878
|
return "continue";
|
|
840
879
|
}
|
|
841
880
|
printThinking(app);
|
|
@@ -856,7 +895,7 @@ function printThinking(app: App): void {
|
|
|
856
895
|
app.io.say(C.dim("\n /thinking <level> to switch. Shift+Tab cycles supported levels.\n"));
|
|
857
896
|
}
|
|
858
897
|
|
|
859
|
-
async function cycleThinking(app: App, tui:
|
|
898
|
+
async function cycleThinking(app: App, tui: Ui | null): Promise<void> {
|
|
860
899
|
const supported = EFFORTS.filter((level) => effortSupported(app.providerConfig.spec, level));
|
|
861
900
|
const next = cycle(supported, app.providerConfig.effort, 1);
|
|
862
901
|
if (next === undefined) return;
|
|
@@ -977,10 +1016,11 @@ async function printBack(app: App): Promise<void> {
|
|
|
977
1016
|
* Jumping still needs the TUI; this is so a pipe can see the structure.
|
|
978
1017
|
*/
|
|
979
1018
|
async function printTree(app: App): Promise<void> {
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
for (const
|
|
983
|
-
|
|
1019
|
+
const rows = flattenTree(await treeNodesFor(app));
|
|
1020
|
+
if (!rows.length) { app.io.say(EMPTY_TREE_MESSAGE); return; }
|
|
1021
|
+
for (const row of rows) {
|
|
1022
|
+
const mark = row.node.current ? "● " : row.node.onPath ? " " : `${LEFT_BRANCH_MARK} `;
|
|
1023
|
+
app.io.say(row.branch + mark + (row.node.label ? `[${row.node.label}] ` : "") + firstLine(row.node.prompt, 72));
|
|
984
1024
|
}
|
|
985
1025
|
}
|
|
986
1026
|
|
|
@@ -1045,7 +1085,7 @@ async function printRewind(
|
|
|
1045
1085
|
): Promise<void> {
|
|
1046
1086
|
const C = app.io.palette;
|
|
1047
1087
|
const label = firstLine(point.prompt, 60);
|
|
1048
|
-
const plan = await app.session.previewRewind(point);
|
|
1088
|
+
const plan = await app.session.previewRewind(point, mode);
|
|
1049
1089
|
if (!plan.ok) {
|
|
1050
1090
|
app.io.say(C.red(`\n cannot go back: ${plan.error}\n`));
|
|
1051
1091
|
return;
|
|
@@ -1066,7 +1106,7 @@ async function printRewind(
|
|
|
1066
1106
|
for (const p of extra.slice(0, 20)) app.io.say(C.yellow(` stays ${p} (not in that context)`));
|
|
1067
1107
|
if (extra.length > 20) app.io.say(C.yellow(` ... ${extra.length - 20} more`));
|
|
1068
1108
|
} else {
|
|
1069
|
-
for (const p of plan.willWrite.slice(0, 20)) app.io.say(C.dim(`
|
|
1109
|
+
for (const p of plan.willWrite.slice(0, 20)) app.io.say(C.dim(` restore ${p}`));
|
|
1070
1110
|
if (plan.willWrite.length > 20) app.io.say(C.dim(` ... ${plan.willWrite.length - 20} more`));
|
|
1071
1111
|
for (const p of plan.willDelete.slice(0, 20)) app.io.say(C.red(` delete ${p}`));
|
|
1072
1112
|
if (plan.willDelete.length > 20) app.io.say(C.red(` ... ${plan.willDelete.length - 20} more`));
|
|
@@ -1216,13 +1256,15 @@ interface PendingOAuth {
|
|
|
1216
1256
|
exchange: "anthropic" | "openai";
|
|
1217
1257
|
server: CallbackServer;
|
|
1218
1258
|
done: boolean;
|
|
1219
|
-
tui:
|
|
1259
|
+
tui: Ui | null;
|
|
1220
1260
|
abort: AbortController;
|
|
1261
|
+
/** Closes the UI's waiting dialog, if any. */
|
|
1262
|
+
close?: () => void;
|
|
1221
1263
|
}
|
|
1222
1264
|
|
|
1223
1265
|
// Authentication is scoped to its application, independent of the model/session log.
|
|
1224
1266
|
const pendingLogins = new WeakMap<App, PendingOAuth>();
|
|
1225
|
-
const pendingDevices = new WeakMap<App, { abort: AbortController; tui:
|
|
1267
|
+
const pendingDevices = new WeakMap<App, { abort: AbortController; tui: Ui | null; close?: () => void }>();
|
|
1226
1268
|
const loginEffects = { fetch: ((input, init) => globalThis.fetch(input, init)) as typeof globalThis.fetch,
|
|
1227
1269
|
openBrowser: tryOpenBrowser, callback: startCallbackServer, device: loginGitHubCopilot };
|
|
1228
1270
|
|
|
@@ -1230,8 +1272,7 @@ async function cancelPendingOAuth(app: App): Promise<void> {
|
|
|
1230
1272
|
const pending = pendingLogins.get(app), device = pendingDevices.get(app);
|
|
1231
1273
|
pendingLogins.delete(app); pendingDevices.delete(app);
|
|
1232
1274
|
device?.abort.abort(); pending?.abort.abort();
|
|
1233
|
-
|
|
1234
|
-
tui?.endCredentialInput();
|
|
1275
|
+
pending?.close?.(); device?.close?.();
|
|
1235
1276
|
if (pending) { pending.done = true; await pending.server.close(); }
|
|
1236
1277
|
}
|
|
1237
1278
|
|
|
@@ -1282,7 +1323,7 @@ async function finishOAuthCode(app: App, pending: PendingOAuth, code: string): P
|
|
|
1282
1323
|
} finally {
|
|
1283
1324
|
if (pendingLogins.get(app) === pending) {
|
|
1284
1325
|
pendingLogins.delete(app);
|
|
1285
|
-
pending.
|
|
1326
|
+
pending.close?.();
|
|
1286
1327
|
}
|
|
1287
1328
|
await pending.server.close();
|
|
1288
1329
|
}
|
|
@@ -1313,7 +1354,7 @@ function tryOpenBrowser(url: string): void {
|
|
|
1313
1354
|
}
|
|
1314
1355
|
}
|
|
1315
1356
|
|
|
1316
|
-
async function startSubscriptionLogin(app: App, id: string, tui:
|
|
1357
|
+
async function startSubscriptionLogin(app: App, id: string, tui: Ui | null): Promise<Outcome> {
|
|
1317
1358
|
const C = app.io.palette;
|
|
1318
1359
|
const sub = subscriptionById(id);
|
|
1319
1360
|
if (sub === undefined) return "continue";
|
|
@@ -1321,11 +1362,15 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
|
|
|
1321
1362
|
const effects = tui?.oauthEffects ?? loginEffects;
|
|
1322
1363
|
app.io.say(C.yellow(` ${SUBSCRIPTION_NOTICE}`));
|
|
1323
1364
|
if (sub.id === "github-copilot") {
|
|
1324
|
-
const pending = { abort: new AbortController(), tui };
|
|
1365
|
+
const pending: { abort: AbortController; tui: Ui | null; close?: () => void } = { abort: new AbortController(), tui };
|
|
1325
1366
|
pendingDevices.set(app, pending);
|
|
1326
1367
|
const work = async () => { try {
|
|
1327
1368
|
const credential = await effects.device({
|
|
1328
|
-
notify: (message) => {
|
|
1369
|
+
notify: (message) => {
|
|
1370
|
+
if (pendingDevices.get(app) !== pending) return;
|
|
1371
|
+
if (tui) { pending.close?.(); pending.close = tui.waiting("GitHub Copilot", message.split("\n"), () => { void cancelPendingOAuth(app); }); }
|
|
1372
|
+
else app.io.say(` ${message}`);
|
|
1373
|
+
},
|
|
1329
1374
|
prompt: async () => "",
|
|
1330
1375
|
fetch: effects.fetch,
|
|
1331
1376
|
signal: pending.abort.signal,
|
|
@@ -1339,10 +1384,10 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
|
|
|
1339
1384
|
} finally {
|
|
1340
1385
|
if (pendingDevices.get(app) === pending) {
|
|
1341
1386
|
pendingDevices.delete(app);
|
|
1342
|
-
|
|
1387
|
+
pending.close?.();
|
|
1343
1388
|
}
|
|
1344
1389
|
} };
|
|
1345
|
-
if (tui) { tui.
|
|
1390
|
+
if (tui) { pending.close = tui.waiting("GitHub Copilot", ["Requesting a device code…"], () => { void cancelPendingOAuth(app); }); tui.runExtensionAction(work); return "continue"; }
|
|
1346
1391
|
await work();
|
|
1347
1392
|
return "continue";
|
|
1348
1393
|
}
|
|
@@ -1372,8 +1417,10 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
|
|
|
1372
1417
|
close: async () => {},
|
|
1373
1418
|
};
|
|
1374
1419
|
}
|
|
1375
|
-
|
|
1376
|
-
|
|
1420
|
+
if (!tui) {
|
|
1421
|
+
app.io.say(` ${url}`);
|
|
1422
|
+
app.io.say(C.dim(" complete login in the browser, or paste the redirect URL. It is not shown."));
|
|
1423
|
+
}
|
|
1377
1424
|
effects.openBrowser(url);
|
|
1378
1425
|
const pending: PendingOAuth = {
|
|
1379
1426
|
id: sub.id,
|
|
@@ -1392,6 +1439,12 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
|
|
|
1392
1439
|
if (!got.ok || pending.done) return;
|
|
1393
1440
|
await finishOAuthCode(app, pending, got.code);
|
|
1394
1441
|
});
|
|
1442
|
+
if (tui) {
|
|
1443
|
+
// The URL is shown (it is not a secret); the pasted redirect is, so it goes through a masked field.
|
|
1444
|
+
pending.close = tui.waiting(sub.label, ["Finish signing in in your browser.", "", url, "", "Or paste the redirect URL here if the browser cannot reach this machine."],
|
|
1445
|
+
() => { void cancelPendingOAuth(app); }, "Redirect URL", value => tui.runExtensionAction(() => completeOAuthPaste(app, value)));
|
|
1446
|
+
return "continue";
|
|
1447
|
+
}
|
|
1395
1448
|
return { login: OAUTH_PASTE };
|
|
1396
1449
|
}
|
|
1397
1450
|
|
|
@@ -1452,7 +1505,7 @@ async function switchByPattern(app: App, pattern: string): Promise<void> {
|
|
|
1452
1505
|
await switchToModel(app, selected.model, selected.thinking);
|
|
1453
1506
|
}
|
|
1454
1507
|
|
|
1455
|
-
async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
|
|
1508
|
+
export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
|
|
1456
1509
|
const C = app.io.palette;
|
|
1457
1510
|
if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
|
|
1458
1511
|
if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null);
|
|
@@ -1474,8 +1527,7 @@ async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Prom
|
|
|
1474
1527
|
thinkingDisplay: app.providerConfig.thinkingDisplay,
|
|
1475
1528
|
};
|
|
1476
1529
|
const next = makeProvider(app, nextConfig, got.key);
|
|
1477
|
-
app.io.say(C.
|
|
1478
|
-
app.io.say(C.yellow(" not from this model's thinking blocks."));
|
|
1530
|
+
app.io.say(C.dim(" new segment: the next model starts from a handoff, not this model's thinking"));
|
|
1479
1531
|
try {
|
|
1480
1532
|
await app.session.switchModel(next);
|
|
1481
1533
|
} catch (err) {
|
|
@@ -1485,13 +1537,22 @@ async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Prom
|
|
|
1485
1537
|
app.providerConfig = nextConfig;
|
|
1486
1538
|
app.credential = got;
|
|
1487
1539
|
app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
|
|
1540
|
+
await rememberRecentModel(app, spec);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** Recency feeds the picker's first section; a write failure only costs ordering. */
|
|
1544
|
+
async function rememberRecentModel(app: App, spec: ModelSpec): Promise<void> {
|
|
1545
|
+
if (!app.ui) return;
|
|
1546
|
+
const recentModels = recordRecent(app.ui.settings.recentModels ?? [], modelKey(spec));
|
|
1547
|
+
app.ui.settings = { ...app.ui.settings, recentModels };
|
|
1548
|
+
try { await writeUserSettings({ recentModels }); } catch { /* ordering only */ }
|
|
1488
1549
|
}
|
|
1489
1550
|
|
|
1490
1551
|
function sessionDirOf(app: App): string | undefined {
|
|
1491
1552
|
return sessionDirFor(app.args, app.workspace);
|
|
1492
1553
|
}
|
|
1493
1554
|
|
|
1494
|
-
async function openNewSession(app: App, tui?:
|
|
1555
|
+
async function openNewSession(app: App, tui?: Ui): Promise<void> {
|
|
1495
1556
|
const C = app.io.palette;
|
|
1496
1557
|
const created = await createSession(app.workspace, undefined, sessionDirOf(app));
|
|
1497
1558
|
const switched = await adoptLogDir(app, created.path);
|
|
@@ -1503,7 +1564,7 @@ async function openNewSession(app: App, tui?: Tui): Promise<void> {
|
|
|
1503
1564
|
app.io.say(C.green(` new session ${created.id}`) + C.dim(` ${created.path}`));
|
|
1504
1565
|
}
|
|
1505
1566
|
|
|
1506
|
-
async function resumeSession(app: App, idOrPath: string, tui?:
|
|
1567
|
+
async function resumeSession(app: App, idOrPath: string, tui?: Ui): Promise<void> {
|
|
1507
1568
|
const C = app.io.palette;
|
|
1508
1569
|
const found = await findSessionByPrefix(app.workspace, idOrPath, sessionDirOf(app));
|
|
1509
1570
|
if (!found.ok) {
|
|
@@ -1525,7 +1586,10 @@ async function resumeSession(app: App, idOrPath: string, tui?: Tui): Promise<voi
|
|
|
1525
1586
|
|
|
1526
1587
|
async function adoptLogDir(app: App, logDir: string): Promise<boolean> {
|
|
1527
1588
|
try {
|
|
1589
|
+
app.session.cancel(); await app.session.whenIdle();
|
|
1590
|
+
if (app.extensions && !await app.extensions.releaseIntegrations()) throw Error("An integration still owns unfinished work or unconfirmed cleanup. Wait for it to settle before switching sessions.");
|
|
1528
1591
|
const opened = await openSessionFor({ ...app, logDir, fallback: app.providerConfig, interactive: true });
|
|
1592
|
+
await app.session.dispose();
|
|
1529
1593
|
await app.extensions?.dispose();
|
|
1530
1594
|
app.session = opened.session;
|
|
1531
1595
|
app.providerConfig = opened.providerConfig;
|
|
@@ -1574,7 +1638,7 @@ async function exportSession(app: App, file?: string): Promise<void> {
|
|
|
1574
1638
|
app.io.say(C.green(` exported ${abs}`));
|
|
1575
1639
|
}
|
|
1576
1640
|
|
|
1577
|
-
async function copyLastReply(app: App, tui?:
|
|
1641
|
+
async function copyLastReply(app: App, tui?: Ui): Promise<void> {
|
|
1578
1642
|
const C = app.io.palette;
|
|
1579
1643
|
const text = lastAssistantText(await transcriptEvents(app));
|
|
1580
1644
|
if (text === null) {
|
|
@@ -1590,7 +1654,7 @@ async function copyLastReply(app: App, tui?: Tui): Promise<void> {
|
|
|
1590
1654
|
app.io.say(text);
|
|
1591
1655
|
}
|
|
1592
1656
|
|
|
1593
|
-
async function forkSession(app: App, arg?: string, tui?:
|
|
1657
|
+
async function forkSession(app: App, arg?: string, tui?: Ui): Promise<void> {
|
|
1594
1658
|
const C = app.io.palette;
|
|
1595
1659
|
const points = await app.session.userMessages();
|
|
1596
1660
|
if (arg === undefined) {
|
|
@@ -1616,7 +1680,7 @@ async function forkSession(app: App, arg?: string, tui?: Tui): Promise<void> {
|
|
|
1616
1680
|
if (await spawnForkedSession(app, point.id, `forked before ${firstLine(point.text, 40)}`, "before")) {
|
|
1617
1681
|
await tui?.syncTranscript();
|
|
1618
1682
|
tui?.restoreDraft(point.text, point.images ?? []);
|
|
1619
|
-
tui?.
|
|
1683
|
+
tui?.redraw();
|
|
1620
1684
|
}
|
|
1621
1685
|
}
|
|
1622
1686
|
|
|
@@ -1639,7 +1703,7 @@ async function spawnForkedSession(app: App, untilEvent: string | undefined, labe
|
|
|
1639
1703
|
return true;
|
|
1640
1704
|
}
|
|
1641
1705
|
|
|
1642
|
-
async function trustProject(app: App, target: TrustChoice, tui?:
|
|
1706
|
+
async function trustProject(app: App, target: TrustChoice, tui?: Ui): Promise<void> {
|
|
1643
1707
|
const C = app.io.palette;
|
|
1644
1708
|
const dir = target === "parent" ? dirname(app.workspace) : app.workspace;
|
|
1645
1709
|
const trusted = await applyTrustChoice(app.workspace, target);
|
|
@@ -1655,7 +1719,7 @@ async function trustProject(app: App, target: TrustChoice, tui?: Tui): Promise<v
|
|
|
1655
1719
|
await tui?.refreshResources();
|
|
1656
1720
|
}
|
|
1657
1721
|
|
|
1658
|
-
async function nameSession(app: App, name: string, tui?:
|
|
1722
|
+
async function nameSession(app: App, name: string, tui?: Ui): Promise<void> {
|
|
1659
1723
|
const C = app.io.palette;
|
|
1660
1724
|
if (name.length === 0) {
|
|
1661
1725
|
app.io.say(C.dim(" /name <name>"));
|
|
@@ -1675,7 +1739,7 @@ async function nameSession(app: App, name: string, tui?: Tui): Promise<void> {
|
|
|
1675
1739
|
}
|
|
1676
1740
|
|
|
1677
1741
|
/** Runs a prompt. Returns false if the turn failed, having already said why. */
|
|
1678
|
-
async function run(app: App, prompt: string, signal?: AbortSignal, tui?:
|
|
1742
|
+
async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, images: readonly ImageRef[] = [], skill?: Skill): Promise<boolean> {
|
|
1679
1743
|
if (app.extensions && !initializedExtensions.has(app.extensions)) await bindExtensions(app, tui);
|
|
1680
1744
|
for (const result of await app.extensions?.emit("input", { text: prompt, images, source: "interactive" }) ?? []) {
|
|
1681
1745
|
if (result["action"] === "handled") return true;
|
|
@@ -1754,7 +1818,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
|
|
|
1754
1818
|
return false;
|
|
1755
1819
|
}
|
|
1756
1820
|
try {
|
|
1757
|
-
await runTurn(app, input, signal, tui);
|
|
1821
|
+
await runTurn(app, input, signal, tui, skill);
|
|
1758
1822
|
return true;
|
|
1759
1823
|
} catch (error) {
|
|
1760
1824
|
tui?.discardStream();
|
|
@@ -1768,28 +1832,14 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
|
|
|
1768
1832
|
}
|
|
1769
1833
|
}
|
|
1770
1834
|
|
|
1771
|
-
export
|
|
1772
|
-
/** Interactive UI projects committed log events; stream-only consumers omit this. */
|
|
1773
|
-
syncTranscript?(): Promise<void>;
|
|
1774
|
-
pushDelta(kind: "text" | "thinking", text: string): void;
|
|
1775
|
-
commitStream(): void;
|
|
1776
|
-
discardStream(): void;
|
|
1777
|
-
/** Tool/thinking events that stay in the live region until commit. */
|
|
1778
|
-
pushEvent?(e: LoopEvent): void;
|
|
1779
|
-
pushProgress?(progress: NonNullable<LoopEvent["progress"]>): void;
|
|
1780
|
-
/**
|
|
1781
|
-
* Called by send() at a pairable after_tools moment. Returns already
|
|
1782
|
-
* expanded text, or null. Expansion happens here, not at enqueue —
|
|
1783
|
-
* the model is editing files while the line waits.
|
|
1784
|
-
*/
|
|
1785
|
-
pullQueued?(phase: Phase): Promise<UserInput | null>;
|
|
1786
|
-
}
|
|
1835
|
+
export type { TurnSurface };
|
|
1787
1836
|
|
|
1788
1837
|
export async function runTurn(
|
|
1789
1838
|
app: Pick<App, "session" | "io"> & { readonly ui?: App["ui"]; readonly args?: App["args"]; readonly extensions?: App["extensions"] },
|
|
1790
1839
|
prompt: UserInput,
|
|
1791
1840
|
signal?: AbortSignal,
|
|
1792
1841
|
tui?: TurnSurface,
|
|
1842
|
+
skill?: Skill,
|
|
1793
1843
|
): Promise<void> {
|
|
1794
1844
|
// A pipe cannot unwrite bytes. Non-tty / -p therefore does not stream:
|
|
1795
1845
|
// deltas and discard are ignored, and the final block prints once.
|
|
@@ -1805,7 +1855,7 @@ export async function runTurn(
|
|
|
1805
1855
|
}
|
|
1806
1856
|
await registry?.emit("agent_start");
|
|
1807
1857
|
await registry?.emit("turn_start", { turnIndex: 0 });
|
|
1808
|
-
try { for await (const e of app.session.send(prompt, signal, pull)) {
|
|
1858
|
+
try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : app.session.send(prompt, signal, pull)) {
|
|
1809
1859
|
if (e.kind === "text_delta" || e.kind === "thinking_delta") await registry?.emit("message_update", { assistantMessageEvent: { type: e.kind, delta: e.text } });
|
|
1810
1860
|
if (e.kind === "assistant_text") await registry?.emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: e.text }] } });
|
|
1811
1861
|
if (e.kind === "done") await registry?.emit("turn_end", { reason: e.reason });
|
|
@@ -1825,7 +1875,7 @@ export async function runTurn(
|
|
|
1825
1875
|
await tui.syncTranscript();
|
|
1826
1876
|
if (["assistant_text", "thinking", "tool_call", "tool_result", "done", "cut"].includes(e.kind)) tui.commitStream();
|
|
1827
1877
|
if (e.kind === "snapshot_failed" || e.kind === "undeclared_change" || e.kind === "stop_and_ask") {
|
|
1828
|
-
for (const line of formatEvent(e, app.io.palette,
|
|
1878
|
+
for (const line of formatEvent(e, app.io.palette, Math.max(20, (process.stdout.columns ?? 80) - 1), {}, app.ui?.style)) app.io.say(line);
|
|
1829
1879
|
}
|
|
1830
1880
|
}
|
|
1831
1881
|
continue;
|
|
@@ -1853,14 +1903,6 @@ export async function runTurn(
|
|
|
1853
1903
|
}
|
|
1854
1904
|
if (skipFinal && (e.kind === "assistant_text" || e.kind === "thinking")) continue;
|
|
1855
1905
|
if (skipFinal && e.kind !== "usage") skipFinal = false;
|
|
1856
|
-
if (
|
|
1857
|
-
tui !== undefined &&
|
|
1858
|
-
tui.pushEvent !== undefined &&
|
|
1859
|
-
(e.kind === "tool_call" || e.kind === "tool_result" || e.kind === "thinking")
|
|
1860
|
-
) {
|
|
1861
|
-
tui.pushEvent(e);
|
|
1862
|
-
continue;
|
|
1863
|
-
}
|
|
1864
1906
|
if (e.kind === "assistant_text" && app.io.answer !== undefined) {
|
|
1865
1907
|
app.io.answer(e.text ?? "");
|
|
1866
1908
|
continue;
|
|
@@ -1868,7 +1910,7 @@ export async function runTurn(
|
|
|
1868
1910
|
for (const line of formatEvent(
|
|
1869
1911
|
e,
|
|
1870
1912
|
app.io.palette,
|
|
1871
|
-
|
|
1913
|
+
Math.max(20, (process.stdout.columns ?? 80) - 1),
|
|
1872
1914
|
{},
|
|
1873
1915
|
app.ui?.style,
|
|
1874
1916
|
)) {
|
|
@@ -1880,1273 +1922,3 @@ export async function runTurn(
|
|
|
1880
1922
|
// ---------------------------------------------------------------------------
|
|
1881
1923
|
// TUI
|
|
1882
1924
|
// ---------------------------------------------------------------------------
|
|
1883
|
-
|
|
1884
|
-
export class Tui {
|
|
1885
|
-
readonly term: Terminal;
|
|
1886
|
-
editor: Editor;
|
|
1887
|
-
readonly extensionUi: ExtensionUiController;
|
|
1888
|
-
/** Not yet delivered. Retracting a line must leave no trace in `.xeno/`. */
|
|
1889
|
-
queue: QueuedMessage[] = [];
|
|
1890
|
-
pendingImages: ImageRef[] = [];
|
|
1891
|
-
clipboardReader = readClipboard;
|
|
1892
|
-
oauthEffects = { ...loginEffects };
|
|
1893
|
-
clipboardWriter: (text: string) => boolean | Promise<boolean> = writeClipboard;
|
|
1894
|
-
private pasteBuffer = "";
|
|
1895
|
-
private readonly displayImages = new Map<string, string>();
|
|
1896
|
-
private readonly unavailableImages = new Set<string>();
|
|
1897
|
-
private readonly markdownFeatures: MarkdownFeatures = {
|
|
1898
|
-
highlight: (code, lang, style) => highlightCode(code, lang, (token, text) => style.token?.(token, text) ?? text),
|
|
1899
|
-
transform: (text, width, streaming, style, messageType = "assistant") => transformMermaid(this.extensionUi.transformMarkdown(text, width, streaming, messageType), width, this.app.ui?.settings.markdown?.mermaid ?? "streaming", streaming,
|
|
1900
|
-
(token, content) => style.token?.(token, content) ?? content),
|
|
1901
|
-
};
|
|
1902
|
-
select: SelectList | null = null;
|
|
1903
|
-
settings: Settings | null = null;
|
|
1904
|
-
tree: TreeList | null = null;
|
|
1905
|
-
screen: InteractiveScreen;
|
|
1906
|
-
private readonly document: PaddedDocument;
|
|
1907
|
-
private readonly viewport: ChatViewport;
|
|
1908
|
-
private stopThemeWatch: (() => void) | undefined;
|
|
1909
|
-
private stopColorWatch: (() => void) | undefined;
|
|
1910
|
-
private themeGeneration = 0;
|
|
1911
|
-
private previewTheme: Theme | undefined;
|
|
1912
|
-
private readonly chat: Container;
|
|
1913
|
-
private noticeDir: string;
|
|
1914
|
-
private readonly transcript = new Transcript();
|
|
1915
|
-
private transcriptItems: TranscriptItem[] = [];
|
|
1916
|
-
private historyNode = -1;
|
|
1917
|
-
private historyDir = "";
|
|
1918
|
-
private ancestors: LogEvent[] = [];
|
|
1919
|
-
private historyLength = -1;
|
|
1920
|
-
private liveMarkdown: MarkdownBlock | null = null;
|
|
1921
|
-
private liveStyle: Style | null = null;
|
|
1922
|
-
private readonly live: Container;
|
|
1923
|
-
private readonly editorBox: Container;
|
|
1924
|
-
private readonly footerBox: Container;
|
|
1925
|
-
liveRows = 0;
|
|
1926
|
-
abort: AbortController | null = null;
|
|
1927
|
-
inflight = false;
|
|
1928
|
-
private sessionTransition = false;
|
|
1929
|
-
executionKind: "idle" | "model" | "bash" | "shutdown" = "idle";
|
|
1930
|
-
shutdownRequested = false;
|
|
1931
|
-
private bashOutput: string | null = null;
|
|
1932
|
-
private bashDecoder = new TextDecoder();
|
|
1933
|
-
private readonly toolArguments = new Map<string, { name: string; arguments: string }>();
|
|
1934
|
-
private toolOutput = "";
|
|
1935
|
-
private retryUntil = 0;
|
|
1936
|
-
private retryAttempt = 0;
|
|
1937
|
-
private retryTimer: ReturnType<typeof setInterval> | undefined;
|
|
1938
|
-
awaitingKeyFor: string | null = null;
|
|
1939
|
-
private credentialDraft: string | undefined;
|
|
1940
|
-
streamThinking = "";
|
|
1941
|
-
streamText = "";
|
|
1942
|
-
flushed = 0;
|
|
1943
|
-
toolsExpanded = false;
|
|
1944
|
-
thinkingExpanded = false;
|
|
1945
|
-
sessionName: string | null;
|
|
1946
|
-
private onPick: ((item: SelectItem) => Promise<void>) | null = null;
|
|
1947
|
-
private onSelectCancel: (() => void) | undefined;
|
|
1948
|
-
private onTreePick: ((node: TreeNode) => Promise<void>) | null = null;
|
|
1949
|
-
/** Which selector Ctrl+S should persist. Other lists ignore Ctrl+S. */
|
|
1950
|
-
private selectKind: "model" | "scoped-models" | "thinking" | "other" = "other";
|
|
1951
|
-
private approvalResolve: ((choice: ApprovalChoice) => void) | null = null;
|
|
1952
|
-
private inputs: string[] = [];
|
|
1953
|
-
private wake: (() => void) | null = null;
|
|
1954
|
-
private attached = false;
|
|
1955
|
-
private progressActive = false;
|
|
1956
|
-
private resumeSuspended: (() => void) | undefined;
|
|
1957
|
-
private lastInterrupt = 0;
|
|
1958
|
-
private lastEscape = 0;
|
|
1959
|
-
private pasting = false;
|
|
1960
|
-
private turn: Promise<void> | null = null;
|
|
1961
|
-
private readonly extensionWork = new Set<Promise<void>>();
|
|
1962
|
-
readonly app: App;
|
|
1963
|
-
/** Read once: a git spawn on every keystroke would stall the live region. */
|
|
1964
|
-
private branch: string | null;
|
|
1965
|
-
|
|
1966
|
-
constructor(app: App, term?: Terminal) {
|
|
1967
|
-
this.app = app;
|
|
1968
|
-
this.historyDir = app.logDir;
|
|
1969
|
-
this.noticeDir = app.logDir;
|
|
1970
|
-
this.screen = createInteractiveTui(term, app.ui?.settings.showHardwareCursor ?? true, { ...app.ui?.settings, copySelection: async text => this.clipboardWriter(text) });
|
|
1971
|
-
this.term = this.screen.terminal;
|
|
1972
|
-
this.editor = new Editor({ tui: this.screen });
|
|
1973
|
-
this.editor.bindings = editorBindings((app.ui?.keymap ?? defaultKeymap()).byAction);
|
|
1974
|
-
this.editor.onInput = (data) => { this.inputs.push(data); this.wake?.(); };
|
|
1975
|
-
this.chat = new Container();
|
|
1976
|
-
this.live = new Container();
|
|
1977
|
-
this.editorBox = new Container();
|
|
1978
|
-
this.footerBox = new Container();
|
|
1979
|
-
this.extensionUi = new ExtensionUiController({
|
|
1980
|
-
screen: () => this.screen, editor: this.editor, theme: () => this.app.ui?.theme ?? builtinTheme("dark"), colored: () => this.style !== IDENTITY,
|
|
1981
|
-
bindings: () => this.editor.bindings!, branch: () => this.branch,
|
|
1982
|
-
redraw: () => this.paint(), refreshTranscript: () => this.configureTranscript(), input: data => this.editor.onInput?.(data),
|
|
1983
|
-
notify: (text, type) => this.app.io.say(type === "error" ? this.app.io.palette.red(text) : text),
|
|
1984
|
-
setTheme: theme => this.setTheme(theme), expanded: () => this.toolsExpanded, expand: value => { this.toolsExpanded = value; },
|
|
1985
|
-
cwd: () => this.app.workspace, showImages: () => this.app.ui?.settings.terminal?.showImages !== false, outputPad: () => this.app.ui?.settings.outputPad ?? 1,
|
|
1986
|
-
saveTheme: name => {
|
|
1987
|
-
if (this.app.ui) this.app.ui.settings = { ...this.app.ui.settings, theme: name };
|
|
1988
|
-
this.runExtensionAction(() => writeUserSettings({ theme: name }));
|
|
1989
|
-
},
|
|
1990
|
-
});
|
|
1991
|
-
this.extensionUi.setCompletion(interactiveAutocomplete(app, async refresh => (await fileIndexFor(app, refresh)).paths));
|
|
1992
|
-
this.document = new PaddedDocument();
|
|
1993
|
-
this.document.addChild(this.extensionUi.header);
|
|
1994
|
-
this.document.addChild(this.transcript);
|
|
1995
|
-
this.document.addChild(this.chat);
|
|
1996
|
-
this.transcriptItems = projectTranscript(app.session.state.events, new Set(app.extensions?.entryRenderers.keys()));
|
|
1997
|
-
this.document.addChild(this.live);
|
|
1998
|
-
this.viewport = createChatViewport({ document: this.document, pendingMessages: new Container(), status: new Container(), editor: this.editorBox, footer: this.footerBox,
|
|
1999
|
-
widgetsAbove: this.extensionUi.above, widgetsBelow: this.extensionUi.below,
|
|
2000
|
-
scrollbarTrackStyle: text => this.style.token?.("muted", text) ?? text, scrollbarThumbStyle: text => this.style.token?.("text", text) ?? text });
|
|
2001
|
-
this.mountScreen();
|
|
2002
|
-
this.applyConfiguration();
|
|
2003
|
-
this.sessionName = app.args.sessionName || null;
|
|
2004
|
-
// Before attach: a hung git must not freeze an already-captured tty.
|
|
2005
|
-
this.branch = currentBranch(app.workspace);
|
|
2006
|
-
void warmSyntaxLanguages().then(() => { this.configureTranscript(); this.liveMarkdown?.invalidate(); this.paint(); });
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
private mountScreen(): void {
|
|
2010
|
-
this.screen.addChild(this.document);
|
|
2011
|
-
this.screen.addChild(this.extensionUi.above);
|
|
2012
|
-
this.screen.addChild(this.editorBox);
|
|
2013
|
-
this.screen.addChild(this.extensionUi.below);
|
|
2014
|
-
this.screen.addChild(this.footerBox);
|
|
2015
|
-
if (this.screen.mode === "fullscreen") this.screen.setLayoutRoot(this.viewport.root);
|
|
2016
|
-
}
|
|
2017
|
-
|
|
2018
|
-
applyConfiguration(): void {
|
|
2019
|
-
const settings = this.app.ui?.settings ?? {};
|
|
2020
|
-
// Preserve xeno's collapsed default; explicit settings and Ctrl+T share one source of truth.
|
|
2021
|
-
this.thinkingExpanded = settings.hideThinkingBlock === false;
|
|
2022
|
-
const mode = settings.tuiMode ?? "regular";
|
|
2023
|
-
if (this.screen.mode !== mode) {
|
|
2024
|
-
if (this.attached) this.screen.stop({ preserveScreen: true });
|
|
2025
|
-
this.screen = replaceInteractiveTui(this.screen, { ...settings, copySelection: async text => this.clipboardWriter(text) });
|
|
2026
|
-
this.editor.rebind(this.screen);
|
|
2027
|
-
this.mountScreen();
|
|
2028
|
-
this.extensionUi.rebind();
|
|
2029
|
-
if (this.attached) { this.screen.start(); this.screen.requestRender(true); this.followTheme(); }
|
|
2030
|
-
}
|
|
2031
|
-
this.screen.setShowHardwareCursor(settings.showHardwareCursor ?? true);
|
|
2032
|
-
this.screen.setClearOnShrink(settings.terminal?.clearOnShrink ?? false);
|
|
2033
|
-
if (this.screen.mode === "fullscreen") {
|
|
2034
|
-
this.screen.setCopyOnSelect(settings.fullscreenCopyOnSelect ?? true);
|
|
2035
|
-
this.screen.setWheelScrollLines(settings.fullscreenWheelScrollLines ?? "auto");
|
|
2036
|
-
}
|
|
2037
|
-
this.viewport.transcript.setScrollbar(settings.fullscreenScrollbar ?? "auto");
|
|
2038
|
-
this.editor.configure(settings.editorPaddingX ?? 0, settings.autocompleteMaxVisible ?? 5);
|
|
2039
|
-
this.transcript.setPadding(0);
|
|
2040
|
-
this.document.padding = settings.outputPad ?? 1;
|
|
2041
|
-
this.editor.bindings = installBindings((this.app.ui?.keymap ?? defaultKeymap()).byAction);
|
|
2042
|
-
this.app.session.config.imageOptions = settings.images ?? {};
|
|
2043
|
-
this.configureTranscript();
|
|
2044
|
-
}
|
|
2045
|
-
|
|
2046
|
-
async refreshResources(): Promise<void> {
|
|
2047
|
-
await bindExtensions(this.app, this);
|
|
2048
|
-
this.historyLength = -1;
|
|
2049
|
-
this.applyConfiguration(); this.followTheme();
|
|
2050
|
-
fileIndexes.delete(this.app.workspace); indexNotes.delete(this.app.workspace);
|
|
2051
|
-
this.extensionUi.setCompletion(interactiveAutocomplete(this.app, async refresh => (await fileIndexFor(this.app, refresh)).paths));
|
|
2052
|
-
this.branch = currentBranch(this.app.workspace);
|
|
2053
|
-
this.extensionUi.branchChanged();
|
|
2054
|
-
await this.syncTranscript(); this.paint();
|
|
2055
|
-
}
|
|
2056
|
-
|
|
2057
|
-
private configureTranscript(): void {
|
|
2058
|
-
const settings = this.app.ui?.settings;
|
|
2059
|
-
this.transcript.configure(this.markdownFeatures, this.displayImages, settings?.terminal?.showImages ?? true, settings?.terminal?.imageWidthCells ?? 60);
|
|
2060
|
-
this.transcript.setExtensions({
|
|
2061
|
-
message: (type, text, data, expanded, fallback, metadata) => this.extensionUi.renderMessage(type, text, data, expanded, fallback, metadata),
|
|
2062
|
-
tool: (name, input, result, expanded, call, output, id) => this.extensionUi.renderTool(name, input, result, expanded, call, output, id),
|
|
2063
|
-
hiddenThinkingLabel: this.extensionUi.hiddenThinkingLabel,
|
|
2064
|
-
});
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2067
|
-
private setTheme(theme: Theme): void {
|
|
2068
|
-
if (!this.app.ui) return;
|
|
2069
|
-
this.app.ui.theme = theme;
|
|
2070
|
-
this.app.ui.style = colorEnabled() ? createStyle(theme.colors) : IDENTITY;
|
|
2071
|
-
this.extensionUi.rebind(false); this.configureTranscript();
|
|
2072
|
-
this.paint();
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
followTheme(): void {
|
|
2076
|
-
this.stopThemeWatch?.(); this.stopColorWatch?.();
|
|
2077
|
-
const generation = ++this.themeGeneration;
|
|
2078
|
-
const name = this.app.ui?.settings.theme ?? "dark";
|
|
2079
|
-
const spec = this.app.resources ? selectedThemePath(this.app.resources, name, this.app.args.noThemes) : name;
|
|
2080
|
-
this.stopThemeWatch = watchTheme(spec, theme => this.setTheme(theme), message => this.writeLine(message));
|
|
2081
|
-
const automatic = spec === "system" || spec === "auto";
|
|
2082
|
-
this.screen.setTerminalColorSchemeNotifications(automatic);
|
|
2083
|
-
const update = async (appearance?: "dark" | "light") => {
|
|
2084
|
-
const apply = (colors: Awaited<ReturnType<InteractiveScreen["queryTerminalColors"]>>) => {
|
|
2085
|
-
if (generation !== this.themeGeneration || this.previewTheme) return;
|
|
2086
|
-
const generated = generateSystemThemeColors({ ...colors, ...(appearance ? { appearanceHint: appearance } : {}) });
|
|
2087
|
-
if (spec === "auto") { this.setTheme(builtinTheme(generated.appearance ?? "dark")); return; }
|
|
2088
|
-
const mapped = Object.fromEntries(THEME_TOKENS.map(token => [token, generated.colors[token === "status" ? "dim" : token === "prompt" ? "text" : token]]));
|
|
2089
|
-
this.setTheme(parseTheme({ name: "system", colors: mapped }).theme);
|
|
2090
|
-
};
|
|
2091
|
-
apply(await this.screen.queryTerminalColors({ timeoutMs: 150, onLateReply: apply }));
|
|
2092
|
-
};
|
|
2093
|
-
if (automatic && this.attached) {
|
|
2094
|
-
this.stopColorWatch = this.screen.onTerminalColorSchemeChange(scheme => { void update(scheme); });
|
|
2095
|
-
void update();
|
|
2096
|
-
}
|
|
2097
|
-
}
|
|
2098
|
-
|
|
2099
|
-
get style(): Style {
|
|
2100
|
-
return this.app.ui?.style ?? ANSI;
|
|
2101
|
-
}
|
|
2102
|
-
|
|
2103
|
-
get signal(): AbortSignal | undefined {
|
|
2104
|
-
return this.abort?.signal;
|
|
2105
|
-
}
|
|
2106
|
-
|
|
2107
|
-
writeLine(line: string): void {
|
|
2108
|
-
if (this.noticeDir !== this.app.logDir) { this.chat.clear(); this.noticeDir = this.app.logDir; }
|
|
2109
|
-
this.chat.addChild(new PiText(line, 0, 0));
|
|
2110
|
-
this.paint();
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
dropLive(): void {
|
|
2114
|
-
this.live.clear();
|
|
2115
|
-
this.liveRows = 0;
|
|
2116
|
-
}
|
|
2117
|
-
|
|
2118
|
-
private markdownStyle(): Style {
|
|
2119
|
-
return this.app.io.palette.bold("\0") === "\0" ? IDENTITY : this.style;
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
private clearStream(): void {
|
|
2123
|
-
this.extensionUi.clearPartials();
|
|
2124
|
-
this.streamThinking = "";
|
|
2125
|
-
this.streamText = "";
|
|
2126
|
-
this.flushed = 0;
|
|
2127
|
-
this.bashOutput = null;
|
|
2128
|
-
this.toolArguments.clear();
|
|
2129
|
-
this.toolOutput = "";
|
|
2130
|
-
this.stopRetryTimer();
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
pushDelta(kind: "text" | "thinking", text: string): void {
|
|
2134
|
-
this.stopRetryTimer();
|
|
2135
|
-
if (kind === "thinking") this.streamThinking += text;
|
|
2136
|
-
else this.streamText += text;
|
|
2137
|
-
this.paintStream();
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
pushProgress(progress: NonNullable<LoopEvent["progress"]>): void {
|
|
2141
|
-
if (progress.kind === "retry") {
|
|
2142
|
-
this.stopRetryTimer();
|
|
2143
|
-
this.retryUntil = Date.now() + progress.delayMs;
|
|
2144
|
-
this.retryAttempt = progress.attempt;
|
|
2145
|
-
this.retryTimer = setInterval(() => this.paint(), 200);
|
|
2146
|
-
} else {
|
|
2147
|
-
this.stopRetryTimer();
|
|
2148
|
-
if (progress.kind === "tool") this.toolArguments.set(progress.id, { name: progress.name, arguments: progress.arguments });
|
|
2149
|
-
else this.toolOutput = `${this.toolOutput}${progress.text}`.slice(-64 * 1024);
|
|
2150
|
-
}
|
|
2151
|
-
this.paint();
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
|
-
private stopRetryTimer(): void {
|
|
2155
|
-
clearInterval(this.retryTimer);
|
|
2156
|
-
this.retryTimer = undefined;
|
|
2157
|
-
this.retryUntil = 0;
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
discardStream(): void {
|
|
2161
|
-
this.clearStream();
|
|
2162
|
-
this.dropLive();
|
|
2163
|
-
if (this.inflight) this.paintStream();
|
|
2164
|
-
else this.paint();
|
|
2165
|
-
}
|
|
2166
|
-
|
|
2167
|
-
commitStream(): void {
|
|
2168
|
-
// A stream is never durable history. syncTranscript replaces it from the log.
|
|
2169
|
-
this.clearStream();
|
|
2170
|
-
this.paint();
|
|
2171
|
-
}
|
|
2172
|
-
|
|
2173
|
-
async syncTranscript(): Promise<void> {
|
|
2174
|
-
if (this.attached && this.app.extensions && !initializedExtensions.has(this.app.extensions)) await bindExtensions(this.app, this);
|
|
2175
|
-
const session = this.app.session;
|
|
2176
|
-
const dir = this.app.logDir;
|
|
2177
|
-
const node = session.state.node;
|
|
2178
|
-
if (dir !== this.historyDir || node !== this.historyNode) {
|
|
2179
|
-
const history = await readNodeHistory(dir, node);
|
|
2180
|
-
if (!history.ok) {
|
|
2181
|
-
this.chat.clear();
|
|
2182
|
-
this.chat.addChild(new PiText("Cannot load complete session history: " + history.error, 0, 0));
|
|
2183
|
-
this.paint();
|
|
2184
|
-
return;
|
|
2185
|
-
}
|
|
2186
|
-
if (this.app.session !== session || this.app.logDir !== dir) return;
|
|
2187
|
-
if (this.historyDir !== dir) {
|
|
2188
|
-
this.applyConfiguration();
|
|
2189
|
-
this.followTheme();
|
|
2190
|
-
if (this.noticeDir !== dir) this.chat.clear();
|
|
2191
|
-
this.noticeDir = dir;
|
|
2192
|
-
this.queue = [];
|
|
2193
|
-
this.pendingImages = [];
|
|
2194
|
-
this.displayImages.clear(); this.unavailableImages.clear();
|
|
2195
|
-
this.clearStream();
|
|
2196
|
-
this.editor.applyExternal("");
|
|
2197
|
-
this.editor.setAutocompleteProvider(interactiveAutocomplete(this.app, async (refresh) => (await fileIndexFor(this.app, refresh)).paths));
|
|
2198
|
-
this.branch = currentBranch(this.app.workspace);
|
|
2199
|
-
this.sessionName = (await readCursor(dir))?.name ?? null;
|
|
2200
|
-
}
|
|
2201
|
-
this.ancestors = history.nodes.filter((entry) => entry.node !== node).flatMap((entry) => entry.events);
|
|
2202
|
-
this.historyDir = dir;
|
|
2203
|
-
this.historyNode = node;
|
|
2204
|
-
this.historyLength = -1;
|
|
2205
|
-
}
|
|
2206
|
-
const physical = await readNode(dir, node);
|
|
2207
|
-
if (this.app.session !== session || session.state.node !== node) return;
|
|
2208
|
-
if (physical.corrupt.length > 0) throw new Error("Cannot render corrupt session history");
|
|
2209
|
-
if (this.historyLength !== physical.events.length) {
|
|
2210
|
-
this.transcriptItems = projectTranscript([...this.ancestors, ...physical.events], new Set(this.app.extensions?.entryRenderers.keys()));
|
|
2211
|
-
this.historyLength = physical.events.length;
|
|
2212
|
-
const images = this.transcriptItems.flatMap(item => item.kind === "user" ? item.images ?? [] : item.kind === "tool" ? item.result?.images ?? [] : []);
|
|
2213
|
-
for (const image of images) {
|
|
2214
|
-
if (this.displayImages.has(image.sha256) || this.unavailableImages.has(image.sha256)) continue;
|
|
2215
|
-
try { this.displayImages.set(image.sha256, await loadDisplayImage(image, this.app.blobDir)); }
|
|
2216
|
-
catch { this.unavailableImages.add(image.sha256); }
|
|
2217
|
-
}
|
|
2218
|
-
this.configureTranscript();
|
|
2219
|
-
}
|
|
2220
|
-
this.paint();
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
paintStream(): void {
|
|
2224
|
-
this.rebuildLive();
|
|
2225
|
-
this.rebuildChrome();
|
|
2226
|
-
this.renderScreen();
|
|
2227
|
-
}
|
|
2228
|
-
|
|
2229
|
-
paint(): void {
|
|
2230
|
-
const colored = this.markdownStyle() !== IDENTITY;
|
|
2231
|
-
this.editor.borderColor = thinkingBorderColor(this.app.providerConfig.effort, colored, this.style);
|
|
2232
|
-
this.rebuildLive();
|
|
2233
|
-
this.rebuildChrome();
|
|
2234
|
-
this.renderScreen();
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2237
|
-
private renderScreen(): void {
|
|
2238
|
-
const progress = this.inflight && this.app.ui?.settings.terminal?.showTerminalProgress !== false;
|
|
2239
|
-
if (this.attached && this.progressActive !== progress) {
|
|
2240
|
-
this.screen.terminal.setProgress(progress);
|
|
2241
|
-
this.progressActive = progress;
|
|
2242
|
-
}
|
|
2243
|
-
this.transcript.update(this.transcriptItems, this.markdownStyle(), this.toolsExpanded, this.thinkingExpanded);
|
|
2244
|
-
if (this.attached) this.screen.requestRender();
|
|
2245
|
-
else this.screen.renderNow();
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
private rebuildLive(): void {
|
|
2249
|
-
this.live.clear();
|
|
2250
|
-
const style = this.markdownStyle();
|
|
2251
|
-
if (this.bashOutput !== null) this.live.addChild(new PiText(this.bashOutput, 0, 0));
|
|
2252
|
-
for (const tool of this.toolArguments.values()) this.live.addChild(new PiText(`Preparing ${tool.name}\n${tool.arguments}`, 0, 0));
|
|
2253
|
-
if (this.extensionUi.partials.children.length) this.live.addChild(this.extensionUi.partials);
|
|
2254
|
-
else if (this.toolOutput) this.live.addChild(new PiText(this.toolOutput, 0, 0));
|
|
2255
|
-
if (this.retryUntil) this.live.addChild(new PiText(`Retry ${this.retryAttempt} in ${Math.max(0, (this.retryUntil - Date.now()) / 1000).toFixed(1)}s · Esc to cancel`, 0, 0));
|
|
2256
|
-
if (this.streamThinking.length > 0) {
|
|
2257
|
-
this.live.addChild(
|
|
2258
|
-
!this.thinkingExpanded && this.extensionUi.hiddenThinkingLabel !== undefined ? new PiText(this.extensionUi.hiddenThinkingLabel, 0, 0) : new Lines((width) =>
|
|
2259
|
-
renderToolView({ kind: "thinking", text: this.extensionUi.transformMarkdown(this.streamThinking, width, true, "assistant-thinking") }, this.thinkingExpanded, width, style),
|
|
2260
|
-
),
|
|
2261
|
-
);
|
|
2262
|
-
}
|
|
2263
|
-
if (this.streamText.length > 0) {
|
|
2264
|
-
if (this.liveStyle !== style || this.liveMarkdown === null) {
|
|
2265
|
-
this.liveStyle = style;
|
|
2266
|
-
this.liveMarkdown = new MarkdownBlock(this.streamText, style, this.markdownFeatures, true);
|
|
2267
|
-
}
|
|
2268
|
-
this.liveMarkdown.setText(this.streamText);
|
|
2269
|
-
this.live.addChild(this.liveMarkdown);
|
|
2270
|
-
}
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2273
|
-
private rebuildChrome(): void {
|
|
2274
|
-
const inner = this.tree ?? this.select ?? this.settings ?? this.editor;
|
|
2275
|
-
inner.setStyle(this.style);
|
|
2276
|
-
inner.onInput = this.editor.onInput;
|
|
2277
|
-
this.editorBox.clear();
|
|
2278
|
-
this.editorBox.addChild(this.extensionUi.modal ?? inner);
|
|
2279
|
-
if (!this.extensionUi.overlayFocused) this.screen.setFocus(this.extensionUi.modal ?? inner);
|
|
2280
|
-
this.footerBox.clear();
|
|
2281
|
-
for (const item of this.queue) this.footerBox.addChild(new PiText(`${item.kind === "steering" ? "Steering" : "Follow-up"}: ${firstLine(item.text, 160)}${item.images?.length ? ` · ${item.images.length} image(s)` : ""}`, 0, 0));
|
|
2282
|
-
if (this.pendingImages.length) this.footerBox.addChild(new PiText(`Images: ${this.pendingImages.map(i => i.name).join(", ")} · /attachments to remove`, 0, 0));
|
|
2283
|
-
const working = this.extensionUi.working(this.inflight, this.executionKind === "bash" ? "Running command" : "Working");
|
|
2284
|
-
if (working) this.footerBox.addChild(new PiText(working, 0, 0));
|
|
2285
|
-
if (this.extensionUi.customFooter) this.footerBox.addChild(this.extensionUi.customFooter);
|
|
2286
|
-
else {
|
|
2287
|
-
this.footerBox.addChild(new Lines((width) => this.statusLines(width)));
|
|
2288
|
-
if (this.extensionUi.status) this.footerBox.addChild(new PiText(this.extensionUi.status, 0, 0));
|
|
2289
|
-
}
|
|
2290
|
-
}
|
|
2291
|
-
|
|
2292
|
-
private statusLines(width: number): string[] {
|
|
2293
|
-
if (this.app.ui?.settings.statusBar === false) return [];
|
|
2294
|
-
const attrs = { ...PLAIN_ATTRS, status: true };
|
|
2295
|
-
return renderStatus(this.statusState(), width).map((line) => paint(line, attrs, this.style));
|
|
2296
|
-
}
|
|
2297
|
-
|
|
2298
|
-
private statusState(): StatusState {
|
|
2299
|
-
const s = this.app.session;
|
|
2300
|
-
const budget = contextBudget({
|
|
2301
|
-
frozen: s.config.frozen,
|
|
2302
|
-
handoff: s.state.handoff,
|
|
2303
|
-
events: s.state.events,
|
|
2304
|
-
anchor: s.state.anchor,
|
|
2305
|
-
atTurnBoundary: !this.inflight,
|
|
2306
|
-
...(s.config.counter ? { counter: s.config.counter } : {}),
|
|
2307
|
-
...(s.config.boundary ? { boundary: s.config.boundary } : {}),
|
|
2308
|
-
});
|
|
2309
|
-
const t = s.totals;
|
|
2310
|
-
const cost = reportedCost(t, this.app.providerConfig.spec.price);
|
|
2311
|
-
return {
|
|
2312
|
-
cwd: this.app.workspace,
|
|
2313
|
-
branch: this.branch,
|
|
2314
|
-
tokens: budget.used,
|
|
2315
|
-
triggerTokens: budget.trigger,
|
|
2316
|
-
model: this.app.providerConfig.spec.id,
|
|
2317
|
-
thinking: this.app.providerConfig.effort,
|
|
2318
|
-
cut: budget.decision.cut,
|
|
2319
|
-
reason: budget.decision.reason,
|
|
2320
|
-
...(this.sessionName ? { sessionName: this.sessionName } : {}),
|
|
2321
|
-
home: homedir(),
|
|
2322
|
-
inputTokens: t.input_tokens,
|
|
2323
|
-
outputTokens: t.output_tokens,
|
|
2324
|
-
cacheRead: t.cache_read_input_tokens,
|
|
2325
|
-
cacheWrite: t.cache_creation_input_tokens,
|
|
2326
|
-
...(t.prompt_tokens > 0 ? { cacheHitRate: t.cache_hit_rate * 100 } : {}),
|
|
2327
|
-
...(cost.kind === "usd" ? { cost: cost.amount } : cost.kind === "partial" ? { cost: cost.known } : {}),
|
|
2328
|
-
...(this.queue.length > 0 ? { queued: this.queue.length } : {}),
|
|
2329
|
-
};
|
|
2330
|
-
}
|
|
2331
|
-
|
|
2332
|
-
openSettings(field?: string): void {
|
|
2333
|
-
if (field !== undefined) { this.openSettingValues(field); return; }
|
|
2334
|
-
const settings = this.app.ui?.settings;
|
|
2335
|
-
this.settings = new Settings([
|
|
2336
|
-
{ id: "theme", label: "Theme", currentValue: settings?.theme ?? "dark", values: ["dark", "light"], description: "Apply the palette to the editor, menus and complete history." },
|
|
2337
|
-
{ id: "thinking", label: "Thinking", currentValue: this.app.providerConfig.effort, values: EFFORTS.filter((effort) => effortSupported(this.app.providerConfig.spec, effort)), description: "Reasoning effort for the next turn." },
|
|
2338
|
-
{ id: "statusBar", label: "Status bar", currentValue: settings?.statusBar === false ? "off" : "on", values: ["on", "off"], description: "Workspace, usage and distance to the next context boundary." },
|
|
2339
|
-
{ id: "steeringMode", label: "Steering mode", currentValue: settings?.steeringMode ?? "one-at-a-time", values: ["one-at-a-time", "all"], description: "Deliver queued messages after tool results." },
|
|
2340
|
-
{ id: "followUpMode", label: "Follow-up mode", currentValue: settings?.followUpMode ?? "one-at-a-time", values: ["one-at-a-time", "all"], description: "Deliver queued messages after the current turn." },
|
|
2341
|
-
{ id: "defaultModel", label: "Default model", currentValue: settings?.defaultModel ?? "automatic", values: ["configure"], description: "Choose the provider and model for new sessions." },
|
|
2342
|
-
{ id: "modelThinkingLevels", label: "Model thinking defaults", currentValue: `${Object.keys(settings?.modelThinkingLevels ?? {}).length} configured`, values: ["configure"], description: "Set or clear a thinking default by provider/model." },
|
|
2343
|
-
...DISPLAY_SETTINGS.map(item => ({ ...item, currentValue: settingValue(settings ?? {}, item.id, item.fallback), values: settingValues(item.id) })),
|
|
2344
|
-
], this.style);
|
|
2345
|
-
const menu = this.settings;
|
|
2346
|
-
void discoverThemes(this.app.resources).then(names => { if (this.settings === menu) { menu.updateValues("theme", names); this.paint(); } }).catch(error => this.writeLine(String(error)));
|
|
2347
|
-
this.paint();
|
|
2348
|
-
}
|
|
2349
|
-
|
|
2350
|
-
openLoginSelect(method?: "oauth" | "api_key"): void {
|
|
2351
|
-
const providers = [...new Set(this.app.catalog.map((model) => model.provider))];
|
|
2352
|
-
const status = (provider: string): string => {
|
|
2353
|
-
const spec = this.app.catalog.find(model => model.provider === provider);
|
|
2354
|
-
const resolved = spec ? this.app.credentialOf(spec) : undefined;
|
|
2355
|
-
const stored = this.app.auth[provider];
|
|
2356
|
-
return resolved?.ok ? `${stored?.type === "oauth" && resolved.source === "auth.json" ? "account" : "API key"} · ${resolved.source}` : stored ? "stored credential unavailable" : "login required";
|
|
2357
|
-
};
|
|
2358
|
-
this.select = new SelectList([
|
|
2359
|
-
...(method ? [] : [
|
|
2360
|
-
{ value: "auth:oauth", label: "Sign in with an account", description: "Choose a subscription provider" },
|
|
2361
|
-
{ value: "auth:api_key", label: "Sign in with an API key", description: "Choose a key provider" },
|
|
2362
|
-
]),
|
|
2363
|
-
...(method === "api_key" ? [] : SUBSCRIPTION_OPTIONS.map(option => ({ value: option.id, label: option.id, description: `${option.label} · ${status(option.provider)}` }))),
|
|
2364
|
-
...(method === "oauth" ? [] : providers.map(provider => ({ value: provider, label: provider, description: `API key · ${status(provider)}` }))),
|
|
2365
|
-
], { style: this.style, title: "Login · choose a provider" });
|
|
2366
|
-
this.selectKind = "other";
|
|
2367
|
-
this.onSelectCancel = method ? () => this.openLoginSelect() : undefined;
|
|
2368
|
-
this.onPick = async item => {
|
|
2369
|
-
if (item.value === "auth:oauth" || item.value === "auth:api_key") { this.openLoginSelect(item.value === "auth:oauth" ? "oauth" : "api_key"); return; }
|
|
2370
|
-
const provider = subscriptionById(item.value)?.provider ?? item.value;
|
|
2371
|
-
const spec = this.app.catalog.find(model => model.provider === provider);
|
|
2372
|
-
const resolved = spec ? this.app.credentialOf(spec) : undefined;
|
|
2373
|
-
if (resolved?.ok && resolved.source !== "auth.json") this.app.io.say(` Current credential source: ${resolved.source}. /logout only removes stored credentials. Environment: ${envLabel(provider)}.`);
|
|
2374
|
-
await handleLine(this.app, "/login " + item.value, this);
|
|
2375
|
-
};
|
|
2376
|
-
this.paint();
|
|
2377
|
-
}
|
|
2378
|
-
|
|
2379
|
-
beginCredentialInput(provider: string): void {
|
|
2380
|
-
this.credentialDraft ??= this.editor.state.text;
|
|
2381
|
-
this.awaitingKeyFor = provider;
|
|
2382
|
-
this.editor.setSecret(true);
|
|
2383
|
-
this.editor.applyExternal("");
|
|
2384
|
-
this.paint();
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
endCredentialInput(): void {
|
|
2388
|
-
if (this.credentialDraft === undefined && this.awaitingKeyFor === null) return;
|
|
2389
|
-
const draft = this.credentialDraft ?? "";
|
|
2390
|
-
this.credentialDraft = undefined;
|
|
2391
|
-
this.awaitingKeyFor = null;
|
|
2392
|
-
this.editor.setSecret(false);
|
|
2393
|
-
this.editor.applyExternal(draft);
|
|
2394
|
-
this.paint();
|
|
2395
|
-
}
|
|
2396
|
-
|
|
2397
|
-
openLogoutSelect(): void {
|
|
2398
|
-
this.selectKind = "other";
|
|
2399
|
-
this.select = new SelectList(Object.keys(this.app.auth).filter(provider => this.app.auth[provider] !== undefined)
|
|
2400
|
-
.map(provider => ({ value: provider, label: provider, description: "Remove stored credential" })),
|
|
2401
|
-
{ style: this.style, title: "Logout · choose a provider", emptyMessage: "No stored credentials" });
|
|
2402
|
-
this.onPick = async item => { await logout(this.app, item.value); };
|
|
2403
|
-
this.paint();
|
|
2404
|
-
}
|
|
2405
|
-
|
|
2406
|
-
private scopeModels(): ModelSpec[] {
|
|
2407
|
-
return this.app.catalog.filter(spec => this.app.credentialOf(spec).ok);
|
|
2408
|
-
}
|
|
2409
|
-
|
|
2410
|
-
private activeScope(): string[] {
|
|
2411
|
-
return this.scopeEntries().map(entry => modelKey(entry.model));
|
|
2412
|
-
}
|
|
2413
|
-
|
|
2414
|
-
private scopeEntries() {
|
|
2415
|
-
const fromFlag = splitModelPatterns(this.app.args.models);
|
|
2416
|
-
const configured = this.app.args.modelsExplicit || fromFlag.length ? fromFlag : this.app.ui?.settings.scopedModels;
|
|
2417
|
-
return scopedModels(configured ?? this.scopeModels().map(modelKey), this.scopeModels()).entries;
|
|
2418
|
-
}
|
|
2419
|
-
|
|
2420
|
-
private scopedItems(): SelectItem[] {
|
|
2421
|
-
const enabled = this.activeScope();
|
|
2422
|
-
return this.scopeModels().sort((a, b) => {
|
|
2423
|
-
const ai = enabled.indexOf(modelKey(a)), bi = enabled.indexOf(modelKey(b));
|
|
2424
|
-
return (ai < 0 ? Infinity : ai) - (bi < 0 ? Infinity : bi);
|
|
2425
|
-
}).map(spec => ({ value: modelKey(spec), label: `${enabled.includes(modelKey(spec)) ? "[x]" : "[ ]"} ${spec.id}`,
|
|
2426
|
-
description: `${spec.provider} · ${spec.name}` }));
|
|
2427
|
-
}
|
|
2428
|
-
|
|
2429
|
-
openScopedModels(): void {
|
|
2430
|
-
this.selectKind = "scoped-models";
|
|
2431
|
-
this.select = new SelectList(this.scopedItems(), { style: this.style, title: "Scoped models · session changes",
|
|
2432
|
-
hint: "Enter toggle · Ctrl+A enable · Ctrl+X clear · Ctrl+P provider · Alt+↑↓ order · Ctrl+S save · Esc close",
|
|
2433
|
-
emptyMessage: "No available models. Use /login to add a credential." });
|
|
2434
|
-
this.onPick = null;
|
|
2435
|
-
this.paint();
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
private matchesAction(data: string, action: string): boolean {
|
|
2439
|
-
return (this.app.ui?.keymap ?? defaultKeymap()).byAction[action]?.some(chord => keyMatches(data, chord)) ?? false;
|
|
2440
|
-
}
|
|
2441
|
-
|
|
2442
|
-
private async scopedInput(data: string): Promise<boolean> {
|
|
2443
|
-
if (!this.select || this.selectKind !== "scoped-models") return false;
|
|
2444
|
-
const state = this.select.state, selected = state.items[state.selected]?.value;
|
|
2445
|
-
const ids = state.items.map(item => item.value);
|
|
2446
|
-
if (this.matchesAction(data, "app.models.save")) {
|
|
2447
|
-
const scopedModels = this.scopeEntries().map(scopeReference);
|
|
2448
|
-
await writeUserSettings({ scopedModels });
|
|
2449
|
-
if (this.app.ui) this.app.ui.userOverrides = { ...this.app.ui.userOverrides, scopedModels };
|
|
2450
|
-
this.app.io.say(this.app.io.palette.green(` saved ${scopedModels.length} scoped models`));
|
|
2451
|
-
return true;
|
|
2452
|
-
}
|
|
2453
|
-
let change: ScopeChange | undefined;
|
|
2454
|
-
if (this.matchesAction(data, "app.models.enableAll")) change = { kind: "enable", ids };
|
|
2455
|
-
else if (this.matchesAction(data, "app.models.clearAll")) change = { kind: "disable", ids };
|
|
2456
|
-
else if (selected && this.matchesAction(data, "tui.select.confirm")) change = { kind: "toggle", ids: [selected] };
|
|
2457
|
-
else if (selected && this.matchesAction(data, "app.models.toggleProvider")) {
|
|
2458
|
-
const provider = this.app.catalog.find(spec => modelKey(spec) === selected)?.provider;
|
|
2459
|
-
change = { kind: "toggle", ids: this.scopeModels().filter(spec => spec.provider === provider).map(modelKey) };
|
|
2460
|
-
} else if (selected && this.matchesAction(data, "app.models.reorderUp")) change = { kind: "move", id: selected, delta: -1 };
|
|
2461
|
-
else if (selected && this.matchesAction(data, "app.models.reorderDown")) change = { kind: "move", id: selected, delta: 1 };
|
|
2462
|
-
if (!change) return false;
|
|
2463
|
-
const before = new Map(this.scopeEntries().map(entry => [modelKey(entry.model), scopeReference(entry)]));
|
|
2464
|
-
const scopedModels = changeModelScope(this.activeScope(), change).map(id => before.get(id) ?? id);
|
|
2465
|
-
this.app.args.models = "";
|
|
2466
|
-
this.app.args.modelsExplicit = false;
|
|
2467
|
-
if (this.app.ui) this.app.ui.settings = { ...this.app.ui.settings, scopedModels };
|
|
2468
|
-
this.select.setItems(this.scopedItems());
|
|
2469
|
-
return true;
|
|
2470
|
-
}
|
|
2471
|
-
|
|
2472
|
-
async openForkSelect(): Promise<void> {
|
|
2473
|
-
const points = await this.app.session.userMessages();
|
|
2474
|
-
this.select = new SelectList(points.map((point, i) => ({ value: String(i + 1), label: firstLine(point.text, 120), description: "node " + point.node })),
|
|
2475
|
-
{ style: this.style, title: "Fork · edit a previous user message", emptyMessage: "No user messages yet" });
|
|
2476
|
-
this.onPick = async (item) => { await forkSession(this.app, item.value, this); };
|
|
2477
|
-
this.paint();
|
|
2478
|
-
}
|
|
2479
|
-
|
|
2480
|
-
private openSettingValues(field: string): void {
|
|
2481
|
-
const key = field.toLowerCase();
|
|
2482
|
-
if (key === "defaultmodel") { this.openDefaultModelSelect(); return; }
|
|
2483
|
-
if (key === "modelthinkinglevels") { this.openModelThinkingDefaults(); return; }
|
|
2484
|
-
let items: SelectItem[] = [];
|
|
2485
|
-
if (key === "theme") {
|
|
2486
|
-
items = [
|
|
2487
|
-
{ value: "dark", label: "dark" },
|
|
2488
|
-
{ value: "light", label: "light" },
|
|
2489
|
-
{ value: "system", label: "system" },
|
|
2490
|
-
{ value: "auto", label: "auto" },
|
|
2491
|
-
];
|
|
2492
|
-
} else if (key === "thinking") {
|
|
2493
|
-
items = supportedThinkingLevels(this.app.providerConfig.spec).map((v) => ({ value: v, label: v }));
|
|
2494
|
-
} else if (key === "statusbar" || key === "status") {
|
|
2495
|
-
items = [
|
|
2496
|
-
{ value: "on", label: "on" },
|
|
2497
|
-
{ value: "off", label: "off" },
|
|
2498
|
-
];
|
|
2499
|
-
} else if (settingValues(field).length) {
|
|
2500
|
-
items = settingValues(field).map(value => ({ value, label: value }));
|
|
2501
|
-
} else {
|
|
2502
|
-
this.app.io.say(this.app.io.palette.yellow(` unknown setting: ${field}`));
|
|
2503
|
-
this.paint();
|
|
2504
|
-
return;
|
|
2505
|
-
}
|
|
2506
|
-
this.select = new SelectList(items, { style: this.style, title: "Setting · " + field });
|
|
2507
|
-
this.selectKind = "other";
|
|
2508
|
-
if (key === "theme") {
|
|
2509
|
-
this.previewTheme = this.app.ui?.theme;
|
|
2510
|
-
const selector = this.select;
|
|
2511
|
-
void discoverThemes(this.app.resources).then(names => { if (this.select === selector) { selector.setItems(names.map(name => ({ value: name, label: name }))); this.paint(); } }).catch(error => this.writeLine(String(error)));
|
|
2512
|
-
}
|
|
2513
|
-
this.onPick = async (item) => {
|
|
2514
|
-
this.previewTheme = undefined;
|
|
2515
|
-
await commitSetting(this.app, key === "status" ? "statusBar" : field, item.value, this);
|
|
2516
|
-
};
|
|
2517
|
-
this.paint();
|
|
2518
|
-
}
|
|
2519
|
-
|
|
2520
|
-
private openDefaultModelSelect(): void {
|
|
2521
|
-
this.selectKind = "other";
|
|
2522
|
-
const current = this.app.ui?.settings;
|
|
2523
|
-
this.select = new SelectList(this.app.catalog.map(spec => ({ value: modelKey(spec), label: spec.id, description: spec.provider })),
|
|
2524
|
-
{ style: this.style, title: "Default model · new sessions", selectedValue: `${current?.defaultProvider}/${current?.defaultModel}` });
|
|
2525
|
-
this.onPick = async item => {
|
|
2526
|
-
const spec = this.app.catalog.find(model => modelKey(model) === item.value);
|
|
2527
|
-
if (!spec || !this.app.ui) return;
|
|
2528
|
-
const patch = { defaultProvider: spec.provider, defaultModel: spec.id };
|
|
2529
|
-
await writeUserSettings(patch);
|
|
2530
|
-
this.app.ui.settings = { ...this.app.ui.settings, ...patch };
|
|
2531
|
-
this.app.ui.userOverrides = { ...this.app.ui.userOverrides, ...patch };
|
|
2532
|
-
this.app.io.say(` saved default model ${item.value}`);
|
|
2533
|
-
};
|
|
2534
|
-
this.paint();
|
|
2535
|
-
}
|
|
2536
|
-
|
|
2537
|
-
private openModelThinkingDefaults(identity?: string): void {
|
|
2538
|
-
const ui = this.app.ui;
|
|
2539
|
-
if (!ui) return;
|
|
2540
|
-
const models = new Map(this.app.catalog.map(spec => [modelKey(spec), spec]));
|
|
2541
|
-
this.selectKind = "other";
|
|
2542
|
-
if (!identity) {
|
|
2543
|
-
const keys = [...new Set([...models.keys(), ...Object.keys(ui.settings.modelThinkingLevels ?? {})])];
|
|
2544
|
-
this.select = new SelectList(keys.map(key => ({ value: key, label: key, description: ui.settings.modelThinkingLevels?.[key] ?? "inherited" })),
|
|
2545
|
-
{ style: this.style, title: "Model thinking defaults" });
|
|
2546
|
-
this.onPick = async item => this.openModelThinkingDefaults(item.value);
|
|
2547
|
-
} else {
|
|
2548
|
-
const model = models.get(identity);
|
|
2549
|
-
this.select = new SelectList([
|
|
2550
|
-
{ value: "clear", label: "Clear user override", description: "Use the project or global default" },
|
|
2551
|
-
...(model ? supportedThinkingLevels(model).map(level => ({ value: level, label: level })) : []),
|
|
2552
|
-
], { style: this.style, title: `Thinking default · ${identity}`, selectedValue: ui.settings.modelThinkingLevels?.[identity] ?? "clear" });
|
|
2553
|
-
this.onPick = async item => {
|
|
2554
|
-
const level = asEffort(item.value);
|
|
2555
|
-
if (item.value === "clear") {
|
|
2556
|
-
await removeModelThinkingOverride(identity);
|
|
2557
|
-
const loaded = await loadUiResources({ ...this.app.args, workspace: this.app.workspace, trusted: this.app.projectTrusted ?? this.app.args.approve ?? false });
|
|
2558
|
-
ui.settings = { ...ui.settings, modelThinkingLevels: loaded.settings.modelThinkingLevels ?? {} };
|
|
2559
|
-
ui.userOverrides = { ...ui.userOverrides, modelThinkingLevels: loaded.userOverrides.modelThinkingLevels ?? {} };
|
|
2560
|
-
} else if (level && model && effortSupported(model, level)) {
|
|
2561
|
-
const patch = { modelThinkingLevels: { [identity]: level } };
|
|
2562
|
-
await writeUserSettings(patch);
|
|
2563
|
-
ui.settings = mergeSettings({ builtin: ui.settings, user: patch });
|
|
2564
|
-
ui.userOverrides = mergeSettings({ builtin: ui.userOverrides, user: patch });
|
|
2565
|
-
}
|
|
2566
|
-
this.openModelThinkingDefaults();
|
|
2567
|
-
};
|
|
2568
|
-
}
|
|
2569
|
-
this.paint();
|
|
2570
|
-
}
|
|
2571
|
-
|
|
2572
|
-
openModelSelect(): void {
|
|
2573
|
-
const items: SelectItem[] = [];
|
|
2574
|
-
for (const spec of this.app.catalog) {
|
|
2575
|
-
const got = this.app.credentialOf(spec);
|
|
2576
|
-
const here = modelKey(spec) === modelKey(this.app.providerConfig.spec);
|
|
2577
|
-
items.push({
|
|
2578
|
-
value: modelKey(spec),
|
|
2579
|
-
label: spec.id,
|
|
2580
|
-
description: `${spec.provider} · ${spec.name}${here ? " ✓ current" : got.ok ? " ready" : " login required"}`,
|
|
2581
|
-
});
|
|
2582
|
-
}
|
|
2583
|
-
this.select = new SelectList(items, { style: this.style, title: "Model", selectedValue: modelKey(this.app.providerConfig.spec), hint: "↑↓ select · Enter apply · Ctrl+S save default · Esc cancel" });
|
|
2584
|
-
this.selectKind = "model";
|
|
2585
|
-
this.onPick = async (item) => {
|
|
2586
|
-
const spec = this.app.catalog.find((m) => modelKey(m) === item.value);
|
|
2587
|
-
if (spec !== undefined) await switchToModel(this.app, spec);
|
|
2588
|
-
};
|
|
2589
|
-
this.app.io.say(this.app.io.palette.dim(" switching cuts this node so the next model starts from a handoff"));
|
|
2590
|
-
this.paint();
|
|
2591
|
-
}
|
|
2592
|
-
|
|
2593
|
-
openThinkingSelect(): void {
|
|
2594
|
-
const spec = this.app.providerConfig.spec;
|
|
2595
|
-
const current = this.app.providerConfig.effort;
|
|
2596
|
-
const items: SelectItem[] = EFFORTS.map((level) => {
|
|
2597
|
-
const supported = effortSupported(spec, level);
|
|
2598
|
-
const here = level === current;
|
|
2599
|
-
return {
|
|
2600
|
-
value: level,
|
|
2601
|
-
label: level,
|
|
2602
|
-
description: !supported ? "unsupported" : here ? "now" : "",
|
|
2603
|
-
};
|
|
2604
|
-
});
|
|
2605
|
-
this.select = new SelectList(items, { style: this.style, title: "Thinking", selectedValue: current, hint: "↑↓ select · Enter apply · Ctrl+S save default · Esc cancel" });
|
|
2606
|
-
this.selectKind = "thinking";
|
|
2607
|
-
this.onPick = async (item) => {
|
|
2608
|
-
const effort = asEffort(item.value);
|
|
2609
|
-
if (effort === undefined) return;
|
|
2610
|
-
await applyThinking(this.app, effort, this);
|
|
2611
|
-
};
|
|
2612
|
-
this.paint();
|
|
2613
|
-
}
|
|
2614
|
-
|
|
2615
|
-
private async saveSelectDefault(): Promise<void> {
|
|
2616
|
-
if (this.select === null) return;
|
|
2617
|
-
const item = this.select.state.items[this.select.state.selected];
|
|
2618
|
-
if (item === undefined) return;
|
|
2619
|
-
const C = this.app.io.palette;
|
|
2620
|
-
if (this.selectKind === "model") {
|
|
2621
|
-
// Patch only this key: dumping merged settings would freeze today's defaults.
|
|
2622
|
-
const spec = this.app.catalog.find(model => modelKey(model) === item.value);
|
|
2623
|
-
if (!spec) return;
|
|
2624
|
-
await writeUserSettings({ defaultModel: spec.id, defaultProvider: spec.provider });
|
|
2625
|
-
if (this.app.ui !== undefined) {
|
|
2626
|
-
this.app.ui.userOverrides = { ...this.app.ui.userOverrides, defaultModel: spec.id, defaultProvider: spec.provider };
|
|
2627
|
-
this.app.ui.settings = { ...this.app.ui.settings, defaultModel: spec.id, defaultProvider: spec.provider };
|
|
2628
|
-
}
|
|
2629
|
-
this.app.io.say(C.green(` saved default model ${item.value}`));
|
|
2630
|
-
return;
|
|
2631
|
-
}
|
|
2632
|
-
if (this.selectKind === "thinking") {
|
|
2633
|
-
const effort = asEffort(item.value);
|
|
2634
|
-
if (!effort || !effortSupported(this.app.providerConfig.spec, effort)) {
|
|
2635
|
-
this.app.io.say(C.yellow(" Cannot save an unsupported thinking level."));
|
|
2636
|
-
return;
|
|
2637
|
-
}
|
|
2638
|
-
const patch = { modelThinkingLevels: { [modelKey(this.app.providerConfig.spec)]: effort } };
|
|
2639
|
-
await writeUserSettings(patch);
|
|
2640
|
-
if (this.app.ui !== undefined) {
|
|
2641
|
-
this.app.ui.userOverrides = mergeSettings({ builtin: this.app.ui.userOverrides, user: patch });
|
|
2642
|
-
this.app.ui.settings = mergeSettings({ builtin: this.app.ui.settings, user: patch });
|
|
2643
|
-
}
|
|
2644
|
-
this.app.io.say(C.green(` saved default thinking ${item.value}`));
|
|
2645
|
-
}
|
|
2646
|
-
}
|
|
2647
|
-
|
|
2648
|
-
async openResumeSelect(): Promise<void> {
|
|
2649
|
-
if (this.select instanceof SessionBrowser) this.select.dispose();
|
|
2650
|
-
const browser = new SessionBrowser({ root: { path: dirname(this.app.logDir), workspace: this.app.workspace },
|
|
2651
|
-
style: this.style, active: this.app.logDir, redraw: () => this.paint(), bindings: () => this.app.ui?.keymap ?? defaultKeymap(),
|
|
2652
|
-
renamed: (path, name) => { if (path === this.app.logDir) this.sessionName = name || null; } });
|
|
2653
|
-
this.select = browser;
|
|
2654
|
-
this.selectKind = "other";
|
|
2655
|
-
this.onPick = async (item) => {
|
|
2656
|
-
await resumeSession(this.app, item.value, this);
|
|
2657
|
-
};
|
|
2658
|
-
this.paint();
|
|
2659
|
-
await browser.load();
|
|
2660
|
-
}
|
|
2661
|
-
|
|
2662
|
-
async openResourceSelect(): Promise<void> {
|
|
2663
|
-
const trusted = (await projectTrust(this.app.workspace, this.app.projectTrusted ?? this.app.args.approve)).trusted;
|
|
2664
|
-
const resources = { ...this.app.args, workspace: this.app.workspace, trusted };
|
|
2665
|
-
const index = await discoverResources(resources);
|
|
2666
|
-
if (this.select instanceof SessionBrowser) this.select.dispose();
|
|
2667
|
-
this.select = new ResourceBrowser({ resources, index, style: this.style, redraw: () => this.paint(), changed: async () => {
|
|
2668
|
-
await reloadAppResources(this.app); await this.refreshResources();
|
|
2669
|
-
} });
|
|
2670
|
-
this.selectKind = "other"; this.onPick = null; this.paint();
|
|
2671
|
-
}
|
|
2672
|
-
|
|
2673
|
-
async openTrustSelect(): Promise<void> {
|
|
2674
|
-
const trust = await projectTrust(this.app.workspace, this.app.projectTrusted ?? this.app.args.approve);
|
|
2675
|
-
if (this.select instanceof SessionBrowser) this.select.dispose();
|
|
2676
|
-
this.select = new TrustSelector(this.app.workspace, trust, this.style);
|
|
2677
|
-
this.selectKind = "other";
|
|
2678
|
-
this.onPick = async item => { await trustProject(this.app, item.value as TrustChoice, this); };
|
|
2679
|
-
this.paint();
|
|
2680
|
-
}
|
|
2681
|
-
|
|
2682
|
-
/**
|
|
2683
|
-
* Selection resolves an exact log position through Session.navigateTo.
|
|
2684
|
-
* User messages become drafts; other messages remain included. If a turn is in flight,
|
|
2685
|
-
* the existing AbortSignal fires first — same path Escape already uses.
|
|
2686
|
-
*/
|
|
2687
|
-
async openTreeSelect(): Promise<void> {
|
|
2688
|
-
if (this.select instanceof SessionBrowser) this.select.dispose();
|
|
2689
|
-
this.select = null;
|
|
2690
|
-
this.onPick = null;
|
|
2691
|
-
const nodes = await treeNodesFor(this.app);
|
|
2692
|
-
this.tree = new TreeList(nodes, { style: this.style, filter: this.app.ui?.settings.treeFilterMode ?? "default", bindings: (this.app.ui?.keymap ?? defaultKeymap()).byAction });
|
|
2693
|
-
this.onTreePick = async (node) => {
|
|
2694
|
-
if (node.target === undefined) return;
|
|
2695
|
-
if (this.inflight) { await this.settle(); this.returnQueueToEditor(); }
|
|
2696
|
-
const done = await this.app.session.navigateTo(node.target);
|
|
2697
|
-
await this.syncTranscript();
|
|
2698
|
-
if (done.editorText !== undefined) this.restoreDraft(done.editorText, done.images ?? []);
|
|
2699
|
-
const C = this.app.io.palette;
|
|
2700
|
-
this.app.io.say(C.green(`\n conversation at node ${this.app.session.state.node}`));
|
|
2701
|
-
this.app.io.say("");
|
|
2702
|
-
};
|
|
2703
|
-
this.paint();
|
|
2704
|
-
}
|
|
2705
|
-
|
|
2706
|
-
async nextInput(): Promise<string> {
|
|
2707
|
-
while (this.inputs.length === 0) {
|
|
2708
|
-
await new Promise<void>((resolve) => { this.wake = resolve; });
|
|
2709
|
-
this.wake = null;
|
|
2710
|
-
}
|
|
2711
|
-
return this.inputs.shift()!;
|
|
2712
|
-
}
|
|
2713
|
-
|
|
2714
|
-
attach(): void {
|
|
2715
|
-
this.attached = true;
|
|
2716
|
-
this.rebuildChrome();
|
|
2717
|
-
this.screen.start();
|
|
2718
|
-
this.followTheme();
|
|
2719
|
-
}
|
|
2720
|
-
|
|
2721
|
-
detach(): void {
|
|
2722
|
-
void cancelPendingOAuth(this.app);
|
|
2723
|
-
this.endCredentialInput();
|
|
2724
|
-
this.extensionUi.dispose();
|
|
2725
|
-
if (this.select instanceof SessionBrowser) this.select.dispose();
|
|
2726
|
-
this.stopRetryTimer();
|
|
2727
|
-
this.stopThemeWatch?.(); this.stopColorWatch?.(); this.themeGeneration++;
|
|
2728
|
-
this.progressActive = false;
|
|
2729
|
-
this.attached = false;
|
|
2730
|
-
const hint = this.screen.mode === "fullscreen" && this.app.ui?.settings.fullscreenExitOutput === "resume-hint";
|
|
2731
|
-
if (this.screen.mode === "fullscreen") this.screen.setLayoutRoot(this.document);
|
|
2732
|
-
this.screen.stop({ preserveScreen: hint });
|
|
2733
|
-
if (hint) this.screen.terminal.write("\r\nxeno --session " + JSON.stringify(this.app.logDir) + "\r\n");
|
|
2734
|
-
}
|
|
2735
|
-
|
|
2736
|
-
async settle(): Promise<void> {
|
|
2737
|
-
await cancelPendingOAuth(this.app);
|
|
2738
|
-
this.abort?.abort();
|
|
2739
|
-
this.approvalResolve?.("deny");
|
|
2740
|
-
await this.turn;
|
|
2741
|
-
await Promise.all([...this.extensionWork]);
|
|
2742
|
-
await this.app.extensions?.settle();
|
|
2743
|
-
}
|
|
2744
|
-
|
|
2745
|
-
showDocument(text: string): void {
|
|
2746
|
-
this.chat.addChild(new MarkdownBlock(text, this.style, this.markdownFeatures)); this.paint();
|
|
2747
|
-
}
|
|
2748
|
-
|
|
2749
|
-
runSessionTransition(action: () => Promise<void>): void {
|
|
2750
|
-
if (this.sessionTransition) return;
|
|
2751
|
-
this.sessionTransition = true;
|
|
2752
|
-
this.runExtensionAction(async () => { try { await action(); } finally { this.sessionTransition = false; } });
|
|
2753
|
-
}
|
|
2754
|
-
|
|
2755
|
-
runExtensionAction(action: () => void | Promise<void>): void {
|
|
2756
|
-
const work = Promise.resolve().then(action).catch(error => this.app.io.say(this.app.io.palette.red(describeError(error))))
|
|
2757
|
-
.finally(() => { this.extensionWork.delete(work); void this.syncTranscript().then(() => this.paint()); });
|
|
2758
|
-
this.extensionWork.add(work);
|
|
2759
|
-
}
|
|
2760
|
-
runExtensionCommand(name: string, args: string): void {
|
|
2761
|
-
this.runExtensionAction(async () => { await bindExtensions(this.app, this); await this.app.extensions?.command(name, args); });
|
|
2762
|
-
}
|
|
2763
|
-
deliverExtension(text: string, kind: QueueKind): void {
|
|
2764
|
-
if (this.inflight) this.enqueue({ kind, text });
|
|
2765
|
-
else this.turn = this.runPromptTurn(text).catch(error => this.app.io.say(describeError(error)));
|
|
2766
|
-
}
|
|
2767
|
-
|
|
2768
|
-
requestShutdown(): void {
|
|
2769
|
-
void cancelPendingOAuth(this.app);
|
|
2770
|
-
this.extensionUi.cancelDialogs();
|
|
2771
|
-
this.shutdownRequested = true;
|
|
2772
|
-
this.resumeSuspended?.();
|
|
2773
|
-
this.executionKind = "shutdown";
|
|
2774
|
-
this.abort?.abort();
|
|
2775
|
-
this.approvalResolve?.("deny");
|
|
2776
|
-
this.inputs.push("");
|
|
2777
|
-
this.wake?.();
|
|
2778
|
-
}
|
|
2779
|
-
|
|
2780
|
-
async suspend(): Promise<void> {
|
|
2781
|
-
if (process.platform === "win32" || !this.attached) return;
|
|
2782
|
-
// Pi keeps a live handle while raw input is detached, otherwise fg can return to an exited process.
|
|
2783
|
-
const keepAlive = setInterval(() => {}, 60_000);
|
|
2784
|
-
const ignoreInterrupt = () => {};
|
|
2785
|
-
process.on("SIGINT", ignoreInterrupt);
|
|
2786
|
-
let resume!: () => void;
|
|
2787
|
-
const continued = new Promise<void>(resolve => { resume = resolve; process.once("SIGCONT", resolve); });
|
|
2788
|
-
this.resumeSuspended = resume;
|
|
2789
|
-
try {
|
|
2790
|
-
this.screen.stop({ preserveScreen: true });
|
|
2791
|
-
process.kill(process.pid, "SIGTSTP");
|
|
2792
|
-
await continued;
|
|
2793
|
-
} finally {
|
|
2794
|
-
clearInterval(keepAlive);
|
|
2795
|
-
process.off("SIGINT", ignoreInterrupt);
|
|
2796
|
-
process.off("SIGCONT", resume);
|
|
2797
|
-
this.resumeSuspended = undefined;
|
|
2798
|
-
this.screen.start();
|
|
2799
|
-
this.screen.requestRender(true);
|
|
2800
|
-
this.paint();
|
|
2801
|
-
}
|
|
2802
|
-
}
|
|
2803
|
-
|
|
2804
|
-
ask(request: PermissionRequest): Promise<ApprovalChoice> {
|
|
2805
|
-
return new Promise((resolve) => {
|
|
2806
|
-
this.app.io.say(approvalHeading(request));
|
|
2807
|
-
this.select = new SelectList(approvalItems(request), { style: this.style, title: "Tool approval", search: false });
|
|
2808
|
-
this.approvalResolve = resolve;
|
|
2809
|
-
this.onPick = async (item) => {
|
|
2810
|
-
resolve(choiceFromValue(item.value));
|
|
2811
|
-
};
|
|
2812
|
-
this.paint();
|
|
2813
|
-
});
|
|
2814
|
-
}
|
|
2815
|
-
|
|
2816
|
-
private boundAction(data: string): ActionId | undefined {
|
|
2817
|
-
return Object.entries((this.app.ui?.keymap ?? defaultKeymap()).byKey)
|
|
2818
|
-
.find(([chord]) => keyMatches(data, chord))?.[1];
|
|
2819
|
-
}
|
|
2820
|
-
|
|
2821
|
-
private async openExternalEditor(): Promise<void> {
|
|
2822
|
-
const attached = this.attached;
|
|
2823
|
-
if (attached) this.screen.stop({ preserveScreen: true });
|
|
2824
|
-
if (attached && process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
2825
|
-
try {
|
|
2826
|
-
const result = await editInExternalEditor({ content: this.editor.state.text, command: this.app.ui?.settings.externalEditor ?? resolveEditorCommand() });
|
|
2827
|
-
if (result.ok) this.editor.applyExternal(result.content);
|
|
2828
|
-
else this.app.io.say(result.message);
|
|
2829
|
-
} finally {
|
|
2830
|
-
if (attached) { this.screen.start(); this.screen.requestRender(true); }
|
|
2831
|
-
this.paint();
|
|
2832
|
-
}
|
|
2833
|
-
}
|
|
2834
|
-
|
|
2835
|
-
/** Programmatic compatibility; live stdin always enters as Pi raw input. */
|
|
2836
|
-
async applyKey(key: Key): Promise<"continue" | "quit"> { return this.applyInput(encodeKey(key)); }
|
|
2837
|
-
|
|
2838
|
-
restoreDraft(text: string, images: readonly ImageRef[] = []): void {
|
|
2839
|
-
this.editor.applyExternal([text, this.editor.state.text].filter(Boolean).join("\n"));
|
|
2840
|
-
this.pendingImages = [...images, ...this.pendingImages];
|
|
2841
|
-
this.paint();
|
|
2842
|
-
}
|
|
2843
|
-
|
|
2844
|
-
async pasteClipboard(content?: ClipboardContent): Promise<void> {
|
|
2845
|
-
try {
|
|
2846
|
-
const value = content === undefined ? await this.clipboardReader() : content;
|
|
2847
|
-
if (!value) return;
|
|
2848
|
-
if ("files" in value) {
|
|
2849
|
-
const images: ImageRef[] = [], text: string[] = [];
|
|
2850
|
-
for (const path of value.files) {
|
|
2851
|
-
if (/[\x00-\x1f]/.test(path)) throw new Error("Clipboard file path contains control characters");
|
|
2852
|
-
const absolute = path.startsWith("file://") ? fileURLToPath(path) : resolve(this.app.workspace, path);
|
|
2853
|
-
const image = await attachImageFile(absolute, this.app.blobDir, this.app.ui?.settings.images?.autoResize ?? true);
|
|
2854
|
-
if (image) images.push(image); else text.push("@" + JSON.stringify(path));
|
|
2855
|
-
}
|
|
2856
|
-
this.pendingImages.push(...images);
|
|
2857
|
-
if (text.length) this.editor.acceptInput("\x1b[200~" + text.join(" ") + "\x1b[201~");
|
|
2858
|
-
} else if ("image" in value) {
|
|
2859
|
-
this.pendingImages.push(await attachImageBytes(value.image, "clipboard.png", this.app.blobDir, this.app.ui?.settings.images?.autoResize ?? true));
|
|
2860
|
-
} else this.editor.acceptInput("\x1b[200~" + value.text + "\x1b[201~");
|
|
2861
|
-
} catch (error) { this.app.io.say(" Paste failed: " + describeError(error)); }
|
|
2862
|
-
this.paint();
|
|
2863
|
-
}
|
|
2864
|
-
|
|
2865
|
-
openAttachments(): void {
|
|
2866
|
-
this.select = new SelectList(this.pendingImages.map((image, i) => ({ value: String(i), label: image.name,
|
|
2867
|
-
description: `${image.width}x${image.height} · Enter to remove` })), { title: "Draft images · remove one", style: this.style, emptyMessage: "No draft images" });
|
|
2868
|
-
this.onPick = async item => { this.pendingImages.splice(Number(item.value), 1); };
|
|
2869
|
-
this.paint();
|
|
2870
|
-
}
|
|
2871
|
-
|
|
2872
|
-
async applyInput(data: string, fromTerminal = false): Promise<"continue" | "quit"> {
|
|
2873
|
-
this.editor.bindings = installBindings((this.app.ui?.keymap ?? defaultKeymap()).byAction);
|
|
2874
|
-
if (!fromTerminal) { const filtered = this.extensionUi.filterInput(data); if (filtered === undefined) return "continue"; data = filtered; }
|
|
2875
|
-
const action = this.boundAction(data);
|
|
2876
|
-
if (action === "app.suspend") { await this.suspend(); return "continue"; }
|
|
2877
|
-
if (this.extensionUi.acceptInput(data)) return "continue";
|
|
2878
|
-
// Modal ownership precedes application shortcuts, including during a turn.
|
|
2879
|
-
if (this.settings) {
|
|
2880
|
-
const menu = this.settings;
|
|
2881
|
-
for (const change of menu.acceptInput(data)) {
|
|
2882
|
-
if (change.id === "defaultModel" || change.id === "modelThinkingLevels") { this.settings = null; this.openSettings(change.id); }
|
|
2883
|
-
else await commitSetting(this.app, change.id, change.value, this);
|
|
2884
|
-
}
|
|
2885
|
-
if (menu.cancelled && this.settings === menu) this.settings = null;
|
|
2886
|
-
this.paint();
|
|
2887
|
-
return "continue";
|
|
2888
|
-
}
|
|
2889
|
-
if (this.tree) {
|
|
2890
|
-
const bindings = (this.app.ui?.keymap ?? defaultKeymap()).byAction;
|
|
2891
|
-
const ownerAction = Object.entries(bindings).find(([id, chords]) =>
|
|
2892
|
-
(id.startsWith("app.tree.") || id === "app.message.copy") && chords.some(chord => keyMatches(data, chord)))?.[0];
|
|
2893
|
-
const state = this.tree.acceptInput(data, ownerAction);
|
|
2894
|
-
if (this.tree.labelChange) {
|
|
2895
|
-
const change = this.tree.labelChange;
|
|
2896
|
-
await setSessionLabel(this.app.logDir, change.target, change.text, this.app.session.clock.now());
|
|
2897
|
-
this.tree.setNodes(await treeNodesFor(this.app));
|
|
2898
|
-
}
|
|
2899
|
-
if (this.tree.copyText !== undefined) {
|
|
2900
|
-
const copied = await this.clipboardWriter(this.tree.copyText);
|
|
2901
|
-
this.app.io.say(copied ? " Copied selected message" : " Clipboard unavailable");
|
|
2902
|
-
}
|
|
2903
|
-
if (state.cancelled || state.confirmed) {
|
|
2904
|
-
const pick = this.onTreePick;
|
|
2905
|
-
this.tree = null;
|
|
2906
|
-
this.onTreePick = null;
|
|
2907
|
-
if (state.confirmed && pick) await pick(state.confirmed);
|
|
2908
|
-
}
|
|
2909
|
-
this.paint();
|
|
2910
|
-
return "continue";
|
|
2911
|
-
}
|
|
2912
|
-
if (this.select) {
|
|
2913
|
-
if (this.select instanceof ResourceBrowser && await this.select.handleAction(data)) { this.paint(); return "continue"; }
|
|
2914
|
-
if (this.select instanceof SessionBrowser && await this.select.handleAction(data)) { this.paint(); return "continue"; }
|
|
2915
|
-
if (await this.scopedInput(data)) { this.paint(); return "continue"; }
|
|
2916
|
-
if (action === "app.saveDefault" && !this.approvalResolve) await this.saveSelectDefault();
|
|
2917
|
-
else {
|
|
2918
|
-
const state = this.select.acceptInput(data);
|
|
2919
|
-
if (this.previewTheme && !state.confirmed && !state.cancelled) {
|
|
2920
|
-
const item = state.items[state.selected];
|
|
2921
|
-
if (item) { const diagnostics: string[] = []; const theme = await resolveTheme(this.app.resources ? themeResourcePath(this.app.resources, item.value) ?? item.value : item.value, diagnostics); if (!diagnostics.length) this.setTheme(theme); }
|
|
2922
|
-
}
|
|
2923
|
-
if (state.cancelled || state.confirmed) {
|
|
2924
|
-
const pick = this.onPick, approval = this.approvalResolve, cancel = this.onSelectCancel;
|
|
2925
|
-
if (this.select instanceof SessionBrowser) this.select.dispose();
|
|
2926
|
-
this.select = null;
|
|
2927
|
-
this.onPick = null;
|
|
2928
|
-
this.onSelectCancel = undefined;
|
|
2929
|
-
this.approvalResolve = null;
|
|
2930
|
-
this.selectKind = "other";
|
|
2931
|
-
if (state.confirmed && pick) { await pick(state.confirmed); await this.syncTranscript(); }
|
|
2932
|
-
else { approval?.("deny"); cancel?.(); if (this.previewTheme) { this.setTheme(this.previewTheme); this.previewTheme = undefined; } }
|
|
2933
|
-
}
|
|
2934
|
-
}
|
|
2935
|
-
this.paint();
|
|
2936
|
-
return "continue";
|
|
2937
|
-
}
|
|
2938
|
-
// A secret editor owns every key; model, file and extension actions cannot consume its input.
|
|
2939
|
-
if (this.awaitingKeyFor !== null) {
|
|
2940
|
-
if (action === "app.interrupt" || action === "app.clear") {
|
|
2941
|
-
await cancelPendingOAuth(this.app);
|
|
2942
|
-
this.endCredentialInput();
|
|
2943
|
-
return "continue";
|
|
2944
|
-
}
|
|
2945
|
-
if (action === "app.exit" && this.editor.state.text.length === 0) { await cancelPendingOAuth(this.app); return "quit"; }
|
|
2946
|
-
const state = this.editor.acceptInput(data);
|
|
2947
|
-
if (state.submitted !== null) return this.submitLine(state.submitted, "steering");
|
|
2948
|
-
this.paint(); return "continue";
|
|
2949
|
-
}
|
|
2950
|
-
for (const [key, shortcut] of this.app.extensions?.shortcuts ?? []) if (keyMatches(data, key)) {
|
|
2951
|
-
this.runExtensionAction(() => shortcut.handler(extensionContext(this.app, this))); return "continue";
|
|
2952
|
-
}
|
|
2953
|
-
if (data.includes("\x1b[200~")) this.pasting = true;
|
|
2954
|
-
if (this.pasting) {
|
|
2955
|
-
this.pasteBuffer += data;
|
|
2956
|
-
if (data.includes("\x1b[201~")) {
|
|
2957
|
-
this.pasting = false;
|
|
2958
|
-
const payload = this.pasteBuffer.replace(/^\x1b\[200~/, "").replace(/\x1b\[201~$/, "");
|
|
2959
|
-
const paths = droppedImagePaths(payload);
|
|
2960
|
-
if (paths) {
|
|
2961
|
-
const before = this.pendingImages.length;
|
|
2962
|
-
await this.pasteClipboard({ files: paths });
|
|
2963
|
-
if (this.pendingImages.length === before) this.editor.acceptInput(this.pasteBuffer);
|
|
2964
|
-
} else this.editor.acceptInput(this.pasteBuffer);
|
|
2965
|
-
this.pasteBuffer = "";
|
|
2966
|
-
}
|
|
2967
|
-
this.paint();
|
|
2968
|
-
return "continue";
|
|
2969
|
-
}
|
|
2970
|
-
if (this.editor.autocompleteVisible && (keyMatches(data, "escape") || keyMatches(data, "up") || keyMatches(data, "down") || keyMatches(data, "tab"))) {
|
|
2971
|
-
this.editor.acceptInput(data);
|
|
2972
|
-
this.paint();
|
|
2973
|
-
return "continue";
|
|
2974
|
-
}
|
|
2975
|
-
if (action === "app.clear") {
|
|
2976
|
-
if (this.inflight) { this.abort?.abort(); this.returnQueueToEditor(); }
|
|
2977
|
-
else if (this.editor.state.text.length > 0 || this.pendingImages.length) { this.editor.applyExternal(""); this.pendingImages = []; }
|
|
2978
|
-
else if (Date.now() - this.lastInterrupt < 1000) return "quit";
|
|
2979
|
-
else this.app.io.say(" Press Ctrl+C again to exit, or Ctrl+D on an empty prompt.");
|
|
2980
|
-
this.lastInterrupt = Date.now();
|
|
2981
|
-
this.paint();
|
|
2982
|
-
return "continue";
|
|
2983
|
-
}
|
|
2984
|
-
if (action === "app.exit" && this.editor.state.text.length === 0 && this.pendingImages.length === 0) return "quit";
|
|
2985
|
-
if (action === "app.interrupt") {
|
|
2986
|
-
if (this.inflight) { this.abort?.abort(); this.returnQueueToEditor(); }
|
|
2987
|
-
else if (this.editor.state.text.length === 0 && Date.now() - this.lastEscape < 500) {
|
|
2988
|
-
const action = this.app.ui?.settings.doubleEscapeAction ?? "tree";
|
|
2989
|
-
if (action === "tree") await this.openTreeSelect(); else if (action === "fork") await this.openForkSelect();
|
|
2990
|
-
}
|
|
2991
|
-
this.lastEscape = Date.now();
|
|
2992
|
-
this.paint();
|
|
2993
|
-
return "continue";
|
|
2994
|
-
}
|
|
2995
|
-
if (action === "app.message.dequeue") { this.returnQueueToEditor(); this.paint(); return "continue"; }
|
|
2996
|
-
if (action === "app.toggleTools") { this.toolsExpanded = !this.toolsExpanded; this.paint(); return "continue"; }
|
|
2997
|
-
if (action === "app.toggleThinking") { await commitSetting(this.app, "hideThinkingBlock", this.thinkingExpanded ? "on" : "off", this); return "continue"; }
|
|
2998
|
-
if (action === "app.externalEditor") { await this.openExternalEditor(); return "continue"; }
|
|
2999
|
-
if (action === "app.clipboard.pasteImage") { await this.pasteClipboard(); return "continue"; }
|
|
3000
|
-
if (action === "app.message.copy") {
|
|
3001
|
-
if (this.screen.mode !== "fullscreen" || !await this.screen.copyActiveSelectionToClipboard()) await copyLastReply(this.app);
|
|
3002
|
-
return "continue";
|
|
3003
|
-
}
|
|
3004
|
-
if (!this.inflight) {
|
|
3005
|
-
if (action === "app.session.new") { await openNewSession(this.app, this); return "continue"; }
|
|
3006
|
-
if (action === "app.session.tree") { await this.openTreeSelect(); return "continue"; }
|
|
3007
|
-
if (action === "app.session.fork") { await this.openForkSelect(); return "continue"; }
|
|
3008
|
-
if (action === "app.session.resume") { void this.openResumeSelect(); return "continue"; }
|
|
3009
|
-
if (action === "app.model") { this.openModelSelect(); return "continue"; }
|
|
3010
|
-
if (action === "app.cycleModel" || action === "app.cycleModelReverse") {
|
|
3011
|
-
await cycleScopedModel(this.app, action === "app.cycleModel" ? 1 : -1); this.paint(); return "continue";
|
|
3012
|
-
}
|
|
3013
|
-
if (action === "app.cycleThinking") { await cycleThinking(this.app, this); return "continue"; }
|
|
3014
|
-
}
|
|
3015
|
-
if (action === "app.message.followUp") {
|
|
3016
|
-
const text = this.editor.state.text;
|
|
3017
|
-
this.editor.applyExternal("");
|
|
3018
|
-
return this.submitLine(text, "followup");
|
|
3019
|
-
}
|
|
3020
|
-
if (keyMatches(data, "enter") && this.pendingImages.length && !this.editor.state.text) return this.submitLine("", "steering");
|
|
3021
|
-
const state = this.editor.acceptInput(data);
|
|
3022
|
-
if (state.submitted !== null) return this.submitLine(state.submitted, "steering");
|
|
3023
|
-
this.paint();
|
|
3024
|
-
return "continue";
|
|
3025
|
-
}
|
|
3026
|
-
|
|
3027
|
-
private async submitLine(text: string, queueKind: QueueKind): Promise<"continue" | "quit"> {
|
|
3028
|
-
const line = text.trim();
|
|
3029
|
-
this.paint();
|
|
3030
|
-
if (this.sessionTransition) { this.editor.applyExternal(text); this.app.io.say(" Wait for session import to finish."); return "continue"; }
|
|
3031
|
-
if (this.awaitingKeyFor !== null) {
|
|
3032
|
-
await handleLine(this.app, line, this);
|
|
3033
|
-
return "continue";
|
|
3034
|
-
}
|
|
3035
|
-
if (!line && !this.pendingImages.length) return "continue";
|
|
3036
|
-
const command = parseLine(this.app, line);
|
|
3037
|
-
if (command.kind !== "prompt") {
|
|
3038
|
-
if (this.inflight && commandDefinition(line)?.busy === "idle") {
|
|
3039
|
-
this.app.io.say(" This command needs an idle session. Press Esc to interrupt the current turn first.");
|
|
3040
|
-
this.editor.applyExternal(line);
|
|
3041
|
-
return "continue";
|
|
3042
|
-
}
|
|
3043
|
-
const result = await handleLine(this.app, line, this);
|
|
3044
|
-
await this.syncTranscript();
|
|
3045
|
-
this.paint();
|
|
3046
|
-
return result === "quit" ? "quit" : "continue";
|
|
3047
|
-
}
|
|
3048
|
-
const images = command.text.startsWith("!") ? [] : this.pendingImages;
|
|
3049
|
-
if (!command.text.startsWith("!")) this.pendingImages = [];
|
|
3050
|
-
if (this.inflight) this.enqueue({ kind: command.text.startsWith("!") ? "followup" : queueKind, text: command.text, ...(images.length ? { images } : {}) });
|
|
3051
|
-
else this.turn = this.runPromptTurn(command.text, images).catch((error: unknown) => {
|
|
3052
|
-
this.app.io.say(this.app.io.palette.red(describeError(error)));
|
|
3053
|
-
});
|
|
3054
|
-
this.paint();
|
|
3055
|
-
return "continue";
|
|
3056
|
-
}
|
|
3057
|
-
|
|
3058
|
-
private async runPromptTurn(text: string, images: readonly ImageRef[] = []): Promise<void> {
|
|
3059
|
-
this.abort = new AbortController();
|
|
3060
|
-
this.inflight = true;
|
|
3061
|
-
this.executionKind = text.startsWith("!") ? "bash" : "model";
|
|
3062
|
-
try {
|
|
3063
|
-
const sent = await run(this.app, text, this.abort.signal, this, images);
|
|
3064
|
-
if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
|
|
3065
|
-
this.app.io.say("");
|
|
3066
|
-
} finally {
|
|
3067
|
-
await this.syncTranscript();
|
|
3068
|
-
this.commitStream();
|
|
3069
|
-
this.abort = null;
|
|
3070
|
-
fileIndexes.delete(this.app.workspace);
|
|
3071
|
-
this.inflight = false;
|
|
3072
|
-
this.executionKind = "idle";
|
|
3073
|
-
this.bashOutput = null;
|
|
3074
|
-
this.branch = currentBranch(this.app.workspace);
|
|
3075
|
-
this.clearStream();
|
|
3076
|
-
this.paint();
|
|
3077
|
-
}
|
|
3078
|
-
}
|
|
3079
|
-
|
|
3080
|
-
/**
|
|
3081
|
-
* send() calls this at after_tools. Expansion is here because the
|
|
3082
|
-
* files the user named may have changed while the line waited.
|
|
3083
|
-
*/
|
|
3084
|
-
async pullQueued(phase: Phase): Promise<UserInput | null> {
|
|
3085
|
-
const batch = this.nextBatch(phase);
|
|
3086
|
-
if (!batch.length) return null;
|
|
3087
|
-
const expanded = await Promise.all(batch.map(item => this.expandQueued(item)));
|
|
3088
|
-
this.queue = this.queue.filter((m) => !batch.includes(m));
|
|
3089
|
-
this.paintStream();
|
|
3090
|
-
const present = expanded.filter((input): input is UserInput => input !== null).map(input => userInputData(input));
|
|
3091
|
-
const text = present.map(input => input.text).join("\n\n"), images = present.flatMap(input => input.images ?? []);
|
|
3092
|
-
return images.length ? { text, images } : text || null;
|
|
3093
|
-
}
|
|
3094
|
-
|
|
3095
|
-
private async expandQueued(item: QueuedMessage): Promise<UserInput | null> {
|
|
3096
|
-
const text = item.text;
|
|
3097
|
-
const needsIndex = fileReferences(text).length > 0;
|
|
3098
|
-
const index: FileIndex = needsIndex
|
|
3099
|
-
? await fileIndexFor(this.app)
|
|
3100
|
-
: { paths: [], truncated: false };
|
|
3101
|
-
const expanded = await expandInput(text, {
|
|
3102
|
-
workspace: this.app.workspace,
|
|
3103
|
-
index,
|
|
3104
|
-
say: (line) => this.app.io.say(line),
|
|
3105
|
-
attachImage: path => attachImageFile(path, this.app.blobDir, this.app.ui?.settings.images?.autoResize ?? true),
|
|
3106
|
-
});
|
|
3107
|
-
const images = [...item.images ?? [], ...expanded.images ?? []];
|
|
3108
|
-
return images.length ? { text: expanded.message, images } : expanded.send ? expanded.message : null;
|
|
3109
|
-
}
|
|
3110
|
-
|
|
3111
|
-
private async drainFollowUps(): Promise<void> {
|
|
3112
|
-
while (this.abort !== null && !this.abort.signal.aborted) {
|
|
3113
|
-
const batch = this.nextBatch("turn_end");
|
|
3114
|
-
if (!batch.length) break;
|
|
3115
|
-
this.queue = this.queue.filter((m) => !batch.includes(m));
|
|
3116
|
-
this.paintStream();
|
|
3117
|
-
if (!await run(this.app, batch.map(item => item.text).join("\n\n"), this.abort.signal, this, batch.flatMap(item => item.images ?? []))) break;
|
|
3118
|
-
}
|
|
3119
|
-
}
|
|
3120
|
-
|
|
3121
|
-
private nextBatch(phase: Phase): readonly QueuedMessage[] {
|
|
3122
|
-
const settings = this.app.ui?.settings;
|
|
3123
|
-
return deliveryBatch(this.queue, phase, { steering: settings?.steeringMode ?? "one-at-a-time", followup: settings?.followUpMode ?? "one-at-a-time" });
|
|
3124
|
-
}
|
|
3125
|
-
|
|
3126
|
-
private enqueue(item: QueuedMessage): void {
|
|
3127
|
-
this.queue.push(item);
|
|
3128
|
-
}
|
|
3129
|
-
|
|
3130
|
-
beginBash(command: string): void {
|
|
3131
|
-
this.executionKind = "bash";
|
|
3132
|
-
this.bashDecoder = new TextDecoder();
|
|
3133
|
-
this.bashOutput = `$ ${command}\n`;
|
|
3134
|
-
this.paint();
|
|
3135
|
-
}
|
|
3136
|
-
|
|
3137
|
-
pushBash(chunk: Uint8Array): void {
|
|
3138
|
-
// Only the live preview is bounded; observeCommand archives every byte.
|
|
3139
|
-
this.bashOutput = `${this.bashOutput ?? ""}${this.bashDecoder.decode(chunk, { stream: true })}`.slice(-64 * 1024);
|
|
3140
|
-
this.paint();
|
|
3141
|
-
}
|
|
3142
|
-
|
|
3143
|
-
private returnQueueToEditor(): void {
|
|
3144
|
-
if (this.queue.length === 0) return;
|
|
3145
|
-
const draft = this.editor.state.text;
|
|
3146
|
-
const parts = this.queue.map((m) => m.text);
|
|
3147
|
-
this.pendingImages = [...this.queue.flatMap(m => m.images ?? []), ...this.pendingImages];
|
|
3148
|
-
if (draft.trim().length > 0) parts.push(draft);
|
|
3149
|
-
this.queue = [];
|
|
3150
|
-
this.editor.applyExternal(parts.join("\n"));
|
|
3151
|
-
}
|
|
3152
|
-
}
|