@visual-z/xeno 0.1.4 → 0.2.1

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.
Files changed (147) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +23 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +45 -18
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup-input.ts +94 -0
  21. package/src/apps/startup.ts +413 -295
  22. package/src/core/action-defaults.ts +306 -0
  23. package/src/core/attachment-input.ts +17 -0
  24. package/src/core/catalog-merge.ts +19 -0
  25. package/src/core/event-validation.ts +19 -2
  26. package/src/core/events.ts +45 -2
  27. package/src/core/export.ts +6 -2
  28. package/src/core/extension-flags.ts +14 -0
  29. package/src/core/images.ts +46 -0
  30. package/src/core/json-event.ts +2 -0
  31. package/src/core/keymap.ts +43 -119
  32. package/src/core/model-replay.ts +22 -0
  33. package/src/core/model-scope.ts +53 -0
  34. package/src/core/packages.ts +47 -0
  35. package/src/core/project.ts +21 -3
  36. package/src/core/prompts.ts +33 -5
  37. package/src/core/queue.ts +18 -0
  38. package/src/core/resource-selection.ts +36 -0
  39. package/src/{runtime → core}/resume.ts +15 -22
  40. package/src/core/session-archive.ts +4 -0
  41. package/src/core/session-browser.ts +82 -0
  42. package/src/core/session-entry.ts +56 -0
  43. package/src/core/session-position.ts +26 -0
  44. package/src/core/settings.ts +169 -154
  45. package/src/core/theme.ts +15 -6
  46. package/src/core/thinking.ts +26 -0
  47. package/src/core/tokens.ts +2 -0
  48. package/src/core/transcript.ts +15 -5
  49. package/src/runtime/auth.ts +116 -11
  50. package/src/runtime/catalog.ts +54 -151
  51. package/src/runtime/clipboard.ts +114 -0
  52. package/src/runtime/extension-loader.ts +37 -0
  53. package/src/runtime/extension-ui.ts +55 -0
  54. package/src/runtime/extensions.ts +273 -0
  55. package/src/runtime/images.ts +76 -0
  56. package/src/runtime/loop.ts +213 -83
  57. package/src/runtime/model-config.ts +232 -0
  58. package/src/runtime/model-data.LICENSE +27 -0
  59. package/src/runtime/model-data.ts +21 -0
  60. package/src/runtime/model-replay.ts +10 -0
  61. package/src/runtime/model-scope.ts +20 -0
  62. package/src/runtime/model-sources.ts +151 -0
  63. package/src/runtime/model.ts +74 -11
  64. package/src/runtime/models-fetch.ts +6 -104
  65. package/src/runtime/oauth.ts +4 -3
  66. package/src/runtime/openai-responses.ts +12 -4
  67. package/src/runtime/openai.ts +13 -4
  68. package/src/runtime/package-manager.ts +125 -0
  69. package/src/runtime/pi-provider.ts +235 -0
  70. package/src/runtime/progress.ts +26 -0
  71. package/src/runtime/provider-access.ts +21 -0
  72. package/src/runtime/provider-protocols.ts +29 -0
  73. package/src/runtime/provider-request.ts +73 -0
  74. package/src/runtime/provider.ts +26 -6
  75. package/src/runtime/rich-text.ts +36 -0
  76. package/src/runtime/tools.ts +41 -5
  77. package/src/store/command.ts +7 -1
  78. package/src/store/context-files.ts +1 -1
  79. package/src/store/log.ts +65 -0
  80. package/src/store/models.ts +28 -9
  81. package/src/store/observations.ts +4 -2
  82. package/src/store/packages.ts +98 -0
  83. package/src/store/recovery.ts +8 -0
  84. package/src/store/resource-index.ts +170 -0
  85. package/src/store/resources.ts +75 -142
  86. package/src/store/session-archive.ts +162 -0
  87. package/src/store/session-browser.ts +68 -0
  88. package/src/store/session-position.ts +27 -0
  89. package/src/store/sessions.ts +87 -66
  90. package/src/store/trust.ts +13 -0
  91. package/src/tui/chat-viewport.ts +47 -0
  92. package/src/tui/editor.ts +62 -15
  93. package/src/tui/host.ts +50 -9
  94. package/src/tui/input.ts +8 -2
  95. package/src/tui/select.ts +24 -6
  96. package/src/tui/settings.ts +4 -0
  97. package/src/tui/system-theme.ts +700 -0
  98. package/src/tui/transcript.ts +63 -11
  99. package/src/tui/tree.ts +161 -95
  100. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  101. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  102. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  103. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  104. package/src/vendor/pi-rich/html.ts +51 -0
  105. package/src/vendor/pi-rich/image-convert.ts +49 -0
  106. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  107. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  108. package/src/vendor/pi-rich/image-resize.ts +123 -0
  109. package/src/vendor/pi-rich/mime.ts +116 -0
  110. package/src/vendor/pi-rich/photon.ts +139 -0
  111. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  112. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  113. package/src/vendor/pi-tui/VENDOR.md +7 -5
  114. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  115. package/src/vendor/pi-tui/colors.ts +367 -0
  116. package/src/vendor/pi-tui/components/box.ts +8 -9
  117. package/src/vendor/pi-tui/components/editor.ts +4 -2
  118. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  119. package/src/vendor/pi-tui/index.ts +31 -1
  120. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  121. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  122. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  123. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  124. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  125. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  126. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  127. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  128. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  129. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  130. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  131. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  132. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  133. package/src/vendor/pi-tui/native/napi.h +91 -0
  134. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  135. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  136. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  137. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  138. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  139. package/src/vendor/pi-tui/native-platform.ts +2 -0
  140. package/src/vendor/pi-tui/oklab.ts +233 -0
  141. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  142. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  143. package/src/vendor/pi-tui/terminal.ts +16 -9
  144. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  145. package/src/vendor/pi-tui/tui.ts +112 -75
  146. package/src/vendor/pi-tui/utils.ts +89 -49
  147. 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 { modelsPath, readModelsText, writeModelsFile } from "../store/models.ts";
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 { saveTrust, trustPath } from "../store/trust.ts";
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, type ListedSession,
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 { TuiMainScreen } from "../tui/host.ts";
73
+ import { Container, PaddedDocument, createInteractiveTui, replaceInteractiveTui, Lines, PiText, MarkdownBlock, thinkingBorderColor } from "../tui/host.ts";
74
+ import type { InteractiveScreen, MarkdownFeatures } from "../tui/host.ts";
75
+ import { createChatViewport, type ChatViewport } from "../tui/chat-viewport.ts";
76
+ import { generateSystemThemeColors } from "../tui/system-theme.ts";
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 { nextDelivery, type Phase, type QueuedMessage, type QueueKind } from "../core/queue.ts";
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 { writeUserSettings } from "../store/resources.ts";
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, toJsonl } from "../core/export.ts";
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
- createSessionFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, type App,
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 (pendingOAuth === null || pendingOAuth.done) {
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(C.dim(HINT));
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
- const result = await tui.applyInput(data).catch((error: unknown) => {
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
- await cancelPendingOAuth();
290
- await tui.screen.terminal.drainInput();
291
- tui.detach();
292
- delete app.askSlot.current;
293
- app.io.say = previousSay;
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
- await tui.openResumeSelect();
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
- tui.awaitingKeyFor = null;
441
- tui.editor.setSecret(false);
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
- await completeOAuthPaste(app, text);
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.awaitingKeyFor = outcome.login;
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 === undefined) {
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
- if (key === "statusbar" || key === "status") {
605
- const on = value === "on" || value === "true" || value === "1";
606
- const off = value === "off" || value === "false" || value === "0";
607
- if (!on && !off) {
608
- app.io.say(C.yellow(" statusBar must be on or off"));
609
- return;
610
- }
611
- app.ui.settings = { ...app.ui.settings, statusBar: on };
612
- app.ui.userOverrides = { ...app.ui.userOverrides, statusBar: on };
613
- await writeUserSettings({ statusBar: on });
614
- app.io.say(C.dim(` statusBar ${on ? "on" : "off"}`));
615
- tui?.paint();
616
- return;
617
- }
618
- app.io.say(C.yellow(` unknown setting: ${field}`));
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: readonly Effort[] = ["low", "medium", "high", "xhigh", "max"];
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
- const map = spec.thinkingLevelMap;
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): Outcome | Promise<Outcome> {
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 low|medium|high|xhigh|max"));
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 : [...(app.ui?.settings.scopedModels ?? [])];
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 ids: string[] = [];
718
- for (const pattern of names) {
719
- const found = findModelByPattern(pattern, app.catalog);
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.id, dir);
727
- if (next === undefined || next === app.providerConfig.spec.id) return;
728
- await switchByPattern(app, next);
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 messages = new Map<string, TreeNode[]>();
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
- const turns = points.filter((point) => point.node === node);
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
- if (event.t === "user") { turn++; continue; }
840
- const point = turns[turn];
841
- if (!point) continue;
842
- const target = `${point.node}:${point.atEvent}`;
843
- const labels = event.t === "assistant" ? event.blocks.map((block) =>
844
- block.type === "tool_use" ? `tool · ${block.name}` : `${block.type === "text" ? "assistant" : "thinking"} · ${firstLine(block.text, 120)}`)
845
- : event.t === "tool_result" ? [`tool result · ${event.ok ? "ok" : "failed"} · ${firstLine(event.content, 100)}`]
846
- : event.t === "cut" ? [`context boundary · ${event.reason}`] : [];
847
- const children = messages.get(target) ?? [];
848
- labels.forEach((prompt, i) => children.push({ id: `${node}:${event.id}:${i}`, target, prompt,
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
- const enrich = (nodes: readonly TreeNode[]): TreeNode[] => nodes.map((node) => ({ ...node,
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.id === app.providerConfig.spec.id;
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: writeModelsFile,
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 userFile = await readModelsText();
987
- const loaded = loadCatalog({ userText: userFile.text });
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) → ${modelsPath()}`),
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] = { type: "api_key", key };
1005
- await writeAuthFile(app.auth);
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
- await writeAuthFile(app.auth);
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
- let pendingOAuth: PendingOAuth | null = null;
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 = pendingOAuth;
1066
- pendingOAuth = null;
1067
- if (pending === null) return;
1068
- pending.done = true;
1069
- await pending.server.close();
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
- pendingOAuth = null;
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: globalThis.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: globalThis.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 = pendingOAuth;
1123
- if (pending === null || pending.done) return;
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
- try {
1153
- const credential = await loginGitHubCopilot({
1154
- notify: (message) => app.io.say(` ${message}`),
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: globalThis.fetch,
1157
- signal: new AbortController().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 startCallbackServer({
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
- tryOpenBrowser(url);
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
- pendingOAuth = pending;
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 = findModelByPattern(pattern, app.catalog);
1262
- if (!found.ok) {
1263
- app.io.say(C.red(` ${found.reason}`));
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, found.spec);
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.sessionDir ?? "").length > 0 ? app.args.sessionDir : undefined;
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 next.start();
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
- const body = html
1377
- ? toHtml(events, {
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
- : toJsonl(events);
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 = copyToClipboard(text);
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(" no clipboard command (tried pbcopy, wl-copy, xclip). Printing it instead:"));
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.restorePoints();
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.prompt, 60)}`);
1605
+ app.io.say(` ${C.bold(n)} ${firstLine(p.text, 60)}`);
1447
1606
  });
1448
- app.io.say(C.dim("\n /fork <n> copies up to that turn into a new session\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.atEvent, `forked at ${firstLine(point.prompt, 40)}`);
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<void> {
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: "here" | "parent" | "never"): Promise<void> {
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 = target !== "never";
1482
- await saveTrust(dir, trusted);
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(" restart to load or skip .xeno/settings.json. Trust does not affect tool execution."),
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, expanded.message, signal, tui);
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<string | null>;
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: string,
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
- for await (const e of app.session.send(prompt, signal, pull)) {
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: TuiMainScreen;
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 readonly branch: string | null;
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.screen.addChild(this.transcript);
1734
- this.screen.addChild(this.chat);
1735
- this.transcriptItems = projectTranscript(app.session.state.events);
1736
- this.screen.addChild(this.live);
1737
- this.screen.addChild(this.editorBox);
1738
- this.screen.addChild(this.footerBox);
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
- if (this.historyLength !== session.state.events.length) {
1819
- this.transcriptItems = projectTranscript([...this.ancestors, ...session.state.events]);
1820
- this.historyLength = session.state.events.length;
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 Lines((width) => this.statusLines(width)));
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
- ...SUBSCRIPTION_OPTIONS.map((option) => ({ value: option.id, label: option.id, description: option.label })),
1931
- ...providers.map((provider) => ({ value: provider, label: provider, description: "API key" })),
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.onPick = async (item) => { await handleLine(this.app, "/login " + item.value, 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.restorePoints();
1939
- this.select = new SelectList(points.flatMap((point, i) => point.onPath ? [{ value: String(i + 1), label: firstLine(point.prompt, 120), description: "node " + point.node }] : []),
1940
- { style: this.style, title: "Fork · choose a completed exchange", emptyMessage: "No exchanges on this branch yet" });
1941
- this.onPick = async (item) => { await forkSession(this.app, item.value); await this.syncTranscript(); };
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 = ["low", "medium", "high", "xhigh", "max"].map((v) => ({ value: v, label: v }));
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.id === this.app.providerConfig.spec.id;
2576
+ const here = modelKey(spec) === modelKey(this.app.providerConfig.spec);
1978
2577
  items.push({
1979
- value: spec.id,
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.id, hint: "↑↓ select · Enter apply · Ctrl+S save default · Esc cancel" });
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.id === item.value);
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
- await writeUserSettings({ defaultModel: item.value });
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: item.value };
2026
- this.app.ui.settings = { ...this.app.ui.settings, defaultModel: item.value };
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
- await writeUserSettings({ defaultThinking: item.value });
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 = { ...this.app.ui.userOverrides, defaultThinking: item.value };
2040
- this.app.ui.settings = { ...this.app.ui.settings, defaultThinking: item.value };
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
- const { sessions, truncated } = await listSessions(this.app.workspace, sessionDirOf(this.app));
2048
- const note = truncationNote(truncated);
2049
- if (note) this.app.io.say(this.app.io.palette.yellow(` ${note}`));
2050
- const ok = sessions.filter((s): s is Extract<ListedSession, { ok: true }> => s.ok);
2051
- if (ok.length === 0) {
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
- * Live-region tree. Selection is UI state; Enter calls Session.rewind
2072
- * rather than a second restore implementation. If a turn is in flight,
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
- const point = points.find((p) => `${p.node}:${p.atEvent}` === (node.target ?? node.id));
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.rewind(point, "context");
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
- if (!done.ok) {
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.stop();
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
- async applyInput(data: string): Promise<"continue" | "quit"> {
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
- for (const change of this.settings.acceptInput(data)) await commitSetting(this.app, change.id, change.value, this);
2165
- if (this.settings.cancelled) this.settings = null;
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 state = this.tree.acceptInput(data);
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.editor.acceptInput(data);
2200
- if (data.includes("\x1b[201~")) this.pasting = false;
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.exit" || keyMatches(data, "ctrl+c")) {
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 (keyMatches(data, "ctrl+d") && this.editor.state.text.length === 0) return "quit";
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.awaitingKeyFor !== null) {
2221
- if (this.awaitingKeyFor === OAUTH_PASTE) await cancelPendingOAuth();
2222
- this.awaitingKeyFor = null;
2223
- this.editor.setSecret(false);
2224
- } else if (this.inflight) { this.abort?.abort(); this.returnQueueToEditor(); }
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.recallLast(); this.paint(); return "continue"; }
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.thinkingExpanded = !this.thinkingExpanded; this.paint(); return "continue"; }
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 (this.inflight && action === "app.message.followUp") {
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
- if (this.inflight) this.enqueue({ kind: queueKind, text: command.text });
2273
- else this.turn = this.runPromptTurn(command.text).catch((error: unknown) => {
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<string | null> {
2303
- const item = nextDelivery(this.queue, phase);
2304
- if (item === null) return null;
2305
- this.queue = this.queue.filter((m) => m !== item);
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
- return this.expandQueued(item.text);
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(text: string): Promise<string | null> {
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
- return expanded.send ? expanded.message : null;
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 item = nextDelivery(this.queue, "turn_end");
2326
- if (item === null) break;
2327
- this.queue = this.queue.filter((m) => m !== item);
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
- private recallLast(): void {
2338
- const last = this.queue.pop();
2339
- if (last === undefined) return;
2340
- const draft = this.editor.state.text;
2341
- const text = draft.length > 0 ? `${last.text}\n${draft}` : last.text;
2342
- this.editor.applyExternal(text);
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"));