@visual-z/xeno 0.1.4 → 0.2.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 +19 -0
- package/README.md +143 -33
- package/docs/install.md +18 -6
- package/docs/providers.md +147 -0
- package/package.json +8 -2
- package/src/apps/cli.ts +42 -14
- package/src/apps/commands.ts +32 -4
- package/src/apps/completions.ts +9 -6
- package/src/apps/export-cli.ts +24 -0
- package/src/apps/extension-ui.ts +330 -0
- package/src/apps/input.ts +16 -1
- package/src/apps/model-catalog.ts +42 -0
- package/src/apps/project-trust.ts +49 -0
- package/src/apps/render.ts +1 -0
- package/src/apps/resource-browser.ts +75 -0
- package/src/apps/resources.ts +25 -0
- package/src/apps/session-browser.ts +113 -0
- package/src/apps/session-picker.ts +55 -0
- package/src/apps/shell.ts +1177 -378
- package/src/apps/startup.ts +400 -268
- package/src/core/action-defaults.ts +306 -0
- package/src/core/attachment-input.ts +17 -0
- package/src/core/catalog-merge.ts +19 -0
- package/src/core/event-validation.ts +19 -2
- package/src/core/events.ts +45 -2
- package/src/core/export.ts +6 -2
- package/src/core/extension-flags.ts +14 -0
- package/src/core/images.ts +46 -0
- package/src/core/json-event.ts +2 -0
- package/src/core/keymap.ts +43 -119
- package/src/core/model-replay.ts +22 -0
- package/src/core/model-scope.ts +53 -0
- package/src/core/packages.ts +47 -0
- package/src/core/project.ts +21 -3
- package/src/core/prompts.ts +33 -5
- package/src/core/queue.ts +18 -0
- package/src/core/resource-selection.ts +36 -0
- package/src/{runtime → core}/resume.ts +15 -22
- package/src/core/session-archive.ts +4 -0
- package/src/core/session-browser.ts +82 -0
- package/src/core/session-entry.ts +56 -0
- package/src/core/session-position.ts +26 -0
- package/src/core/settings.ts +169 -154
- package/src/core/theme.ts +15 -6
- package/src/core/thinking.ts +26 -0
- package/src/core/tokens.ts +2 -0
- package/src/core/transcript.ts +15 -5
- package/src/runtime/auth.ts +116 -11
- package/src/runtime/catalog.ts +54 -151
- package/src/runtime/clipboard.ts +114 -0
- package/src/runtime/extension-loader.ts +37 -0
- package/src/runtime/extension-ui.ts +55 -0
- package/src/runtime/extensions.ts +273 -0
- package/src/runtime/images.ts +76 -0
- package/src/runtime/loop.ts +213 -83
- package/src/runtime/model-config.ts +232 -0
- package/src/runtime/model-data.LICENSE +27 -0
- package/src/runtime/model-data.ts +21 -0
- package/src/runtime/model-replay.ts +10 -0
- package/src/runtime/model-scope.ts +20 -0
- package/src/runtime/model-sources.ts +151 -0
- package/src/runtime/model.ts +74 -11
- package/src/runtime/models-fetch.ts +6 -104
- package/src/runtime/oauth.ts +4 -3
- package/src/runtime/openai-responses.ts +12 -4
- package/src/runtime/openai.ts +13 -4
- package/src/runtime/package-manager.ts +125 -0
- package/src/runtime/pi-provider.ts +235 -0
- package/src/runtime/progress.ts +26 -0
- package/src/runtime/provider-access.ts +21 -0
- package/src/runtime/provider-protocols.ts +29 -0
- package/src/runtime/provider-request.ts +73 -0
- package/src/runtime/provider.ts +26 -6
- package/src/runtime/rich-text.ts +36 -0
- package/src/runtime/tools.ts +41 -5
- package/src/store/command.ts +7 -1
- package/src/store/context-files.ts +1 -1
- package/src/store/log.ts +65 -0
- package/src/store/models.ts +28 -9
- package/src/store/observations.ts +4 -2
- package/src/store/packages.ts +98 -0
- package/src/store/recovery.ts +8 -0
- package/src/store/resource-index.ts +170 -0
- package/src/store/resources.ts +75 -142
- package/src/store/session-archive.ts +162 -0
- package/src/store/session-browser.ts +68 -0
- package/src/store/session-position.ts +27 -0
- package/src/store/sessions.ts +87 -66
- package/src/store/trust.ts +13 -0
- package/src/tui/chat-viewport.ts +47 -0
- package/src/tui/editor.ts +62 -15
- package/src/tui/host.ts +50 -9
- package/src/tui/input.ts +8 -2
- package/src/tui/select.ts +24 -6
- package/src/tui/settings.ts +4 -0
- package/src/tui/system-theme.ts +700 -0
- package/src/tui/transcript.ts +63 -11
- package/src/tui/tree.ts +161 -95
- package/src/vendor/pi-rich/LICENSE.upstream +21 -0
- package/src/vendor/pi-rich/UPSTREAM.json +17 -0
- package/src/vendor/pi-rich/clipboard-command.ts +44 -0
- package/src/vendor/pi-rich/exif-orientation.ts +182 -0
- package/src/vendor/pi-rich/html.ts +51 -0
- package/src/vendor/pi-rich/image-convert.ts +49 -0
- package/src/vendor/pi-rich/image-resize-core.ts +164 -0
- package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
- package/src/vendor/pi-rich/image-resize.ts +123 -0
- package/src/vendor/pi-rich/mime.ts +116 -0
- package/src/vendor/pi-rich/photon.ts +139 -0
- package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
- package/src/vendor/pi-tui/UPSTREAM.json +50 -0
- package/src/vendor/pi-tui/VENDOR.md +7 -5
- package/src/vendor/pi-tui/autocomplete.ts +35 -10
- package/src/vendor/pi-tui/colors.ts +367 -0
- package/src/vendor/pi-tui/components/box.ts +8 -9
- package/src/vendor/pi-tui/components/editor.ts +4 -2
- package/src/vendor/pi-tui/components/markdown.ts +8 -2
- package/src/vendor/pi-tui/index.ts +31 -1
- package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
- package/src/vendor/pi-tui/native/clipboard.h +208 -0
- package/src/vendor/pi-tui/native/darwin/README.md +22 -0
- package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
- 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 +132 -0
- package/src/vendor/pi-tui/native/linux/README.md +23 -0
- package/src/vendor/pi-tui/native/linux/build.sh +65 -0
- 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 +70 -0
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
- package/src/vendor/pi-tui/native/napi.h +91 -0
- package/src/vendor/pi-tui/native/win32/README.md +31 -0
- package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
- 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 +241 -0
- package/src/vendor/pi-tui/native-platform.ts +2 -0
- package/src/vendor/pi-tui/oklab.ts +233 -0
- package/src/vendor/pi-tui/terminal-colors.ts +26 -8
- package/src/vendor/pi-tui/terminal-image.ts +43 -8
- package/src/vendor/pi-tui/terminal.ts +16 -9
- package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
- package/src/vendor/pi-tui/tui.ts +112 -75
- package/src/vendor/pi-tui/utils.ts +89 -49
- package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
package/src/apps/shell.ts
CHANGED
|
@@ -6,11 +6,20 @@
|
|
|
6
6
|
* CI run never constructs it — `-p` output is parsed by scripts.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { basename, dirname, join } from "node:path";
|
|
9
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
10
11
|
import { homedir, tmpdir } from "node:os";
|
|
11
|
-
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
12
13
|
import { assertNever } from "../core/exhaustive.ts";
|
|
13
14
|
import type { LogEvent } from "../core/events.ts";
|
|
15
|
+
import { userInputData, type ImageRef, type UserInput } from "../core/images.ts";
|
|
16
|
+
import { droppedImagePaths } from "../core/attachment-input.ts";
|
|
17
|
+
import { attachImageFile, attachImageBytes, loadDisplayImage } from "../runtime/images.ts";
|
|
18
|
+
import { readClipboard, writeClipboard, type ClipboardContent } from "../runtime/clipboard.ts";
|
|
19
|
+
import type { ExtensionContext, ExtensionRegistry } from "../runtime/extensions.ts";
|
|
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";
|
|
14
23
|
import { projectTranscript, type TranscriptItem } from "../core/transcript.ts";
|
|
15
24
|
import { fileReferences } from "../core/file-references.ts";
|
|
16
25
|
import { readNodeHistory } from "../store/restore.ts";
|
|
@@ -22,6 +31,7 @@ import { anchoredCount, estimateTokens, type Anchor, type TokenCounter } from ".
|
|
|
22
31
|
import type { RestorePoint, RewindMode, LoopEvent } from "../runtime/loop.ts";
|
|
23
32
|
import { createProvider, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
|
|
24
33
|
import { outputTokenBudget, type Effort, type ModelSpec } from "../runtime/model.ts";
|
|
34
|
+
import { scopedModels } from "../runtime/model-scope.ts";
|
|
25
35
|
import { equivalentCost, reportedCost } from "../runtime/usage.ts";
|
|
26
36
|
import type { ProviderConfig } from "../runtime/provider.ts";
|
|
27
37
|
import { describeMissingCredential, envLabel, ensureFreshCredential, storedIsOAuth } from "../runtime/auth.ts";
|
|
@@ -34,40 +44,55 @@ import {
|
|
|
34
44
|
type CallbackServer, type OAuthCredential, type SubscriptionId,
|
|
35
45
|
} from "../runtime/oauth.ts";
|
|
36
46
|
import { writeAuthFile } from "../store/auth-store.ts";
|
|
37
|
-
import { authPath } from "../store/config.ts";
|
|
38
|
-
import {
|
|
47
|
+
import { authPath, configDir } from "../store/config.ts";
|
|
48
|
+
import { catalogCachePath, readModelSources, writeCatalogCache } from "../store/models.ts";
|
|
39
49
|
import {
|
|
40
50
|
buildFileIndex, indexTruncationNote, type FileIndex,
|
|
41
51
|
} from "../store/file-index.ts";
|
|
42
52
|
import { currentBranch } from "../store/git-branch.ts";
|
|
43
|
-
import {
|
|
53
|
+
import { projectTrust, trustPath } from "../store/trust.ts";
|
|
54
|
+
import { discoverResources, themeResourcePath } from "../store/resource-index.ts";
|
|
55
|
+
import { ResourceBrowser } from "./resource-browser.ts";
|
|
56
|
+
import { TrustSelector, applyTrustChoice, type TrustChoice } from "./project-trust.ts";
|
|
57
|
+
import { reloadAppResources } from "./resources.ts";
|
|
58
|
+
import { publishModelCatalog } from "./model-catalog.ts";
|
|
44
59
|
import type { PermissionRequest } from "../core/permissions.ts";
|
|
45
60
|
import type { ApprovalChoice } from "../runtime/tools.ts";
|
|
46
61
|
import { approvalHeading, approvalItems, choiceFromValue } from "./approve.ts";
|
|
47
|
-
import { readCursor, writeCursor, listNodes, readNode } from "../store/log.ts";
|
|
62
|
+
import { readCursor, writeCursor, listNodes, readNode, readManifest, setSessionLabel } from "../store/log.ts";
|
|
63
|
+
import { registerSessionRoot } from "../store/session-browser.ts";
|
|
48
64
|
import {
|
|
49
|
-
createSession, findSessionByPrefix, listSessions, truncationNote,
|
|
65
|
+
createSession, findSessionByPrefix, listSessions, truncationNote,
|
|
66
|
+
sessionsDir,
|
|
50
67
|
} from "../store/sessions.ts";
|
|
68
|
+
import { exportSessionArchive, importSessionArchive } from "../store/session-archive.ts";
|
|
51
69
|
import { Editor } from "../tui/editor.ts";
|
|
52
70
|
import type { Key } from "../tui/keys.ts";
|
|
53
|
-
import { editorBindings, encodeKey, keyMatches } from "../tui/input.ts";
|
|
71
|
+
import { editorBindings, installBindings, encodeKey, keyMatches } from "../tui/input.ts";
|
|
54
72
|
import { interactiveAutocomplete } from "./completions.ts";
|
|
55
|
-
import { Container, createInteractiveTui, Lines, PiText, MarkdownBlock, thinkingBorderColor } from "../tui/host.ts";
|
|
56
|
-
import type {
|
|
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";
|
|
57
77
|
import { Settings } from "../tui/settings.ts";
|
|
58
78
|
import { SelectList, type SelectItem } from "../tui/select.ts";
|
|
59
|
-
import { TreeList, renderTree, treeFromPoints, type TreeNode, type NodeParent } from "../tui/tree.ts";
|
|
79
|
+
import { TreeList, renderTree, treeFromPoints, type TreeNode, type NodeParent, type TreePoint } from "../tui/tree.ts";
|
|
60
80
|
import { renderStatus, type StatusState } from "../tui/statusline.ts";
|
|
61
|
-
import {
|
|
81
|
+
import { deliveryBatch, type Phase, type QueuedMessage, type QueueKind } from "../core/queue.ts";
|
|
62
82
|
import { cycle } from "../core/cycle.ts";
|
|
83
|
+
import { THINKING_LEVELS, asThinkingLevel as asEffort, supportedThinkingLevels, clampThinkingLevel } from "../core/thinking.ts";
|
|
84
|
+
import { changeModelScope, scopeReference, type ScopeChange } from "../core/model-scope.ts";
|
|
85
|
+
import { modelKey } from "../core/session-position.ts";
|
|
63
86
|
import { usableColumns, type Terminal } from "../tui/terminal.ts";
|
|
64
87
|
import { ANSI, IDENTITY, PLAIN_ATTRS, createStyle, paint, type Style } from "../tui/style.ts";
|
|
65
88
|
import { defaultKeymap, listBindings, type ActionId } from "../core/keymap.ts";
|
|
66
|
-
import { builtinTheme } from "../core/theme.ts";
|
|
67
|
-
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";
|
|
68
92
|
import { colorEnabled } from "./render.ts";
|
|
69
93
|
import { renderToolView } from "../tui/toolview.ts";
|
|
70
|
-
import { lastAssistantText, toHtml
|
|
94
|
+
import { lastAssistantText, toHtml } from "../core/export.ts";
|
|
95
|
+
import { visibleWidth } from "../vendor/pi-tui/utils.ts";
|
|
71
96
|
import { toJsonEvent } from "../core/json-event.ts";
|
|
72
97
|
import { COMMANDS, commandDefinition, parseCommand, type Command } from "./commands.ts";
|
|
73
98
|
import {
|
|
@@ -77,13 +102,86 @@ import {
|
|
|
77
102
|
describeError, firstLine, formatEvent, indent, stdinIsTty, stdoutIsTty, wrap,
|
|
78
103
|
} from "./render.ts";
|
|
79
104
|
import {
|
|
80
|
-
|
|
105
|
+
openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, visibleSchemas, type App,
|
|
81
106
|
} from "./startup.ts";
|
|
107
|
+
import { SessionBrowser } from "./session-browser.ts";
|
|
82
108
|
|
|
83
109
|
const HINT = `type a prompt, or ${COMMANDS.map((command) => `/${command.name}`).join(" ")}`;
|
|
84
110
|
const OAUTH_PASTE = "\0oauth";
|
|
111
|
+
const OAUTH_DEVICE = "\0oauth-device";
|
|
112
|
+
|
|
113
|
+
const initializedExtensions = new WeakSet<ExtensionRegistry>();
|
|
114
|
+
const extensionHistories = new WeakMap<App, LogEvent[]>();
|
|
115
|
+
function extensionContext(app: App, tui?: Tui): ExtensionContext {
|
|
116
|
+
const entries = () => structuredClone([...(extensionHistories.get(app) ?? []).filter(event => !app.session.state.events.some(current => current.id === event.id)), ...app.session.state.events]);
|
|
117
|
+
return {
|
|
118
|
+
ui: tui?.extensionUi.context ?? headlessUI((text, type) => app.io.say(type === "error" ? app.io.palette.red(text) : text)),
|
|
119
|
+
cwd: app.workspace, mode: tui ? "tui" : app.args.mode === "json" ? "json" : "print", hasUI: !!tui,
|
|
120
|
+
model: app.providerConfig.spec, thinkingLevel: app.providerConfig.effort, signal: tui?.signal,
|
|
121
|
+
isIdle: () => !app.session.running, isProjectTrusted: () => app.projectTrusted === true,
|
|
122
|
+
hasPendingMessages: () => !!tui?.queue.length,
|
|
123
|
+
abort: () => { tui?.abort?.abort(); app.session.cancel(); }, shutdown: () => { app.shutdownRequested = true; app.session.cancel(); tui?.requestShutdown(); },
|
|
124
|
+
getSystemPrompt: () => app.session.config.frozen.system,
|
|
125
|
+
getContextUsage: () => ({ tokens: app.session.state.anchor?.promptTokens ?? null, contextWindow: app.providerConfig.spec.contextWindow }),
|
|
126
|
+
waitForIdle: () => app.session.whenIdle(),
|
|
127
|
+
reload: async () => { if (app.session.running) throw new Error("Reload requires an idle session"); await reloadAppResources(app); await tui?.refreshResources(); },
|
|
128
|
+
newSession: async () => { await openNewSession(app, tui); await bindExtensions(app, tui); return { cancelled: false }; },
|
|
129
|
+
switchSession: async (path: string) => { const changed = await adoptLogDir(app, path); if (changed) { await bindExtensions(app, tui); await tui?.refreshResources(); } return { cancelled: !changed }; },
|
|
130
|
+
fork: async (id: string) => { const changed = await spawnForkedSession(app, id, "forked by extension", "before"); if (changed) await bindExtensions(app, tui); return { cancelled: !changed }; },
|
|
131
|
+
sessionManager: { getEntries: entries, getBranch: entries, getSessionId: () => basename(app.logDir), getSessionFile: () => app.logDir, getCwd: () => app.workspace },
|
|
132
|
+
modelRegistry: { getAll: () => [...app.catalog], getAvailable: () => app.catalog.filter(model => app.credentialOf(model).ok), find: (provider: string, id: string) => app.catalog.find(model => model.provider === provider && model.id === id) },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function bindExtensions(app: App, tui?: Tui): Promise<void> {
|
|
137
|
+
await tui?.extensionUi.bind(app.extensions, app.resources);
|
|
138
|
+
const registry = app.extensions; if (!registry) return;
|
|
139
|
+
const history = await readNodeHistory(app.logDir, app.session.state.node);
|
|
140
|
+
if (history.ok) extensionHistories.set(app, history.nodes.flatMap(node => node.events));
|
|
141
|
+
registry.bind({
|
|
142
|
+
context: () => extensionContext(app, tui),
|
|
143
|
+
append: async (entry, options) => {
|
|
144
|
+
if (entry.inContext && app.session.running) await app.session.whenIdle();
|
|
145
|
+
await app.session.appendExtension(entry); await tui?.syncTranscript();
|
|
146
|
+
if (options?.triggerTurn) { if (tui) tui.deliverExtension("Continue.", "followup"); else await run(app, "Continue."); }
|
|
147
|
+
},
|
|
148
|
+
user: async (text, options) => { if (tui) tui.deliverExtension(text, options?.deliverAs === "steer" ? "steering" : "followup"); else await run(app, text); },
|
|
149
|
+
setName: name => nameSession(app, name, tui),
|
|
150
|
+
setLabel: async (id, label) => { await setSessionLabel(app.logDir, id, label ?? "", app.session.clock.now()); },
|
|
151
|
+
getTools: () => visibleSchemas(app.args, registry).map(tool => ({ name: tool.name, description: tool.description, parameters: tool.input_schema })),
|
|
152
|
+
setTools: async names => {
|
|
153
|
+
if (app.session.running) throw new Error("Active tools can only change at an idle checkpoint");
|
|
154
|
+
const args = { ...app.args, tools: names, noTools: false };
|
|
155
|
+
const tools = visibleSchemas(args, registry);
|
|
156
|
+
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 }); app.args = args;
|
|
158
|
+
},
|
|
159
|
+
setModel: async model => {
|
|
160
|
+
if (typeof model !== "object" || !model || !("provider" in model) || !("id" in model)) return false;
|
|
161
|
+
await switchByPattern(app, String(model.provider) + "/" + String(model.id)); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
|
|
162
|
+
},
|
|
163
|
+
setThinking: async level => { await applyThinkingCommand(app, level, tui ?? null); },
|
|
164
|
+
exec: async (command, args, options) => {
|
|
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
|
+
},
|
|
174
|
+
});
|
|
175
|
+
if (!initializedExtensions.has(registry)) {
|
|
176
|
+
initializedExtensions.add(registry);
|
|
177
|
+
const start = async () => { await registry.emit("session_start"); await registry.settle(); };
|
|
178
|
+
if (tui) tui.runExtensionAction(start); else await start();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
85
181
|
|
|
86
182
|
function parseLine(app: App, text: string): Command {
|
|
183
|
+
const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text.trim());
|
|
184
|
+
if (match && app.extensions?.commands.has(match[1]!)) return { kind: "extension", name: match[1]!, args: match[2] ?? "" };
|
|
87
185
|
return parseCommand(text, app.slash);
|
|
88
186
|
}
|
|
89
187
|
|
|
@@ -216,23 +314,27 @@ async function fileIndexFor(app: App, refresh = false): Promise<FileIndex> {
|
|
|
216
314
|
}
|
|
217
315
|
|
|
218
316
|
export async function runPrompt(app: App, prompt: string): Promise<void> {
|
|
317
|
+
await bindExtensions(app);
|
|
318
|
+
if (app.shutdownRequested) { await app.extensions?.dispose(); return; }
|
|
219
319
|
const ok = await run(app, prompt);
|
|
320
|
+
await app.extensions?.dispose();
|
|
220
321
|
printUsage(app);
|
|
221
322
|
// One-shot mode is scriptable, so a failed turn has to be a failed exit.
|
|
222
323
|
if (!ok) process.exit(1);
|
|
223
324
|
}
|
|
224
325
|
|
|
225
|
-
export async function runRepl(app: App): Promise<void> {
|
|
326
|
+
export async function runRepl(app: App, releaseEarlyTty?: () => void): Promise<void> {
|
|
226
327
|
if (stdinIsTty() && stdoutIsTty()) {
|
|
227
|
-
await runTui(app);
|
|
328
|
+
await runTui(app, releaseEarlyTty);
|
|
228
329
|
} else {
|
|
229
330
|
await runLines(app);
|
|
230
331
|
}
|
|
231
332
|
}
|
|
232
333
|
|
|
233
334
|
async function runLines(app: App): Promise<void> {
|
|
335
|
+
await bindExtensions(app);
|
|
234
336
|
const C = app.io.palette;
|
|
235
|
-
app.io.say(C.dim(HINT));
|
|
337
|
+
if (app.ui?.settings.quietStartup !== true || app.args.verbose) app.io.say(C.dim(HINT));
|
|
236
338
|
/** Set by `/login <provider>`; the next line typed is the key, not a prompt. */
|
|
237
339
|
let awaitingKeyFor: string | null = null;
|
|
238
340
|
for await (const line of console) {
|
|
@@ -241,12 +343,12 @@ async function runLines(app: App): Promise<void> {
|
|
|
241
343
|
const provider = awaitingKeyFor;
|
|
242
344
|
awaitingKeyFor = null;
|
|
243
345
|
if (text.length === 0) {
|
|
244
|
-
if (provider === OAUTH_PASTE) await cancelPendingOAuth();
|
|
346
|
+
if (provider === OAUTH_PASTE) await cancelPendingOAuth(app);
|
|
245
347
|
app.io.say(C.dim(" cancelled\n"));
|
|
246
348
|
continue;
|
|
247
349
|
}
|
|
248
350
|
if (provider === OAUTH_PASTE) {
|
|
249
|
-
if (
|
|
351
|
+
if (!pendingLogins.has(app) || pendingLogins.get(app)?.done) {
|
|
250
352
|
const outcome = await execute(app, parseLine(app, text), null);
|
|
251
353
|
if (outcome === "quit") break;
|
|
252
354
|
if (typeof outcome === "object") awaitingKeyFor = outcome.login;
|
|
@@ -260,37 +362,69 @@ async function runLines(app: App): Promise<void> {
|
|
|
260
362
|
}
|
|
261
363
|
if (!text) continue;
|
|
262
364
|
const outcome = await execute(app, parseLine(app, text), null);
|
|
263
|
-
if (outcome === "quit") break;
|
|
365
|
+
if (outcome === "quit" || app.shutdownRequested) break;
|
|
264
366
|
if (typeof outcome === "object") awaitingKeyFor = outcome.login;
|
|
265
367
|
}
|
|
368
|
+
await cancelPendingOAuth(app);
|
|
369
|
+
await app.extensions?.dispose();
|
|
266
370
|
printUsage(app);
|
|
267
371
|
}
|
|
268
372
|
|
|
269
|
-
async function runTui(app: App): Promise<void> {
|
|
373
|
+
async function runTui(app: App, releaseEarlyTty?: () => void): Promise<void> {
|
|
270
374
|
const C = app.io.palette;
|
|
271
|
-
app.io.say(
|
|
375
|
+
if (!app.args.noSession) await registerSessionRoot({ path: dirname(app.logDir), workspace: app.workspace }).catch(error => app.io.say(String(error)));
|
|
376
|
+
if (app.ui?.settings.quietStartup !== true || app.args.verbose) app.io.say(C.dim(HINT));
|
|
272
377
|
const tui = new Tui(app);
|
|
273
378
|
const previousSay = app.io.say;
|
|
274
379
|
app.io.say = (line) => tui.writeLine(line);
|
|
275
380
|
await tui.syncTranscript();
|
|
381
|
+
// Transfer ownership synchronously: Pi must remember the shell's cooked mode, not startup's temporary raw mode.
|
|
382
|
+
releaseEarlyTty?.();
|
|
276
383
|
tui.attach();
|
|
384
|
+
await bindExtensions(app, tui);
|
|
277
385
|
app.askSlot.current = (request) => tui.ask(request);
|
|
278
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);
|
|
279
403
|
try {
|
|
280
404
|
while (true) {
|
|
281
405
|
const data = await tui.nextInput();
|
|
282
|
-
|
|
406
|
+
if (shuttingDown || tui.shutdownRequested) { await tui.settle(); break; }
|
|
407
|
+
const result = await tui.applyInput(data, true).catch((error: unknown) => {
|
|
283
408
|
tui.writeLine(app.io.palette.red(describeError(error)));
|
|
284
409
|
return "continue" as const;
|
|
285
410
|
});
|
|
286
411
|
if (result === "quit") { await tui.settle(); break; }
|
|
287
412
|
}
|
|
288
413
|
} finally {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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);
|
|
294
428
|
printUsage(app);
|
|
295
429
|
}
|
|
296
430
|
}
|
|
@@ -299,6 +433,14 @@ type Outcome = "continue" | "quit" | { login: string };
|
|
|
299
433
|
|
|
300
434
|
async function execute(app: App, command: Command, tui: Tui | null): Promise<Outcome> {
|
|
301
435
|
switch (command.kind) {
|
|
436
|
+
case "extension":
|
|
437
|
+
if (tui) tui.runExtensionCommand(command.name, command.args);
|
|
438
|
+
else { await bindExtensions(app); await app.extensions?.command(command.name, command.args); }
|
|
439
|
+
return "continue";
|
|
440
|
+
case "attachments":
|
|
441
|
+
if (tui) tui.openAttachments();
|
|
442
|
+
else app.io.say("Attachments are edited in the interactive prompt.");
|
|
443
|
+
return "continue";
|
|
302
444
|
case "quit":
|
|
303
445
|
return "quit";
|
|
304
446
|
case "usage":
|
|
@@ -315,6 +457,19 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
315
457
|
return "continue";
|
|
316
458
|
case "settings":
|
|
317
459
|
return applySettingsCommand(app, command.field, command.value, tui);
|
|
460
|
+
case "reload": {
|
|
461
|
+
const cut = await reloadAppResources(app);
|
|
462
|
+
if (tui) await tui.refreshResources(); else await bindExtensions(app);
|
|
463
|
+
app.io.say(app.io.palette.green(` Resources reloaded${cut ? " at a context checkpoint" : ""}`));
|
|
464
|
+
return "continue";
|
|
465
|
+
}
|
|
466
|
+
case "config":
|
|
467
|
+
if (tui) await tui.openResourceSelect();
|
|
468
|
+
else {
|
|
469
|
+
const resources = await discoverResources({ ...app.args, workspace: app.workspace, trusted: (await projectTrust(app.workspace, app.projectTrusted ?? app.args.approve)).trusted });
|
|
470
|
+
for (const entry of resources.entries) app.io.say(`${entry.enabled ? "[x]" : "[ ]"} ${entry.kind} ${entry.scope} ${entry.path}`);
|
|
471
|
+
}
|
|
472
|
+
return "continue";
|
|
318
473
|
case "session":
|
|
319
474
|
await printSession(app);
|
|
320
475
|
return "continue";
|
|
@@ -329,6 +484,10 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
329
484
|
}
|
|
330
485
|
printModels(app);
|
|
331
486
|
return "continue";
|
|
487
|
+
case "scoped-models":
|
|
488
|
+
if (tui) tui.openScopedModels();
|
|
489
|
+
else app.io.say((app.ui?.settings.scopedModels ?? app.catalog.filter(spec => app.credentialOf(spec).ok).map(modelKey)).join("\n"));
|
|
490
|
+
return "continue";
|
|
332
491
|
case "thinking":
|
|
333
492
|
return applyThinkingCommand(app, command.level, tui);
|
|
334
493
|
case "login": {
|
|
@@ -361,6 +520,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
361
520
|
return { login: command.provider };
|
|
362
521
|
}
|
|
363
522
|
case "logout": {
|
|
523
|
+
if (tui && command.provider === undefined) { tui.openLogoutSelect(); return "continue"; }
|
|
364
524
|
await logout(app, command.provider);
|
|
365
525
|
return "continue";
|
|
366
526
|
}
|
|
@@ -393,7 +553,7 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
393
553
|
return "continue";
|
|
394
554
|
}
|
|
395
555
|
if (tui) {
|
|
396
|
-
|
|
556
|
+
void tui.openResumeSelect();
|
|
397
557
|
return "continue";
|
|
398
558
|
}
|
|
399
559
|
await printSessionList(app);
|
|
@@ -404,20 +564,49 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
404
564
|
case "export":
|
|
405
565
|
await exportSession(app, command.file);
|
|
406
566
|
return "continue";
|
|
567
|
+
case "import": {
|
|
568
|
+
if (!command.file) { app.io.say(" Usage: /import <session.jsonl>"); return "continue"; }
|
|
569
|
+
const importing = async () => {
|
|
570
|
+
if (tui && !await tui.extensionUi.context.confirm("Import session", "Open the archive as a separate session? The current session stays saved.")) { app.io.say(" Import cancelled"); return; }
|
|
571
|
+
try {
|
|
572
|
+
const path = await importSessionArchive(resolve(app.workspace, command.file!), sessionsDir(app.workspace, sessionDirFor(app.args, app.workspace)), app.workspace);
|
|
573
|
+
if (await adoptLogDir(app, path)) { await bindExtensions(app, tui ?? undefined); await tui?.refreshResources(); await tui?.syncTranscript(); app.io.say(` Imported session: ${path}`); }
|
|
574
|
+
} catch (error) { app.io.say(app.io.palette.red(` Cannot import: ${describeError(error)}`)); }
|
|
575
|
+
};
|
|
576
|
+
if (tui) tui.runSessionTransition(importing); else await importing();
|
|
577
|
+
return "continue";
|
|
578
|
+
}
|
|
579
|
+
case "debug": {
|
|
580
|
+
const file = resolve(app.workspace, command.file ?? join(configDir(), "debug.log"));
|
|
581
|
+
const width = tui?.screen.terminal.columns ?? 80, height = tui?.screen.terminal.rows ?? 0;
|
|
582
|
+
const lines = tui?.screen.render(width) ?? [];
|
|
583
|
+
const messages = projectContext({ frozen: app.session.config.frozen, handoff: app.session.state.handoff, events: app.session.state.events });
|
|
584
|
+
const body = [`xeno diagnostics ${app.session.clock.now()}`, `Terminal: ${width}x${height}`, `Total lines: ${lines.length}`,
|
|
585
|
+
...lines.map((line, i) => `[${i}] (w=${visibleWidth(line)}) ${JSON.stringify(line)}`), "", "Agent messages (JSONL)", ...messages.messages.map(message => JSON.stringify(message)), ""].join("\n");
|
|
586
|
+
try { await mkdir(dirname(file), { recursive: true }); await writeFile(file, body, { mode: 0o600 }); app.io.say(` Debug log written: ${file}`); }
|
|
587
|
+
catch (error) { app.io.say(` Cannot write debug log: ${describeError(error)}`); }
|
|
588
|
+
return "continue";
|
|
589
|
+
}
|
|
590
|
+
case "changelog": {
|
|
591
|
+
const text = await readFile(new URL("../../CHANGELOG.md", import.meta.url), "utf8");
|
|
592
|
+
if (tui) tui.showDocument(text); else app.io.say(text);
|
|
593
|
+
return "continue";
|
|
594
|
+
}
|
|
407
595
|
case "copy":
|
|
408
|
-
await copyLastReply(app);
|
|
596
|
+
await copyLastReply(app, tui ?? undefined);
|
|
409
597
|
return "continue";
|
|
410
598
|
case "fork":
|
|
411
599
|
if (tui && command.arg === undefined) { await tui.openForkSelect(); return "continue"; }
|
|
412
|
-
await forkSession(app, command.arg);
|
|
413
|
-
await tui?.syncTranscript();
|
|
600
|
+
await forkSession(app, command.arg, tui ?? undefined);
|
|
414
601
|
return "continue";
|
|
415
602
|
case "clone":
|
|
416
603
|
await cloneSession(app);
|
|
417
604
|
await tui?.syncTranscript();
|
|
418
605
|
return "continue";
|
|
419
606
|
case "trust":
|
|
420
|
-
await trustProject(app, command.target);
|
|
607
|
+
if (command.target) await trustProject(app, command.target, tui ?? undefined);
|
|
608
|
+
else if (tui) await tui.openTrustSelect();
|
|
609
|
+
else app.io.say(" /trust once|here|parent|never controls project resources; tool permissions are separate.");
|
|
421
610
|
return "continue";
|
|
422
611
|
case "models-refresh":
|
|
423
612
|
await refreshModels(app);
|
|
@@ -437,24 +626,28 @@ async function execute(app: App, command: Command, tui: Tui | null): Promise<Out
|
|
|
437
626
|
async function handleLine(app: App, text: string, tui: Tui): Promise<Outcome> {
|
|
438
627
|
if (tui.awaitingKeyFor !== null) {
|
|
439
628
|
const provider = tui.awaitingKeyFor;
|
|
440
|
-
|
|
441
|
-
|
|
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
|
+
}
|
|
442
634
|
if (text.length === 0) {
|
|
443
|
-
if (provider === OAUTH_PASTE) await cancelPendingOAuth();
|
|
635
|
+
if (provider === OAUTH_PASTE) await cancelPendingOAuth(app);
|
|
636
|
+
tui.endCredentialInput();
|
|
444
637
|
app.io.say(app.io.palette.dim(" cancelled\n"));
|
|
445
638
|
return "continue";
|
|
446
639
|
}
|
|
447
640
|
if (provider === OAUTH_PASTE) {
|
|
448
|
-
|
|
641
|
+
tui.editor.applyExternal("");
|
|
642
|
+
tui.runExtensionAction(() => completeOAuthPaste(app, text));
|
|
449
643
|
return "continue";
|
|
450
644
|
}
|
|
451
|
-
await login(app, provider, text);
|
|
645
|
+
try { await login(app, provider, text); } finally { tui.endCredentialInput(); }
|
|
452
646
|
return "continue";
|
|
453
647
|
}
|
|
454
648
|
const outcome = await execute(app, parseLine(app, text), tui);
|
|
455
649
|
if (typeof outcome === "object") {
|
|
456
|
-
tui.
|
|
457
|
-
tui.editor.setSecret(true);
|
|
650
|
+
tui.beginCredentialInput(outcome.login);
|
|
458
651
|
}
|
|
459
652
|
return outcome;
|
|
460
653
|
}
|
|
@@ -573,71 +766,34 @@ async function commitSetting(app: App, field: string, value: string, tui: Tui |
|
|
|
573
766
|
app.io.say(C.yellow(" settings are not available in this session"));
|
|
574
767
|
return;
|
|
575
768
|
}
|
|
576
|
-
if (key === "theme") {
|
|
577
|
-
const theme = value === "dark" || value === "light" ? builtinTheme(value) : app.ui.theme;
|
|
578
|
-
if (value === "dark" || value === "light") {
|
|
579
|
-
app.ui.theme = theme;
|
|
580
|
-
app.ui.style = colorEnabled() ? createStyle(theme.colors) : IDENTITY;
|
|
581
|
-
} else {
|
|
582
|
-
app.io.say(C.yellow(" theme must be dark or light (a path is --theme / file)"));
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
app.ui.settings = { ...app.ui.settings, theme: value };
|
|
586
|
-
app.ui.userOverrides = { ...app.ui.userOverrides, theme: value };
|
|
587
|
-
await writeUserSettings({ theme: value });
|
|
588
|
-
app.io.say(C.dim(` theme ${value}`));
|
|
589
|
-
tui?.paint();
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
769
|
if (key === "thinking") {
|
|
593
770
|
const effort = asEffort(value);
|
|
594
|
-
if (effort
|
|
595
|
-
app.io.say(C.yellow(" thinking must be low|medium|high|xhigh|max"));
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
if (!applyThinking(app, effort, tui)) return;
|
|
599
|
-
app.ui.settings = { ...app.ui.settings, thinking: effort };
|
|
600
|
-
app.ui.userOverrides = { ...app.ui.userOverrides, thinking: effort };
|
|
601
|
-
await writeUserSettings({ thinking: effort });
|
|
602
|
-
return;
|
|
771
|
+
if (!effort || !await applyThinking(app, effort, tui)) return;
|
|
603
772
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
app.ui.
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
function asEffort(value: string): import("../runtime/model.ts").Effort | undefined {
|
|
622
|
-
if (value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") {
|
|
623
|
-
return value;
|
|
624
|
-
}
|
|
625
|
-
return undefined;
|
|
773
|
+
const parsed = settingPatch(key === "status" ? "statusBar" : field, value);
|
|
774
|
+
if (parsed.diagnostics.length) { app.io.say(C.yellow(parsed.diagnostics.join("\n"))); return; }
|
|
775
|
+
if (key === "theme") {
|
|
776
|
+
const diagnostics: string[] = [];
|
|
777
|
+
const theme = await resolveTheme(app.resources ? themeResourcePath(app.resources, value) ?? value : value, diagnostics);
|
|
778
|
+
if (diagnostics.length) { app.io.say(C.yellow(diagnostics.join("\n"))); return; }
|
|
779
|
+
app.ui.theme = theme;
|
|
780
|
+
app.ui.style = colorEnabled() ? createStyle(theme.colors) : IDENTITY;
|
|
781
|
+
}
|
|
782
|
+
await writeUserSettings(parsed.settings);
|
|
783
|
+
app.ui.settings = mergeSettings({ builtin: app.ui.settings, user: parsed.settings });
|
|
784
|
+
app.ui.userOverrides = mergeSettings({ builtin: app.ui.userOverrides, user: parsed.settings });
|
|
785
|
+
if (key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
|
|
786
|
+
app.io.say(C.dim(` ${field} ${value}`));
|
|
787
|
+
tui?.applyConfiguration();
|
|
788
|
+
if (key === "theme") tui?.followTheme();
|
|
789
|
+
tui?.paint();
|
|
626
790
|
}
|
|
627
791
|
|
|
628
|
-
const EFFORTS
|
|
792
|
+
const EFFORTS = THINKING_LEVELS;
|
|
629
793
|
|
|
630
|
-
/**
|
|
631
|
-
* null in thinkingLevelMap means the model does not have that level.
|
|
632
|
-
* An absent map or absent entry is passthrough, not a missing level.
|
|
633
|
-
* Never treat null as "nearest neighbour" — that silently changes a
|
|
634
|
-
* deliberate slower/dearer choice (feat-model-and-thinking).
|
|
635
|
-
*/
|
|
794
|
+
/** Explicit user choices are refused; automatic model changes use the pure clamp policy. */
|
|
636
795
|
function effortSupported(spec: ModelSpec, level: Effort): boolean {
|
|
637
|
-
|
|
638
|
-
if (map === undefined) return true;
|
|
639
|
-
if (!(level in map)) return true;
|
|
640
|
-
return map[level] !== null;
|
|
796
|
+
return supportedThinkingLevels(spec).includes(level);
|
|
641
797
|
}
|
|
642
798
|
|
|
643
799
|
/**
|
|
@@ -645,7 +801,7 @@ function effortSupported(spec: ModelSpec, level: Effort): boolean {
|
|
|
645
801
|
* must not cut: a cut drops the prefix cache, and thinking blocks stay
|
|
646
802
|
* valid because the model did not change.
|
|
647
803
|
*/
|
|
648
|
-
function applyThinking(app: App, level: Effort, tui: Tui | null): boolean {
|
|
804
|
+
async function applyThinking(app: App, level: Effort, tui: Tui | null): Promise<boolean> {
|
|
649
805
|
const C = app.io.palette;
|
|
650
806
|
if (!effortSupported(app.providerConfig.spec, level)) {
|
|
651
807
|
app.io.say(
|
|
@@ -659,6 +815,7 @@ function applyThinking(app: App, level: Effort, tui: Tui | null): boolean {
|
|
|
659
815
|
if (app.credential.ok) {
|
|
660
816
|
app.session.provider = makeProvider(app, app.providerConfig, app.credential.key);
|
|
661
817
|
}
|
|
818
|
+
await app.session.setThinking(level);
|
|
662
819
|
if (app.ui !== undefined) {
|
|
663
820
|
app.ui.settings = { ...app.ui.settings, thinking: level };
|
|
664
821
|
}
|
|
@@ -667,14 +824,14 @@ function applyThinking(app: App, level: Effort, tui: Tui | null): boolean {
|
|
|
667
824
|
return true;
|
|
668
825
|
}
|
|
669
826
|
|
|
670
|
-
function applyThinkingCommand(app: App, level: string | undefined, tui: Tui | null):
|
|
827
|
+
async function applyThinkingCommand(app: App, level: string | undefined, tui: Tui | null): Promise<Outcome> {
|
|
671
828
|
if (level !== undefined) {
|
|
672
829
|
const effort = asEffort(level);
|
|
673
830
|
if (effort === undefined) {
|
|
674
|
-
app.io.say(app.io.palette.yellow(" thinking must be
|
|
831
|
+
app.io.say(app.io.palette.yellow(" thinking must be " + THINKING_LEVELS.join("|")));
|
|
675
832
|
return "continue";
|
|
676
833
|
}
|
|
677
|
-
applyThinking(app, effort, tui);
|
|
834
|
+
await applyThinking(app, effort, tui);
|
|
678
835
|
return "continue";
|
|
679
836
|
}
|
|
680
837
|
if (tui) {
|
|
@@ -699,33 +856,32 @@ function printThinking(app: App): void {
|
|
|
699
856
|
app.io.say(C.dim("\n /thinking <level> to switch. Shift+Tab cycles supported levels.\n"));
|
|
700
857
|
}
|
|
701
858
|
|
|
702
|
-
function cycleThinking(app: App, tui: Tui | null): void {
|
|
859
|
+
async function cycleThinking(app: App, tui: Tui | null): Promise<void> {
|
|
703
860
|
const supported = EFFORTS.filter((level) => effortSupported(app.providerConfig.spec, level));
|
|
704
861
|
const next = cycle(supported, app.providerConfig.effort, 1);
|
|
705
862
|
if (next === undefined) return;
|
|
706
|
-
applyThinking(app, next, tui);
|
|
863
|
+
await applyThinking(app, next, tui);
|
|
707
864
|
}
|
|
708
865
|
|
|
709
866
|
async function cycleScopedModel(app: App, dir: 1 | -1): Promise<void> {
|
|
710
867
|
const C = app.io.palette;
|
|
711
868
|
const fromFlag = splitModelPatterns(app.args.models);
|
|
712
|
-
const names = fromFlag.length > 0 ? fromFlag :
|
|
869
|
+
const names = app.args.modelsExplicit || fromFlag.length > 0 ? fromFlag : app.ui?.settings.scopedModels ?? app.catalog.filter(spec => app.credentialOf(spec).ok).map(modelKey);
|
|
713
870
|
if (names.length === 0) {
|
|
714
871
|
app.io.say(C.dim(" no scoped models. Pass --models a,b or set scopedModels."));
|
|
715
872
|
return;
|
|
716
873
|
}
|
|
717
|
-
const
|
|
718
|
-
for (const
|
|
719
|
-
|
|
720
|
-
if (found.ok && !ids.includes(found.spec.id)) ids.push(found.spec.id);
|
|
721
|
-
}
|
|
874
|
+
const scope = scopedModels(names, app.catalog.filter(spec => app.credentialOf(spec).ok));
|
|
875
|
+
for (const message of scope.diagnostics) app.io.say(C.yellow(` ${message}`));
|
|
876
|
+
const ids = scope.entries.map(entry => modelKey(entry.model));
|
|
722
877
|
if (ids.length === 0) {
|
|
723
878
|
app.io.say(C.yellow(" --models matched nothing"));
|
|
724
879
|
return;
|
|
725
880
|
}
|
|
726
|
-
const next = cycle(ids, app.providerConfig.spec
|
|
727
|
-
if (next === undefined
|
|
728
|
-
|
|
881
|
+
const next = cycle(ids, modelKey(app.providerConfig.spec), dir);
|
|
882
|
+
if (next === undefined) return;
|
|
883
|
+
const selected = scope.entries.find(entry => modelKey(entry.model) === next)!;
|
|
884
|
+
await switchToModel(app, selected.model, selected.thinking);
|
|
729
885
|
}
|
|
730
886
|
|
|
731
887
|
function printHelp(app: App): void {
|
|
@@ -735,6 +891,7 @@ function printHelp(app: App): void {
|
|
|
735
891
|
const hint = "argumentHint" in command ? ` ${command.argumentHint}` : "";
|
|
736
892
|
app.io.say(` /${(command.name + hint).padEnd(34)} ${command.description}`);
|
|
737
893
|
}
|
|
894
|
+
for (const [name, command] of app.extensions?.commands ?? []) app.io.say(` /${name.padEnd(34)} ${command.description ?? "Extension command"}`);
|
|
738
895
|
app.io.say(' @file or @"file name" attach a project file anywhere in the prompt');
|
|
739
896
|
app.io.say(" !cmd run in the workspace and send stdout/stderr/exit to the model");
|
|
740
897
|
app.io.say(" !!cmd run without sending output");
|
|
@@ -756,7 +913,7 @@ async function printSession(app: App): Promise<void> {
|
|
|
756
913
|
|
|
757
914
|
async function printSessionList(app: App): Promise<void> {
|
|
758
915
|
const C = app.io.palette;
|
|
759
|
-
const { sessions, truncated } = await listSessions(app.workspace, sessionDirOf(app));
|
|
916
|
+
const { sessions, truncated } = await listSessions(app.workspace, sessionDirOf(app), Infinity);
|
|
760
917
|
const note = truncationNote(truncated);
|
|
761
918
|
app.io.say(C.bold("\nsessions"));
|
|
762
919
|
if (note) app.io.say(C.yellow(` ${note}`));
|
|
@@ -828,31 +985,29 @@ async function printTree(app: App): Promise<void> {
|
|
|
828
985
|
}
|
|
829
986
|
|
|
830
987
|
async function treeNodesFor(app: App): Promise<TreeNode[]> {
|
|
831
|
-
const points = await app.session.restorePoints();
|
|
832
988
|
const parents = await loadNodeParents(app.logDir);
|
|
833
|
-
const
|
|
989
|
+
const manifest = await readManifest(app.logDir);
|
|
990
|
+
const history = await readNodeHistory(app.logDir, app.session.state.node);
|
|
991
|
+
if (!history.ok) throw new Error(history.error);
|
|
992
|
+
const onPath = new Set(history.nodes.flatMap(n => n.events.map(e => `${n.node}:${e.id}`)));
|
|
993
|
+
const points: TreePoint[] = [];
|
|
834
994
|
for (const node of parents.keys()) {
|
|
835
|
-
const { events } = await readNode(app.logDir, node);
|
|
836
|
-
|
|
837
|
-
let turn = -1;
|
|
995
|
+
const { events, corrupt } = await readNode(app.logDir, node);
|
|
996
|
+
if (corrupt.length) throw new Error("Cannot display corrupt session events");
|
|
838
997
|
for (const event of events) {
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
onPath: point.onPath, current: false, kind: "other", children: [] }));
|
|
850
|
-
messages.set(target, children);
|
|
998
|
+
const content = event.t === "user" ? [event.text, ...(event.images ?? []).map(i => `[Image: ${i.name}]`)].filter(Boolean).join("\n") :
|
|
999
|
+
event.t === "assistant" ? event.blocks.map(b => b.type === "tool_use" ? `tool · ${b.name} ${JSON.stringify(b.input)}` : `${b.type === "thinking" ? "thinking · " : ""}${b.text}`).join("\n") :
|
|
1000
|
+
event.t === "tool_result" ? event.content : event.t === "user_bash" ? `$ ${event.command}\n${event.content}` :
|
|
1001
|
+
event.t === "cut" ? `context boundary · ${event.reason}` : JSON.stringify(event);
|
|
1002
|
+
const tool = event.t === "tool_result" || event.t === "user_bash" || event.t === "assistant" && event.blocks.every(b => b.type === "tool_use");
|
|
1003
|
+
const category = tool ? "tool" : event.t === "user" || event.t === "assistant" ? "message" : "metadata";
|
|
1004
|
+
const label = manifest.labels?.[event.id];
|
|
1005
|
+
points.push({ node, atEvent: event.id, target: event.id, prompt: `${event.t === "user" ? "" : event.t === "assistant" ? "assistant · " : `${event.t} · `}${firstLine(content, 160)}`,
|
|
1006
|
+
onPath: onPath.has(`${node}:${event.id}`), kind: event.t === "user" ? "user" : "other", category, content, at: event.at,
|
|
1007
|
+
...(label ? { label: label.text, labelAt: label.at } : {}) });
|
|
851
1008
|
}
|
|
852
1009
|
}
|
|
853
|
-
|
|
854
|
-
children: [...(messages.get(node.id) ?? []), ...enrich(node.children)] }));
|
|
855
|
-
return enrich(treeFromPoints(points, parents, app.session.state.node));
|
|
1010
|
+
return treeFromPoints(points, parents, app.session.state.node);
|
|
856
1011
|
}
|
|
857
1012
|
|
|
858
1013
|
async function loadNodeParents(logDir: string): Promise<Map<number, NodeParent>> {
|
|
@@ -963,7 +1118,7 @@ function printModels(app: App): void {
|
|
|
963
1118
|
app.io.say(C.bold(` ${group.provider}`));
|
|
964
1119
|
for (const spec of group.models) {
|
|
965
1120
|
const got = app.credentialOf(spec);
|
|
966
|
-
const here = spec
|
|
1121
|
+
const here = modelKey(spec) === modelKey(app.providerConfig.spec);
|
|
967
1122
|
const mark = here ? C.cyan(" now ") : got.ok ? C.green("ready") : C.dim(" -- ");
|
|
968
1123
|
app.io.say(` ${mark} ${spec.id.padEnd(28)}${C.dim(spec.name.padEnd(28))}${whyNot(spec, got, app.auth, C)}`);
|
|
969
1124
|
}
|
|
@@ -976,19 +1131,18 @@ async function refreshModels(app: App): Promise<void> {
|
|
|
976
1131
|
const C = app.io.palette;
|
|
977
1132
|
const result = await runModelsRefresh({
|
|
978
1133
|
fetch: globalThis.fetch,
|
|
979
|
-
write:
|
|
1134
|
+
write: writeCatalogCache,
|
|
980
1135
|
});
|
|
981
1136
|
if (!result.ok) {
|
|
982
1137
|
app.io.say(C.yellow(` ${result.reason}`));
|
|
983
1138
|
app.io.say(C.dim(" existing models.json left unchanged\n"));
|
|
984
1139
|
return;
|
|
985
1140
|
}
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
app.catalog = loaded.models;
|
|
1141
|
+
const loaded = loadCatalog(await readModelSources());
|
|
1142
|
+
await publishModelCatalog(app, loaded.models);
|
|
989
1143
|
for (const warning of loaded.warnings) app.io.say(C.yellow(` ${warning}`));
|
|
990
1144
|
app.io.say(
|
|
991
|
-
C.green(` added ${result.added}, changed ${result.changed} (${result.count} total) → ${
|
|
1145
|
+
C.green(` added ${result.added}, changed ${result.changed} (${result.count} total) → ${catalogCachePath()}`),
|
|
992
1146
|
);
|
|
993
1147
|
app.io.say(C.dim(` ${app.catalog.length} reachable after overlay\n`));
|
|
994
1148
|
}
|
|
@@ -1001,8 +1155,9 @@ async function login(app: App, provider: string, key: string): Promise<void> {
|
|
|
1001
1155
|
app.io.say(C.dim(` known: ${[...new Set(app.catalog.map((m) => m.provider))].join(", ")}\n`));
|
|
1002
1156
|
return;
|
|
1003
1157
|
}
|
|
1004
|
-
app.auth[provider]
|
|
1005
|
-
await writeAuthFile(
|
|
1158
|
+
const next = { ...app.auth, [provider]: { type: "api_key" as const, key } };
|
|
1159
|
+
await writeAuthFile(next);
|
|
1160
|
+
app.auth[provider] = next[provider]!;
|
|
1006
1161
|
// The editor masked the paste. Printing `key` here would be the only
|
|
1007
1162
|
// remaining way it reaches scrollback.
|
|
1008
1163
|
app.io.say(C.green(` stored ${provider} in ${authPath()}`) + C.dim(" (mode 0600)"));
|
|
@@ -1033,8 +1188,13 @@ async function logout(app: App, provider: string | undefined): Promise<void> {
|
|
|
1033
1188
|
app.io.say(C.dim(` no stored credential for ${provider}\n`));
|
|
1034
1189
|
return;
|
|
1035
1190
|
}
|
|
1191
|
+
const next = { ...app.auth };
|
|
1192
|
+
delete next[provider];
|
|
1193
|
+
await writeAuthFile(next);
|
|
1036
1194
|
delete app.auth[provider];
|
|
1037
|
-
|
|
1195
|
+
const resolved = app.credentialOf(app.providerConfig.spec);
|
|
1196
|
+
app.credential = resolved;
|
|
1197
|
+
app.session.provider = makeProvider(app, app.providerConfig, resolved.ok ? resolved.key : "");
|
|
1038
1198
|
app.io.say(C.green(` removed ${provider} from ${authPath()}\n`));
|
|
1039
1199
|
}
|
|
1040
1200
|
|
|
@@ -1044,7 +1204,7 @@ function providerOAuthOpts(app: App, spec: ModelSpec): { oauth: true } | undefin
|
|
|
1044
1204
|
}
|
|
1045
1205
|
|
|
1046
1206
|
function makeProvider(app: App, config: ProviderConfig, key: string) {
|
|
1047
|
-
return createProvider(config, key, retryHandler(app.io), providerOAuthOpts(app, config.spec));
|
|
1207
|
+
return createProvider(config, key, retryHandler(app.io), { ...providerOAuthOpts(app, config.spec), effects: app.effects });
|
|
1048
1208
|
}
|
|
1049
1209
|
|
|
1050
1210
|
interface PendingOAuth {
|
|
@@ -1057,22 +1217,28 @@ interface PendingOAuth {
|
|
|
1057
1217
|
server: CallbackServer;
|
|
1058
1218
|
done: boolean;
|
|
1059
1219
|
tui: Tui | null;
|
|
1220
|
+
abort: AbortController;
|
|
1060
1221
|
}
|
|
1061
1222
|
|
|
1062
|
-
|
|
1223
|
+
// Authentication is scoped to its application, independent of the model/session log.
|
|
1224
|
+
const pendingLogins = new WeakMap<App, PendingOAuth>();
|
|
1225
|
+
const pendingDevices = new WeakMap<App, { abort: AbortController; tui: Tui | null }>();
|
|
1226
|
+
const loginEffects = { fetch: ((input, init) => globalThis.fetch(input, init)) as typeof globalThis.fetch,
|
|
1227
|
+
openBrowser: tryOpenBrowser, callback: startCallbackServer, device: loginGitHubCopilot };
|
|
1063
1228
|
|
|
1064
|
-
async function cancelPendingOAuth(): Promise<void> {
|
|
1065
|
-
const pending =
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1229
|
+
async function cancelPendingOAuth(app: App): Promise<void> {
|
|
1230
|
+
const pending = pendingLogins.get(app), device = pendingDevices.get(app);
|
|
1231
|
+
pendingLogins.delete(app); pendingDevices.delete(app);
|
|
1232
|
+
device?.abort.abort(); pending?.abort.abort();
|
|
1233
|
+
const tui = pending?.tui ?? device?.tui;
|
|
1234
|
+
tui?.endCredentialInput();
|
|
1235
|
+
if (pending) { pending.done = true; await pending.server.close(); }
|
|
1070
1236
|
}
|
|
1071
1237
|
|
|
1072
1238
|
async function storeOAuth(app: App, provider: string, credential: OAuthCredential): Promise<void> {
|
|
1073
1239
|
const C = app.io.palette;
|
|
1240
|
+
await writeAuthFile({ ...app.auth, [provider]: credential });
|
|
1074
1241
|
app.auth[provider] = credential;
|
|
1075
|
-
await writeAuthFile(app.auth);
|
|
1076
1242
|
app.io.say(C.green(` stored ${provider} in ${authPath()}`) + C.dim(" (mode 0600)"));
|
|
1077
1243
|
const ready = app.catalog.filter((m) => m.provider === provider && app.credentialOf(m).ok);
|
|
1078
1244
|
if (ready.length === 0) {
|
|
@@ -1087,11 +1253,7 @@ async function storeOAuth(app: App, provider: string, credential: OAuthCredentia
|
|
|
1087
1253
|
async function finishOAuthCode(app: App, pending: PendingOAuth, code: string): Promise<void> {
|
|
1088
1254
|
if (pending.done) return;
|
|
1089
1255
|
pending.done = true;
|
|
1090
|
-
|
|
1091
|
-
if (pending.tui) {
|
|
1092
|
-
pending.tui.awaitingKeyFor = null;
|
|
1093
|
-
pending.tui.editor.setSecret(false);
|
|
1094
|
-
}
|
|
1256
|
+
const effects = pending.tui?.oauthEffects ?? loginEffects;
|
|
1095
1257
|
try {
|
|
1096
1258
|
const credential =
|
|
1097
1259
|
pending.exchange === "anthropic"
|
|
@@ -1100,36 +1262,44 @@ async function finishOAuthCode(app: App, pending: PendingOAuth, code: string): P
|
|
|
1100
1262
|
state: pending.state,
|
|
1101
1263
|
verifier: pending.verifier,
|
|
1102
1264
|
redirectUri: pending.redirectUri,
|
|
1103
|
-
fetch:
|
|
1265
|
+
fetch: effects.fetch,
|
|
1266
|
+
signal: pending.abort.signal,
|
|
1104
1267
|
})
|
|
1105
1268
|
: await exchangeOpenAICode({
|
|
1106
1269
|
code,
|
|
1107
1270
|
verifier: pending.verifier,
|
|
1108
1271
|
redirectUri: pending.redirectUri,
|
|
1109
|
-
fetch:
|
|
1272
|
+
fetch: effects.fetch,
|
|
1273
|
+
signal: pending.abort.signal,
|
|
1110
1274
|
});
|
|
1275
|
+
if (pending.abort.signal.aborted || pendingLogins.get(app) !== pending) return;
|
|
1111
1276
|
await storeOAuth(app, pending.provider, credential);
|
|
1112
1277
|
} catch (err) {
|
|
1278
|
+
if (pending.abort.signal.aborted || pendingLogins.get(app) !== pending) return;
|
|
1113
1279
|
const message = err instanceof Error ? err.message : "token exchange failed";
|
|
1114
1280
|
app.io.say(app.io.palette.red(` ${message}`));
|
|
1115
1281
|
app.io.say(app.io.palette.dim(" nothing was stored.\n"));
|
|
1116
1282
|
} finally {
|
|
1283
|
+
if (pendingLogins.get(app) === pending) {
|
|
1284
|
+
pendingLogins.delete(app);
|
|
1285
|
+
pending.tui?.endCredentialInput();
|
|
1286
|
+
}
|
|
1117
1287
|
await pending.server.close();
|
|
1118
1288
|
}
|
|
1119
1289
|
}
|
|
1120
1290
|
|
|
1121
1291
|
async function completeOAuthPaste(app: App, input: string): Promise<void> {
|
|
1122
|
-
const pending =
|
|
1123
|
-
if (pending
|
|
1292
|
+
const pending = pendingLogins.get(app);
|
|
1293
|
+
if (!pending || pending.done) return;
|
|
1124
1294
|
const parsed = parseAuthorizationInput(input);
|
|
1125
1295
|
if (parsed.state !== undefined && parsed.state !== pending.state) {
|
|
1126
1296
|
app.io.say(app.io.palette.red(" OAuth state mismatch"));
|
|
1127
|
-
await cancelPendingOAuth();
|
|
1297
|
+
await cancelPendingOAuth(app);
|
|
1128
1298
|
return;
|
|
1129
1299
|
}
|
|
1130
1300
|
if (!parsed.code) {
|
|
1131
1301
|
app.io.say(app.io.palette.red(" Missing authorization code"));
|
|
1132
|
-
await cancelPendingOAuth();
|
|
1302
|
+
await cancelPendingOAuth(app);
|
|
1133
1303
|
return;
|
|
1134
1304
|
}
|
|
1135
1305
|
await finishOAuthCode(app, pending, parsed.code);
|
|
@@ -1147,23 +1317,35 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
|
|
|
1147
1317
|
const C = app.io.palette;
|
|
1148
1318
|
const sub = subscriptionById(id);
|
|
1149
1319
|
if (sub === undefined) return "continue";
|
|
1320
|
+
await cancelPendingOAuth(app);
|
|
1321
|
+
const effects = tui?.oauthEffects ?? loginEffects;
|
|
1150
1322
|
app.io.say(C.yellow(` ${SUBSCRIPTION_NOTICE}`));
|
|
1151
1323
|
if (sub.id === "github-copilot") {
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1324
|
+
const pending = { abort: new AbortController(), tui };
|
|
1325
|
+
pendingDevices.set(app, pending);
|
|
1326
|
+
const work = async () => { try {
|
|
1327
|
+
const credential = await effects.device({
|
|
1328
|
+
notify: (message) => { if (pendingDevices.get(app) === pending) app.io.say(` ${message}`); },
|
|
1155
1329
|
prompt: async () => "",
|
|
1156
|
-
fetch:
|
|
1157
|
-
signal:
|
|
1330
|
+
fetch: effects.fetch,
|
|
1331
|
+
signal: pending.abort.signal,
|
|
1158
1332
|
});
|
|
1333
|
+
if (pending.abort.signal.aborted || pendingDevices.get(app) !== pending) return;
|
|
1159
1334
|
await storeOAuth(app, sub.provider, credential);
|
|
1160
1335
|
} catch (err) {
|
|
1336
|
+
if (pending.abort.signal.aborted || pendingDevices.get(app) !== pending) return;
|
|
1161
1337
|
const message = err instanceof Error ? err.message : "login failed";
|
|
1162
1338
|
app.io.say(C.red(` ${message}\n`));
|
|
1163
|
-
}
|
|
1339
|
+
} finally {
|
|
1340
|
+
if (pendingDevices.get(app) === pending) {
|
|
1341
|
+
pendingDevices.delete(app);
|
|
1342
|
+
tui?.endCredentialInput();
|
|
1343
|
+
}
|
|
1344
|
+
} };
|
|
1345
|
+
if (tui) { tui.beginCredentialInput(OAUTH_DEVICE); tui.runExtensionAction(work); return "continue"; }
|
|
1346
|
+
await work();
|
|
1164
1347
|
return "continue";
|
|
1165
1348
|
}
|
|
1166
|
-
await cancelPendingOAuth();
|
|
1167
1349
|
const pkce = await generatePKCE();
|
|
1168
1350
|
const state = generateState();
|
|
1169
1351
|
const anthropic = sub.id === "claude-pro";
|
|
@@ -1172,7 +1354,7 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
|
|
|
1172
1354
|
: openaiCodexAuthorizeUrl({ challenge: pkce.challenge, state });
|
|
1173
1355
|
let server: CallbackServer;
|
|
1174
1356
|
try {
|
|
1175
|
-
server = await
|
|
1357
|
+
server = await effects.callback({
|
|
1176
1358
|
expectedState: state,
|
|
1177
1359
|
host: "127.0.0.1",
|
|
1178
1360
|
port: anthropic ? ANTHROPIC_CALLBACK_PORT : OPENAI_CALLBACK_PORT,
|
|
@@ -1192,7 +1374,7 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
|
|
|
1192
1374
|
}
|
|
1193
1375
|
app.io.say(` ${url}`);
|
|
1194
1376
|
app.io.say(C.dim(" complete login in the browser, or paste the redirect URL. It is not shown."));
|
|
1195
|
-
|
|
1377
|
+
effects.openBrowser(url);
|
|
1196
1378
|
const pending: PendingOAuth = {
|
|
1197
1379
|
id: sub.id,
|
|
1198
1380
|
provider: sub.provider,
|
|
@@ -1203,8 +1385,9 @@ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Pr
|
|
|
1203
1385
|
server,
|
|
1204
1386
|
done: false,
|
|
1205
1387
|
tui,
|
|
1388
|
+
abort: new AbortController(),
|
|
1206
1389
|
};
|
|
1207
|
-
|
|
1390
|
+
pendingLogins.set(app, pending);
|
|
1208
1391
|
void pending.server.waitForCode().then(async (got) => {
|
|
1209
1392
|
if (!got.ok || pending.done) return;
|
|
1210
1393
|
await finishOAuthCode(app, pending, got.code);
|
|
@@ -1232,14 +1415,14 @@ async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<v
|
|
|
1232
1415
|
if (spec === undefined) return;
|
|
1233
1416
|
const got = app.credentialOf(spec);
|
|
1234
1417
|
if (!got.ok) return;
|
|
1235
|
-
if (app.credential.ok) {
|
|
1418
|
+
if (app.credential.ok || app.session.state.handoff !== null || app.session.state.events.some((e) => e.t === "assistant")) {
|
|
1236
1419
|
await switchToModel(app, spec);
|
|
1237
1420
|
return;
|
|
1238
1421
|
}
|
|
1239
1422
|
const nextConfig: ProviderConfig = {
|
|
1240
1423
|
spec,
|
|
1241
1424
|
maxTokens: outputTokenBudget(spec),
|
|
1242
|
-
effort: app.providerConfig.effort,
|
|
1425
|
+
effort: clampThinkingLevel(spec, app.ui?.settings.modelThinkingLevels?.[modelKey(spec)] ?? app.providerConfig.effort),
|
|
1243
1426
|
thinkingDisplay: app.providerConfig.thinkingDisplay,
|
|
1244
1427
|
};
|
|
1245
1428
|
app.session.provider = makeProvider(app, nextConfig, got.key);
|
|
@@ -1250,6 +1433,8 @@ async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<v
|
|
|
1250
1433
|
await writeCursor(app.logDir, {
|
|
1251
1434
|
...cursor,
|
|
1252
1435
|
model: spec.id,
|
|
1436
|
+
provider: spec.provider,
|
|
1437
|
+
thinking: nextConfig.effort,
|
|
1253
1438
|
updated_at: app.session.clock.now(),
|
|
1254
1439
|
});
|
|
1255
1440
|
}
|
|
@@ -1258,17 +1443,19 @@ async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<v
|
|
|
1258
1443
|
|
|
1259
1444
|
async function switchByPattern(app: App, pattern: string): Promise<void> {
|
|
1260
1445
|
const C = app.io.palette;
|
|
1261
|
-
const found =
|
|
1262
|
-
if (!
|
|
1263
|
-
app.
|
|
1446
|
+
const found = scopedModels([pattern], app.catalog, true), selected = found.entries[0];
|
|
1447
|
+
if (!selected) {
|
|
1448
|
+
const diagnostic = findModelByPattern(pattern, app.catalog);
|
|
1449
|
+
app.io.say(C.red(` ${diagnostic.ok ? found.diagnostics.join("; ") : diagnostic.reason}`));
|
|
1264
1450
|
return;
|
|
1265
1451
|
}
|
|
1266
|
-
await switchToModel(app,
|
|
1452
|
+
await switchToModel(app, selected.model, selected.thinking);
|
|
1267
1453
|
}
|
|
1268
1454
|
|
|
1269
|
-
async function switchToModel(app: App, spec: ModelSpec): Promise<void> {
|
|
1455
|
+
async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort): Promise<void> {
|
|
1270
1456
|
const C = app.io.palette;
|
|
1271
|
-
if (spec.id === app.providerConfig.spec.id) {
|
|
1457
|
+
if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
|
|
1458
|
+
if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null);
|
|
1272
1459
|
app.io.say(C.dim(` already on ${spec.id}`));
|
|
1273
1460
|
return;
|
|
1274
1461
|
}
|
|
@@ -1283,7 +1470,7 @@ async function switchToModel(app: App, spec: ModelSpec): Promise<void> {
|
|
|
1283
1470
|
const nextConfig: ProviderConfig = {
|
|
1284
1471
|
spec,
|
|
1285
1472
|
maxTokens: outputTokenBudget(spec),
|
|
1286
|
-
effort: app.providerConfig.effort,
|
|
1473
|
+
effort: clampThinkingLevel(spec, thinking ?? app.ui?.settings.modelThinkingLevels?.[modelKey(spec)] ?? app.providerConfig.effort),
|
|
1287
1474
|
thinkingDisplay: app.providerConfig.thinkingDisplay,
|
|
1288
1475
|
};
|
|
1289
1476
|
const next = makeProvider(app, nextConfig, got.key);
|
|
@@ -1301,7 +1488,7 @@ async function switchToModel(app: App, spec: ModelSpec): Promise<void> {
|
|
|
1301
1488
|
}
|
|
1302
1489
|
|
|
1303
1490
|
function sessionDirOf(app: App): string | undefined {
|
|
1304
|
-
return (app.args
|
|
1491
|
+
return sessionDirFor(app.args, app.workspace);
|
|
1305
1492
|
}
|
|
1306
1493
|
|
|
1307
1494
|
async function openNewSession(app: App, tui?: Tui): Promise<void> {
|
|
@@ -1337,26 +1524,26 @@ async function resumeSession(app: App, idOrPath: string, tui?: Tui): Promise<voi
|
|
|
1337
1524
|
}
|
|
1338
1525
|
|
|
1339
1526
|
async function adoptLogDir(app: App, logDir: string): Promise<boolean> {
|
|
1340
|
-
const next = createSessionFor({
|
|
1341
|
-
args: app.args,
|
|
1342
|
-
workspace: app.workspace,
|
|
1343
|
-
logDir,
|
|
1344
|
-
blobDir: app.blobDir,
|
|
1345
|
-
system: app.system,
|
|
1346
|
-
providerConfig: app.providerConfig,
|
|
1347
|
-
credential: app.credential,
|
|
1348
|
-
io: app.io,
|
|
1349
|
-
askSlot: app.askSlot,
|
|
1350
|
-
...(providerOAuthOpts(app, app.providerConfig.spec) ? { oauth: true } : {}),
|
|
1351
|
-
});
|
|
1352
1527
|
try {
|
|
1353
|
-
await
|
|
1528
|
+
const opened = await openSessionFor({ ...app, logDir, fallback: app.providerConfig, interactive: true });
|
|
1529
|
+
await app.extensions?.dispose();
|
|
1530
|
+
app.session = opened.session;
|
|
1531
|
+
app.providerConfig = opened.providerConfig;
|
|
1532
|
+
app.credential = opened.credential;
|
|
1533
|
+
app.logDir = logDir;
|
|
1534
|
+
app.workspace = opened.workspace;
|
|
1535
|
+
app.blobDir = opened.blobDir;
|
|
1536
|
+
app.system = opened.system;
|
|
1537
|
+
app.loadedContext = opened.resources.loadedContext;
|
|
1538
|
+
app.slash = opened.resources.slash;
|
|
1539
|
+
app.ui = opened.resources.ui;
|
|
1540
|
+
app.resources = opened.resources.index;
|
|
1541
|
+
app.projectTrusted = opened.resources.trusted;
|
|
1542
|
+
app.extensions = opened.resources.extensions;
|
|
1354
1543
|
} catch (err) {
|
|
1355
1544
|
app.io.say(app.io.palette.red(` ${describeError(err)}`));
|
|
1356
1545
|
return false;
|
|
1357
1546
|
}
|
|
1358
|
-
app.session = next;
|
|
1359
|
-
app.logDir = logDir;
|
|
1360
1547
|
return true;
|
|
1361
1548
|
}
|
|
1362
1549
|
|
|
@@ -1373,15 +1560,13 @@ async function exportSession(app: App, file?: string): Promise<void> {
|
|
|
1373
1560
|
const dest = file !== undefined && file.length > 0 ? file : defaultPath;
|
|
1374
1561
|
const abs = dest.startsWith("/") ? dest : join(app.workspace, dest);
|
|
1375
1562
|
const html = abs.toLowerCase().endsWith(".jsonl") === false;
|
|
1376
|
-
|
|
1377
|
-
|
|
1563
|
+
try {
|
|
1564
|
+
if (html) await writeFile(abs, toHtml(events, {
|
|
1378
1565
|
title: basename(app.logDir),
|
|
1379
1566
|
exportedAt: app.session.clock.now(),
|
|
1380
1567
|
sessionId: basename(app.logDir),
|
|
1381
|
-
})
|
|
1382
|
-
|
|
1383
|
-
try {
|
|
1384
|
-
await writeFile(abs, body, "utf8");
|
|
1568
|
+
}), "utf8");
|
|
1569
|
+
else await exportSessionArchive(app.logDir, abs, app.blobDir);
|
|
1385
1570
|
} catch (err) {
|
|
1386
1571
|
app.io.say(C.red(` cannot write ${abs}: ${describeError(err)}`));
|
|
1387
1572
|
return;
|
|
@@ -1389,51 +1574,25 @@ async function exportSession(app: App, file?: string): Promise<void> {
|
|
|
1389
1574
|
app.io.say(C.green(` exported ${abs}`));
|
|
1390
1575
|
}
|
|
1391
1576
|
|
|
1392
|
-
async function copyLastReply(app: App): Promise<void> {
|
|
1577
|
+
async function copyLastReply(app: App, tui?: Tui): Promise<void> {
|
|
1393
1578
|
const C = app.io.palette;
|
|
1394
1579
|
const text = lastAssistantText(await transcriptEvents(app));
|
|
1395
1580
|
if (text === null) {
|
|
1396
1581
|
app.io.say(C.dim(" no assistant reply to copy yet"));
|
|
1397
1582
|
return;
|
|
1398
1583
|
}
|
|
1399
|
-
const copied =
|
|
1584
|
+
const copied = await (tui?.clipboardWriter ?? writeClipboard)(text);
|
|
1400
1585
|
if (copied) {
|
|
1401
1586
|
app.io.say(C.green(" copied last assistant reply"));
|
|
1402
1587
|
return;
|
|
1403
1588
|
}
|
|
1404
|
-
app.io.say(C.yellow("
|
|
1589
|
+
app.io.say(C.yellow(" Clipboard unavailable. Printing the reply instead:"));
|
|
1405
1590
|
app.io.say(text);
|
|
1406
1591
|
}
|
|
1407
1592
|
|
|
1408
|
-
|
|
1409
|
-
* System clipboard without an npm package. Missing binaries are a fallback
|
|
1410
|
-
* that prints the text, not a silent no-op.
|
|
1411
|
-
*/
|
|
1412
|
-
function copyToClipboard(text: string): boolean {
|
|
1413
|
-
const cmds: string[][] = [
|
|
1414
|
-
["pbcopy"],
|
|
1415
|
-
["wl-copy"],
|
|
1416
|
-
["xclip", "-selection", "clipboard"],
|
|
1417
|
-
];
|
|
1418
|
-
for (const cmd of cmds) {
|
|
1419
|
-
try {
|
|
1420
|
-
const proc = Bun.spawnSync({
|
|
1421
|
-
cmd,
|
|
1422
|
-
stdin: Buffer.from(text),
|
|
1423
|
-
stdout: "ignore",
|
|
1424
|
-
stderr: "ignore",
|
|
1425
|
-
});
|
|
1426
|
-
if (proc.exitCode === 0) return true;
|
|
1427
|
-
} catch {
|
|
1428
|
-
// Try the next binary.
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
return false;
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
async function forkSession(app: App, arg?: string): Promise<void> {
|
|
1593
|
+
async function forkSession(app: App, arg?: string, tui?: Tui): Promise<void> {
|
|
1435
1594
|
const C = app.io.palette;
|
|
1436
|
-
const points = await app.session.
|
|
1595
|
+
const points = await app.session.userMessages();
|
|
1437
1596
|
if (arg === undefined) {
|
|
1438
1597
|
app.io.say(C.bold("\nfork from a user turn") + C.dim(" (newest last)"));
|
|
1439
1598
|
if (points.length === 0) {
|
|
@@ -1443,9 +1602,9 @@ async function forkSession(app: App, arg?: string): Promise<void> {
|
|
|
1443
1602
|
}
|
|
1444
1603
|
points.forEach((p, i) => {
|
|
1445
1604
|
const n = String(i + 1).padStart(2);
|
|
1446
|
-
app.io.say(` ${C.bold(n)} ${firstLine(p.
|
|
1605
|
+
app.io.say(` ${C.bold(n)} ${firstLine(p.text, 60)}`);
|
|
1447
1606
|
});
|
|
1448
|
-
app.io.say(C.dim("\n /fork <n>
|
|
1607
|
+
app.io.say(C.dim("\n /fork <n> resumes before that user message and restores its draft\n"));
|
|
1449
1608
|
return;
|
|
1450
1609
|
}
|
|
1451
1610
|
const i = Number(arg);
|
|
@@ -1454,39 +1613,46 @@ async function forkSession(app: App, arg?: string): Promise<void> {
|
|
|
1454
1613
|
app.io.say(C.red(` no such turn: ${arg}. /fork lists them.`));
|
|
1455
1614
|
return;
|
|
1456
1615
|
}
|
|
1457
|
-
await spawnForkedSession(app, point.
|
|
1616
|
+
if (await spawnForkedSession(app, point.id, `forked before ${firstLine(point.text, 40)}`, "before")) {
|
|
1617
|
+
await tui?.syncTranscript();
|
|
1618
|
+
tui?.restoreDraft(point.text, point.images ?? []);
|
|
1619
|
+
tui?.paint();
|
|
1620
|
+
}
|
|
1458
1621
|
}
|
|
1459
1622
|
|
|
1460
1623
|
async function cloneSession(app: App): Promise<void> {
|
|
1461
1624
|
await spawnForkedSession(app, undefined, "cloned");
|
|
1462
1625
|
}
|
|
1463
1626
|
|
|
1464
|
-
async function spawnForkedSession(app: App, untilEvent: string | undefined, label: string): Promise<
|
|
1627
|
+
async function spawnForkedSession(app: App, untilEvent: string | undefined, label: string, position: "at" | "before" = "at"): Promise<boolean> {
|
|
1465
1628
|
const C = app.io.palette;
|
|
1466
1629
|
const created = await createSession(app.workspace, undefined, sessionDirOf(app));
|
|
1467
1630
|
try {
|
|
1468
|
-
await app.session.fork(created.path, untilEvent);
|
|
1631
|
+
await app.session.fork(created.path, untilEvent, position);
|
|
1469
1632
|
} catch (err) {
|
|
1470
1633
|
app.io.say(C.red(` cannot fork: ${describeError(err)}`));
|
|
1471
|
-
return;
|
|
1634
|
+
return false;
|
|
1472
1635
|
}
|
|
1473
1636
|
const switched = await adoptLogDir(app, created.path);
|
|
1474
|
-
if (!switched) return;
|
|
1637
|
+
if (!switched) return false;
|
|
1475
1638
|
app.io.say(C.green(` ${label} → ${created.id}`) + C.dim(` ${created.path}`));
|
|
1639
|
+
return true;
|
|
1476
1640
|
}
|
|
1477
1641
|
|
|
1478
|
-
async function trustProject(app: App, target:
|
|
1642
|
+
async function trustProject(app: App, target: TrustChoice, tui?: Tui): Promise<void> {
|
|
1479
1643
|
const C = app.io.palette;
|
|
1480
1644
|
const dir = target === "parent" ? dirname(app.workspace) : app.workspace;
|
|
1481
|
-
const trusted =
|
|
1482
|
-
|
|
1645
|
+
const trusted = await applyTrustChoice(app.workspace, target);
|
|
1646
|
+
app.projectTrusted = trusted;
|
|
1483
1647
|
app.io.say(
|
|
1484
1648
|
C.green(` ${trusted ? "trusted" : "will not trust"} ${dir}`) +
|
|
1485
|
-
C.dim(` saved in ${trustPath()}`),
|
|
1649
|
+
C.dim(target === "once" ? " for this session" : ` saved in ${trustPath()}`),
|
|
1486
1650
|
);
|
|
1487
1651
|
app.io.say(
|
|
1488
|
-
C.dim("
|
|
1652
|
+
C.dim(" Project resources reloaded. Tool permissions remain separate."),
|
|
1489
1653
|
);
|
|
1654
|
+
await reloadAppResources(app);
|
|
1655
|
+
await tui?.refreshResources();
|
|
1490
1656
|
}
|
|
1491
1657
|
|
|
1492
1658
|
async function nameSession(app: App, name: string, tui?: Tui): Promise<void> {
|
|
@@ -1509,13 +1675,29 @@ async function nameSession(app: App, name: string, tui?: Tui): Promise<void> {
|
|
|
1509
1675
|
}
|
|
1510
1676
|
|
|
1511
1677
|
/** Runs a prompt. Returns false if the turn failed, having already said why. */
|
|
1512
|
-
async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui): Promise<boolean> {
|
|
1678
|
+
async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui, images: readonly ImageRef[] = []): Promise<boolean> {
|
|
1679
|
+
if (app.extensions && !initializedExtensions.has(app.extensions)) await bindExtensions(app, tui);
|
|
1680
|
+
for (const result of await app.extensions?.emit("input", { text: prompt, images, source: "interactive" }) ?? []) {
|
|
1681
|
+
if (result["action"] === "handled") return true;
|
|
1682
|
+
if (result["action"] === "transform" && typeof result["text"] === "string") prompt = result["text"];
|
|
1683
|
+
}
|
|
1684
|
+
if (prompt.startsWith("!")) {
|
|
1685
|
+
const excluded = prompt.startsWith("!!");
|
|
1686
|
+
if (tui) tui.beginBash(prompt.slice(excluded ? 2 : 1));
|
|
1687
|
+
const result = await app.session.runUserBash(prompt.slice(excluded ? 2 : 1), excluded, signal,
|
|
1688
|
+
tui ? chunk => tui.pushBash(chunk) : undefined);
|
|
1689
|
+
if (tui) { await tui.syncTranscript(); tui.commitStream(); }
|
|
1690
|
+
else app.io.say(`$ ${result.command}\n${result.content}`);
|
|
1691
|
+
return true;
|
|
1692
|
+
}
|
|
1513
1693
|
if (!app.credential.ok) {
|
|
1514
1694
|
// Must not call session.send: that records a user event before the
|
|
1515
1695
|
// request, and a refused prompt is not a turn.
|
|
1516
1696
|
const C = app.io.palette;
|
|
1517
1697
|
app.io.say(C.yellow(" no credential yet -- this prompt was not sent."));
|
|
1698
|
+
app.io.say(C.dim(` ${app.credential.reason}`));
|
|
1518
1699
|
app.io.say(C.dim(" export an API key, or /login <provider>.\n"));
|
|
1700
|
+
tui?.restoreDraft(prompt, images);
|
|
1519
1701
|
return false;
|
|
1520
1702
|
}
|
|
1521
1703
|
|
|
@@ -1538,6 +1720,7 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui): P
|
|
|
1538
1720
|
const C = app.io.palette;
|
|
1539
1721
|
app.io.say(C.red(` ${fresh.reason}`));
|
|
1540
1722
|
app.io.say(C.dim(" this prompt was not sent.\n"));
|
|
1723
|
+
tui?.restoreDraft(prompt, images);
|
|
1541
1724
|
return false;
|
|
1542
1725
|
}
|
|
1543
1726
|
if (!app.credential.ok || fresh.key !== app.credential.key) {
|
|
@@ -1545,6 +1728,8 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui): P
|
|
|
1545
1728
|
app.session.provider = makeProvider(app, app.providerConfig, fresh.key);
|
|
1546
1729
|
}
|
|
1547
1730
|
|
|
1731
|
+
let input: UserInput;
|
|
1732
|
+
try {
|
|
1548
1733
|
const needsIndex = fileReferences(prompt).length > 0;
|
|
1549
1734
|
const index: FileIndex = needsIndex
|
|
1550
1735
|
? await fileIndexFor(app)
|
|
@@ -1553,10 +1738,23 @@ async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Tui): P
|
|
|
1553
1738
|
workspace: app.workspace,
|
|
1554
1739
|
index,
|
|
1555
1740
|
say: (text) => app.io.say(text),
|
|
1741
|
+
attachImage: path => attachImageFile(path, app.blobDir, app.ui?.settings.images?.autoResize ?? true),
|
|
1556
1742
|
});
|
|
1557
|
-
if (!expanded.send) return true;
|
|
1743
|
+
if (!expanded.send && !images.length) return true;
|
|
1744
|
+
const attached = [...images, ...expanded.images ?? []];
|
|
1745
|
+
input = attached.length ? { text: expanded.message, images: attached } : expanded.message;
|
|
1746
|
+
app.session.config.imageOptions = app.ui?.settings.images ?? {};
|
|
1747
|
+
if (attached.length && (app.ui?.settings.images?.blockImages || !app.providerConfig.spec.input?.includes("image"))) {
|
|
1748
|
+
app.io.say(" Images are kept in this session; this request uses text placeholders because " +
|
|
1749
|
+
(app.ui?.settings.images?.blockImages ? "image transmission is disabled." : "the selected model does not support image input."));
|
|
1750
|
+
}
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
app.io.say(" Attachment failed: " + describeError(error));
|
|
1753
|
+
tui?.restoreDraft(prompt, images);
|
|
1754
|
+
return false;
|
|
1755
|
+
}
|
|
1558
1756
|
try {
|
|
1559
|
-
await runTurn(app,
|
|
1757
|
+
await runTurn(app, input, signal, tui);
|
|
1560
1758
|
return true;
|
|
1561
1759
|
} catch (error) {
|
|
1562
1760
|
tui?.discardStream();
|
|
@@ -1578,17 +1776,18 @@ export interface TurnSurface {
|
|
|
1578
1776
|
discardStream(): void;
|
|
1579
1777
|
/** Tool/thinking events that stay in the live region until commit. */
|
|
1580
1778
|
pushEvent?(e: LoopEvent): void;
|
|
1779
|
+
pushProgress?(progress: NonNullable<LoopEvent["progress"]>): void;
|
|
1581
1780
|
/**
|
|
1582
1781
|
* Called by send() at a pairable after_tools moment. Returns already
|
|
1583
1782
|
* expanded text, or null. Expansion happens here, not at enqueue —
|
|
1584
1783
|
* the model is editing files while the line waits.
|
|
1585
1784
|
*/
|
|
1586
|
-
pullQueued?(phase: Phase): Promise<
|
|
1785
|
+
pullQueued?(phase: Phase): Promise<UserInput | null>;
|
|
1587
1786
|
}
|
|
1588
1787
|
|
|
1589
1788
|
export async function runTurn(
|
|
1590
|
-
app: Pick<App, "session" | "io"> & { readonly ui?: App["ui"]; readonly args?: App["args"] },
|
|
1591
|
-
prompt:
|
|
1789
|
+
app: Pick<App, "session" | "io"> & { readonly ui?: App["ui"]; readonly args?: App["args"]; readonly extensions?: App["extensions"] },
|
|
1790
|
+
prompt: UserInput,
|
|
1592
1791
|
signal?: AbortSignal,
|
|
1593
1792
|
tui?: TurnSurface,
|
|
1594
1793
|
): Promise<void> {
|
|
@@ -1598,19 +1797,33 @@ export async function runTurn(
|
|
|
1598
1797
|
let skipFinal = false;
|
|
1599
1798
|
const json = app.args?.mode === "json";
|
|
1600
1799
|
const pull = tui?.pullQueued?.bind(tui);
|
|
1601
|
-
|
|
1800
|
+
const registry = app.extensions;
|
|
1801
|
+
const before = await registry?.emit("before_agent_start", { prompt: userInputData(prompt).text, systemPrompt: app.session.config.frozen.system }) ?? [];
|
|
1802
|
+
for (const result of before) {
|
|
1803
|
+
if (typeof result["systemPrompt"] === "string" && result["systemPrompt"] !== app.session.config.frozen.system)
|
|
1804
|
+
await app.session.reloadResources({ ...app.session.config.frozen, system: result["systemPrompt"] });
|
|
1805
|
+
}
|
|
1806
|
+
await registry?.emit("agent_start");
|
|
1807
|
+
await registry?.emit("turn_start", { turnIndex: 0 });
|
|
1808
|
+
try { for await (const e of app.session.send(prompt, signal, pull)) {
|
|
1809
|
+
if (e.kind === "text_delta" || e.kind === "thinking_delta") await registry?.emit("message_update", { assistantMessageEvent: { type: e.kind, delta: e.text } });
|
|
1810
|
+
if (e.kind === "assistant_text") await registry?.emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: e.text }] } });
|
|
1811
|
+
if (e.kind === "done") await registry?.emit("turn_end", { reason: e.reason });
|
|
1602
1812
|
if (json) {
|
|
1603
1813
|
const payload = toJsonEvent(e, app.session.clock.now());
|
|
1604
1814
|
if (payload !== null) process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
1605
1815
|
continue;
|
|
1606
1816
|
}
|
|
1817
|
+
if (e.kind === "progress") { if (e.progress) tui?.pushProgress?.(e.progress); continue; }
|
|
1607
1818
|
if (tui?.syncTranscript) {
|
|
1819
|
+
if (e.kind === "usage" && e.ok === false && e.prefix && app.ui?.settings.showCacheMissNotices === true)
|
|
1820
|
+
app.io.say(app.io.palette.yellow(` Cache: ${e.prefix}`));
|
|
1608
1821
|
if (e.kind === "text_delta" || e.kind === "thinking_delta") {
|
|
1609
1822
|
tui.pushDelta(e.kind === "text_delta" ? "text" : "thinking", e.text ?? "");
|
|
1610
1823
|
} else {
|
|
1611
1824
|
if (e.kind === "discard") tui.discardStream();
|
|
1612
1825
|
await tui.syncTranscript();
|
|
1613
|
-
if (["assistant_text", "thinking", "tool_call", "done", "cut"].includes(e.kind)) tui.commitStream();
|
|
1826
|
+
if (["assistant_text", "thinking", "tool_call", "tool_result", "done", "cut"].includes(e.kind)) tui.commitStream();
|
|
1614
1827
|
if (e.kind === "snapshot_failed" || e.kind === "undeclared_change" || e.kind === "stop_and_ask") {
|
|
1615
1828
|
for (const line of formatEvent(e, app.io.palette, usableColumns(process.stdout.columns), {}, app.ui?.style)) app.io.say(line);
|
|
1616
1829
|
}
|
|
@@ -1661,7 +1874,7 @@ export async function runTurn(
|
|
|
1661
1874
|
)) {
|
|
1662
1875
|
app.io.say(line);
|
|
1663
1876
|
}
|
|
1664
|
-
}
|
|
1877
|
+
} } finally { await registry?.emit("agent_end", { messages: structuredClone(app.session.state.events) }); }
|
|
1665
1878
|
}
|
|
1666
1879
|
|
|
1667
1880
|
// ---------------------------------------------------------------------------
|
|
@@ -1671,12 +1884,31 @@ export async function runTurn(
|
|
|
1671
1884
|
export class Tui {
|
|
1672
1885
|
readonly term: Terminal;
|
|
1673
1886
|
editor: Editor;
|
|
1887
|
+
readonly extensionUi: ExtensionUiController;
|
|
1674
1888
|
/** Not yet delivered. Retracting a line must leave no trace in `.xeno/`. */
|
|
1675
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
|
+
};
|
|
1676
1902
|
select: SelectList | null = null;
|
|
1677
1903
|
settings: Settings | null = null;
|
|
1678
1904
|
tree: TreeList | null = null;
|
|
1679
|
-
screen:
|
|
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;
|
|
1680
1912
|
private readonly chat: Container;
|
|
1681
1913
|
private noticeDir: string;
|
|
1682
1914
|
private readonly transcript = new Transcript();
|
|
@@ -1693,7 +1925,18 @@ export class Tui {
|
|
|
1693
1925
|
liveRows = 0;
|
|
1694
1926
|
abort: AbortController | null = null;
|
|
1695
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;
|
|
1696
1938
|
awaitingKeyFor: string | null = null;
|
|
1939
|
+
private credentialDraft: string | undefined;
|
|
1697
1940
|
streamThinking = "";
|
|
1698
1941
|
streamText = "";
|
|
1699
1942
|
flushed = 0;
|
|
@@ -1701,44 +1944,156 @@ export class Tui {
|
|
|
1701
1944
|
thinkingExpanded = false;
|
|
1702
1945
|
sessionName: string | null;
|
|
1703
1946
|
private onPick: ((item: SelectItem) => Promise<void>) | null = null;
|
|
1947
|
+
private onSelectCancel: (() => void) | undefined;
|
|
1704
1948
|
private onTreePick: ((node: TreeNode) => Promise<void>) | null = null;
|
|
1705
1949
|
/** Which selector Ctrl+S should persist. Other lists ignore Ctrl+S. */
|
|
1706
|
-
private selectKind: "model" | "thinking" | "other" = "other";
|
|
1950
|
+
private selectKind: "model" | "scoped-models" | "thinking" | "other" = "other";
|
|
1707
1951
|
private approvalResolve: ((choice: ApprovalChoice) => void) | null = null;
|
|
1708
1952
|
private inputs: string[] = [];
|
|
1709
1953
|
private wake: (() => void) | null = null;
|
|
1710
1954
|
private attached = false;
|
|
1955
|
+
private progressActive = false;
|
|
1956
|
+
private resumeSuspended: (() => void) | undefined;
|
|
1711
1957
|
private lastInterrupt = 0;
|
|
1712
1958
|
private lastEscape = 0;
|
|
1713
1959
|
private pasting = false;
|
|
1714
1960
|
private turn: Promise<void> | null = null;
|
|
1961
|
+
private readonly extensionWork = new Set<Promise<void>>();
|
|
1715
1962
|
readonly app: App;
|
|
1716
1963
|
/** Read once: a git spawn on every keystroke would stall the live region. */
|
|
1717
|
-
private
|
|
1964
|
+
private branch: string | null;
|
|
1718
1965
|
|
|
1719
1966
|
constructor(app: App, term?: Terminal) {
|
|
1720
1967
|
this.app = app;
|
|
1721
1968
|
this.historyDir = app.logDir;
|
|
1722
1969
|
this.noticeDir = app.logDir;
|
|
1723
|
-
this.screen = createInteractiveTui(term);
|
|
1970
|
+
this.screen = createInteractiveTui(term, app.ui?.settings.showHardwareCursor ?? true, { ...app.ui?.settings, copySelection: async text => this.clipboardWriter(text) });
|
|
1724
1971
|
this.term = this.screen.terminal;
|
|
1725
1972
|
this.editor = new Editor({ tui: this.screen });
|
|
1726
1973
|
this.editor.bindings = editorBindings((app.ui?.keymap ?? defaultKeymap()).byAction);
|
|
1727
|
-
this.editor.setAutocompleteProvider(interactiveAutocomplete(app, async (refresh) => (await fileIndexFor(app, refresh)).paths));
|
|
1728
1974
|
this.editor.onInput = (data) => { this.inputs.push(data); this.wake?.(); };
|
|
1729
1975
|
this.chat = new Container();
|
|
1730
1976
|
this.live = new Container();
|
|
1731
1977
|
this.editorBox = new Container();
|
|
1732
1978
|
this.footerBox = new Container();
|
|
1733
|
-
this.
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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();
|
|
1739
2003
|
this.sessionName = app.args.sessionName || null;
|
|
1740
2004
|
// Before attach: a hung git must not freeze an already-captured tty.
|
|
1741
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
|
+
}
|
|
1742
2097
|
}
|
|
1743
2098
|
|
|
1744
2099
|
get style(): Style {
|
|
@@ -1765,17 +2120,43 @@ export class Tui {
|
|
|
1765
2120
|
}
|
|
1766
2121
|
|
|
1767
2122
|
private clearStream(): void {
|
|
2123
|
+
this.extensionUi.clearPartials();
|
|
1768
2124
|
this.streamThinking = "";
|
|
1769
2125
|
this.streamText = "";
|
|
1770
2126
|
this.flushed = 0;
|
|
2127
|
+
this.bashOutput = null;
|
|
2128
|
+
this.toolArguments.clear();
|
|
2129
|
+
this.toolOutput = "";
|
|
2130
|
+
this.stopRetryTimer();
|
|
1771
2131
|
}
|
|
1772
2132
|
|
|
1773
2133
|
pushDelta(kind: "text" | "thinking", text: string): void {
|
|
2134
|
+
this.stopRetryTimer();
|
|
1774
2135
|
if (kind === "thinking") this.streamThinking += text;
|
|
1775
2136
|
else this.streamText += text;
|
|
1776
2137
|
this.paintStream();
|
|
1777
2138
|
}
|
|
1778
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
|
+
|
|
1779
2160
|
discardStream(): void {
|
|
1780
2161
|
this.clearStream();
|
|
1781
2162
|
this.dropLive();
|
|
@@ -1790,6 +2171,7 @@ export class Tui {
|
|
|
1790
2171
|
}
|
|
1791
2172
|
|
|
1792
2173
|
async syncTranscript(): Promise<void> {
|
|
2174
|
+
if (this.attached && this.app.extensions && !initializedExtensions.has(this.app.extensions)) await bindExtensions(this.app, this);
|
|
1793
2175
|
const session = this.app.session;
|
|
1794
2176
|
const dir = this.app.logDir;
|
|
1795
2177
|
const node = session.state.node;
|
|
@@ -1803,11 +2185,17 @@ export class Tui {
|
|
|
1803
2185
|
}
|
|
1804
2186
|
if (this.app.session !== session || this.app.logDir !== dir) return;
|
|
1805
2187
|
if (this.historyDir !== dir) {
|
|
2188
|
+
this.applyConfiguration();
|
|
2189
|
+
this.followTheme();
|
|
1806
2190
|
if (this.noticeDir !== dir) this.chat.clear();
|
|
1807
2191
|
this.noticeDir = dir;
|
|
1808
2192
|
this.queue = [];
|
|
2193
|
+
this.pendingImages = [];
|
|
2194
|
+
this.displayImages.clear(); this.unavailableImages.clear();
|
|
1809
2195
|
this.clearStream();
|
|
1810
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);
|
|
1811
2199
|
this.sessionName = (await readCursor(dir))?.name ?? null;
|
|
1812
2200
|
}
|
|
1813
2201
|
this.ancestors = history.nodes.filter((entry) => entry.node !== node).flatMap((entry) => entry.events);
|
|
@@ -1815,9 +2203,19 @@ export class Tui {
|
|
|
1815
2203
|
this.historyNode = node;
|
|
1816
2204
|
this.historyLength = -1;
|
|
1817
2205
|
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
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();
|
|
1821
2219
|
}
|
|
1822
2220
|
this.paint();
|
|
1823
2221
|
}
|
|
@@ -1837,6 +2235,11 @@ export class Tui {
|
|
|
1837
2235
|
}
|
|
1838
2236
|
|
|
1839
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
|
+
}
|
|
1840
2243
|
this.transcript.update(this.transcriptItems, this.markdownStyle(), this.toolsExpanded, this.thinkingExpanded);
|
|
1841
2244
|
if (this.attached) this.screen.requestRender();
|
|
1842
2245
|
else this.screen.renderNow();
|
|
@@ -1845,17 +2248,22 @@ export class Tui {
|
|
|
1845
2248
|
private rebuildLive(): void {
|
|
1846
2249
|
this.live.clear();
|
|
1847
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));
|
|
1848
2256
|
if (this.streamThinking.length > 0) {
|
|
1849
2257
|
this.live.addChild(
|
|
1850
|
-
new Lines((width) =>
|
|
1851
|
-
renderToolView({ kind: "thinking", text: this.streamThinking }, this.thinkingExpanded, width, style),
|
|
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),
|
|
1852
2260
|
),
|
|
1853
2261
|
);
|
|
1854
2262
|
}
|
|
1855
2263
|
if (this.streamText.length > 0) {
|
|
1856
2264
|
if (this.liveStyle !== style || this.liveMarkdown === null) {
|
|
1857
2265
|
this.liveStyle = style;
|
|
1858
|
-
this.liveMarkdown = new MarkdownBlock(this.streamText, style);
|
|
2266
|
+
this.liveMarkdown = new MarkdownBlock(this.streamText, style, this.markdownFeatures, true);
|
|
1859
2267
|
}
|
|
1860
2268
|
this.liveMarkdown.setText(this.streamText);
|
|
1861
2269
|
this.live.addChild(this.liveMarkdown);
|
|
@@ -1867,10 +2275,18 @@ export class Tui {
|
|
|
1867
2275
|
inner.setStyle(this.style);
|
|
1868
2276
|
inner.onInput = this.editor.onInput;
|
|
1869
2277
|
this.editorBox.clear();
|
|
1870
|
-
this.editorBox.addChild(inner);
|
|
1871
|
-
this.screen.setFocus(inner);
|
|
2278
|
+
this.editorBox.addChild(this.extensionUi.modal ?? inner);
|
|
2279
|
+
if (!this.extensionUi.overlayFocused) this.screen.setFocus(this.extensionUi.modal ?? inner);
|
|
1872
2280
|
this.footerBox.clear();
|
|
1873
|
-
this.footerBox.addChild(new
|
|
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
|
+
}
|
|
1874
2290
|
}
|
|
1875
2291
|
|
|
1876
2292
|
private statusLines(width: number): string[] {
|
|
@@ -1920,43 +2336,168 @@ export class Tui {
|
|
|
1920
2336
|
{ id: "theme", label: "Theme", currentValue: settings?.theme ?? "dark", values: ["dark", "light"], description: "Apply the palette to the editor, menus and complete history." },
|
|
1921
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." },
|
|
1922
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) })),
|
|
1923
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)));
|
|
1924
2347
|
this.paint();
|
|
1925
2348
|
}
|
|
1926
2349
|
|
|
1927
|
-
openLoginSelect(): void {
|
|
2350
|
+
openLoginSelect(method?: "oauth" | "api_key"): void {
|
|
1928
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
|
+
};
|
|
1929
2358
|
this.select = new SelectList([
|
|
1930
|
-
...
|
|
1931
|
-
|
|
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)}` }))),
|
|
1932
2365
|
], { style: this.style, title: "Login · choose a provider" });
|
|
1933
|
-
this.
|
|
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); };
|
|
1934
2403
|
this.paint();
|
|
1935
2404
|
}
|
|
1936
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
|
+
|
|
1937
2472
|
async openForkSelect(): Promise<void> {
|
|
1938
|
-
const points = await this.app.session.
|
|
1939
|
-
this.select = new SelectList(points.
|
|
1940
|
-
{ style: this.style, title: "Fork ·
|
|
1941
|
-
this.onPick = async (item) => { await forkSession(this.app, item.value
|
|
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); };
|
|
1942
2477
|
this.paint();
|
|
1943
2478
|
}
|
|
1944
2479
|
|
|
1945
2480
|
private openSettingValues(field: string): void {
|
|
1946
2481
|
const key = field.toLowerCase();
|
|
2482
|
+
if (key === "defaultmodel") { this.openDefaultModelSelect(); return; }
|
|
2483
|
+
if (key === "modelthinkinglevels") { this.openModelThinkingDefaults(); return; }
|
|
1947
2484
|
let items: SelectItem[] = [];
|
|
1948
2485
|
if (key === "theme") {
|
|
1949
2486
|
items = [
|
|
1950
2487
|
{ value: "dark", label: "dark" },
|
|
1951
2488
|
{ value: "light", label: "light" },
|
|
2489
|
+
{ value: "system", label: "system" },
|
|
2490
|
+
{ value: "auto", label: "auto" },
|
|
1952
2491
|
];
|
|
1953
2492
|
} else if (key === "thinking") {
|
|
1954
|
-
items =
|
|
2493
|
+
items = supportedThinkingLevels(this.app.providerConfig.spec).map((v) => ({ value: v, label: v }));
|
|
1955
2494
|
} else if (key === "statusbar" || key === "status") {
|
|
1956
2495
|
items = [
|
|
1957
2496
|
{ value: "on", label: "on" },
|
|
1958
2497
|
{ value: "off", label: "off" },
|
|
1959
2498
|
];
|
|
2499
|
+
} else if (settingValues(field).length) {
|
|
2500
|
+
items = settingValues(field).map(value => ({ value, label: value }));
|
|
1960
2501
|
} else {
|
|
1961
2502
|
this.app.io.say(this.app.io.palette.yellow(` unknown setting: ${field}`));
|
|
1962
2503
|
this.paint();
|
|
@@ -1964,27 +2505,85 @@ export class Tui {
|
|
|
1964
2505
|
}
|
|
1965
2506
|
this.select = new SelectList(items, { style: this.style, title: "Setting · " + field });
|
|
1966
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
|
+
}
|
|
1967
2513
|
this.onPick = async (item) => {
|
|
2514
|
+
this.previewTheme = undefined;
|
|
1968
2515
|
await commitSetting(this.app, key === "status" ? "statusBar" : field, item.value, this);
|
|
1969
2516
|
};
|
|
1970
2517
|
this.paint();
|
|
1971
2518
|
}
|
|
1972
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
|
+
|
|
1973
2572
|
openModelSelect(): void {
|
|
1974
2573
|
const items: SelectItem[] = [];
|
|
1975
2574
|
for (const spec of this.app.catalog) {
|
|
1976
2575
|
const got = this.app.credentialOf(spec);
|
|
1977
|
-
const here = spec
|
|
2576
|
+
const here = modelKey(spec) === modelKey(this.app.providerConfig.spec);
|
|
1978
2577
|
items.push({
|
|
1979
|
-
value: spec
|
|
2578
|
+
value: modelKey(spec),
|
|
1980
2579
|
label: spec.id,
|
|
1981
2580
|
description: `${spec.provider} · ${spec.name}${here ? " ✓ current" : got.ok ? " ready" : " login required"}`,
|
|
1982
2581
|
});
|
|
1983
2582
|
}
|
|
1984
|
-
this.select = new SelectList(items, { style: this.style, title: "Model", selectedValue: this.app.providerConfig.spec
|
|
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" });
|
|
1985
2584
|
this.selectKind = "model";
|
|
1986
2585
|
this.onPick = async (item) => {
|
|
1987
|
-
const spec = this.app.catalog.find((m) => m
|
|
2586
|
+
const spec = this.app.catalog.find((m) => modelKey(m) === item.value);
|
|
1988
2587
|
if (spec !== undefined) await switchToModel(this.app, spec);
|
|
1989
2588
|
};
|
|
1990
2589
|
this.app.io.say(this.app.io.palette.dim(" switching cuts this node so the next model starts from a handoff"));
|
|
@@ -2008,7 +2607,7 @@ export class Tui {
|
|
|
2008
2607
|
this.onPick = async (item) => {
|
|
2009
2608
|
const effort = asEffort(item.value);
|
|
2010
2609
|
if (effort === undefined) return;
|
|
2011
|
-
applyThinking(this.app, effort, this);
|
|
2610
|
+
await applyThinking(this.app, effort, this);
|
|
2012
2611
|
};
|
|
2013
2612
|
this.paint();
|
|
2014
2613
|
}
|
|
@@ -2020,10 +2619,12 @@ export class Tui {
|
|
|
2020
2619
|
const C = this.app.io.palette;
|
|
2021
2620
|
if (this.selectKind === "model") {
|
|
2022
2621
|
// Patch only this key: dumping merged settings would freeze today's defaults.
|
|
2023
|
-
|
|
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 });
|
|
2024
2625
|
if (this.app.ui !== undefined) {
|
|
2025
|
-
this.app.ui.userOverrides = { ...this.app.ui.userOverrides, defaultModel:
|
|
2026
|
-
this.app.ui.settings = { ...this.app.ui.settings, defaultModel:
|
|
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 };
|
|
2027
2628
|
}
|
|
2028
2629
|
this.app.io.say(C.green(` saved default model ${item.value}`));
|
|
2029
2630
|
return;
|
|
@@ -2034,66 +2635,69 @@ export class Tui {
|
|
|
2034
2635
|
this.app.io.say(C.yellow(" Cannot save an unsupported thinking level."));
|
|
2035
2636
|
return;
|
|
2036
2637
|
}
|
|
2037
|
-
|
|
2638
|
+
const patch = { modelThinkingLevels: { [modelKey(this.app.providerConfig.spec)]: effort } };
|
|
2639
|
+
await writeUserSettings(patch);
|
|
2038
2640
|
if (this.app.ui !== undefined) {
|
|
2039
|
-
this.app.ui.userOverrides = {
|
|
2040
|
-
this.app.ui.settings = {
|
|
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 });
|
|
2041
2643
|
}
|
|
2042
2644
|
this.app.io.say(C.green(` saved default thinking ${item.value}`));
|
|
2043
2645
|
}
|
|
2044
2646
|
}
|
|
2045
2647
|
|
|
2046
2648
|
async openResumeSelect(): Promise<void> {
|
|
2047
|
-
|
|
2048
|
-
const
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
this.app.io.say(this.app.io.palette.dim(" no sessions yet"));
|
|
2053
|
-
return;
|
|
2054
|
-
}
|
|
2055
|
-
this.select = new SelectList(
|
|
2056
|
-
ok.map((s) => ({
|
|
2057
|
-
value: s.path,
|
|
2058
|
-
label: s.name ?? s.id,
|
|
2059
|
-
description: [s.model ?? "", s.updated_at ?? "", `${s.events} events`].filter((x) => x.length > 0).join(" "),
|
|
2060
|
-
})),
|
|
2061
|
-
{ style: this.style, title: "Resume session", selectedValue: this.app.logDir },
|
|
2062
|
-
);
|
|
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;
|
|
2063
2654
|
this.selectKind = "other";
|
|
2064
2655
|
this.onPick = async (item) => {
|
|
2065
2656
|
await resumeSession(this.app, item.value, this);
|
|
2066
2657
|
};
|
|
2067
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();
|
|
2068
2680
|
}
|
|
2069
2681
|
|
|
2070
2682
|
/**
|
|
2071
|
-
*
|
|
2072
|
-
*
|
|
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,
|
|
2073
2685
|
* the existing AbortSignal fires first — same path Escape already uses.
|
|
2074
2686
|
*/
|
|
2075
2687
|
async openTreeSelect(): Promise<void> {
|
|
2688
|
+
if (this.select instanceof SessionBrowser) this.select.dispose();
|
|
2076
2689
|
this.select = null;
|
|
2077
2690
|
this.onPick = null;
|
|
2078
|
-
const points = await this.app.session.restorePoints();
|
|
2079
2691
|
const nodes = await treeNodesFor(this.app);
|
|
2080
|
-
this.tree = new TreeList(nodes, { style: this.style });
|
|
2692
|
+
this.tree = new TreeList(nodes, { style: this.style, filter: this.app.ui?.settings.treeFilterMode ?? "default", bindings: (this.app.ui?.keymap ?? defaultKeymap()).byAction });
|
|
2081
2693
|
this.onTreePick = async (node) => {
|
|
2082
|
-
|
|
2083
|
-
if (point === undefined) return;
|
|
2694
|
+
if (node.target === undefined) return;
|
|
2084
2695
|
if (this.inflight) { await this.settle(); this.returnQueueToEditor(); }
|
|
2085
|
-
const done = await this.app.session.
|
|
2696
|
+
const done = await this.app.session.navigateTo(node.target);
|
|
2086
2697
|
await this.syncTranscript();
|
|
2698
|
+
if (done.editorText !== undefined) this.restoreDraft(done.editorText, done.images ?? []);
|
|
2087
2699
|
const C = this.app.io.palette;
|
|
2088
|
-
|
|
2089
|
-
this.app.io.say(C.red(`\n could not go back: ${done.error}\n`));
|
|
2090
|
-
return;
|
|
2091
|
-
}
|
|
2092
|
-
this.app.io.say(
|
|
2093
|
-
C.green(`\n back at node ${done.node}`) +
|
|
2094
|
-
C.dim(` ${done.restored.length} restored, ${done.removed.length} removed`),
|
|
2095
|
-
);
|
|
2096
|
-
if (done.note.length > 0) this.app.io.say(C.dim(` ${done.note}`));
|
|
2700
|
+
this.app.io.say(C.green(`\n conversation at node ${this.app.session.state.node}`));
|
|
2097
2701
|
this.app.io.say("");
|
|
2098
2702
|
};
|
|
2099
2703
|
this.paint();
|
|
@@ -2111,16 +2715,90 @@ export class Tui {
|
|
|
2111
2715
|
this.attached = true;
|
|
2112
2716
|
this.rebuildChrome();
|
|
2113
2717
|
this.screen.start();
|
|
2718
|
+
this.followTheme();
|
|
2114
2719
|
}
|
|
2115
2720
|
|
|
2116
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;
|
|
2117
2729
|
this.attached = false;
|
|
2118
|
-
this.screen.
|
|
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");
|
|
2119
2734
|
}
|
|
2120
2735
|
|
|
2121
2736
|
async settle(): Promise<void> {
|
|
2737
|
+
await cancelPendingOAuth(this.app);
|
|
2122
2738
|
this.abort?.abort();
|
|
2739
|
+
this.approvalResolve?.("deny");
|
|
2123
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
|
+
}
|
|
2124
2802
|
}
|
|
2125
2803
|
|
|
2126
2804
|
ask(request: PermissionRequest): Promise<ApprovalChoice> {
|
|
@@ -2142,14 +2820,14 @@ export class Tui {
|
|
|
2142
2820
|
|
|
2143
2821
|
private async openExternalEditor(): Promise<void> {
|
|
2144
2822
|
const attached = this.attached;
|
|
2145
|
-
if (attached) this.screen.stop();
|
|
2823
|
+
if (attached) this.screen.stop({ preserveScreen: true });
|
|
2146
2824
|
if (attached && process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
2147
2825
|
try {
|
|
2148
|
-
const result = await editInExternalEditor({ content: this.editor.state.text, command: resolveEditorCommand() });
|
|
2826
|
+
const result = await editInExternalEditor({ content: this.editor.state.text, command: this.app.ui?.settings.externalEditor ?? resolveEditorCommand() });
|
|
2149
2827
|
if (result.ok) this.editor.applyExternal(result.content);
|
|
2150
2828
|
else this.app.io.say(result.message);
|
|
2151
2829
|
} finally {
|
|
2152
|
-
if (attached) this.screen.start();
|
|
2830
|
+
if (attached) { this.screen.start(); this.screen.requestRender(true); }
|
|
2153
2831
|
this.paint();
|
|
2154
2832
|
}
|
|
2155
2833
|
}
|
|
@@ -2157,17 +2835,71 @@ export class Tui {
|
|
|
2157
2835
|
/** Programmatic compatibility; live stdin always enters as Pi raw input. */
|
|
2158
2836
|
async applyKey(key: Key): Promise<"continue" | "quit"> { return this.applyInput(encodeKey(key)); }
|
|
2159
2837
|
|
|
2160
|
-
|
|
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; }
|
|
2161
2875
|
const action = this.boundAction(data);
|
|
2876
|
+
if (action === "app.suspend") { await this.suspend(); return "continue"; }
|
|
2877
|
+
if (this.extensionUi.acceptInput(data)) return "continue";
|
|
2162
2878
|
// Modal ownership precedes application shortcuts, including during a turn.
|
|
2163
2879
|
if (this.settings) {
|
|
2164
|
-
|
|
2165
|
-
|
|
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;
|
|
2166
2886
|
this.paint();
|
|
2167
2887
|
return "continue";
|
|
2168
2888
|
}
|
|
2169
2889
|
if (this.tree) {
|
|
2170
|
-
const
|
|
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
|
+
}
|
|
2171
2903
|
if (state.cancelled || state.confirmed) {
|
|
2172
2904
|
const pick = this.onTreePick;
|
|
2173
2905
|
this.tree = null;
|
|
@@ -2178,26 +2910,60 @@ export class Tui {
|
|
|
2178
2910
|
return "continue";
|
|
2179
2911
|
}
|
|
2180
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"; }
|
|
2181
2916
|
if (action === "app.saveDefault" && !this.approvalResolve) await this.saveSelectDefault();
|
|
2182
2917
|
else {
|
|
2183
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
|
+
}
|
|
2184
2923
|
if (state.cancelled || state.confirmed) {
|
|
2185
|
-
const pick = this.onPick, approval = this.approvalResolve;
|
|
2924
|
+
const pick = this.onPick, approval = this.approvalResolve, cancel = this.onSelectCancel;
|
|
2925
|
+
if (this.select instanceof SessionBrowser) this.select.dispose();
|
|
2186
2926
|
this.select = null;
|
|
2187
2927
|
this.onPick = null;
|
|
2928
|
+
this.onSelectCancel = undefined;
|
|
2188
2929
|
this.approvalResolve = null;
|
|
2189
2930
|
this.selectKind = "other";
|
|
2190
2931
|
if (state.confirmed && pick) { await pick(state.confirmed); await this.syncTranscript(); }
|
|
2191
|
-
else approval?.("deny");
|
|
2932
|
+
else { approval?.("deny"); cancel?.(); if (this.previewTheme) { this.setTheme(this.previewTheme); this.previewTheme = undefined; } }
|
|
2192
2933
|
}
|
|
2193
2934
|
}
|
|
2194
2935
|
this.paint();
|
|
2195
2936
|
return "continue";
|
|
2196
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
|
+
}
|
|
2197
2953
|
if (data.includes("\x1b[200~")) this.pasting = true;
|
|
2198
2954
|
if (this.pasting) {
|
|
2199
|
-
this.
|
|
2200
|
-
if (data.includes("\x1b[201~"))
|
|
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
|
+
}
|
|
2201
2967
|
this.paint();
|
|
2202
2968
|
return "continue";
|
|
2203
2969
|
}
|
|
@@ -2206,43 +2972,52 @@ export class Tui {
|
|
|
2206
2972
|
this.paint();
|
|
2207
2973
|
return "continue";
|
|
2208
2974
|
}
|
|
2209
|
-
if (action === "app.
|
|
2975
|
+
if (action === "app.clear") {
|
|
2210
2976
|
if (this.inflight) { this.abort?.abort(); this.returnQueueToEditor(); }
|
|
2211
|
-
else if (this.editor.state.text.length > 0) this.editor.applyExternal("");
|
|
2977
|
+
else if (this.editor.state.text.length > 0 || this.pendingImages.length) { this.editor.applyExternal(""); this.pendingImages = []; }
|
|
2212
2978
|
else if (Date.now() - this.lastInterrupt < 1000) return "quit";
|
|
2213
2979
|
else this.app.io.say(" Press Ctrl+C again to exit, or Ctrl+D on an empty prompt.");
|
|
2214
2980
|
this.lastInterrupt = Date.now();
|
|
2215
2981
|
this.paint();
|
|
2216
2982
|
return "continue";
|
|
2217
2983
|
}
|
|
2218
|
-
if (
|
|
2984
|
+
if (action === "app.exit" && this.editor.state.text.length === 0 && this.pendingImages.length === 0) return "quit";
|
|
2219
2985
|
if (action === "app.interrupt") {
|
|
2220
|
-
if (this.
|
|
2221
|
-
|
|
2222
|
-
this.
|
|
2223
|
-
this.
|
|
2224
|
-
}
|
|
2225
|
-
else if (this.editor.state.text.length === 0 && Date.now() - this.lastEscape < 500) await this.openTreeSelect();
|
|
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
|
+
}
|
|
2226
2991
|
this.lastEscape = Date.now();
|
|
2227
2992
|
this.paint();
|
|
2228
2993
|
return "continue";
|
|
2229
2994
|
}
|
|
2230
|
-
if (action === "app.message.dequeue") { this.
|
|
2995
|
+
if (action === "app.message.dequeue") { this.returnQueueToEditor(); this.paint(); return "continue"; }
|
|
2231
2996
|
if (action === "app.toggleTools") { this.toolsExpanded = !this.toolsExpanded; this.paint(); return "continue"; }
|
|
2232
|
-
if (action === "app.toggleThinking") { this.
|
|
2997
|
+
if (action === "app.toggleThinking") { await commitSetting(this.app, "hideThinkingBlock", this.thinkingExpanded ? "on" : "off", this); return "continue"; }
|
|
2233
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
|
+
}
|
|
2234
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"; }
|
|
2235
3009
|
if (action === "app.model") { this.openModelSelect(); return "continue"; }
|
|
2236
3010
|
if (action === "app.cycleModel" || action === "app.cycleModelReverse") {
|
|
2237
3011
|
await cycleScopedModel(this.app, action === "app.cycleModel" ? 1 : -1); this.paint(); return "continue";
|
|
2238
3012
|
}
|
|
2239
|
-
if (action === "app.cycleThinking") { cycleThinking(this.app, this); return "continue"; }
|
|
3013
|
+
if (action === "app.cycleThinking") { await cycleThinking(this.app, this); return "continue"; }
|
|
2240
3014
|
}
|
|
2241
|
-
if (
|
|
3015
|
+
if (action === "app.message.followUp") {
|
|
2242
3016
|
const text = this.editor.state.text;
|
|
2243
3017
|
this.editor.applyExternal("");
|
|
2244
3018
|
return this.submitLine(text, "followup");
|
|
2245
3019
|
}
|
|
3020
|
+
if (keyMatches(data, "enter") && this.pendingImages.length && !this.editor.state.text) return this.submitLine("", "steering");
|
|
2246
3021
|
const state = this.editor.acceptInput(data);
|
|
2247
3022
|
if (state.submitted !== null) return this.submitLine(state.submitted, "steering");
|
|
2248
3023
|
this.paint();
|
|
@@ -2252,11 +3027,12 @@ export class Tui {
|
|
|
2252
3027
|
private async submitLine(text: string, queueKind: QueueKind): Promise<"continue" | "quit"> {
|
|
2253
3028
|
const line = text.trim();
|
|
2254
3029
|
this.paint();
|
|
3030
|
+
if (this.sessionTransition) { this.editor.applyExternal(text); this.app.io.say(" Wait for session import to finish."); return "continue"; }
|
|
2255
3031
|
if (this.awaitingKeyFor !== null) {
|
|
2256
3032
|
await handleLine(this.app, line, this);
|
|
2257
3033
|
return "continue";
|
|
2258
3034
|
}
|
|
2259
|
-
if (!line) return "continue";
|
|
3035
|
+
if (!line && !this.pendingImages.length) return "continue";
|
|
2260
3036
|
const command = parseLine(this.app, line);
|
|
2261
3037
|
if (command.kind !== "prompt") {
|
|
2262
3038
|
if (this.inflight && commandDefinition(line)?.busy === "idle") {
|
|
@@ -2269,20 +3045,23 @@ export class Tui {
|
|
|
2269
3045
|
this.paint();
|
|
2270
3046
|
return result === "quit" ? "quit" : "continue";
|
|
2271
3047
|
}
|
|
2272
|
-
|
|
2273
|
-
|
|
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) => {
|
|
2274
3052
|
this.app.io.say(this.app.io.palette.red(describeError(error)));
|
|
2275
3053
|
});
|
|
2276
3054
|
this.paint();
|
|
2277
3055
|
return "continue";
|
|
2278
3056
|
}
|
|
2279
3057
|
|
|
2280
|
-
private async runPromptTurn(text: string): Promise<void> {
|
|
3058
|
+
private async runPromptTurn(text: string, images: readonly ImageRef[] = []): Promise<void> {
|
|
2281
3059
|
this.abort = new AbortController();
|
|
2282
3060
|
this.inflight = true;
|
|
3061
|
+
this.executionKind = text.startsWith("!") ? "bash" : "model";
|
|
2283
3062
|
try {
|
|
2284
|
-
await run(this.app, text, this.abort.signal, this);
|
|
2285
|
-
if (!this.abort.signal.aborted) await this.drainFollowUps();
|
|
3063
|
+
const sent = await run(this.app, text, this.abort.signal, this, images);
|
|
3064
|
+
if (sent && !this.abort.signal.aborted) await this.drainFollowUps();
|
|
2286
3065
|
this.app.io.say("");
|
|
2287
3066
|
} finally {
|
|
2288
3067
|
await this.syncTranscript();
|
|
@@ -2290,6 +3069,9 @@ export class Tui {
|
|
|
2290
3069
|
this.abort = null;
|
|
2291
3070
|
fileIndexes.delete(this.app.workspace);
|
|
2292
3071
|
this.inflight = false;
|
|
3072
|
+
this.executionKind = "idle";
|
|
3073
|
+
this.bashOutput = null;
|
|
3074
|
+
this.branch = currentBranch(this.app.workspace);
|
|
2293
3075
|
this.clearStream();
|
|
2294
3076
|
this.paint();
|
|
2295
3077
|
}
|
|
@@ -2299,15 +3081,19 @@ export class Tui {
|
|
|
2299
3081
|
* send() calls this at after_tools. Expansion is here because the
|
|
2300
3082
|
* files the user named may have changed while the line waited.
|
|
2301
3083
|
*/
|
|
2302
|
-
async pullQueued(phase: Phase): Promise<
|
|
2303
|
-
const
|
|
2304
|
-
if (
|
|
2305
|
-
|
|
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));
|
|
2306
3089
|
this.paintStream();
|
|
2307
|
-
|
|
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;
|
|
2308
3093
|
}
|
|
2309
3094
|
|
|
2310
|
-
private async expandQueued(
|
|
3095
|
+
private async expandQueued(item: QueuedMessage): Promise<UserInput | null> {
|
|
3096
|
+
const text = item.text;
|
|
2311
3097
|
const needsIndex = fileReferences(text).length > 0;
|
|
2312
3098
|
const index: FileIndex = needsIndex
|
|
2313
3099
|
? await fileIndexFor(this.app)
|
|
@@ -2316,36 +3102,49 @@ export class Tui {
|
|
|
2316
3102
|
workspace: this.app.workspace,
|
|
2317
3103
|
index,
|
|
2318
3104
|
say: (line) => this.app.io.say(line),
|
|
3105
|
+
attachImage: path => attachImageFile(path, this.app.blobDir, this.app.ui?.settings.images?.autoResize ?? true),
|
|
2319
3106
|
});
|
|
2320
|
-
|
|
3107
|
+
const images = [...item.images ?? [], ...expanded.images ?? []];
|
|
3108
|
+
return images.length ? { text: expanded.message, images } : expanded.send ? expanded.message : null;
|
|
2321
3109
|
}
|
|
2322
3110
|
|
|
2323
3111
|
private async drainFollowUps(): Promise<void> {
|
|
2324
3112
|
while (this.abort !== null && !this.abort.signal.aborted) {
|
|
2325
|
-
const
|
|
2326
|
-
if (
|
|
2327
|
-
this.queue = this.queue.filter((m) => m
|
|
3113
|
+
const batch = this.nextBatch("turn_end");
|
|
3114
|
+
if (!batch.length) break;
|
|
3115
|
+
this.queue = this.queue.filter((m) => !batch.includes(m));
|
|
2328
3116
|
this.paintStream();
|
|
2329
|
-
await run(this.app, item.text, this.abort.signal, this);
|
|
3117
|
+
if (!await run(this.app, batch.map(item => item.text).join("\n\n"), this.abort.signal, this, batch.flatMap(item => item.images ?? []))) break;
|
|
2330
3118
|
}
|
|
2331
3119
|
}
|
|
2332
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
|
+
|
|
2333
3126
|
private enqueue(item: QueuedMessage): void {
|
|
2334
3127
|
this.queue.push(item);
|
|
2335
3128
|
}
|
|
2336
3129
|
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
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();
|
|
2343
3141
|
}
|
|
2344
3142
|
|
|
2345
3143
|
private returnQueueToEditor(): void {
|
|
2346
3144
|
if (this.queue.length === 0) return;
|
|
2347
3145
|
const draft = this.editor.state.text;
|
|
2348
3146
|
const parts = this.queue.map((m) => m.text);
|
|
3147
|
+
this.pendingImages = [...this.queue.flatMap(m => m.images ?? []), ...this.pendingImages];
|
|
2349
3148
|
if (draft.trim().length > 0) parts.push(draft);
|
|
2350
3149
|
this.queue = [];
|
|
2351
3150
|
this.editor.applyExternal(parts.join("\n"));
|