@visual-z/xeno 0.2.1 → 0.4.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 +18 -0
- package/README.md +126 -34
- package/docs/app-server.md +84 -0
- package/docs/install.md +51 -13
- package/docs/providers.md +24 -3
- package/package.json +7 -5
- package/src/apps/app-server.ts +28 -0
- package/src/apps/approve.ts +3 -3
- package/src/apps/cli.ts +25 -10
- package/src/apps/commands.ts +45 -8
- package/src/apps/completions.ts +78 -22
- package/src/apps/control-transport.ts +89 -0
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/model-catalog.ts +2 -1
- 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 +333 -1542
- package/src/apps/startup-input.ts +45 -9
- package/src/apps/startup.ts +149 -94
- package/src/apps/ui/app.ts +1553 -0
- package/src/apps/ui/control-model.ts +26 -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 +86 -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-action.ts +20 -0
- package/src/core/completion-review.ts +37 -21
- 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/control-protocol.ts +62 -0
- package/src/core/control-view.ts +42 -0
- 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 +81 -6
- package/src/core/events.ts +99 -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/harness-diagnostics.ts +206 -0
- 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/oauth-fields.ts +44 -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/program-status.ts +31 -0
- package/src/core/project.ts +70 -19
- 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/request-progress.ts +87 -0
- package/src/core/request-state.ts +114 -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 +33 -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-input.ts +6 -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 +11 -4
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/catalog.ts +13 -3
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/control-connection.ts +104 -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 +462 -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 +1088 -282
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/model.ts +9 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +146 -0
- package/src/runtime/oauth.ts +80 -3
- package/src/runtime/pi-provider.ts +169 -12
- package/src/runtime/program-status.ts +38 -0
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/provider-request.ts +13 -2
- package/src/runtime/provider.ts +23 -9
- 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/session-control.ts +198 -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 +200 -0
- package/src/tui/ui/control-panel.ts +23 -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 +279 -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,42 +1,43 @@
|
|
|
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 { createProvider, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
|
|
32
|
+
import type { RestorePoint, RewindMode } from "../runtime/loop.ts";
|
|
33
|
+
import { createProvider, providerSessionId, 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";
|
|
35
36
|
import { equivalentCost, reportedCost } from "../runtime/usage.ts";
|
|
36
37
|
import type { ProviderConfig } from "../runtime/provider.ts";
|
|
37
38
|
import { describeMissingCredential, envLabel, ensureFreshCredential, storedIsOAuth } from "../runtime/auth.ts";
|
|
38
39
|
import {
|
|
39
|
-
SUBSCRIPTION_NOTICE, SUBSCRIPTION_OPTIONS,
|
|
40
|
+
SUBSCRIPTION_NOTICE, SUBSCRIPTION_OPTIONS, subscriptionById, loginGrok,
|
|
40
41
|
loginGitHubCopilot, generatePKCE, generateState, anthropicAuthorizeUrl, openaiCodexAuthorizeUrl,
|
|
41
42
|
startCallbackServer, exchangeAnthropicCode, exchangeOpenAICode, parseAuthorizationInput,
|
|
42
43
|
ANTHROPIC_CALLBACK_PORT, ANTHROPIC_CALLBACK_PATH, ANTHROPIC_REDIRECT_URI,
|
|
@@ -46,81 +47,58 @@ 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 {
|
|
98
|
-
import {
|
|
99
|
-
|
|
100
|
-
} from "./input.ts";
|
|
73
|
+
import { sessionControl } from "../runtime/session-control.ts";
|
|
74
|
+
import { COMMANDS, parseCommand, type Command } from "./commands.ts";
|
|
75
|
+
import { expandInput } from "./input.ts";
|
|
101
76
|
import {
|
|
102
77
|
describeError, firstLine, formatEvent, indent, stdinIsTty, stdoutIsTty, wrap,
|
|
103
78
|
} from "./render.ts";
|
|
104
79
|
import {
|
|
105
|
-
openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh,
|
|
80
|
+
openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, sessionToolCatalog, type App,
|
|
106
81
|
} from "./startup.ts";
|
|
107
|
-
import {
|
|
82
|
+
import type { TurnSurface, Ui } from "./ui/surface.ts";
|
|
108
83
|
|
|
109
|
-
|
|
84
|
+
/**
|
|
85
|
+
* One line. Listing all ~40 commands here wrapped into a two-line wall that
|
|
86
|
+
* nobody reads; typing `/` already opens the searchable command menu.
|
|
87
|
+
*/
|
|
88
|
+
const HINT = `type a prompt · / for ${COMMANDS.length} commands · /provider connects a provider · /model switches · /help`;
|
|
110
89
|
const OAUTH_PASTE = "\0oauth";
|
|
111
|
-
const OAUTH_DEVICE = "\0oauth-device";
|
|
112
90
|
|
|
113
91
|
const initializedExtensions = new WeakSet<ExtensionRegistry>();
|
|
114
92
|
const extensionHistories = new WeakMap<App, LogEvent[]>();
|
|
115
|
-
function extensionContext(app: App, tui?:
|
|
93
|
+
function extensionContext(app: App, tui?: Ui): ExtensionContext {
|
|
116
94
|
const entries = () => structuredClone([...(extensionHistories.get(app) ?? []).filter(event => !app.session.state.events.some(current => current.id === event.id)), ...app.session.state.events]);
|
|
117
95
|
return {
|
|
118
|
-
ui: tui?.extensionUi
|
|
96
|
+
ui: tui?.extensionUi ?? headlessUI((text, type) => app.io.say(type === "error" ? app.io.palette.red(text) : text)),
|
|
119
97
|
cwd: app.workspace, mode: tui ? "tui" : app.args.mode === "json" ? "json" : "print", hasUI: !!tui,
|
|
120
98
|
model: app.providerConfig.spec, thinkingLevel: app.providerConfig.effort, signal: tui?.signal,
|
|
121
99
|
isIdle: () => !app.session.running, isProjectTrusted: () => app.projectTrusted === true,
|
|
122
|
-
hasPendingMessages: () =>
|
|
123
|
-
abort: () => { tui?.
|
|
100
|
+
hasPendingMessages: () => (tui?.queueLength ?? 0) > 0,
|
|
101
|
+
abort: () => { tui?.interrupt(); app.session.cancel(); }, shutdown: () => { app.shutdownRequested = true; app.session.cancel(); tui?.requestShutdown(); },
|
|
124
102
|
getSystemPrompt: () => app.session.config.frozen.system,
|
|
125
103
|
getContextUsage: () => ({ tokens: app.session.state.anchor?.promptTokens ?? null, contextWindow: app.providerConfig.spec.contextWindow }),
|
|
126
104
|
waitForIdle: () => app.session.whenIdle(),
|
|
@@ -133,8 +111,8 @@ function extensionContext(app: App, tui?: Tui): ExtensionContext {
|
|
|
133
111
|
};
|
|
134
112
|
}
|
|
135
113
|
|
|
136
|
-
async function bindExtensions(app: App, tui?:
|
|
137
|
-
|
|
114
|
+
async function bindExtensions(app: App, tui?: Ui): Promise<void> {
|
|
115
|
+
tui?.bindExtensionRegistry();
|
|
138
116
|
const registry = app.extensions; if (!registry) return;
|
|
139
117
|
const history = await readNodeHistory(app.logDir, app.session.state.node);
|
|
140
118
|
if (history.ok) extensionHistories.set(app, history.nodes.flatMap(node => node.events));
|
|
@@ -148,29 +126,28 @@ async function bindExtensions(app: App, tui?: Tui): Promise<void> {
|
|
|
148
126
|
user: async (text, options) => { if (tui) tui.deliverExtension(text, options?.deliverAs === "steer" ? "steering" : "followup"); else await run(app, text); },
|
|
149
127
|
setName: name => nameSession(app, name, tui),
|
|
150
128
|
setLabel: async (id, label) => { await setSessionLabel(app.logDir, id, label ?? "", app.session.clock.now()); },
|
|
151
|
-
getTools: () =>
|
|
129
|
+
getTools: () => app.session.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" &&
|
|
130
|
+
"name" in tool && typeof tool.name === "string" && "description" in tool && typeof tool.description === "string" && "input_schema" in tool
|
|
131
|
+
? [{ name: tool.name, description: tool.description, parameters: JSON.parse(JSON.stringify(tool.input_schema)) as unknown }] : []),
|
|
132
|
+
getAllTools: () => {
|
|
133
|
+
const { schemas, deferred } = sessionToolCatalog(app.args, registry, app.session.config.frozen.tools);
|
|
134
|
+
return [...new Map([...schemas, ...deferred].map(tool => [tool.name, tool])).values()]
|
|
135
|
+
.map(tool => ({ name: tool.name, description: tool.description, parameters: tool.input_schema }));
|
|
136
|
+
},
|
|
152
137
|
setTools: async names => {
|
|
153
138
|
if (app.session.running) throw new Error("Active tools can only change at an idle checkpoint");
|
|
154
139
|
const args = { ...app.args, tools: names, noTools: false };
|
|
155
|
-
const tools =
|
|
140
|
+
const { schemas: tools, deferred } = sessionToolCatalog(args, registry);
|
|
156
141
|
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 });
|
|
142
|
+
await app.session.reloadResources({ ...app.session.config.frozen, tools });
|
|
143
|
+
app.session.config.tools.deferredTools = deferred; app.args = args;
|
|
158
144
|
},
|
|
159
145
|
setModel: async model => {
|
|
160
146
|
if (typeof model !== "object" || !model || !("provider" in model) || !("id" in model)) return false;
|
|
161
147
|
await switchByPattern(app, String(model.provider) + "/" + String(model.id)); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
|
|
162
148
|
},
|
|
163
149
|
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
|
-
},
|
|
150
|
+
exec: (command, args, options) => app.session.runExtensionCommand(command, args, options),
|
|
174
151
|
});
|
|
175
152
|
if (!initializedExtensions.has(registry)) {
|
|
176
153
|
initializedExtensions.add(registry);
|
|
@@ -187,31 +164,40 @@ function parseLine(app: App, text: string): Command {
|
|
|
187
164
|
|
|
188
165
|
/**
|
|
189
166
|
* 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.
|
|
167
|
+
* this same set -- a second estimate would disagree with the actual cut. Policy,
|
|
168
|
+
* window and reserve have no defaults: a default is how the footer came to
|
|
169
|
+
* measure against a boundary the active policy never consults.
|
|
191
170
|
*/
|
|
192
171
|
export interface BudgetInput {
|
|
172
|
+
readonly logHint?: { manifestPath: string; segmentPath: string };
|
|
193
173
|
readonly frozen: FrozenPrefix;
|
|
194
174
|
readonly handoff: Handoff | null;
|
|
195
175
|
readonly events: readonly LogEvent[];
|
|
196
176
|
readonly anchor: Anchor | null;
|
|
197
177
|
readonly counter?: TokenCounter;
|
|
198
178
|
readonly boundary?: Partial<BoundaryConfig>;
|
|
199
|
-
|
|
200
|
-
readonly
|
|
179
|
+
readonly policy: ContextPolicy;
|
|
180
|
+
readonly contextWindow: number | undefined;
|
|
181
|
+
readonly outputReserve: number;
|
|
201
182
|
}
|
|
202
183
|
|
|
203
184
|
export interface ContextBudget {
|
|
204
185
|
readonly used: number;
|
|
205
|
-
|
|
186
|
+
/** Nothing the provider reported backs the count: before the first response, after a cut. */
|
|
187
|
+
readonly estimated: boolean;
|
|
188
|
+
/** Where the active policy next acts; null when it names no boundary. */
|
|
189
|
+
readonly trigger: number | null;
|
|
206
190
|
readonly inherited: number;
|
|
207
|
-
readonly
|
|
191
|
+
readonly limits: ContextLimits;
|
|
192
|
+
readonly decision: ContextDecision;
|
|
193
|
+
readonly level: GaugeLevel;
|
|
208
194
|
}
|
|
209
195
|
|
|
210
196
|
/**
|
|
211
197
|
* The numbers the status gauge shows, computed the same way maybeCut does:
|
|
212
198
|
* anchoredCount for the current projection, inheritedTokens from the empty
|
|
213
|
-
* node,
|
|
214
|
-
* recompute the work floor.
|
|
199
|
+
* node, contextLimits and decideContext for what the next turn boundary does.
|
|
200
|
+
* The TUI must not recompute the work floor.
|
|
215
201
|
*/
|
|
216
202
|
export function contextBudget(input: BudgetInput): ContextBudget {
|
|
217
203
|
const counter = input.counter ?? estimateTokens;
|
|
@@ -219,8 +205,9 @@ export function contextBudget(input: BudgetInput): ContextBudget {
|
|
|
219
205
|
frozen: input.frozen,
|
|
220
206
|
handoff: input.handoff,
|
|
221
207
|
events: input.events,
|
|
208
|
+
...(input.logHint ? { logHint: input.logHint } : {}),
|
|
222
209
|
});
|
|
223
|
-
const { tokens } = anchoredCount(
|
|
210
|
+
const { tokens, exactUpTo } = anchoredCount(
|
|
224
211
|
projected.messages,
|
|
225
212
|
projected.system,
|
|
226
213
|
input.anchor,
|
|
@@ -231,16 +218,38 @@ export function contextBudget(input: BudgetInput): ContextBudget {
|
|
|
231
218
|
frozen: input.frozen,
|
|
232
219
|
handoff: input.handoff,
|
|
233
220
|
events: [],
|
|
221
|
+
...(input.logHint ? { logHint: input.logHint } : {}),
|
|
234
222
|
});
|
|
235
223
|
const inherited = counter.count(floor.messages, floor.system, input.frozen.tools);
|
|
236
|
-
const
|
|
237
|
-
|
|
224
|
+
const limits = contextLimits({
|
|
225
|
+
policy: input.policy,
|
|
226
|
+
contextWindow: input.contextWindow,
|
|
227
|
+
outputReserve: input.outputReserve,
|
|
228
|
+
...(input.boundary ? { boundary: input.boundary } : {}),
|
|
229
|
+
});
|
|
230
|
+
// The footer asks what the next turn boundary does, even mid-turn. Readiness
|
|
231
|
+
// and price are not known here, so adaptive reads as deferrable at its soft
|
|
232
|
+
// trigger and certain only at the hard limit.
|
|
233
|
+
const decision = decideContext({
|
|
234
|
+
policy: input.policy,
|
|
235
|
+
continuationReady: false,
|
|
236
|
+
cost: null,
|
|
238
237
|
tokens,
|
|
239
238
|
inheritedTokens: inherited,
|
|
240
|
-
atTurnBoundary:
|
|
239
|
+
atTurnBoundary: true,
|
|
240
|
+
...(input.contextWindow === undefined ? {} : { contextWindow: input.contextWindow }),
|
|
241
|
+
outputReserve: input.outputReserve,
|
|
241
242
|
...(input.boundary ? { config: input.boundary } : {}),
|
|
242
243
|
});
|
|
243
|
-
return {
|
|
244
|
+
return {
|
|
245
|
+
used: tokens,
|
|
246
|
+
estimated: exactUpTo === 0,
|
|
247
|
+
trigger: nextBoundary(limits),
|
|
248
|
+
inherited,
|
|
249
|
+
limits,
|
|
250
|
+
decision,
|
|
251
|
+
level: gaugeLevel(tokens, limits, decision),
|
|
252
|
+
};
|
|
244
253
|
}
|
|
245
254
|
|
|
246
255
|
export function resolveEditorCommand(
|
|
@@ -315,9 +324,10 @@ async function fileIndexFor(app: App, refresh = false): Promise<FileIndex> {
|
|
|
315
324
|
|
|
316
325
|
export async function runPrompt(app: App, prompt: string): Promise<void> {
|
|
317
326
|
await bindExtensions(app);
|
|
318
|
-
if (app.shutdownRequested) { await app.extensions?.dispose(); return; }
|
|
319
|
-
|
|
320
|
-
await app
|
|
327
|
+
if (app.shutdownRequested) { await app.session.dispose(); await app.extensions?.dispose(); return; }
|
|
328
|
+
let ok: boolean;
|
|
329
|
+
try { ok = await run(app, prompt); }
|
|
330
|
+
finally { await app.session.dispose(); await app.extensions?.dispose(); }
|
|
321
331
|
printUsage(app);
|
|
322
332
|
// One-shot mode is scriptable, so a failed turn has to be a failed exit.
|
|
323
333
|
if (!ok) process.exit(1);
|
|
@@ -335,7 +345,7 @@ async function runLines(app: App): Promise<void> {
|
|
|
335
345
|
await bindExtensions(app);
|
|
336
346
|
const C = app.io.palette;
|
|
337
347
|
if (app.ui?.settings.quietStartup !== true || app.args.verbose) app.io.say(C.dim(HINT));
|
|
338
|
-
/** Set by `/
|
|
348
|
+
/** Set by `/provider <provider>`; the next line typed is the key, not a prompt. */
|
|
339
349
|
let awaitingKeyFor: string | null = null;
|
|
340
350
|
for await (const line of console) {
|
|
341
351
|
const text = line.trim();
|
|
@@ -366,72 +376,54 @@ async function runLines(app: App): Promise<void> {
|
|
|
366
376
|
if (typeof outcome === "object") awaitingKeyFor = outcome.login;
|
|
367
377
|
}
|
|
368
378
|
await cancelPendingOAuth(app);
|
|
379
|
+
await app.session.dispose();
|
|
369
380
|
await app.extensions?.dispose();
|
|
370
381
|
printUsage(app);
|
|
371
382
|
}
|
|
372
383
|
|
|
373
384
|
async function runTui(app: App, releaseEarlyTty?: () => void): Promise<void> {
|
|
374
|
-
const C = app.io.palette;
|
|
375
385
|
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
|
-
}
|
|
386
|
+
// Process start is the one place `auto` meets the real terminal (core/tui-mode.ts).
|
|
387
|
+
if (app.ui) app.ui.settings = { ...app.ui.settings, tuiMode: resolveTuiMode({ flag: app.args.tuiMode, setting: app.ui.settings.tuiMode, env: process.env }).mode };
|
|
388
|
+
const { runInteractive } = await import("./ui/app.ts");
|
|
389
|
+
await runInteractive(app, interactiveHooks, releaseEarlyTty);
|
|
390
|
+
printUsage(app);
|
|
430
391
|
}
|
|
431
392
|
|
|
393
|
+
/**
|
|
394
|
+
* The business operations the interactive controller drives. Exported as one
|
|
395
|
+
* object so apps/ui/app.ts depends on shell.ts in one direction only and the
|
|
396
|
+
* set of things the UI can do is visible in one place.
|
|
397
|
+
*/
|
|
398
|
+
export const interactiveHooks = {
|
|
399
|
+
handleLine: (app: App, text: string, ui: Ui) => handleLine(app, text, ui),
|
|
400
|
+
run: (app: App, prompt: string, signal: AbortSignal | undefined, ui: Ui | undefined, images: readonly ImageRef[] = [], skill?: Skill) => run(app, prompt, signal, ui, images, skill),
|
|
401
|
+
parseLine: (app: App, text: string) => parseLine(app, text),
|
|
402
|
+
bindExtensions: (app: App, ui?: Ui) => bindExtensions(app, ui),
|
|
403
|
+
extensionContext: (app: App, ui?: Ui) => extensionContext(app, ui),
|
|
404
|
+
cancelPendingOAuth: (app: App) => cancelPendingOAuth(app),
|
|
405
|
+
switchToModel: (app: App, spec: ModelSpec) => switchToModel(app, spec),
|
|
406
|
+
applyThinking: (app: App, effort: Effort, ui: Ui | null) => applyThinking(app, effort, ui),
|
|
407
|
+
cycleThinking: (app: App, ui: Ui | null) => cycleThinking(app, ui),
|
|
408
|
+
cycleScopedModel: (app: App, direction: 1 | -1) => cycleScopedModel(app, direction),
|
|
409
|
+
commitSetting: (app: App, field: string, value: string, ui: Ui | null) => commitSetting(app, field, value, ui),
|
|
410
|
+
startSubscriptionLogin: (app: App, id: string, ui: Ui | null) => startSubscriptionLogin(app, id, ui),
|
|
411
|
+
login: (app: App, provider: string, key: string) => login(app, provider, key),
|
|
412
|
+
logout: (app: App, provider: string) => logout(app, provider),
|
|
413
|
+
resumeSession: (app: App, path: string, ui: Ui) => resumeSession(app, path, ui),
|
|
414
|
+
openNewSession: (app: App, ui: Ui) => openNewSession(app, ui),
|
|
415
|
+
forkSession: (app: App, arg: string, ui: Ui) => forkSession(app, arg, ui),
|
|
416
|
+
trustProject: (app: App, choice: TrustChoice, ui: Ui) => trustProject(app, choice, ui),
|
|
417
|
+
treeNodesFor: (app: App) => treeNodesFor(app),
|
|
418
|
+
copyLastReply: (app: App) => copyLastReply(app),
|
|
419
|
+
effortSupported: (spec: ModelSpec, level: Effort) => effortSupported(spec, level),
|
|
420
|
+
fileIndexFor: (app: App, refresh?: boolean) => fileIndexFor(app, refresh),
|
|
421
|
+
dropFileIndex: (workspace: string) => { fileIndexes.delete(workspace); },
|
|
422
|
+
};
|
|
423
|
+
|
|
432
424
|
type Outcome = "continue" | "quit" | { login: string };
|
|
433
425
|
|
|
434
|
-
async function execute(app: App, command: Command, tui:
|
|
426
|
+
async function execute(app: App, command: Command, tui: Ui | null): Promise<Outcome> {
|
|
435
427
|
switch (command.kind) {
|
|
436
428
|
case "extension":
|
|
437
429
|
if (tui) tui.runExtensionCommand(command.name, command.args);
|
|
@@ -447,7 +439,40 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
447
439
|
printUsage(app);
|
|
448
440
|
return "continue";
|
|
449
441
|
case "state":
|
|
450
|
-
printState(app);
|
|
442
|
+
await printState(app);
|
|
443
|
+
return "continue";
|
|
444
|
+
case "control":
|
|
445
|
+
if (tui) await tui.openControl();
|
|
446
|
+
else app.io.say(JSON.stringify(await sessionControl(app.session).snapshot(), null, 2));
|
|
447
|
+
return "continue";
|
|
448
|
+
case "context":
|
|
449
|
+
printContext(app);
|
|
450
|
+
return "continue";
|
|
451
|
+
case "acceptance":
|
|
452
|
+
await printAcceptance(app);
|
|
453
|
+
return "continue";
|
|
454
|
+
case "tasks": {
|
|
455
|
+
const items = await app.session.workItems();
|
|
456
|
+
app.io.say(`\nwork tasks: ${items.length}`);
|
|
457
|
+
for (const item of items) {
|
|
458
|
+
const ready = workReadiness(items, item.id);
|
|
459
|
+
app.io.say(` ${item.id}: ${ready.reason} · ${item.what} · verification=${item.verification}${item.owner ? ` · owner=${item.owner}` : ""}`);
|
|
460
|
+
if (ready.waiting_for.length) app.io.say(` waiting for: ${ready.waiting_for.join(", ")}`);
|
|
461
|
+
}
|
|
462
|
+
return "continue";
|
|
463
|
+
}
|
|
464
|
+
case "branches": {
|
|
465
|
+
if (command.action !== undefined && command.node !== undefined) app.io.say((await app.session.resolveBranch(command.node, command.action)).content);
|
|
466
|
+
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}` : ""}`);
|
|
467
|
+
return "continue";
|
|
468
|
+
}
|
|
469
|
+
case "processes": {
|
|
470
|
+
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}` : ""}`);
|
|
471
|
+
return "continue";
|
|
472
|
+
}
|
|
473
|
+
case "accept":
|
|
474
|
+
await app.session.acceptCriterion(command.criterion);
|
|
475
|
+
await printAcceptance(app);
|
|
451
476
|
return "continue";
|
|
452
477
|
case "help":
|
|
453
478
|
printHelp(app);
|
|
@@ -464,7 +489,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
464
489
|
return "continue";
|
|
465
490
|
}
|
|
466
491
|
case "config":
|
|
467
|
-
if (tui) await tui.
|
|
492
|
+
if (tui) await tui.openResources();
|
|
468
493
|
else {
|
|
469
494
|
const resources = await discoverResources({ ...app.args, workspace: app.workspace, trusted: (await projectTrust(app.workspace, app.projectTrusted ?? app.args.approve)).trusted });
|
|
470
495
|
for (const entry of resources.entries) app.io.say(`${entry.enabled ? "[x]" : "[ ]"} ${entry.kind} ${entry.scope} ${entry.path}`);
|
|
@@ -479,7 +504,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
479
504
|
return "continue";
|
|
480
505
|
}
|
|
481
506
|
if (tui) {
|
|
482
|
-
tui.
|
|
507
|
+
tui.openModel();
|
|
483
508
|
return "continue";
|
|
484
509
|
}
|
|
485
510
|
printModels(app);
|
|
@@ -492,10 +517,10 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
492
517
|
return applyThinkingCommand(app, command.level, tui);
|
|
493
518
|
case "login": {
|
|
494
519
|
if (command.provider === undefined) {
|
|
495
|
-
if (tui) { tui.
|
|
520
|
+
if (tui) { tui.openLogin(); return "continue"; }
|
|
496
521
|
const C = app.io.palette;
|
|
497
|
-
const providers = [...new Set(app.catalog.map((m) => m.provider))];
|
|
498
|
-
app.io.say(C.bold("\
|
|
522
|
+
const providers = [...new Set(app.catalog.map((m) => m.provider))].filter(provider => !subscriptionById(provider));
|
|
523
|
+
app.io.say(C.bold("\nproviders"));
|
|
499
524
|
app.io.say(C.bold(" API keys"));
|
|
500
525
|
for (const p of providers) {
|
|
501
526
|
app.io.say(` ${p.padEnd(12)}${C.dim(envLabel(p))}`);
|
|
@@ -504,23 +529,31 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
504
529
|
for (const s of SUBSCRIPTION_OPTIONS) {
|
|
505
530
|
app.io.say(` ${s.id.padEnd(16)}${C.dim(s.label)}`);
|
|
506
531
|
}
|
|
507
|
-
app.io.say(C.dim("\n /
|
|
508
|
-
app.io.say(C.dim(
|
|
532
|
+
app.io.say(C.dim("\n /provider <provider> then paste the key. Empty line cancels."));
|
|
533
|
+
app.io.say(C.dim(` /provider ${SUBSCRIPTION_OPTIONS.map(option => option.id).join("|")} starts an account login.\n`));
|
|
509
534
|
return "continue";
|
|
510
535
|
}
|
|
511
|
-
|
|
512
|
-
|
|
536
|
+
const subscription = subscriptionById(command.provider);
|
|
537
|
+
if (subscription) {
|
|
538
|
+
return startSubscriptionLogin(app, subscription.id, tui);
|
|
539
|
+
}
|
|
540
|
+
if (!app.catalog.some(model => model.provider === command.provider)) { app.io.say(app.io.palette.red(` unknown provider: ${command.provider}. Use /provider to choose one.`)); return "continue"; }
|
|
541
|
+
if (tui) {
|
|
542
|
+
const provider = command.provider;
|
|
543
|
+
const key = await tui.askSecret(`API key · ${provider}`, "Key", "paste the key · $VAR reads the environment · !cmd runs a command · enter save · esc cancel");
|
|
544
|
+
if (key) await login(app, provider, key); else if (key !== undefined) tui.notice(" cancelled");
|
|
545
|
+
return "continue";
|
|
513
546
|
}
|
|
514
547
|
const C = app.io.palette;
|
|
515
|
-
//
|
|
516
|
-
// "it will be visible here" is how a secret ends up in a screenshot.
|
|
548
|
+
// Line mode echoes nothing for this line; the sentence must say so,
|
|
549
|
+
// because "it will be visible here" is how a secret ends up in a screenshot.
|
|
517
550
|
app.io.say(C.yellow(` paste the ${command.provider} key and press enter. It is not shown.`));
|
|
518
551
|
app.io.say(C.dim(" a value starting with ! runs as a command; $VAR reads the environment."));
|
|
519
552
|
app.io.say(C.dim(" empty line cancels."));
|
|
520
553
|
return { login: command.provider };
|
|
521
554
|
}
|
|
522
555
|
case "logout": {
|
|
523
|
-
if (tui && command.provider === undefined) { tui.
|
|
556
|
+
if (tui && command.provider === undefined) { tui.openLogout(); return "continue"; }
|
|
524
557
|
await logout(app, command.provider);
|
|
525
558
|
return "continue";
|
|
526
559
|
}
|
|
@@ -539,7 +572,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
539
572
|
}
|
|
540
573
|
case "tree":
|
|
541
574
|
if (tui) {
|
|
542
|
-
await tui.
|
|
575
|
+
await tui.openTree();
|
|
543
576
|
return "continue";
|
|
544
577
|
}
|
|
545
578
|
await printTree(app);
|
|
@@ -553,7 +586,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
553
586
|
return "continue";
|
|
554
587
|
}
|
|
555
588
|
if (tui) {
|
|
556
|
-
void tui.
|
|
589
|
+
void tui.openResume();
|
|
557
590
|
return "continue";
|
|
558
591
|
}
|
|
559
592
|
await printSessionList(app);
|
|
@@ -567,7 +600,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
567
600
|
case "import": {
|
|
568
601
|
if (!command.file) { app.io.say(" Usage: /import <session.jsonl>"); return "continue"; }
|
|
569
602
|
const importing = async () => {
|
|
570
|
-
if (tui && !await tui.
|
|
603
|
+
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
604
|
try {
|
|
572
605
|
const path = await importSessionArchive(resolve(app.workspace, command.file!), sessionsDir(app.workspace, sessionDirFor(app.args, app.workspace)), app.workspace);
|
|
573
606
|
if (await adoptLogDir(app, path)) { await bindExtensions(app, tui ?? undefined); await tui?.refreshResources(); await tui?.syncTranscript(); app.io.say(` Imported session: ${path}`); }
|
|
@@ -578,25 +611,25 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
578
611
|
}
|
|
579
612
|
case "debug": {
|
|
580
613
|
const file = resolve(app.workspace, command.file ?? join(configDir(), "debug.log"));
|
|
581
|
-
const
|
|
582
|
-
const lines =
|
|
614
|
+
const frame = tui?.snapshot() ?? { width: 80, height: 0, lines: [] };
|
|
615
|
+
const { width, height, lines } = frame;
|
|
583
616
|
const messages = projectContext({ frozen: app.session.config.frozen, handoff: app.session.state.handoff, events: app.session.state.events });
|
|
584
617
|
const body = [`xeno diagnostics ${app.session.clock.now()}`, `Terminal: ${width}x${height}`, `Total lines: ${lines.length}`,
|
|
585
|
-
...lines.map((line, i) => `[${i}]
|
|
618
|
+
...lines.map((line, i) => `[${i}] ${JSON.stringify(line)}`), "", "Agent messages (JSONL)", ...messages.messages.map(message => JSON.stringify(message)), ""].join("\n");
|
|
586
619
|
try { await mkdir(dirname(file), { recursive: true }); await writeFile(file, body, { mode: 0o600 }); app.io.say(` Debug log written: ${file}`); }
|
|
587
620
|
catch (error) { app.io.say(` Cannot write debug log: ${describeError(error)}`); }
|
|
588
621
|
return "continue";
|
|
589
622
|
}
|
|
590
623
|
case "changelog": {
|
|
591
624
|
const text = await readFile(new URL("../../CHANGELOG.md", import.meta.url), "utf8");
|
|
592
|
-
if (tui) tui.showDocument(text); else app.io.say(text);
|
|
625
|
+
if (tui) tui.showDocument("Changelog", text); else app.io.say(text);
|
|
593
626
|
return "continue";
|
|
594
627
|
}
|
|
595
628
|
case "copy":
|
|
596
629
|
await copyLastReply(app, tui ?? undefined);
|
|
597
630
|
return "continue";
|
|
598
631
|
case "fork":
|
|
599
|
-
if (tui && command.arg === undefined) { await tui.
|
|
632
|
+
if (tui && command.arg === undefined) { await tui.openFork(); return "continue"; }
|
|
600
633
|
await forkSession(app, command.arg, tui ?? undefined);
|
|
601
634
|
return "continue";
|
|
602
635
|
case "clone":
|
|
@@ -605,7 +638,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
605
638
|
return "continue";
|
|
606
639
|
case "trust":
|
|
607
640
|
if (command.target) await trustProject(app, command.target, tui ?? undefined);
|
|
608
|
-
else if (tui) await tui.
|
|
641
|
+
else if (tui) await tui.openTrust();
|
|
609
642
|
else app.io.say(" /trust once|here|parent|never controls project resources; tool permissions are separate.");
|
|
610
643
|
return "continue";
|
|
611
644
|
case "models-refresh":
|
|
@@ -615,7 +648,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
615
648
|
app.io.say(app.io.palette.yellow(` unknown command: ${command.input}. /help lists them.`));
|
|
616
649
|
return "continue";
|
|
617
650
|
case "prompt":
|
|
618
|
-
await run(app, command.text, tui?.signal, tui ?? undefined);
|
|
651
|
+
await run(app, command.text, tui?.signal, tui ?? undefined, [], command.skill);
|
|
619
652
|
app.io.say("");
|
|
620
653
|
return "continue";
|
|
621
654
|
default:
|
|
@@ -623,33 +656,8 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
623
656
|
}
|
|
624
657
|
}
|
|
625
658
|
|
|
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;
|
|
659
|
+
async function handleLine(app: App, text: string, tui: Ui): Promise<Outcome> {
|
|
660
|
+
return execute(app, parseLine(app, text), tui);
|
|
653
661
|
}
|
|
654
662
|
|
|
655
663
|
function printUsage(app: App): void {
|
|
@@ -702,12 +710,21 @@ function printUsage(app: App): void {
|
|
|
702
710
|
app.io.say("");
|
|
703
711
|
}
|
|
704
712
|
|
|
705
|
-
function printState(app: App): void {
|
|
713
|
+
async function printState(app: App): Promise<void> {
|
|
706
714
|
const C = app.io.palette;
|
|
707
715
|
const s = app.session.state;
|
|
708
716
|
app.io.say(C.bold("\nstate"));
|
|
709
717
|
app.io.say(` node ${s.node} (depth ${s.depth})`);
|
|
710
718
|
app.io.say(` events in node ${s.events.length}`);
|
|
719
|
+
const request = await app.session.requestState();
|
|
720
|
+
if (request) {
|
|
721
|
+
app.io.say(` request ${request.request.event} · ${request.status}`);
|
|
722
|
+
app.io.say(` execution ${request.execution?.reason ?? "in progress or unknown"}`);
|
|
723
|
+
app.io.say(` command evidence ${request.commands.filter(command => command.status === "pass").length} current passes / ${request.commands.length} receipts`);
|
|
724
|
+
app.io.say(` observations ${request.progress.observations.length} new; artifact changes ${request.progress.artifact_changes.length}; acceptance advances ${request.progress.acceptance_advances.length}`);
|
|
725
|
+
app.io.say(` diagnostics ${request.progress.new_diagnostics.length} new; unchanged retries ${request.progress.unchanged_retries.length}; unknown ${request.progress.unknown_diagnostics.length}`);
|
|
726
|
+
if (!request.acceptance) app.io.say(" task verification no declared acceptance contract; command success alone does not verify the task");
|
|
727
|
+
}
|
|
711
728
|
if (s.handoff) {
|
|
712
729
|
const h = s.handoff;
|
|
713
730
|
app.io.say(` completed ${h.state.completed.length}`);
|
|
@@ -724,6 +741,43 @@ function printState(app: App): void {
|
|
|
724
741
|
app.io.say("");
|
|
725
742
|
}
|
|
726
743
|
|
|
744
|
+
function printContext(app: App): void {
|
|
745
|
+
const session = app.session;
|
|
746
|
+
const detail = contextDetail({ ...session.contextInput(), anchor: session.state.anchor,
|
|
747
|
+
policy: session.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
|
|
748
|
+
contextWindow: session.provider.config.spec.contextWindow, outputReserve: session.provider.config.maxTokens,
|
|
749
|
+
...(session.config.counter ? { counter: session.config.counter } : {}),
|
|
750
|
+
...(session.config.boundary ? { boundary: session.config.boundary } : {}),
|
|
751
|
+
});
|
|
752
|
+
app.io.say(app.io.palette.bold(`\ncontext v${detail.version} · node ${session.state.node} · ${detail.policy}`));
|
|
753
|
+
for (const [name, tokens] of Object.entries(detail.components)) app.io.say(` ${name.padEnd(18)} ~${tokens} tokens`);
|
|
754
|
+
app.io.say(` total ${detail.used} (${detail.countKind}; anchor correction ${detail.anchorCorrection}; measured through ${detail.measuredThrough} messages)`);
|
|
755
|
+
app.io.say(` limits ${JSON.stringify(detail.limits)}`);
|
|
756
|
+
const sources = detail.sources.flat();
|
|
757
|
+
app.io.say(` sources ${sources.length} references: ${sources.slice(-12).join(", ") || "none"}`);
|
|
758
|
+
if (sources.length > 12) app.io.say(` earlier sources ${sources.length - 12}; use history(recent=true) or history(event=ID)`);
|
|
759
|
+
for (const section of detail.sections.filter(s => s.omitted > 0)) {
|
|
760
|
+
app.io.say(` ${section.section} retained ${section.retained}/${section.total}; omitted ${section.omitted}; history(${JSON.stringify(section.recovery)})`);
|
|
761
|
+
}
|
|
762
|
+
app.io.say(` metadata ${detail.omittedEvents.length} events do not replay as messages; available in the log`);
|
|
763
|
+
if (detail.violations.length) app.io.say(` violations ${JSON.stringify(detail.violations)}`);
|
|
764
|
+
app.io.say("");
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function printAcceptance(app: App): Promise<void> {
|
|
768
|
+
const state = await app.session.acceptanceState();
|
|
769
|
+
app.io.say(state ? `\nacceptance ${state.contract_id}: ${state.status}` : "\nacceptance: no declared task contract");
|
|
770
|
+
for (const target of state?.targets ?? []) {
|
|
771
|
+
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"}`);
|
|
772
|
+
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"}`);
|
|
773
|
+
}
|
|
774
|
+
for (const item of state?.criteria ?? []) {
|
|
775
|
+
app.io.say(` ${item.id}: ${item.status} · ${item.description}`);
|
|
776
|
+
if (item.kind === "command") app.io.say(` command: ${item.command}; evidence: ${item.evidence?.event_id ?? "none"}`);
|
|
777
|
+
else if (item.status !== "accepted") app.io.say(` /accept ${item.id} records your acceptance${item.target ? ` of the observed target ${item.target}` : ""}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
727
781
|
function printHotkeys(app: App): void {
|
|
728
782
|
const C = app.io.palette;
|
|
729
783
|
const map = app.ui?.keymap ?? defaultKeymap();
|
|
@@ -740,7 +794,7 @@ async function applySettingsCommand(
|
|
|
740
794
|
app: App,
|
|
741
795
|
field: string | undefined,
|
|
742
796
|
value: string | undefined,
|
|
743
|
-
tui:
|
|
797
|
+
tui: Ui | null,
|
|
744
798
|
): Promise<Outcome> {
|
|
745
799
|
if (field !== undefined && value !== undefined) {
|
|
746
800
|
await commitSetting(app, field, value, tui);
|
|
@@ -759,7 +813,7 @@ async function applySettingsCommand(
|
|
|
759
813
|
return "continue";
|
|
760
814
|
}
|
|
761
815
|
|
|
762
|
-
async function commitSetting(app: App, field: string, value: string, tui:
|
|
816
|
+
async function commitSetting(app: App, field: string, value: string, tui: Ui | null): Promise<void> {
|
|
763
817
|
const C = app.io.palette;
|
|
764
818
|
const key = field.toLowerCase();
|
|
765
819
|
if (app.ui === undefined) {
|
|
@@ -783,10 +837,11 @@ async function commitSetting(app: App, field: string, value: string, tui: Tui |
|
|
|
783
837
|
app.ui.settings = mergeSettings({ builtin: app.ui.settings, user: parsed.settings });
|
|
784
838
|
app.ui.userOverrides = mergeSettings({ builtin: app.ui.userOverrides, user: parsed.settings });
|
|
785
839
|
if (key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
|
|
786
|
-
|
|
840
|
+
if (tui) tui.toast(`${field} · ${value}`, "success");
|
|
841
|
+
else app.io.say(C.dim(` ${field} ${value}`));
|
|
787
842
|
tui?.applyConfiguration();
|
|
788
843
|
if (key === "theme") tui?.followTheme();
|
|
789
|
-
tui?.
|
|
844
|
+
tui?.redraw();
|
|
790
845
|
}
|
|
791
846
|
|
|
792
847
|
const EFFORTS = THINKING_LEVELS;
|
|
@@ -801,7 +856,7 @@ function effortSupported(spec: ModelSpec, level: Effort): boolean {
|
|
|
801
856
|
* must not cut: a cut drops the prefix cache, and thinking blocks stay
|
|
802
857
|
* valid because the model did not change.
|
|
803
858
|
*/
|
|
804
|
-
async function applyThinking(app: App, level: Effort, tui:
|
|
859
|
+
async function applyThinking(app: App, level: Effort, tui: Ui | null): Promise<boolean> {
|
|
805
860
|
const C = app.io.palette;
|
|
806
861
|
if (!effortSupported(app.providerConfig.spec, level)) {
|
|
807
862
|
app.io.say(
|
|
@@ -820,11 +875,11 @@ async function applyThinking(app: App, level: Effort, tui: Tui | null): Promise<
|
|
|
820
875
|
app.ui.settings = { ...app.ui.settings, thinking: level };
|
|
821
876
|
}
|
|
822
877
|
app.io.say(C.dim(` thinking ${level}`));
|
|
823
|
-
tui?.
|
|
878
|
+
tui?.redraw();
|
|
824
879
|
return true;
|
|
825
880
|
}
|
|
826
881
|
|
|
827
|
-
async function applyThinkingCommand(app: App, level: string | undefined, tui:
|
|
882
|
+
async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null): Promise<Outcome> {
|
|
828
883
|
if (level !== undefined) {
|
|
829
884
|
const effort = asEffort(level);
|
|
830
885
|
if (effort === undefined) {
|
|
@@ -835,7 +890,7 @@ async function applyThinkingCommand(app: App, level: string | undefined, tui: Tu
|
|
|
835
890
|
return "continue";
|
|
836
891
|
}
|
|
837
892
|
if (tui) {
|
|
838
|
-
tui.
|
|
893
|
+
tui.openThinking();
|
|
839
894
|
return "continue";
|
|
840
895
|
}
|
|
841
896
|
printThinking(app);
|
|
@@ -856,7 +911,7 @@ function printThinking(app: App): void {
|
|
|
856
911
|
app.io.say(C.dim("\n /thinking <level> to switch. Shift+Tab cycles supported levels.\n"));
|
|
857
912
|
}
|
|
858
913
|
|
|
859
|
-
async function cycleThinking(app: App, tui:
|
|
914
|
+
async function cycleThinking(app: App, tui: Ui | null): Promise<void> {
|
|
860
915
|
const supported = EFFORTS.filter((level) => effortSupported(app.providerConfig.spec, level));
|
|
861
916
|
const next = cycle(supported, app.providerConfig.effort, 1);
|
|
862
917
|
if (next === undefined) return;
|
|
@@ -977,10 +1032,11 @@ async function printBack(app: App): Promise<void> {
|
|
|
977
1032
|
* Jumping still needs the TUI; this is so a pipe can see the structure.
|
|
978
1033
|
*/
|
|
979
1034
|
async function printTree(app: App): Promise<void> {
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
for (const
|
|
983
|
-
|
|
1035
|
+
const rows = flattenTree(await treeNodesFor(app));
|
|
1036
|
+
if (!rows.length) { app.io.say(EMPTY_TREE_MESSAGE); return; }
|
|
1037
|
+
for (const row of rows) {
|
|
1038
|
+
const mark = row.node.current ? "● " : row.node.onPath ? " " : `${LEFT_BRANCH_MARK} `;
|
|
1039
|
+
app.io.say(row.branch + mark + (row.node.label ? `[${row.node.label}] ` : "") + firstLine(row.node.prompt, 72));
|
|
984
1040
|
}
|
|
985
1041
|
}
|
|
986
1042
|
|
|
@@ -1045,7 +1101,7 @@ async function printRewind(
|
|
|
1045
1101
|
): Promise<void> {
|
|
1046
1102
|
const C = app.io.palette;
|
|
1047
1103
|
const label = firstLine(point.prompt, 60);
|
|
1048
|
-
const plan = await app.session.previewRewind(point);
|
|
1104
|
+
const plan = await app.session.previewRewind(point, mode);
|
|
1049
1105
|
if (!plan.ok) {
|
|
1050
1106
|
app.io.say(C.red(`\n cannot go back: ${plan.error}\n`));
|
|
1051
1107
|
return;
|
|
@@ -1066,7 +1122,7 @@ async function printRewind(
|
|
|
1066
1122
|
for (const p of extra.slice(0, 20)) app.io.say(C.yellow(` stays ${p} (not in that context)`));
|
|
1067
1123
|
if (extra.length > 20) app.io.say(C.yellow(` ... ${extra.length - 20} more`));
|
|
1068
1124
|
} else {
|
|
1069
|
-
for (const p of plan.willWrite.slice(0, 20)) app.io.say(C.dim(`
|
|
1125
|
+
for (const p of plan.willWrite.slice(0, 20)) app.io.say(C.dim(` restore ${p}`));
|
|
1070
1126
|
if (plan.willWrite.length > 20) app.io.say(C.dim(` ... ${plan.willWrite.length - 20} more`));
|
|
1071
1127
|
for (const p of plan.willDelete.slice(0, 20)) app.io.say(C.red(` delete ${p}`));
|
|
1072
1128
|
if (plan.willDelete.length > 20) app.io.say(C.red(` ... ${plan.willDelete.length - 20} more`));
|
|
@@ -1204,7 +1260,7 @@ function providerOAuthOpts(app: App, spec: ModelSpec): { oauth: true } | undefin
|
|
|
1204
1260
|
}
|
|
1205
1261
|
|
|
1206
1262
|
function makeProvider(app: App, config: ProviderConfig, key: string) {
|
|
1207
|
-
return createProvider(config, key, retryHandler(app.io), { ...providerOAuthOpts(app, config.spec), effects: app.effects });
|
|
1263
|
+
return createProvider(config, key, retryHandler(app.io), { sessionId: providerSessionId(app.session.config.logDir), ...providerOAuthOpts(app, config.spec), effects: app.effects });
|
|
1208
1264
|
}
|
|
1209
1265
|
|
|
1210
1266
|
interface PendingOAuth {
|
|
@@ -1216,13 +1272,15 @@ interface PendingOAuth {
|
|
|
1216
1272
|
exchange: "anthropic" | "openai";
|
|
1217
1273
|
server: CallbackServer;
|
|
1218
1274
|
done: boolean;
|
|
1219
|
-
tui:
|
|
1275
|
+
tui: Ui | null;
|
|
1220
1276
|
abort: AbortController;
|
|
1277
|
+
/** Closes the UI's waiting dialog, if any. */
|
|
1278
|
+
close?: () => void;
|
|
1221
1279
|
}
|
|
1222
1280
|
|
|
1223
1281
|
// Authentication is scoped to its application, independent of the model/session log.
|
|
1224
1282
|
const pendingLogins = new WeakMap<App, PendingOAuth>();
|
|
1225
|
-
const pendingDevices = new WeakMap<App, { abort: AbortController; tui:
|
|
1283
|
+
const pendingDevices = new WeakMap<App, { abort: AbortController; tui: Ui | null; close?: () => void }>();
|
|
1226
1284
|
const loginEffects = { fetch: ((input, init) => globalThis.fetch(input, init)) as typeof globalThis.fetch,
|
|
1227
1285
|
openBrowser: tryOpenBrowser, callback: startCallbackServer, device: loginGitHubCopilot };
|
|
1228
1286
|
|
|
@@ -1230,8 +1288,7 @@ async function cancelPendingOAuth(app: App): Promise<void> {
|
|
|
1230
1288
|
const pending = pendingLogins.get(app), device = pendingDevices.get(app);
|
|
1231
1289
|
pendingLogins.delete(app); pendingDevices.delete(app);
|
|
1232
1290
|
device?.abort.abort(); pending?.abort.abort();
|
|
1233
|
-
|
|
1234
|
-
tui?.endCredentialInput();
|
|
1291
|
+
pending?.close?.(); device?.close?.();
|
|
1235
1292
|
if (pending) { pending.done = true; await pending.server.close(); }
|
|
1236
1293
|
}
|
|
1237
1294
|
|
|
@@ -1282,7 +1339,7 @@ async function finishOAuthCode(app: App, pending: PendingOAuth, code: string): P
|
|
|
1282
1339
|
} finally {
|
|
1283
1340
|
if (pendingLogins.get(app) === pending) {
|
|
1284
1341
|
pendingLogins.delete(app);
|
|
1285
|
-
pending.
|
|
1342
|
+
pending.close?.();
|
|
1286
1343
|
}
|
|
1287
1344
|
await pending.server.close();
|
|
1288
1345
|
}
|
|
@@ -1313,22 +1370,27 @@ function tryOpenBrowser(url: string): void {
|
|
|
1313
1370
|
}
|
|
1314
1371
|
}
|
|
1315
1372
|
|
|
1316
|
-
async function startSubscriptionLogin(app: App, id: string, tui:
|
|
1373
|
+
async function startSubscriptionLogin(app: App, id: string, tui: Ui | null): Promise<Outcome> {
|
|
1317
1374
|
const C = app.io.palette;
|
|
1318
1375
|
const sub = subscriptionById(id);
|
|
1319
1376
|
if (sub === undefined) return "continue";
|
|
1320
1377
|
await cancelPendingOAuth(app);
|
|
1321
1378
|
const effects = tui?.oauthEffects ?? loginEffects;
|
|
1322
1379
|
app.io.say(C.yellow(` ${SUBSCRIPTION_NOTICE}`));
|
|
1323
|
-
if (sub.id === "github-copilot") {
|
|
1324
|
-
const pending = { abort: new AbortController(), tui };
|
|
1380
|
+
if (sub.id === "github-copilot" || sub.id === "grok") {
|
|
1381
|
+
const pending: { abort: AbortController; tui: Ui | null; close?: () => void } = { abort: new AbortController(), tui };
|
|
1325
1382
|
pendingDevices.set(app, pending);
|
|
1326
1383
|
const work = async () => { try {
|
|
1327
|
-
const credential = await effects.device({
|
|
1328
|
-
notify: (message) => {
|
|
1384
|
+
const credential = await (sub.id === "grok" ? loginGrok : effects.device)({
|
|
1385
|
+
notify: (message) => {
|
|
1386
|
+
if (pendingDevices.get(app) !== pending) return;
|
|
1387
|
+
if (tui) { pending.close?.(); pending.close = tui.waiting(sub.label, message.split("\n"), () => { void cancelPendingOAuth(app); }); }
|
|
1388
|
+
else app.io.say(` ${message}`);
|
|
1389
|
+
},
|
|
1329
1390
|
prompt: async () => "",
|
|
1330
1391
|
fetch: effects.fetch,
|
|
1331
1392
|
signal: pending.abort.signal,
|
|
1393
|
+
openBrowser: effects.openBrowser,
|
|
1332
1394
|
});
|
|
1333
1395
|
if (pending.abort.signal.aborted || pendingDevices.get(app) !== pending) return;
|
|
1334
1396
|
await storeOAuth(app, sub.provider, credential);
|
|
@@ -1339,10 +1401,10 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
|
|
|
1339
1401
|
} finally {
|
|
1340
1402
|
if (pendingDevices.get(app) === pending) {
|
|
1341
1403
|
pendingDevices.delete(app);
|
|
1342
|
-
|
|
1404
|
+
pending.close?.();
|
|
1343
1405
|
}
|
|
1344
1406
|
} };
|
|
1345
|
-
if (tui) { tui.
|
|
1407
|
+
if (tui) { pending.close = tui.waiting(sub.label, ["Requesting a device code…"], () => { void cancelPendingOAuth(app); }); tui.runExtensionAction(work); return "continue"; }
|
|
1346
1408
|
await work();
|
|
1347
1409
|
return "continue";
|
|
1348
1410
|
}
|
|
@@ -1372,8 +1434,10 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
|
|
|
1372
1434
|
close: async () => {},
|
|
1373
1435
|
};
|
|
1374
1436
|
}
|
|
1375
|
-
|
|
1376
|
-
|
|
1437
|
+
if (!tui) {
|
|
1438
|
+
app.io.say(` ${url}`);
|
|
1439
|
+
app.io.say(C.dim(" complete login in the browser, or paste the redirect URL. It is not shown."));
|
|
1440
|
+
}
|
|
1377
1441
|
effects.openBrowser(url);
|
|
1378
1442
|
const pending: PendingOAuth = {
|
|
1379
1443
|
id: sub.id,
|
|
@@ -1392,6 +1456,12 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
|
|
|
1392
1456
|
if (!got.ok || pending.done) return;
|
|
1393
1457
|
await finishOAuthCode(app, pending, got.code);
|
|
1394
1458
|
});
|
|
1459
|
+
if (tui) {
|
|
1460
|
+
// The URL is shown (it is not a secret); the pasted redirect is, so it goes through a masked field.
|
|
1461
|
+
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."],
|
|
1462
|
+
() => { void cancelPendingOAuth(app); }, "Redirect URL", value => tui.runExtensionAction(() => completeOAuthPaste(app, value)));
|
|
1463
|
+
return "continue";
|
|
1464
|
+
}
|
|
1395
1465
|
return { login: OAUTH_PASTE };
|
|
1396
1466
|
}
|
|
1397
1467
|
|
|
@@ -1452,7 +1522,7 @@ async function switchByPattern(app: App, pattern: string): Promise<void> {
|
|
|
1452
1522
|
await switchToModel(app, selected.model, selected.thinking);
|
|
1453
1523
|
}
|
|
1454
1524
|
|
|
1455
|
-
async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
|
|
1525
|
+
export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
|
|
1456
1526
|
const C = app.io.palette;
|
|
1457
1527
|
if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
|
|
1458
1528
|
if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null);
|
|
@@ -1462,7 +1532,7 @@ async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Prom
|
|
|
1462
1532
|
const got = app.credentialOf(spec);
|
|
1463
1533
|
if (!got.ok) {
|
|
1464
1534
|
app.io.say(C.red(`\n ${got.reason}\n`));
|
|
1465
|
-
if (!got.reason.includes("/
|
|
1535
|
+
if (!got.reason.includes("/provider")) {
|
|
1466
1536
|
app.io.say(C.dim(` ${describeMissingCredential(spec.provider)}\n`));
|
|
1467
1537
|
}
|
|
1468
1538
|
return;
|
|
@@ -1474,8 +1544,7 @@ async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Prom
|
|
|
1474
1544
|
thinkingDisplay: app.providerConfig.thinkingDisplay,
|
|
1475
1545
|
};
|
|
1476
1546
|
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."));
|
|
1547
|
+
app.io.say(C.dim(" new segment: the next model starts from a handoff, not this model's thinking"));
|
|
1479
1548
|
try {
|
|
1480
1549
|
await app.session.switchModel(next);
|
|
1481
1550
|
} catch (err) {
|
|
@@ -1485,13 +1554,22 @@ async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Prom
|
|
|
1485
1554
|
app.providerConfig = nextConfig;
|
|
1486
1555
|
app.credential = got;
|
|
1487
1556
|
app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
|
|
1557
|
+
await rememberRecentModel(app, spec);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/** Recency feeds the picker's first section; a write failure only costs ordering. */
|
|
1561
|
+
async function rememberRecentModel(app: App, spec: ModelSpec): Promise<void> {
|
|
1562
|
+
if (!app.ui) return;
|
|
1563
|
+
const recentModels = recordRecent(app.ui.settings.recentModels ?? [], modelKey(spec));
|
|
1564
|
+
app.ui.settings = { ...app.ui.settings, recentModels };
|
|
1565
|
+
try { await writeUserSettings({ recentModels }); } catch { /* ordering only */ }
|
|
1488
1566
|
}
|
|
1489
1567
|
|
|
1490
1568
|
function sessionDirOf(app: App): string | undefined {
|
|
1491
1569
|
return sessionDirFor(app.args, app.workspace);
|
|
1492
1570
|
}
|
|
1493
1571
|
|
|
1494
|
-
async function openNewSession(app: App, tui?:
|
|
1572
|
+
async function openNewSession(app: App, tui?: Ui): Promise<void> {
|
|
1495
1573
|
const C = app.io.palette;
|
|
1496
1574
|
const created = await createSession(app.workspace, undefined, sessionDirOf(app));
|
|
1497
1575
|
const switched = await adoptLogDir(app, created.path);
|
|
@@ -1503,7 +1581,7 @@ async function openNewSession(app: App, tui?: Tui): Promise<void> {
|
|
|
1503
1581
|
app.io.say(C.green(` new session ${created.id}`) + C.dim(` ${created.path}`));
|
|
1504
1582
|
}
|
|
1505
1583
|
|
|
1506
|
-
async function resumeSession(app: App, idOrPath: string, tui?:
|
|
1584
|
+
async function resumeSession(app: App, idOrPath: string, tui?: Ui): Promise<void> {
|
|
1507
1585
|
const C = app.io.palette;
|
|
1508
1586
|
const found = await findSessionByPrefix(app.workspace, idOrPath, sessionDirOf(app));
|
|
1509
1587
|
if (!found.ok) {
|
|
@@ -1525,7 +1603,10 @@ async function resumeSession(app: App, idOrPath: string, tui?: Tui): Promise<voi
|
|
|
1525
1603
|
|
|
1526
1604
|
async function adoptLogDir(app: App, logDir: string): Promise<boolean> {
|
|
1527
1605
|
try {
|
|
1606
|
+
app.session.cancel(); await app.session.whenIdle();
|
|
1607
|
+
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
1608
|
const opened = await openSessionFor({ ...app, logDir, fallback: app.providerConfig, interactive: true });
|
|
1609
|
+
await app.session.dispose();
|
|
1529
1610
|
await app.extensions?.dispose();
|
|
1530
1611
|
app.session = opened.session;
|
|
1531
1612
|
app.providerConfig = opened.providerConfig;
|
|
@@ -1574,7 +1655,7 @@ async function exportSession(app: App, file?: string): Promise<void> {
|
|
|
1574
1655
|
app.io.say(C.green(` exported ${abs}`));
|
|
1575
1656
|
}
|
|
1576
1657
|
|
|
1577
|
-
async function copyLastReply(app: App, tui?:
|
|
1658
|
+
async function copyLastReply(app: App, tui?: Ui): Promise<void> {
|
|
1578
1659
|
const C = app.io.palette;
|
|
1579
1660
|
const text = lastAssistantText(await transcriptEvents(app));
|
|
1580
1661
|
if (text === null) {
|
|
@@ -1590,7 +1671,7 @@ async function copyLastReply(app: App, tui?: Tui): Promise<void> {
|
|
|
1590
1671
|
app.io.say(text);
|
|
1591
1672
|
}
|
|
1592
1673
|
|
|
1593
|
-
async function forkSession(app: App, arg?: string, tui?:
|
|
1674
|
+
async function forkSession(app: App, arg?: string, tui?: Ui): Promise<void> {
|
|
1594
1675
|
const C = app.io.palette;
|
|
1595
1676
|
const points = await app.session.userMessages();
|
|
1596
1677
|
if (arg === undefined) {
|
|
@@ -1616,7 +1697,7 @@ async function forkSession(app: App, arg?: string, tui?: Tui): Promise<void> {
|
|
|
1616
1697
|
if (await spawnForkedSession(app, point.id, `forked before ${firstLine(point.text, 40)}`, "before")) {
|
|
1617
1698
|
await tui?.syncTranscript();
|
|
1618
1699
|
tui?.restoreDraft(point.text, point.images ?? []);
|
|
1619
|
-
tui?.
|
|
1700
|
+
tui?.redraw();
|
|
1620
1701
|
}
|
|
1621
1702
|
}
|
|
1622
1703
|
|
|
@@ -1639,7 +1720,7 @@ async function spawnForkedSession(app: App, untilEvent: string | undefined, labe
|
|
|
1639
1720
|
return true;
|
|
1640
1721
|
}
|
|
1641
1722
|
|
|
1642
|
-
async function trustProject(app: App, target: TrustChoice, tui?:
|
|
1723
|
+
async function trustProject(app: App, target: TrustChoice, tui?: Ui): Promise<void> {
|
|
1643
1724
|
const C = app.io.palette;
|
|
1644
1725
|
const dir = target === "parent" ? dirname(app.workspace) : app.workspace;
|
|
1645
1726
|
const trusted = await applyTrustChoice(app.workspace, target);
|
|
@@ -1655,7 +1736,7 @@ async function trustProject(app: App, target: TrustChoice, tui?: Tui): Promise<v
|
|
|
1655
1736
|
await tui?.refreshResources();
|
|
1656
1737
|
}
|
|
1657
1738
|
|
|
1658
|
-
async function nameSession(app: App, name: string, tui?:
|
|
1739
|
+
async function nameSession(app: App, name: string, tui?: Ui): Promise<void> {
|
|
1659
1740
|
const C = app.io.palette;
|
|
1660
1741
|
if (name.length === 0) {
|
|
1661
1742
|
app.io.say(C.dim(" /name <name>"));
|
|
@@ -1675,7 +1756,7 @@ async function nameSession(app: App, name: string, tui?: Tui): Promise<void> {
|
|
|
1675
1756
|
}
|
|
1676
1757
|
|
|
1677
1758
|
/** Runs a prompt. Returns false if the turn failed, having already said why. */
|
|
1678
|
-
async function run(app: App, prompt: string, signal?: AbortSignal, tui?:
|
|
1759
|
+
async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, images: readonly ImageRef[] = [], skill?: Skill): Promise<boolean> {
|
|
1679
1760
|
if (app.extensions && !initializedExtensions.has(app.extensions)) await bindExtensions(app, tui);
|
|
1680
1761
|
for (const result of await app.extensions?.emit("input", { text: prompt, images, source: "interactive" }) ?? []) {
|
|
1681
1762
|
if (result["action"] === "handled") return true;
|
|
@@ -1686,6 +1767,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
|
|
|
1686
1767
|
if (tui) tui.beginBash(prompt.slice(excluded ? 2 : 1));
|
|
1687
1768
|
const result = await app.session.runUserBash(prompt.slice(excluded ? 2 : 1), excluded, signal,
|
|
1688
1769
|
tui ? chunk => tui.pushBash(chunk) : undefined);
|
|
1770
|
+
tui?.observeEvent?.({ kind: "done", outcome: result.outcome });
|
|
1689
1771
|
if (tui) { await tui.syncTranscript(); tui.commitStream(); }
|
|
1690
1772
|
else app.io.say(`$ ${result.command}\n${result.content}`);
|
|
1691
1773
|
return true;
|
|
@@ -1696,7 +1778,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
|
|
|
1696
1778
|
const C = app.io.palette;
|
|
1697
1779
|
app.io.say(C.yellow(" no credential yet -- this prompt was not sent."));
|
|
1698
1780
|
app.io.say(C.dim(` ${app.credential.reason}`));
|
|
1699
|
-
app.io.say(C.dim(" export an API key, or /
|
|
1781
|
+
app.io.say(C.dim(" export an API key, or /provider <provider>.\n"));
|
|
1700
1782
|
tui?.restoreDraft(prompt, images);
|
|
1701
1783
|
return false;
|
|
1702
1784
|
}
|
|
@@ -1754,7 +1836,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
|
|
|
1754
1836
|
return false;
|
|
1755
1837
|
}
|
|
1756
1838
|
try {
|
|
1757
|
-
await runTurn(app, input, signal, tui);
|
|
1839
|
+
await runTurn(app, input, signal, tui, skill);
|
|
1758
1840
|
return true;
|
|
1759
1841
|
} catch (error) {
|
|
1760
1842
|
tui?.discardStream();
|
|
@@ -1768,28 +1850,14 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, im
|
|
|
1768
1850
|
}
|
|
1769
1851
|
}
|
|
1770
1852
|
|
|
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
|
-
}
|
|
1853
|
+
export type { TurnSurface };
|
|
1787
1854
|
|
|
1788
1855
|
export async function runTurn(
|
|
1789
1856
|
app: Pick<App, "session" | "io"> & { readonly ui?: App["ui"]; readonly args?: App["args"]; readonly extensions?: App["extensions"] },
|
|
1790
1857
|
prompt: UserInput,
|
|
1791
1858
|
signal?: AbortSignal,
|
|
1792
1859
|
tui?: TurnSurface,
|
|
1860
|
+
skill?: Skill,
|
|
1793
1861
|
): Promise<void> {
|
|
1794
1862
|
// A pipe cannot unwrite bytes. Non-tty / -p therefore does not stream:
|
|
1795
1863
|
// deltas and discard are ignored, and the final block prints once.
|
|
@@ -1805,7 +1873,8 @@ export async function runTurn(
|
|
|
1805
1873
|
}
|
|
1806
1874
|
await registry?.emit("agent_start");
|
|
1807
1875
|
await registry?.emit("turn_start", { turnIndex: 0 });
|
|
1808
|
-
try { for await (const e of app.session.send(prompt, signal, pull)) {
|
|
1876
|
+
try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : sessionControl(app.session).send(prompt, signal, pull)) {
|
|
1877
|
+
tui?.observeEvent?.(e);
|
|
1809
1878
|
if (e.kind === "text_delta" || e.kind === "thinking_delta") await registry?.emit("message_update", { assistantMessageEvent: { type: e.kind, delta: e.text } });
|
|
1810
1879
|
if (e.kind === "assistant_text") await registry?.emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: e.text }] } });
|
|
1811
1880
|
if (e.kind === "done") await registry?.emit("turn_end", { reason: e.reason });
|
|
@@ -1825,7 +1894,7 @@ export async function runTurn(
|
|
|
1825
1894
|
await tui.syncTranscript();
|
|
1826
1895
|
if (["assistant_text", "thinking", "tool_call", "tool_result", "done", "cut"].includes(e.kind)) tui.commitStream();
|
|
1827
1896
|
if (e.kind === "snapshot_failed" || e.kind === "undeclared_change" || e.kind === "stop_and_ask") {
|
|
1828
|
-
for (const line of formatEvent(e, app.io.palette,
|
|
1897
|
+
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
1898
|
}
|
|
1830
1899
|
}
|
|
1831
1900
|
continue;
|
|
@@ -1853,14 +1922,6 @@ export async function runTurn(
|
|
|
1853
1922
|
}
|
|
1854
1923
|
if (skipFinal && (e.kind === "assistant_text" || e.kind === "thinking")) continue;
|
|
1855
1924
|
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
1925
|
if (e.kind === "assistant_text" && app.io.answer !== undefined) {
|
|
1865
1926
|
app.io.answer(e.text ?? "");
|
|
1866
1927
|
continue;
|
|
@@ -1868,7 +1929,7 @@ export async function runTurn(
|
|
|
1868
1929
|
for (const line of formatEvent(
|
|
1869
1930
|
e,
|
|
1870
1931
|
app.io.palette,
|
|
1871
|
-
|
|
1932
|
+
Math.max(20, (process.stdout.columns ?? 80) - 1),
|
|
1872
1933
|
{},
|
|
1873
1934
|
app.ui?.style,
|
|
1874
1935
|
)) {
|
|
@@ -1880,1273 +1941,3 @@ export async function runTurn(
|
|
|
1880
1941
|
// ---------------------------------------------------------------------------
|
|
1881
1942
|
// TUI
|
|
1882
1943
|
// ---------------------------------------------------------------------------
|
|
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
|
-
}
|