@visual-z/xeno 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +498 -0
  2. package/docs/install.md +130 -0
  3. package/package.json +43 -0
  4. package/src/apps/approve.ts +49 -0
  5. package/src/apps/cli.ts +37 -0
  6. package/src/apps/commands.ts +226 -0
  7. package/src/apps/completions.ts +28 -0
  8. package/src/apps/input.ts +297 -0
  9. package/src/apps/render.ts +207 -0
  10. package/src/apps/shell.ts +2353 -0
  11. package/src/apps/startup.ts +1302 -0
  12. package/src/core/benchmark.ts +195 -0
  13. package/src/core/boundary.ts +174 -0
  14. package/src/core/churn.ts +59 -0
  15. package/src/core/completion-review.ts +66 -0
  16. package/src/core/context-policy.ts +60 -0
  17. package/src/core/context-window.ts +54 -0
  18. package/src/core/continuation.ts +114 -0
  19. package/src/core/cycle.ts +17 -0
  20. package/src/core/data-query.ts +201 -0
  21. package/src/core/deadline.ts +39 -0
  22. package/src/core/derive.ts +489 -0
  23. package/src/core/edit-plan.ts +200 -0
  24. package/src/core/environment.ts +29 -0
  25. package/src/core/event-validation.ts +99 -0
  26. package/src/core/events.ts +486 -0
  27. package/src/core/evidence-links.ts +71 -0
  28. package/src/core/evidence.ts +89 -0
  29. package/src/core/execution.ts +12 -0
  30. package/src/core/exhaustive.ts +18 -0
  31. package/src/core/experiment.ts +40 -0
  32. package/src/core/export.ts +169 -0
  33. package/src/core/file-references.ts +35 -0
  34. package/src/core/frontmatter.ts +213 -0
  35. package/src/core/handoff.ts +397 -0
  36. package/src/core/harness-comparison.ts +90 -0
  37. package/src/core/history-query.ts +103 -0
  38. package/src/core/history.ts +100 -0
  39. package/src/core/json-event.ts +135 -0
  40. package/src/core/json-pointer.ts +46 -0
  41. package/src/core/keymap.ts +241 -0
  42. package/src/core/observation-view.ts +87 -0
  43. package/src/core/outcome.ts +45 -0
  44. package/src/core/overflow.ts +59 -0
  45. package/src/core/permissions.ts +182 -0
  46. package/src/core/policy-evaluation.ts +55 -0
  47. package/src/core/progress.ts +101 -0
  48. package/src/core/project.ts +372 -0
  49. package/src/core/prompts.ts +83 -0
  50. package/src/core/queue.ts +60 -0
  51. package/src/core/read-bounds.ts +15 -0
  52. package/src/core/recall-analysis.ts +90 -0
  53. package/src/core/recall-trial.ts +153 -0
  54. package/src/core/response-recovery.ts +46 -0
  55. package/src/core/scheduling.ts +8 -0
  56. package/src/core/settings.ts +174 -0
  57. package/src/core/skills.ts +121 -0
  58. package/src/core/stall.ts +48 -0
  59. package/src/core/text-normalize.ts +81 -0
  60. package/src/core/theme.ts +179 -0
  61. package/src/core/tokens.ts +149 -0
  62. package/src/core/tool-contract.ts +102 -0
  63. package/src/core/tool-input.ts +38 -0
  64. package/src/core/transcript.ts +60 -0
  65. package/src/core/window-trial.ts +65 -0
  66. package/src/core/work-state.ts +103 -0
  67. package/src/core/yaml.ts +282 -0
  68. package/src/index.ts +10 -0
  69. package/src/runtime/auth.ts +255 -0
  70. package/src/runtime/benchmark.ts +187 -0
  71. package/src/runtime/catalog.ts +365 -0
  72. package/src/runtime/evidence-notes.ts +33 -0
  73. package/src/runtime/exec-sessions.ts +214 -0
  74. package/src/runtime/execution-control.ts +39 -0
  75. package/src/runtime/harness-comparison.ts +356 -0
  76. package/src/runtime/history-tools.ts +108 -0
  77. package/src/runtime/loop.ts +1983 -0
  78. package/src/runtime/model.ts +614 -0
  79. package/src/runtime/models-fetch.ts +155 -0
  80. package/src/runtime/oauth-device.ts +86 -0
  81. package/src/runtime/oauth.ts +759 -0
  82. package/src/runtime/openai-responses.ts +286 -0
  83. package/src/runtime/openai.ts +675 -0
  84. package/src/runtime/provider.ts +498 -0
  85. package/src/runtime/recall-trial.ts +226 -0
  86. package/src/runtime/resume.ts +66 -0
  87. package/src/runtime/retry.ts +297 -0
  88. package/src/runtime/tools.ts +1193 -0
  89. package/src/runtime/usage.ts +225 -0
  90. package/src/runtime/window-trial.ts +140 -0
  91. package/src/runtime/working-notes.ts +30 -0
  92. package/src/store/atomic.ts +31 -0
  93. package/src/store/auth-store.ts +50 -0
  94. package/src/store/blobs.ts +142 -0
  95. package/src/store/command.ts +75 -0
  96. package/src/store/config.ts +69 -0
  97. package/src/store/context-files.ts +356 -0
  98. package/src/store/file-index.ts +81 -0
  99. package/src/store/git-branch.ts +35 -0
  100. package/src/store/history-search.ts +19 -0
  101. package/src/store/log.ts +642 -0
  102. package/src/store/models.ts +41 -0
  103. package/src/store/observations.ts +181 -0
  104. package/src/store/query-source.ts +44 -0
  105. package/src/store/recovery.ts +39 -0
  106. package/src/store/replay.ts +63 -0
  107. package/src/store/resources.ts +361 -0
  108. package/src/store/restore.ts +302 -0
  109. package/src/store/search.ts +644 -0
  110. package/src/store/serial.ts +13 -0
  111. package/src/store/sessions.ts +463 -0
  112. package/src/store/trust.ts +69 -0
  113. package/src/store/workspace.ts +256 -0
  114. package/src/store/worktree.ts +233 -0
  115. package/src/tui/ansi.ts +67 -0
  116. package/src/tui/autocomplete.ts +29 -0
  117. package/src/tui/complete.ts +87 -0
  118. package/src/tui/diff.ts +160 -0
  119. package/src/tui/editor.ts +893 -0
  120. package/src/tui/exhaustive.ts +14 -0
  121. package/src/tui/fuzzy.ts +75 -0
  122. package/src/tui/host.ts +223 -0
  123. package/src/tui/input.ts +69 -0
  124. package/src/tui/keys.ts +378 -0
  125. package/src/tui/markdown.ts +17 -0
  126. package/src/tui/render.ts +153 -0
  127. package/src/tui/select.ts +240 -0
  128. package/src/tui/settings.ts +40 -0
  129. package/src/tui/statusline.ts +162 -0
  130. package/src/tui/style.ts +205 -0
  131. package/src/tui/terminal.ts +30 -0
  132. package/src/tui/text.ts +52 -0
  133. package/src/tui/toolview.ts +386 -0
  134. package/src/tui/transcript.ts +71 -0
  135. package/src/tui/tree.ts +405 -0
  136. package/src/tui/width.ts +567 -0
  137. package/src/vendor/pi-tui/LICENSE.upstream +21 -0
  138. package/src/vendor/pi-tui/VENDOR.md +23 -0
  139. package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
  140. package/src/vendor/pi-tui/autocomplete.ts +835 -0
  141. package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
  142. package/src/vendor/pi-tui/components/box.ts +167 -0
  143. package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
  144. package/src/vendor/pi-tui/components/editor.ts +2470 -0
  145. package/src/vendor/pi-tui/components/h-stack.ts +44 -0
  146. package/src/vendor/pi-tui/components/image.ts +127 -0
  147. package/src/vendor/pi-tui/components/input.ts +494 -0
  148. package/src/vendor/pi-tui/components/loader.ts +101 -0
  149. package/src/vendor/pi-tui/components/markdown.ts +1015 -0
  150. package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
  151. package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
  152. package/src/vendor/pi-tui/components/select-list.ts +273 -0
  153. package/src/vendor/pi-tui/components/settings-list.ts +328 -0
  154. package/src/vendor/pi-tui/components/spacer.ts +28 -0
  155. package/src/vendor/pi-tui/components/stack.ts +154 -0
  156. package/src/vendor/pi-tui/components/text.ts +107 -0
  157. package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
  158. package/src/vendor/pi-tui/components/v-stack.ts +33 -0
  159. package/src/vendor/pi-tui/editor-component.ts +74 -0
  160. package/src/vendor/pi-tui/fuzzy.ts +138 -0
  161. package/src/vendor/pi-tui/index.ts +156 -0
  162. package/src/vendor/pi-tui/keybindings.ts +320 -0
  163. package/src/vendor/pi-tui/keys.ts +1401 -0
  164. package/src/vendor/pi-tui/kill-ring.ts +46 -0
  165. package/src/vendor/pi-tui/latex.ts +1506 -0
  166. package/src/vendor/pi-tui/layout-node.ts +51 -0
  167. package/src/vendor/pi-tui/layout.ts +449 -0
  168. package/src/vendor/pi-tui/native-modifiers.ts +13 -0
  169. package/src/vendor/pi-tui/native-module-path.ts +31 -0
  170. package/src/vendor/pi-tui/native-platform.ts +63 -0
  171. package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
  172. package/src/vendor/pi-tui/terminal-colors.ts +73 -0
  173. package/src/vendor/pi-tui/terminal-image.ts +696 -0
  174. package/src/vendor/pi-tui/terminal.ts +547 -0
  175. package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
  176. package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
  177. package/src/vendor/pi-tui/tui.ts +1456 -0
  178. package/src/vendor/pi-tui/undo-stack.ts +28 -0
  179. package/src/vendor/pi-tui/utils.ts +1345 -0
  180. package/src/vendor/pi-tui/word-navigation.ts +117 -0
@@ -0,0 +1,2353 @@
1
+ /**
2
+ * Interactive loop and one-shot runner.
3
+ *
4
+ * The live UI is pi's TuiMainScreen (D19): the transcript sits in the
5
+ * activity area and a collapse may clear terminal scrollback. A pipe or
6
+ * CI run never constructs it — `-p` output is parsed by scripts.
7
+ */
8
+
9
+ import { basename, dirname, join } from "node:path";
10
+ import { homedir, tmpdir } from "node:os";
11
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
12
+ import { assertNever } from "../core/exhaustive.ts";
13
+ import type { LogEvent } from "../core/events.ts";
14
+ import { projectTranscript, type TranscriptItem } from "../core/transcript.ts";
15
+ import { fileReferences } from "../core/file-references.ts";
16
+ import { readNodeHistory } from "../store/restore.ts";
17
+ import { Transcript } from "../tui/transcript.ts";
18
+ import type { Handoff } from "../core/handoff.ts";
19
+ import { projectContext, type FrozenPrefix } from "../core/project.ts";
20
+ import { shouldCut, DEFAULT_BOUNDARY, type BoundaryConfig, type BoundaryDecision } from "../core/boundary.ts";
21
+ import { anchoredCount, estimateTokens, type Anchor, type TokenCounter } from "../core/tokens.ts";
22
+ import type { RestorePoint, RewindMode, LoopEvent } from "../runtime/loop.ts";
23
+ import { createProvider, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
24
+ import { outputTokenBudget, type Effort, type ModelSpec } from "../runtime/model.ts";
25
+ import { equivalentCost, reportedCost } from "../runtime/usage.ts";
26
+ import type { ProviderConfig } from "../runtime/provider.ts";
27
+ import { describeMissingCredential, envLabel, ensureFreshCredential, storedIsOAuth } from "../runtime/auth.ts";
28
+ import {
29
+ SUBSCRIPTION_NOTICE, SUBSCRIPTION_OPTIONS, isSubscriptionLogin, subscriptionById,
30
+ loginGitHubCopilot, generatePKCE, generateState, anthropicAuthorizeUrl, openaiCodexAuthorizeUrl,
31
+ startCallbackServer, exchangeAnthropicCode, exchangeOpenAICode, parseAuthorizationInput,
32
+ ANTHROPIC_CALLBACK_PORT, ANTHROPIC_CALLBACK_PATH, ANTHROPIC_REDIRECT_URI,
33
+ OPENAI_CALLBACK_PORT, OPENAI_CALLBACK_PATH, OPENAI_REDIRECT_URI,
34
+ type CallbackServer, type OAuthCredential, type SubscriptionId,
35
+ } from "../runtime/oauth.ts";
36
+ import { writeAuthFile } from "../store/auth-store.ts";
37
+ import { authPath } from "../store/config.ts";
38
+ import { modelsPath, readModelsText, writeModelsFile } from "../store/models.ts";
39
+ import {
40
+ buildFileIndex, indexTruncationNote, type FileIndex,
41
+ } from "../store/file-index.ts";
42
+ import { currentBranch } from "../store/git-branch.ts";
43
+ import { saveTrust, trustPath } from "../store/trust.ts";
44
+ import type { PermissionRequest } from "../core/permissions.ts";
45
+ import type { ApprovalChoice } from "../runtime/tools.ts";
46
+ import { approvalHeading, approvalItems, choiceFromValue } from "./approve.ts";
47
+ import { readCursor, writeCursor, listNodes, readNode } from "../store/log.ts";
48
+ import {
49
+ createSession, findSessionByPrefix, listSessions, truncationNote, type ListedSession,
50
+ } from "../store/sessions.ts";
51
+ import { Editor } from "../tui/editor.ts";
52
+ import type { Key } from "../tui/keys.ts";
53
+ import { editorBindings, encodeKey, keyMatches } from "../tui/input.ts";
54
+ 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";
57
+ import { Settings } from "../tui/settings.ts";
58
+ import { SelectList, type SelectItem } from "../tui/select.ts";
59
+ import { TreeList, renderTree, treeFromPoints, type TreeNode, type NodeParent } from "../tui/tree.ts";
60
+ import { renderStatus, type StatusState } from "../tui/statusline.ts";
61
+ import { nextDelivery, type Phase, type QueuedMessage, type QueueKind } from "../core/queue.ts";
62
+ import { cycle } from "../core/cycle.ts";
63
+ import { usableColumns, type Terminal } from "../tui/terminal.ts";
64
+ import { ANSI, IDENTITY, PLAIN_ATTRS, createStyle, paint, type Style } from "../tui/style.ts";
65
+ import { defaultKeymap, listBindings, type ActionId } from "../core/keymap.ts";
66
+ import { builtinTheme } from "../core/theme.ts";
67
+ import { writeUserSettings } from "../store/resources.ts";
68
+ import { colorEnabled } from "./render.ts";
69
+ import { renderToolView } from "../tui/toolview.ts";
70
+ import { lastAssistantText, toHtml, toJsonl } from "../core/export.ts";
71
+ import { toJsonEvent } from "../core/json-event.ts";
72
+ import { COMMANDS, commandDefinition, parseCommand, type Command } from "./commands.ts";
73
+ import {
74
+ expandInput,
75
+ } from "./input.ts";
76
+ import {
77
+ describeError, firstLine, formatEvent, indent, stdinIsTty, stdoutIsTty, wrap,
78
+ } from "./render.ts";
79
+ import {
80
+ createSessionFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, type App,
81
+ } from "./startup.ts";
82
+
83
+ const HINT = `type a prompt, or ${COMMANDS.map((command) => `/${command.name}`).join(" ")}`;
84
+ const OAUTH_PASTE = "\0oauth";
85
+
86
+ function parseLine(app: App, text: string): Command {
87
+ return parseCommand(text, app.slash);
88
+ }
89
+
90
+ /**
91
+ * Inputs maybeCut reads when it decides whether to cut. The gauge must use
92
+ * this same set -- a second estimate would disagree with the actual cut.
93
+ */
94
+ export interface BudgetInput {
95
+ readonly frozen: FrozenPrefix;
96
+ readonly handoff: Handoff | null;
97
+ readonly events: readonly LogEvent[];
98
+ readonly anchor: Anchor | null;
99
+ readonly counter?: TokenCounter;
100
+ readonly boundary?: Partial<BoundaryConfig>;
101
+ /** maybeCut only runs at a turn boundary; the prompt is that boundary. */
102
+ readonly atTurnBoundary?: boolean;
103
+ }
104
+
105
+ export interface ContextBudget {
106
+ readonly used: number;
107
+ readonly trigger: number;
108
+ readonly inherited: number;
109
+ readonly decision: BoundaryDecision;
110
+ }
111
+
112
+ /**
113
+ * The numbers the status gauge shows, computed the same way maybeCut does:
114
+ * anchoredCount for the current projection, inheritedTokens from the empty
115
+ * node, shouldCut for whether a cut is actually possible. The TUI must not
116
+ * recompute the work floor.
117
+ */
118
+ export function contextBudget(input: BudgetInput): ContextBudget {
119
+ const counter = input.counter ?? estimateTokens;
120
+ const projected = projectContext({
121
+ frozen: input.frozen,
122
+ handoff: input.handoff,
123
+ events: input.events,
124
+ });
125
+ const { tokens } = anchoredCount(
126
+ projected.messages,
127
+ projected.system,
128
+ input.anchor,
129
+ counter,
130
+ input.frozen.tools,
131
+ );
132
+ const floor = projectContext({
133
+ frozen: input.frozen,
134
+ handoff: input.handoff,
135
+ events: [],
136
+ });
137
+ const inherited = counter.count(floor.messages, floor.system, input.frozen.tools);
138
+ const cfg = { ...DEFAULT_BOUNDARY, ...input.boundary };
139
+ const decision = shouldCut({
140
+ tokens,
141
+ inheritedTokens: inherited,
142
+ atTurnBoundary: input.atTurnBoundary ?? true,
143
+ ...(input.boundary ? { config: input.boundary } : {}),
144
+ });
145
+ return { used: tokens, trigger: cfg.triggerTokens, inherited, decision };
146
+ }
147
+
148
+ export function resolveEditorCommand(
149
+ env: { readonly [key: string]: string | undefined } = process.env,
150
+ ): string {
151
+ const visual = env["VISUAL"]?.trim();
152
+ if (visual !== undefined && visual.length > 0) return visual;
153
+ const editor = env["EDITOR"]?.trim();
154
+ if (editor !== undefined && editor.length > 0) return editor;
155
+ return "nano";
156
+ }
157
+
158
+ export type ExternalEditorResult =
159
+ | { ok: true; content: string }
160
+ | { ok: false; message: string };
161
+
162
+ /**
163
+ * The only I/O in the editor-completion card. applyKey stays pure; this
164
+ * writes a temp file, runs $VISUAL/$EDITOR/nano, and deletes the file
165
+ * whether the editor saved or :cq'd. A non-zero exit is a discard — vim's
166
+ * :cq means "I do not want this" and stuffing the half-written file back
167
+ * would turn a cancel into an edit.
168
+ */
169
+ export async function editInExternalEditor(input: {
170
+ readonly content: string;
171
+ readonly command: string;
172
+ }): Promise<ExternalEditorResult> {
173
+ const dir = await mkdtemp(join(tmpdir(), "xeno-editor-"));
174
+ const filePath = join(dir, "prompt.md");
175
+ try {
176
+ await writeFile(filePath, input.content, "utf8");
177
+ const parts = input.command.trim().split(/\s+/).filter((p) => p.length > 0);
178
+ const bin = parts[0];
179
+ if (bin === undefined) {
180
+ return { ok: false, message: "external editor: no command; discarding changes" };
181
+ }
182
+ const proc = Bun.spawn([...parts, filePath], {
183
+ stdin: "inherit",
184
+ stdout: "inherit",
185
+ stderr: "inherit",
186
+ });
187
+ const code = await proc.exited;
188
+ if (code !== 0) {
189
+ return { ok: false, message: `external editor exited ${code}; discarding changes` };
190
+ }
191
+ const content = await readFile(filePath, "utf8");
192
+ return { ok: true, content };
193
+ } catch (err) {
194
+ const msg = err instanceof Error ? err.message : String(err);
195
+ return { ok: false, message: `external editor failed (${msg}); discarding changes` };
196
+ } finally {
197
+ await rm(dir, { recursive: true, force: true });
198
+ }
199
+ }
200
+
201
+ /** Scan once per workspace: a glob on every @ is the stall the card rejected. */
202
+ const fileIndexes = new Map<string, FileIndex>();
203
+ const indexNotes = new Set<string>();
204
+
205
+ async function fileIndexFor(app: App, refresh = false): Promise<FileIndex> {
206
+ const hit = fileIndexes.get(app.workspace);
207
+ if (!refresh && hit !== undefined) return hit;
208
+ const built = await buildFileIndex(app.workspace);
209
+ fileIndexes.set(app.workspace, built);
210
+ const note = indexTruncationNote(built);
211
+ if (note !== null && !indexNotes.has(app.workspace)) {
212
+ indexNotes.add(app.workspace);
213
+ app.io.say(app.io.palette.yellow(` ${note}`));
214
+ }
215
+ return built;
216
+ }
217
+
218
+ export async function runPrompt(app: App, prompt: string): Promise<void> {
219
+ const ok = await run(app, prompt);
220
+ printUsage(app);
221
+ // One-shot mode is scriptable, so a failed turn has to be a failed exit.
222
+ if (!ok) process.exit(1);
223
+ }
224
+
225
+ export async function runRepl(app: App): Promise<void> {
226
+ if (stdinIsTty() && stdoutIsTty()) {
227
+ await runTui(app);
228
+ } else {
229
+ await runLines(app);
230
+ }
231
+ }
232
+
233
+ async function runLines(app: App): Promise<void> {
234
+ const C = app.io.palette;
235
+ app.io.say(C.dim(HINT));
236
+ /** Set by `/login <provider>`; the next line typed is the key, not a prompt. */
237
+ let awaitingKeyFor: string | null = null;
238
+ for await (const line of console) {
239
+ const text = line.trim();
240
+ if (awaitingKeyFor !== null) {
241
+ const provider = awaitingKeyFor;
242
+ awaitingKeyFor = null;
243
+ if (text.length === 0) {
244
+ if (provider === OAUTH_PASTE) await cancelPendingOAuth();
245
+ app.io.say(C.dim(" cancelled\n"));
246
+ continue;
247
+ }
248
+ if (provider === OAUTH_PASTE) {
249
+ if (pendingOAuth === null || pendingOAuth.done) {
250
+ const outcome = await execute(app, parseLine(app, text), null);
251
+ if (outcome === "quit") break;
252
+ if (typeof outcome === "object") awaitingKeyFor = outcome.login;
253
+ continue;
254
+ }
255
+ await completeOAuthPaste(app, text);
256
+ continue;
257
+ }
258
+ await login(app, provider, text);
259
+ continue;
260
+ }
261
+ if (!text) continue;
262
+ const outcome = await execute(app, parseLine(app, text), null);
263
+ if (outcome === "quit") break;
264
+ if (typeof outcome === "object") awaitingKeyFor = outcome.login;
265
+ }
266
+ printUsage(app);
267
+ }
268
+
269
+ async function runTui(app: App): Promise<void> {
270
+ const C = app.io.palette;
271
+ app.io.say(C.dim(HINT));
272
+ const tui = new Tui(app);
273
+ const previousSay = app.io.say;
274
+ app.io.say = (line) => tui.writeLine(line);
275
+ await tui.syncTranscript();
276
+ tui.attach();
277
+ app.askSlot.current = (request) => tui.ask(request);
278
+ tui.paint();
279
+ try {
280
+ while (true) {
281
+ const data = await tui.nextInput();
282
+ const result = await tui.applyInput(data).catch((error: unknown) => {
283
+ tui.writeLine(app.io.palette.red(describeError(error)));
284
+ return "continue" as const;
285
+ });
286
+ if (result === "quit") { await tui.settle(); break; }
287
+ }
288
+ } finally {
289
+ await cancelPendingOAuth();
290
+ await tui.screen.terminal.drainInput();
291
+ tui.detach();
292
+ delete app.askSlot.current;
293
+ app.io.say = previousSay;
294
+ printUsage(app);
295
+ }
296
+ }
297
+
298
+ type Outcome = "continue" | "quit" | { login: string };
299
+
300
+ async function execute(app: App, command: Command, tui: Tui | null): Promise<Outcome> {
301
+ switch (command.kind) {
302
+ case "quit":
303
+ return "quit";
304
+ case "usage":
305
+ printUsage(app);
306
+ return "continue";
307
+ case "state":
308
+ printState(app);
309
+ return "continue";
310
+ case "help":
311
+ printHelp(app);
312
+ return "continue";
313
+ case "hotkeys":
314
+ printHotkeys(app);
315
+ return "continue";
316
+ case "settings":
317
+ return applySettingsCommand(app, command.field, command.value, tui);
318
+ case "session":
319
+ await printSession(app);
320
+ return "continue";
321
+ case "model":
322
+ if (command.pattern !== undefined) {
323
+ await switchByPattern(app, command.pattern);
324
+ return "continue";
325
+ }
326
+ if (tui) {
327
+ tui.openModelSelect();
328
+ return "continue";
329
+ }
330
+ printModels(app);
331
+ return "continue";
332
+ case "thinking":
333
+ return applyThinkingCommand(app, command.level, tui);
334
+ case "login": {
335
+ if (command.provider === undefined) {
336
+ if (tui) { tui.openLoginSelect(); return "continue"; }
337
+ const C = app.io.palette;
338
+ const providers = [...new Set(app.catalog.map((m) => m.provider))];
339
+ app.io.say(C.bold("\nlogin"));
340
+ app.io.say(C.bold(" API keys"));
341
+ for (const p of providers) {
342
+ app.io.say(` ${p.padEnd(12)}${C.dim(envLabel(p))}`);
343
+ }
344
+ app.io.say(C.bold(" Subscriptions"));
345
+ for (const s of SUBSCRIPTION_OPTIONS) {
346
+ app.io.say(` ${s.id.padEnd(16)}${C.dim(s.label)}`);
347
+ }
348
+ app.io.say(C.dim("\n /login <provider> then paste the key. Empty line cancels."));
349
+ app.io.say(C.dim(" /login claude-pro|codex|github-copilot starts a subscription login.\n"));
350
+ return "continue";
351
+ }
352
+ if (isSubscriptionLogin(command.provider)) {
353
+ return startSubscriptionLogin(app, command.provider, tui);
354
+ }
355
+ const C = app.io.palette;
356
+ // The editor paints a mask; this sentence has to match that, because
357
+ // "it will be visible here" is how a secret ends up in a screenshot.
358
+ app.io.say(C.yellow(` paste the ${command.provider} key and press enter. It is not shown.`));
359
+ app.io.say(C.dim(" a value starting with ! runs as a command; $VAR reads the environment."));
360
+ app.io.say(C.dim(" empty line cancels."));
361
+ return { login: command.provider };
362
+ }
363
+ case "logout": {
364
+ await logout(app, command.provider);
365
+ return "continue";
366
+ }
367
+ case "back": {
368
+ if (command.arg === undefined) { await printBack(app); return "continue"; }
369
+ const points = await app.session.restorePoints();
370
+ const i = Number(command.arg);
371
+ const point = Number.isInteger(i) ? points[i - 1] : undefined;
372
+ if (!point) {
373
+ app.io.say(app.io.palette.red(` no such point: ${command.arg}. /back lists them.`));
374
+ return "continue";
375
+ }
376
+ await printRewind(app, point, command.mode, command.confirm);
377
+ await tui?.syncTranscript();
378
+ return "continue";
379
+ }
380
+ case "tree":
381
+ if (tui) {
382
+ await tui.openTreeSelect();
383
+ return "continue";
384
+ }
385
+ await printTree(app);
386
+ return "continue";
387
+ case "new":
388
+ await openNewSession(app, tui ?? undefined);
389
+ return "continue";
390
+ case "resume":
391
+ if (command.id !== undefined) {
392
+ await resumeSession(app, command.id, tui ?? undefined);
393
+ return "continue";
394
+ }
395
+ if (tui) {
396
+ await tui.openResumeSelect();
397
+ return "continue";
398
+ }
399
+ await printSessionList(app);
400
+ return "continue";
401
+ case "name":
402
+ await nameSession(app, command.name, tui ?? undefined);
403
+ return "continue";
404
+ case "export":
405
+ await exportSession(app, command.file);
406
+ return "continue";
407
+ case "copy":
408
+ await copyLastReply(app);
409
+ return "continue";
410
+ case "fork":
411
+ if (tui && command.arg === undefined) { await tui.openForkSelect(); return "continue"; }
412
+ await forkSession(app, command.arg);
413
+ await tui?.syncTranscript();
414
+ return "continue";
415
+ case "clone":
416
+ await cloneSession(app);
417
+ await tui?.syncTranscript();
418
+ return "continue";
419
+ case "trust":
420
+ await trustProject(app, command.target);
421
+ return "continue";
422
+ case "models-refresh":
423
+ await refreshModels(app);
424
+ return "continue";
425
+ case "unknown":
426
+ app.io.say(app.io.palette.yellow(` unknown command: ${command.input}. /help lists them.`));
427
+ return "continue";
428
+ case "prompt":
429
+ await run(app, command.text, tui?.signal, tui ?? undefined);
430
+ app.io.say("");
431
+ return "continue";
432
+ default:
433
+ return assertNever(command, "execute");
434
+ }
435
+ }
436
+
437
+ async function handleLine(app: App, text: string, tui: Tui): Promise<Outcome> {
438
+ if (tui.awaitingKeyFor !== null) {
439
+ const provider = tui.awaitingKeyFor;
440
+ tui.awaitingKeyFor = null;
441
+ tui.editor.setSecret(false);
442
+ if (text.length === 0) {
443
+ if (provider === OAUTH_PASTE) await cancelPendingOAuth();
444
+ app.io.say(app.io.palette.dim(" cancelled\n"));
445
+ return "continue";
446
+ }
447
+ if (provider === OAUTH_PASTE) {
448
+ await completeOAuthPaste(app, text);
449
+ return "continue";
450
+ }
451
+ await login(app, provider, text);
452
+ return "continue";
453
+ }
454
+ const outcome = await execute(app, parseLine(app, text), tui);
455
+ if (typeof outcome === "object") {
456
+ tui.awaitingKeyFor = outcome.login;
457
+ tui.editor.setSecret(true);
458
+ }
459
+ return outcome;
460
+ }
461
+
462
+ function printUsage(app: App): void {
463
+ const C = app.io.palette;
464
+ const t = app.session.totals;
465
+ const spec = app.session.provider.config.spec;
466
+ const rates = spec.rates;
467
+ app.io.say(C.bold("\nusage"));
468
+ app.io.say(` requests ${t.requests}`);
469
+ app.io.say(` prompt tokens ${t.prompt_tokens.toLocaleString()}`);
470
+ app.io.say(` uncached ${t.input_tokens.toLocaleString()}`);
471
+ if (rates === undefined) {
472
+ app.io.say(` cache write ${t.cache_creation_input_tokens.toLocaleString()} (unknown)`);
473
+ app.io.say(` cache read ${t.cache_read_input_tokens.toLocaleString()} (unknown)`);
474
+ } else {
475
+ // The multiples come from the model that actually ran. They used to be
476
+ // Anthropic's, printed verbatim beside a DeepSeek total, where the write
477
+ // rate is 0 and the read rate is a third of what the label claimed.
478
+ app.io.say(
479
+ ` cache write ${t.cache_creation_input_tokens.toLocaleString()} ` +
480
+ `(${rates.write}x${rates.write === 0 ? ", not billed" : ""})`,
481
+ );
482
+ app.io.say(` cache read ${t.cache_read_input_tokens.toLocaleString()} (${rates.read.toFixed(3)}x)`);
483
+ }
484
+ app.io.say(` output tokens ${t.output_tokens.toLocaleString()}`);
485
+ const rate = (t.cache_hit_rate * 100).toFixed(1);
486
+ const rateColour = t.cache_hit_rate > 0.7 ? C.green : t.cache_hit_rate > 0.3 ? C.yellow : C.red;
487
+ app.io.say(` cache hit rate ${rateColour(rate + "%")}`);
488
+ if (rates !== undefined) {
489
+ const cost = equivalentCost(t, rates);
490
+ app.io.say(
491
+ ` input equivalent ${Math.round(cost.input_equivalent).toLocaleString()} ` +
492
+ C.dim(`(vs ${Math.round(cost.uncached_equivalent).toLocaleString()} uncached, ` +
493
+ `${cost.savings_ratio.toFixed(2)}x saved)`),
494
+ );
495
+ }
496
+ const dollars = reportedCost(t, spec.price);
497
+ if (dollars.kind === "unknown") {
498
+ app.io.say(` cost unknown`);
499
+ } else if (dollars.kind === "usd") {
500
+ app.io.say(` cost $${dollars.amount.toFixed(4)}`);
501
+ } else if (dollars.kind === "partial") {
502
+ // `known` is not a complete bill. Printing it on the usd path would
503
+ // look like cache had been free.
504
+ app.io.say(` cost $${dollars.known.toFixed(4)} (cache not counted)`);
505
+ } else {
506
+ return assertNever(dollars, "printUsage");
507
+ }
508
+ app.io.say(` wall clock ${(t.ms / 1000).toFixed(1)}s`);
509
+ app.io.say("");
510
+ }
511
+
512
+ function printState(app: App): void {
513
+ const C = app.io.palette;
514
+ const s = app.session.state;
515
+ app.io.say(C.bold("\nstate"));
516
+ app.io.say(` node ${s.node} (depth ${s.depth})`);
517
+ app.io.say(` events in node ${s.events.length}`);
518
+ if (s.handoff) {
519
+ const h = s.handoff;
520
+ app.io.say(` completed ${h.state.completed.length}`);
521
+ app.io.say(` unverified ${h.state.unverified_claims.length}`);
522
+ app.io.say(` in flight ${h.state.in_flight.length}`);
523
+ app.io.say(` blocked ${h.state.blocked.length}`);
524
+ app.io.say(
525
+ ` gates ${h.verification.map((v) => `${v.gate}=${v.status}`).join(" ") || "none"}`,
526
+ );
527
+ app.io.say(` no progress for ${h.budget.nodes_without_progress} node(s)`);
528
+ } else {
529
+ app.io.say(C.dim(" no handoff yet (still in the first node)"));
530
+ }
531
+ app.io.say("");
532
+ }
533
+
534
+ function printHotkeys(app: App): void {
535
+ const C = app.io.palette;
536
+ const map = app.ui?.keymap ?? defaultKeymap();
537
+ const enabled = app.session.config.frozen.tools.length;
538
+ app.io.say(C.bold("\nhotkeys"));
539
+ app.io.say(` tools enabled ${enabled}`);
540
+ for (const row of listBindings(map)) {
541
+ app.io.say(` ${row.action.padEnd(28)}${row.keys.join(", ")}`);
542
+ }
543
+ app.io.say("");
544
+ }
545
+
546
+ async function applySettingsCommand(
547
+ app: App,
548
+ field: string | undefined,
549
+ value: string | undefined,
550
+ tui: Tui | null,
551
+ ): Promise<Outcome> {
552
+ if (field !== undefined && value !== undefined) {
553
+ await commitSetting(app, field, value, tui);
554
+ return "continue";
555
+ }
556
+ if (tui) {
557
+ tui.openSettings(field);
558
+ return "continue";
559
+ }
560
+ const s = app.ui?.settings;
561
+ app.io.say(app.io.palette.bold("\nsettings"));
562
+ app.io.say(` theme ${s?.theme ?? "dark"}`);
563
+ app.io.say(` thinking ${s?.thinking ?? app.providerConfig.effort}`);
564
+ app.io.say(` statusBar ${s?.statusBar === false ? "off" : "on"}`);
565
+ app.io.say(app.io.palette.dim(" /settings <theme|thinking|statusBar> <value> to change.\n"));
566
+ return "continue";
567
+ }
568
+
569
+ async function commitSetting(app: App, field: string, value: string, tui: Tui | null): Promise<void> {
570
+ const C = app.io.palette;
571
+ const key = field.toLowerCase();
572
+ if (app.ui === undefined) {
573
+ app.io.say(C.yellow(" settings are not available in this session"));
574
+ return;
575
+ }
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
+ if (key === "thinking") {
593
+ 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;
603
+ }
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;
626
+ }
627
+
628
+ const EFFORTS: readonly Effort[] = ["low", "medium", "high", "xhigh", "max"];
629
+
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
+ */
636
+ 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;
641
+ }
642
+
643
+ /**
644
+ * Effort is a request parameter on the current provider. Switching it
645
+ * must not cut: a cut drops the prefix cache, and thinking blocks stay
646
+ * valid because the model did not change.
647
+ */
648
+ function applyThinking(app: App, level: Effort, tui: Tui | null): boolean {
649
+ const C = app.io.palette;
650
+ if (!effortSupported(app.providerConfig.spec, level)) {
651
+ app.io.say(
652
+ C.yellow(
653
+ ` ${level} is not supported by ${app.providerConfig.spec.id}; thinking unchanged`,
654
+ ),
655
+ );
656
+ return false;
657
+ }
658
+ app.providerConfig = { ...app.providerConfig, effort: level };
659
+ if (app.credential.ok) {
660
+ app.session.provider = makeProvider(app, app.providerConfig, app.credential.key);
661
+ }
662
+ if (app.ui !== undefined) {
663
+ app.ui.settings = { ...app.ui.settings, thinking: level };
664
+ }
665
+ app.io.say(C.dim(` thinking ${level}`));
666
+ tui?.paint();
667
+ return true;
668
+ }
669
+
670
+ function applyThinkingCommand(app: App, level: string | undefined, tui: Tui | null): Outcome | Promise<Outcome> {
671
+ if (level !== undefined) {
672
+ const effort = asEffort(level);
673
+ if (effort === undefined) {
674
+ app.io.say(app.io.palette.yellow(" thinking must be low|medium|high|xhigh|max"));
675
+ return "continue";
676
+ }
677
+ applyThinking(app, effort, tui);
678
+ return "continue";
679
+ }
680
+ if (tui) {
681
+ tui.openThinkingSelect();
682
+ return "continue";
683
+ }
684
+ printThinking(app);
685
+ return "continue";
686
+ }
687
+
688
+ function printThinking(app: App): void {
689
+ const C = app.io.palette;
690
+ const spec = app.providerConfig.spec;
691
+ app.io.say(C.bold("\nthinking"));
692
+ for (const level of EFFORTS) {
693
+ const supported = effortSupported(spec, level);
694
+ const here = level === app.providerConfig.effort;
695
+ const mark = !supported ? C.dim(" -- ") : here ? C.cyan(" now ") : C.green(" ");
696
+ const note = supported ? "" : C.dim(" unsupported");
697
+ app.io.say(` ${mark} ${level}${note}`);
698
+ }
699
+ app.io.say(C.dim("\n /thinking <level> to switch. Shift+Tab cycles supported levels.\n"));
700
+ }
701
+
702
+ function cycleThinking(app: App, tui: Tui | null): void {
703
+ const supported = EFFORTS.filter((level) => effortSupported(app.providerConfig.spec, level));
704
+ const next = cycle(supported, app.providerConfig.effort, 1);
705
+ if (next === undefined) return;
706
+ applyThinking(app, next, tui);
707
+ }
708
+
709
+ async function cycleScopedModel(app: App, dir: 1 | -1): Promise<void> {
710
+ const C = app.io.palette;
711
+ const fromFlag = splitModelPatterns(app.args.models);
712
+ const names = fromFlag.length > 0 ? fromFlag : [...(app.ui?.settings.scopedModels ?? [])];
713
+ if (names.length === 0) {
714
+ app.io.say(C.dim(" no scoped models. Pass --models a,b or set scopedModels."));
715
+ return;
716
+ }
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
+ }
722
+ if (ids.length === 0) {
723
+ app.io.say(C.yellow(" --models matched nothing"));
724
+ return;
725
+ }
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);
729
+ }
730
+
731
+ function printHelp(app: App): void {
732
+ const C = app.io.palette;
733
+ app.io.say(C.bold("\ncommands"));
734
+ for (const command of COMMANDS) {
735
+ const hint = "argumentHint" in command ? ` ${command.argumentHint}` : "";
736
+ app.io.say(` /${(command.name + hint).padEnd(34)} ${command.description}`);
737
+ }
738
+ app.io.say(' @file or @"file name" attach a project file anywhere in the prompt');
739
+ app.io.say(" !cmd run in the workspace and send stdout/stderr/exit to the model");
740
+ app.io.say(" !!cmd run without sending output");
741
+ app.io.say("");
742
+ }
743
+
744
+ async function printSession(app: App): Promise<void> {
745
+ const C = app.io.palette;
746
+ const cursor = await readCursor(app.logDir);
747
+ app.io.say(C.bold("\nsession"));
748
+ app.io.say(` id ${basename(app.logDir)}`);
749
+ app.io.say(` name ${cursor?.name ?? "(unnamed)"}`);
750
+ app.io.say(` path ${app.logDir}`);
751
+ app.io.say(` node ${app.session.state.node}`);
752
+ app.io.say(` events ${app.session.state.events.length}`);
753
+ app.io.say(` model ${app.providerConfig.spec.id}`);
754
+ printUsage(app);
755
+ }
756
+
757
+ async function printSessionList(app: App): Promise<void> {
758
+ const C = app.io.palette;
759
+ const { sessions, truncated } = await listSessions(app.workspace, sessionDirOf(app));
760
+ const note = truncationNote(truncated);
761
+ app.io.say(C.bold("\nsessions"));
762
+ if (note) app.io.say(C.yellow(` ${note}`));
763
+ if (sessions.length === 0) {
764
+ app.io.say(C.dim(" none yet -- /new starts one"));
765
+ app.io.say("");
766
+ return;
767
+ }
768
+ for (const s of sessions) {
769
+ if (!s.ok) {
770
+ app.io.say(C.red(` ${s.id} unreadable ${s.error}`));
771
+ continue;
772
+ }
773
+ const here = s.path === app.logDir ? C.cyan(" now ") : " ";
774
+ const label = (s.name ?? s.id).padEnd(24);
775
+ app.io.say(` ${here} ${label}${C.dim(`${s.events} events ${s.model ?? ""}`)}`);
776
+ }
777
+ app.io.say(C.dim("\n /resume <id> to switch\n"));
778
+ }
779
+
780
+ /**
781
+ * Lists somewhere to go back to, newest last, numbered for typing.
782
+ *
783
+ * Numbered rather than addressed by node and event id: the address is an
784
+ * implementation detail and nobody can type `e00017`. The numbering is of
785
+ * the list as printed, so it is re-read on every command rather than
786
+ * remembered -- a rewind changes the ancestry and with it the list.
787
+ */
788
+ async function printBack(app: App): Promise<void> {
789
+ const C = app.io.palette;
790
+ const points = await app.session.restorePoints();
791
+ app.io.say(C.bold("\nsomewhere to go back to") + C.dim(" (newest last)"));
792
+ if (points.length === 0) {
793
+ app.io.say(C.dim(" nothing yet -- no exchange has changed a file"));
794
+ }
795
+ points.forEach((p, i) => {
796
+ const n = String(i + 1).padStart(2);
797
+ const files = `${p.changed} file${p.changed === 1 ? "" : "s"}`;
798
+ // Off-path entries are branches this session has left, usually by
799
+ // rewinding. Listed, because a node that survives on disk and cannot be
800
+ // found again is lost in every sense that matters to a person.
801
+ const mark = p.onPath ? " " : C.yellow("↩ ");
802
+ app.io.say(` ${C.bold(n)} ${mark} ${C.dim(files.padEnd(9))}${firstLine(p.prompt, 60)}`);
803
+ });
804
+ if (points.some((p) => !p.onPath)) {
805
+ app.io.say(C.dim(`\n ${C.yellow("↩")} left behind by an earlier /back -- still reachable`));
806
+ }
807
+ app.io.say(C.dim("\n /back <n> move the files and the conversation"));
808
+ app.io.say(C.dim(" /back <n> files move the files, keep what was learned"));
809
+ app.io.say(C.dim(" /back <n> context move the conversation, keep the code"));
810
+ // Restated from the write-ups rather than left for the person to work
811
+ // out, because picking wrong is how a rewind makes things worse: the
812
+ // documented trap is keeping bad code with no memory of writing it.
813
+ app.io.say(C.dim("\n code broken + model confused -> plain /back"));
814
+ app.io.say(C.dim(" code broken, model was right -> files"));
815
+ app.io.say(C.dim(" code fine, model went wrong -> context\n"));
816
+ }
817
+
818
+ /**
819
+ * Non-tty /tree: the same layout as the live view, committed to scrollback.
820
+ * Jumping still needs the TUI; this is so a pipe can see the structure.
821
+ */
822
+ async function printTree(app: App): Promise<void> {
823
+ const nodes = await treeNodesFor(app);
824
+ const style = app.ui?.style ?? IDENTITY;
825
+ for (const line of renderTree(nodes, { index: 0, filter: "all" }, 80, style)) {
826
+ app.io.say(line);
827
+ }
828
+ }
829
+
830
+ async function treeNodesFor(app: App): Promise<TreeNode[]> {
831
+ const points = await app.session.restorePoints();
832
+ const parents = await loadNodeParents(app.logDir);
833
+ const messages = new Map<string, TreeNode[]>();
834
+ 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;
838
+ 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);
851
+ }
852
+ }
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));
856
+ }
857
+
858
+ async function loadNodeParents(logDir: string): Promise<Map<number, NodeParent>> {
859
+ const map = new Map<number, NodeParent>();
860
+ for (const id of await listNodes(logDir)) {
861
+ const { header } = await readNode(logDir, id);
862
+ if (header === null) continue;
863
+ if (header.parent === null) {
864
+ map.set(id, null);
865
+ continue;
866
+ }
867
+ map.set(
868
+ id,
869
+ header.parent.at_event === undefined
870
+ ? { node: header.parent.node }
871
+ : { node: header.parent.node, at_event: header.parent.at_event },
872
+ );
873
+ }
874
+ return map;
875
+ }
876
+
877
+ /**
878
+ * Goes back: the files and the conversation move together.
879
+ *
880
+ * Preview first, always. The half of this that nobody asked for is the
881
+ * deletion of everything the target did not have -- including a file a
882
+ * person wrote by hand while the session sat idle -- and that half is
883
+ * invisible in the request.
884
+ */
885
+ async function printRewind(
886
+ app: App,
887
+ point: RestorePoint,
888
+ mode: RewindMode,
889
+ confirmed: boolean,
890
+ ): Promise<void> {
891
+ const C = app.io.palette;
892
+ const label = firstLine(point.prompt, 60);
893
+ const plan = await app.session.previewRewind(point);
894
+ if (!plan.ok) {
895
+ app.io.say(C.red(`\n cannot go back: ${plan.error}\n`));
896
+ return;
897
+ }
898
+
899
+ const what =
900
+ mode === "both" ? "the files and the conversation"
901
+ : mode === "files" ? "the files only"
902
+ : "the conversation only";
903
+ app.io.say(C.bold("\ngo back to") + ` ${label}` + C.dim(` (${what})`));
904
+
905
+ if (mode === "context") {
906
+ // Nothing on disk changes, so listing what the target HAD would read
907
+ // like a plan to write it. What matters is the gap the model is about
908
+ // to be told about.
909
+ const extra = plan.willDelete;
910
+ app.io.say(C.dim(` no file is touched`));
911
+ for (const p of extra.slice(0, 20)) app.io.say(C.yellow(` stays ${p} (not in that context)`));
912
+ if (extra.length > 20) app.io.say(C.yellow(` ... ${extra.length - 20} more`));
913
+ } else {
914
+ for (const p of plan.willWrite.slice(0, 20)) app.io.say(C.dim(` keep ${p}`));
915
+ if (plan.willWrite.length > 20) app.io.say(C.dim(` ... ${plan.willWrite.length - 20} more`));
916
+ for (const p of plan.willDelete.slice(0, 20)) app.io.say(C.red(` delete ${p}`));
917
+ if (plan.willDelete.length > 20) app.io.say(C.red(` ... ${plan.willDelete.length - 20} more`));
918
+ }
919
+
920
+ if (!confirmed) {
921
+ app.io.say(C.yellow('\n nothing changed. add "confirm" to go back.'));
922
+ if (mode === "both") {
923
+ app.io.say(C.dim(" the turns after this point stop being what the model sees."));
924
+ app.io.say(C.dim(" They are not deleted -- this list will still offer them."));
925
+ } else {
926
+ app.io.say(C.dim(" the two will disagree afterwards. The model is told which"));
927
+ app.io.say(C.dim(" paths, and to re-read before editing -- it is not left to"));
928
+ app.io.say(C.dim(" work it out from a context that no longer matches disk."));
929
+ }
930
+ app.io.say("");
931
+ return;
932
+ }
933
+
934
+ const done = await app.session.rewind(point, mode);
935
+ if (!done.ok) {
936
+ app.io.say(C.red(`\n could not go back: ${done.error}\n`));
937
+ return;
938
+ }
939
+ if (done.unrecoverable.length > 0) {
940
+ app.io.say(C.red(`\n restore not applied: missing content for ${done.unrecoverable.join(", ")}`));
941
+ app.io.say(C.dim(` ${done.note}\n`));
942
+ return;
943
+ }
944
+ app.io.say(
945
+ C.green(`\n back at node ${done.node}`) +
946
+ C.dim(
947
+ mode === "context"
948
+ ? " no file touched"
949
+ : ` ${done.restored.length} restored, ${done.removed.length} removed`,
950
+ ),
951
+ );
952
+ if (done.unrecoverable.length > 0) {
953
+ app.io.say(C.red(` content no longer stored: ${done.unrecoverable.join(", ")}`));
954
+ }
955
+ app.io.say(C.dim(indent(wrap(done.note, 72), " ")));
956
+ app.io.say("");
957
+ }
958
+
959
+ function printModels(app: App): void {
960
+ const C = app.io.palette;
961
+ app.io.say(C.bold("\nmodels"));
962
+ for (const group of groupedModels(app.catalog)) {
963
+ app.io.say(C.bold(` ${group.provider}`));
964
+ for (const spec of group.models) {
965
+ const got = app.credentialOf(spec);
966
+ const here = spec.id === app.providerConfig.spec.id;
967
+ const mark = here ? C.cyan(" now ") : got.ok ? C.green("ready") : C.dim(" -- ");
968
+ app.io.say(` ${mark} ${spec.id.padEnd(28)}${C.dim(spec.name.padEnd(28))}${whyNot(spec, got, app.auth, C)}`);
969
+ }
970
+ }
971
+ app.io.say(C.dim("\n /model <id> to switch. Switching cuts this node so the next"));
972
+ app.io.say(C.dim(" model starts from a handoff, not from this model's thinking blocks.\n"));
973
+ }
974
+
975
+ async function refreshModels(app: App): Promise<void> {
976
+ const C = app.io.palette;
977
+ const result = await runModelsRefresh({
978
+ fetch: globalThis.fetch,
979
+ write: writeModelsFile,
980
+ });
981
+ if (!result.ok) {
982
+ app.io.say(C.yellow(` ${result.reason}`));
983
+ app.io.say(C.dim(" existing models.json left unchanged\n"));
984
+ return;
985
+ }
986
+ const userFile = await readModelsText();
987
+ const loaded = loadCatalog({ userText: userFile.text });
988
+ app.catalog = loaded.models;
989
+ for (const warning of loaded.warnings) app.io.say(C.yellow(` ${warning}`));
990
+ app.io.say(
991
+ C.green(` added ${result.added}, changed ${result.changed} (${result.count} total) → ${modelsPath()}`),
992
+ );
993
+ app.io.say(C.dim(` ${app.catalog.length} reachable after overlay\n`));
994
+ }
995
+
996
+ /** Stores a key under a provider, 0600, after saying what is about to happen. */
997
+ async function login(app: App, provider: string, key: string): Promise<void> {
998
+ const C = app.io.palette;
999
+ if (!app.catalog.some((m) => m.provider === provider)) {
1000
+ app.io.say(C.red(` unknown provider: ${provider}`));
1001
+ app.io.say(C.dim(` known: ${[...new Set(app.catalog.map((m) => m.provider))].join(", ")}\n`));
1002
+ return;
1003
+ }
1004
+ app.auth[provider] = { type: "api_key", key };
1005
+ await writeAuthFile(app.auth);
1006
+ // The editor masked the paste. Printing `key` here would be the only
1007
+ // remaining way it reaches scrollback.
1008
+ app.io.say(C.green(` stored ${provider} in ${authPath()}`) + C.dim(" (mode 0600)"));
1009
+ const ready = app.catalog.filter((m) => m.provider === provider && app.credentialOf(m).ok);
1010
+ if (ready.length === 0) {
1011
+ app.io.say(C.yellow(" stored, but it still does not resolve -- check the value.\n"));
1012
+ return;
1013
+ }
1014
+ app.io.say(C.dim(` now ready: ${ready.map((m) => m.id).join(", ")}`));
1015
+ await adoptCredential(app, ready);
1016
+ app.io.say(C.dim(" this session can send now.\n"));
1017
+ }
1018
+
1019
+ async function logout(app: App, provider: string | undefined): Promise<void> {
1020
+ const C = app.io.palette;
1021
+ if (provider === undefined) {
1022
+ const names = Object.keys(app.auth);
1023
+ if (names.length === 0) {
1024
+ app.io.say(C.dim(" nothing stored\n"));
1025
+ return;
1026
+ }
1027
+ app.io.say(C.bold("\nlogout"));
1028
+ for (const name of names) app.io.say(` ${name}`);
1029
+ app.io.say(C.dim("\n /logout <provider>\n"));
1030
+ return;
1031
+ }
1032
+ if (app.auth[provider] === undefined) {
1033
+ app.io.say(C.dim(` no stored credential for ${provider}\n`));
1034
+ return;
1035
+ }
1036
+ delete app.auth[provider];
1037
+ await writeAuthFile(app.auth);
1038
+ app.io.say(C.green(` removed ${provider} from ${authPath()}\n`));
1039
+ }
1040
+
1041
+ function providerOAuthOpts(app: App, spec: ModelSpec): { oauth: true } | undefined {
1042
+ if (app.args.apiKey.length > 0) return undefined;
1043
+ return storedIsOAuth(app.auth, spec.provider) ? { oauth: true } : undefined;
1044
+ }
1045
+
1046
+ function makeProvider(app: App, config: ProviderConfig, key: string) {
1047
+ return createProvider(config, key, retryHandler(app.io), providerOAuthOpts(app, config.spec));
1048
+ }
1049
+
1050
+ interface PendingOAuth {
1051
+ id: SubscriptionId;
1052
+ provider: string;
1053
+ state: string;
1054
+ verifier: string;
1055
+ redirectUri: string;
1056
+ exchange: "anthropic" | "openai";
1057
+ server: CallbackServer;
1058
+ done: boolean;
1059
+ tui: Tui | null;
1060
+ }
1061
+
1062
+ let pendingOAuth: PendingOAuth | null = null;
1063
+
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();
1070
+ }
1071
+
1072
+ async function storeOAuth(app: App, provider: string, credential: OAuthCredential): Promise<void> {
1073
+ const C = app.io.palette;
1074
+ app.auth[provider] = credential;
1075
+ await writeAuthFile(app.auth);
1076
+ app.io.say(C.green(` stored ${provider} in ${authPath()}`) + C.dim(" (mode 0600)"));
1077
+ const ready = app.catalog.filter((m) => m.provider === provider && app.credentialOf(m).ok);
1078
+ if (ready.length === 0) {
1079
+ app.io.say(C.dim(" stored. No catalog model uses this provider yet.\n"));
1080
+ return;
1081
+ }
1082
+ app.io.say(C.dim(` now ready: ${ready.map((m) => m.id).join(", ")}`));
1083
+ await adoptCredential(app, ready);
1084
+ app.io.say(C.dim(" this session can send now.\n"));
1085
+ }
1086
+
1087
+ async function finishOAuthCode(app: App, pending: PendingOAuth, code: string): Promise<void> {
1088
+ if (pending.done) return;
1089
+ pending.done = true;
1090
+ pendingOAuth = null;
1091
+ if (pending.tui) {
1092
+ pending.tui.awaitingKeyFor = null;
1093
+ pending.tui.editor.setSecret(false);
1094
+ }
1095
+ try {
1096
+ const credential =
1097
+ pending.exchange === "anthropic"
1098
+ ? await exchangeAnthropicCode({
1099
+ code,
1100
+ state: pending.state,
1101
+ verifier: pending.verifier,
1102
+ redirectUri: pending.redirectUri,
1103
+ fetch: globalThis.fetch,
1104
+ })
1105
+ : await exchangeOpenAICode({
1106
+ code,
1107
+ verifier: pending.verifier,
1108
+ redirectUri: pending.redirectUri,
1109
+ fetch: globalThis.fetch,
1110
+ });
1111
+ await storeOAuth(app, pending.provider, credential);
1112
+ } catch (err) {
1113
+ const message = err instanceof Error ? err.message : "token exchange failed";
1114
+ app.io.say(app.io.palette.red(` ${message}`));
1115
+ app.io.say(app.io.palette.dim(" nothing was stored.\n"));
1116
+ } finally {
1117
+ await pending.server.close();
1118
+ }
1119
+ }
1120
+
1121
+ async function completeOAuthPaste(app: App, input: string): Promise<void> {
1122
+ const pending = pendingOAuth;
1123
+ if (pending === null || pending.done) return;
1124
+ const parsed = parseAuthorizationInput(input);
1125
+ if (parsed.state !== undefined && parsed.state !== pending.state) {
1126
+ app.io.say(app.io.palette.red(" OAuth state mismatch"));
1127
+ await cancelPendingOAuth();
1128
+ return;
1129
+ }
1130
+ if (!parsed.code) {
1131
+ app.io.say(app.io.palette.red(" Missing authorization code"));
1132
+ await cancelPendingOAuth();
1133
+ return;
1134
+ }
1135
+ await finishOAuthCode(app, pending, parsed.code);
1136
+ }
1137
+
1138
+ function tryOpenBrowser(url: string): void {
1139
+ try {
1140
+ Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
1141
+ } catch {
1142
+ // Printed URL is the fallback.
1143
+ }
1144
+ }
1145
+
1146
+ async function startSubscriptionLogin(app: App, id: string, tui: Tui | null): Promise<Outcome> {
1147
+ const C = app.io.palette;
1148
+ const sub = subscriptionById(id);
1149
+ if (sub === undefined) return "continue";
1150
+ app.io.say(C.yellow(` ${SUBSCRIPTION_NOTICE}`));
1151
+ if (sub.id === "github-copilot") {
1152
+ try {
1153
+ const credential = await loginGitHubCopilot({
1154
+ notify: (message) => app.io.say(` ${message}`),
1155
+ prompt: async () => "",
1156
+ fetch: globalThis.fetch,
1157
+ signal: new AbortController().signal,
1158
+ });
1159
+ await storeOAuth(app, sub.provider, credential);
1160
+ } catch (err) {
1161
+ const message = err instanceof Error ? err.message : "login failed";
1162
+ app.io.say(C.red(` ${message}\n`));
1163
+ }
1164
+ return "continue";
1165
+ }
1166
+ await cancelPendingOAuth();
1167
+ const pkce = await generatePKCE();
1168
+ const state = generateState();
1169
+ const anthropic = sub.id === "claude-pro";
1170
+ const url = anthropic
1171
+ ? anthropicAuthorizeUrl({ challenge: pkce.challenge, state })
1172
+ : openaiCodexAuthorizeUrl({ challenge: pkce.challenge, state });
1173
+ let server: CallbackServer;
1174
+ try {
1175
+ server = await startCallbackServer({
1176
+ expectedState: state,
1177
+ host: "127.0.0.1",
1178
+ port: anthropic ? ANTHROPIC_CALLBACK_PORT : OPENAI_CALLBACK_PORT,
1179
+ path: anthropic ? ANTHROPIC_CALLBACK_PATH : OPENAI_CALLBACK_PATH,
1180
+ });
1181
+ } catch (err) {
1182
+ const message = err instanceof Error ? err.message : "could not bind callback port";
1183
+ app.io.say(C.yellow(` ${message}`));
1184
+ app.io.say(C.dim(" paste the redirect URL after signing in.\n"));
1185
+ // Still allow paste without a server: we just won't get a browser callback.
1186
+ server = {
1187
+ host: "127.0.0.1",
1188
+ port: 0,
1189
+ waitForCode: () => new Promise(() => {}),
1190
+ close: async () => {},
1191
+ };
1192
+ }
1193
+ app.io.say(` ${url}`);
1194
+ app.io.say(C.dim(" complete login in the browser, or paste the redirect URL. It is not shown."));
1195
+ tryOpenBrowser(url);
1196
+ const pending: PendingOAuth = {
1197
+ id: sub.id,
1198
+ provider: sub.provider,
1199
+ state,
1200
+ verifier: pkce.verifier,
1201
+ redirectUri: anthropic ? ANTHROPIC_REDIRECT_URI : OPENAI_REDIRECT_URI,
1202
+ exchange: anthropic ? "anthropic" : "openai",
1203
+ server,
1204
+ done: false,
1205
+ tui,
1206
+ };
1207
+ pendingOAuth = pending;
1208
+ void pending.server.waitForCode().then(async (got) => {
1209
+ if (!got.ok || pending.done) return;
1210
+ await finishOAuthCode(app, pending, got.code);
1211
+ });
1212
+ return { login: OAUTH_PASTE };
1213
+ }
1214
+
1215
+ /**
1216
+ * Make the just-stored key the one the next prompt uses, without a restart.
1217
+ *
1218
+ * Same model: swap the adapter in place (no thinking blocks to isolate).
1219
+ * Placeholder session that never sent: swap onto an unlocked model in place
1220
+ * so the first prompt is the first event, not a cut of an empty node.
1221
+ * Already talking to a different live model: /model is the cut.
1222
+ */
1223
+ async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<void> {
1224
+ const C = app.io.palette;
1225
+ const currentGot = app.credentialOf(app.providerConfig.spec);
1226
+ if (currentGot.ok) {
1227
+ app.session.provider = makeProvider(app, app.providerConfig, currentGot.key);
1228
+ app.credential = currentGot;
1229
+ return;
1230
+ }
1231
+ const spec = ready[0];
1232
+ if (spec === undefined) return;
1233
+ const got = app.credentialOf(spec);
1234
+ if (!got.ok) return;
1235
+ if (app.credential.ok) {
1236
+ await switchToModel(app, spec);
1237
+ return;
1238
+ }
1239
+ const nextConfig: ProviderConfig = {
1240
+ spec,
1241
+ maxTokens: outputTokenBudget(spec),
1242
+ effort: app.providerConfig.effort,
1243
+ thinkingDisplay: app.providerConfig.thinkingDisplay,
1244
+ };
1245
+ app.session.provider = makeProvider(app, nextConfig, got.key);
1246
+ app.providerConfig = nextConfig;
1247
+ app.credential = got;
1248
+ const cursor = await readCursor(app.logDir);
1249
+ if (cursor !== null) {
1250
+ await writeCursor(app.logDir, {
1251
+ ...cursor,
1252
+ model: spec.id,
1253
+ updated_at: app.session.clock.now(),
1254
+ });
1255
+ }
1256
+ app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
1257
+ }
1258
+
1259
+ async function switchByPattern(app: App, pattern: string): Promise<void> {
1260
+ const C = app.io.palette;
1261
+ const found = findModelByPattern(pattern, app.catalog);
1262
+ if (!found.ok) {
1263
+ app.io.say(C.red(` ${found.reason}`));
1264
+ return;
1265
+ }
1266
+ await switchToModel(app, found.spec);
1267
+ }
1268
+
1269
+ async function switchToModel(app: App, spec: ModelSpec): Promise<void> {
1270
+ const C = app.io.palette;
1271
+ if (spec.id === app.providerConfig.spec.id) {
1272
+ app.io.say(C.dim(` already on ${spec.id}`));
1273
+ return;
1274
+ }
1275
+ const got = app.credentialOf(spec);
1276
+ if (!got.ok) {
1277
+ app.io.say(C.red(`\n ${got.reason}\n`));
1278
+ if (!got.reason.includes("/login")) {
1279
+ app.io.say(C.dim(` ${describeMissingCredential(spec.provider)}\n`));
1280
+ }
1281
+ return;
1282
+ }
1283
+ const nextConfig: ProviderConfig = {
1284
+ spec,
1285
+ maxTokens: outputTokenBudget(spec),
1286
+ effort: app.providerConfig.effort,
1287
+ thinkingDisplay: app.providerConfig.thinkingDisplay,
1288
+ };
1289
+ const next = makeProvider(app, nextConfig, got.key);
1290
+ app.io.say(C.yellow(" switching cuts this node so the next model starts from a handoff,"));
1291
+ app.io.say(C.yellow(" not from this model's thinking blocks."));
1292
+ try {
1293
+ await app.session.switchModel(next);
1294
+ } catch (err) {
1295
+ app.io.say(C.red(` cannot switch: ${describeError(err)}`));
1296
+ return;
1297
+ }
1298
+ app.providerConfig = nextConfig;
1299
+ app.credential = got;
1300
+ app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
1301
+ }
1302
+
1303
+ function sessionDirOf(app: App): string | undefined {
1304
+ return (app.args.sessionDir ?? "").length > 0 ? app.args.sessionDir : undefined;
1305
+ }
1306
+
1307
+ async function openNewSession(app: App, tui?: Tui): Promise<void> {
1308
+ const C = app.io.palette;
1309
+ const created = await createSession(app.workspace, undefined, sessionDirOf(app));
1310
+ const switched = await adoptLogDir(app, created.path);
1311
+ if (!switched) {
1312
+ app.io.say(C.red(` cannot start a new session`));
1313
+ return;
1314
+ }
1315
+ if (tui) { tui.sessionName = null; await tui.syncTranscript(); }
1316
+ app.io.say(C.green(` new session ${created.id}`) + C.dim(` ${created.path}`));
1317
+ }
1318
+
1319
+ async function resumeSession(app: App, idOrPath: string, tui?: Tui): Promise<void> {
1320
+ const C = app.io.palette;
1321
+ const found = await findSessionByPrefix(app.workspace, idOrPath, sessionDirOf(app));
1322
+ if (!found.ok) {
1323
+ app.io.say(C.red(` ${found.reason}. /resume lists them.`));
1324
+ return;
1325
+ }
1326
+ if (found.session.path === app.logDir) {
1327
+ app.io.say(C.dim(" already in this session"));
1328
+ return;
1329
+ }
1330
+ const switched = await adoptLogDir(app, found.session.path);
1331
+ if (!switched) {
1332
+ app.io.say(C.red(` cannot resume`));
1333
+ return;
1334
+ }
1335
+ if (tui) { tui.sessionName = found.session.name ?? null; await tui.syncTranscript(); }
1336
+ app.io.say(C.green(` resumed ${found.session.name ?? found.session.id}`) + C.dim(` ${found.session.path}`));
1337
+ }
1338
+
1339
+ 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
+ try {
1353
+ await next.start();
1354
+ } catch (err) {
1355
+ app.io.say(app.io.palette.red(` ${describeError(err)}`));
1356
+ return false;
1357
+ }
1358
+ app.session = next;
1359
+ app.logDir = logDir;
1360
+ return true;
1361
+ }
1362
+
1363
+ async function transcriptEvents(app: App): Promise<LogEvent[]> {
1364
+ const history = await readNodeHistory(app.logDir, app.session.state.node);
1365
+ if (!history.ok) throw new Error(history.error);
1366
+ return history.nodes.flatMap((node) => node.events);
1367
+ }
1368
+
1369
+ async function exportSession(app: App, file?: string): Promise<void> {
1370
+ const C = app.io.palette;
1371
+ const events = await transcriptEvents(app);
1372
+ const defaultPath = join(app.workspace, `${basename(app.logDir)}.html`);
1373
+ const dest = file !== undefined && file.length > 0 ? file : defaultPath;
1374
+ const abs = dest.startsWith("/") ? dest : join(app.workspace, dest);
1375
+ const html = abs.toLowerCase().endsWith(".jsonl") === false;
1376
+ const body = html
1377
+ ? toHtml(events, {
1378
+ title: basename(app.logDir),
1379
+ exportedAt: app.session.clock.now(),
1380
+ sessionId: basename(app.logDir),
1381
+ })
1382
+ : toJsonl(events);
1383
+ try {
1384
+ await writeFile(abs, body, "utf8");
1385
+ } catch (err) {
1386
+ app.io.say(C.red(` cannot write ${abs}: ${describeError(err)}`));
1387
+ return;
1388
+ }
1389
+ app.io.say(C.green(` exported ${abs}`));
1390
+ }
1391
+
1392
+ async function copyLastReply(app: App): Promise<void> {
1393
+ const C = app.io.palette;
1394
+ const text = lastAssistantText(await transcriptEvents(app));
1395
+ if (text === null) {
1396
+ app.io.say(C.dim(" no assistant reply to copy yet"));
1397
+ return;
1398
+ }
1399
+ const copied = copyToClipboard(text);
1400
+ if (copied) {
1401
+ app.io.say(C.green(" copied last assistant reply"));
1402
+ return;
1403
+ }
1404
+ app.io.say(C.yellow(" no clipboard command (tried pbcopy, wl-copy, xclip). Printing it instead:"));
1405
+ app.io.say(text);
1406
+ }
1407
+
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> {
1435
+ const C = app.io.palette;
1436
+ const points = await app.session.restorePoints();
1437
+ if (arg === undefined) {
1438
+ app.io.say(C.bold("\nfork from a user turn") + C.dim(" (newest last)"));
1439
+ if (points.length === 0) {
1440
+ app.io.say(C.dim(" nothing yet -- send a prompt first"));
1441
+ app.io.say("");
1442
+ return;
1443
+ }
1444
+ points.forEach((p, i) => {
1445
+ const n = String(i + 1).padStart(2);
1446
+ app.io.say(` ${C.bold(n)} ${firstLine(p.prompt, 60)}`);
1447
+ });
1448
+ app.io.say(C.dim("\n /fork <n> copies up to that turn into a new session\n"));
1449
+ return;
1450
+ }
1451
+ const i = Number(arg);
1452
+ const point = Number.isInteger(i) ? points[i - 1] : undefined;
1453
+ if (!point) {
1454
+ app.io.say(C.red(` no such turn: ${arg}. /fork lists them.`));
1455
+ return;
1456
+ }
1457
+ await spawnForkedSession(app, point.atEvent, `forked at ${firstLine(point.prompt, 40)}`);
1458
+ }
1459
+
1460
+ async function cloneSession(app: App): Promise<void> {
1461
+ await spawnForkedSession(app, undefined, "cloned");
1462
+ }
1463
+
1464
+ async function spawnForkedSession(app: App, untilEvent: string | undefined, label: string): Promise<void> {
1465
+ const C = app.io.palette;
1466
+ const created = await createSession(app.workspace, undefined, sessionDirOf(app));
1467
+ try {
1468
+ await app.session.fork(created.path, untilEvent);
1469
+ } catch (err) {
1470
+ app.io.say(C.red(` cannot fork: ${describeError(err)}`));
1471
+ return;
1472
+ }
1473
+ const switched = await adoptLogDir(app, created.path);
1474
+ if (!switched) return;
1475
+ app.io.say(C.green(` ${label} → ${created.id}`) + C.dim(` ${created.path}`));
1476
+ }
1477
+
1478
+ async function trustProject(app: App, target: "here" | "parent" | "never"): Promise<void> {
1479
+ const C = app.io.palette;
1480
+ const dir = target === "parent" ? dirname(app.workspace) : app.workspace;
1481
+ const trusted = target !== "never";
1482
+ await saveTrust(dir, trusted);
1483
+ app.io.say(
1484
+ C.green(` ${trusted ? "trusted" : "will not trust"} ${dir}`) +
1485
+ C.dim(` saved in ${trustPath()}`),
1486
+ );
1487
+ app.io.say(
1488
+ C.dim(" restart to load or skip .xeno/settings.json. Trust does not affect tool execution."),
1489
+ );
1490
+ }
1491
+
1492
+ async function nameSession(app: App, name: string, tui?: Tui): Promise<void> {
1493
+ const C = app.io.palette;
1494
+ if (name.length === 0) {
1495
+ app.io.say(C.dim(" /name <name>"));
1496
+ return;
1497
+ }
1498
+ // Name lives on the cursor. sessions.ts has no setter -- writeCursor is
1499
+ // the store function that actually holds it, and it keeps a name across
1500
+ // the loop's own cursor rewrites.
1501
+ const cursor = await readCursor(app.logDir);
1502
+ if (cursor === null) {
1503
+ app.io.say(C.red(" no cursor to name yet"));
1504
+ return;
1505
+ }
1506
+ await writeCursor(app.logDir, { ...cursor, name });
1507
+ if (tui) tui.sessionName = name;
1508
+ app.io.say(C.green(` named ${name}`));
1509
+ }
1510
+
1511
+ /** 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> {
1513
+ if (!app.credential.ok) {
1514
+ // Must not call session.send: that records a user event before the
1515
+ // request, and a refused prompt is not a turn.
1516
+ const C = app.io.palette;
1517
+ app.io.say(C.yellow(" no credential yet -- this prompt was not sent."));
1518
+ app.io.say(C.dim(" export an API key, or /login <provider>.\n"));
1519
+ return false;
1520
+ }
1521
+
1522
+ const fresh = await ensureFreshCredential(
1523
+ app.providerConfig.spec,
1524
+ app.auth,
1525
+ app.args.apiKey.length > 0 ? app.args.apiKey : undefined,
1526
+ app.effects,
1527
+ {
1528
+ fetch: globalThis.fetch,
1529
+ now: () => Date.now(),
1530
+ persist: async (next) => {
1531
+ const entry = next[app.providerConfig.spec.provider];
1532
+ if (entry !== undefined) app.auth[app.providerConfig.spec.provider] = entry;
1533
+ await writeAuthFile(app.auth);
1534
+ },
1535
+ },
1536
+ );
1537
+ if (!fresh.ok) {
1538
+ const C = app.io.palette;
1539
+ app.io.say(C.red(` ${fresh.reason}`));
1540
+ app.io.say(C.dim(" this prompt was not sent.\n"));
1541
+ return false;
1542
+ }
1543
+ if (!app.credential.ok || fresh.key !== app.credential.key) {
1544
+ app.credential = fresh;
1545
+ app.session.provider = makeProvider(app, app.providerConfig, fresh.key);
1546
+ }
1547
+
1548
+ const needsIndex = fileReferences(prompt).length > 0;
1549
+ const index: FileIndex = needsIndex
1550
+ ? await fileIndexFor(app)
1551
+ : { paths: [], truncated: false };
1552
+ const expanded = await expandInput(prompt, {
1553
+ workspace: app.workspace,
1554
+ index,
1555
+ say: (text) => app.io.say(text),
1556
+ });
1557
+ if (!expanded.send) return true;
1558
+ try {
1559
+ await runTurn(app, expanded.message, signal, tui);
1560
+ return true;
1561
+ } catch (error) {
1562
+ tui?.discardStream();
1563
+ // The session survives. Everything up to this turn is on disk, the next
1564
+ // prompt starts from it, and killing the process over one failed request
1565
+ // would throw away work that is still perfectly good.
1566
+ const C = app.io.palette;
1567
+ app.io.say(C.red(`\n request failed: ${describeError(error)}`));
1568
+ app.io.say(C.dim(" nothing was lost -- the log is intact. Try again, or /usage.\n"));
1569
+ return false;
1570
+ }
1571
+ }
1572
+
1573
+ export interface TurnSurface {
1574
+ /** Interactive UI projects committed log events; stream-only consumers omit this. */
1575
+ syncTranscript?(): Promise<void>;
1576
+ pushDelta(kind: "text" | "thinking", text: string): void;
1577
+ commitStream(): void;
1578
+ discardStream(): void;
1579
+ /** Tool/thinking events that stay in the live region until commit. */
1580
+ pushEvent?(e: LoopEvent): void;
1581
+ /**
1582
+ * Called by send() at a pairable after_tools moment. Returns already
1583
+ * expanded text, or null. Expansion happens here, not at enqueue —
1584
+ * the model is editing files while the line waits.
1585
+ */
1586
+ pullQueued?(phase: Phase): Promise<string | null>;
1587
+ }
1588
+
1589
+ export async function runTurn(
1590
+ app: Pick<App, "session" | "io"> & { readonly ui?: App["ui"]; readonly args?: App["args"] },
1591
+ prompt: string,
1592
+ signal?: AbortSignal,
1593
+ tui?: TurnSurface,
1594
+ ): Promise<void> {
1595
+ // A pipe cannot unwrite bytes. Non-tty / -p therefore does not stream:
1596
+ // deltas and discard are ignored, and the final block prints once.
1597
+ let live = false;
1598
+ let skipFinal = false;
1599
+ const json = app.args?.mode === "json";
1600
+ const pull = tui?.pullQueued?.bind(tui);
1601
+ for await (const e of app.session.send(prompt, signal, pull)) {
1602
+ if (json) {
1603
+ const payload = toJsonEvent(e, app.session.clock.now());
1604
+ if (payload !== null) process.stdout.write(`${JSON.stringify(payload)}\n`);
1605
+ continue;
1606
+ }
1607
+ if (tui?.syncTranscript) {
1608
+ if (e.kind === "text_delta" || e.kind === "thinking_delta") {
1609
+ tui.pushDelta(e.kind === "text_delta" ? "text" : "thinking", e.text ?? "");
1610
+ } else {
1611
+ if (e.kind === "discard") tui.discardStream();
1612
+ await tui.syncTranscript();
1613
+ if (["assistant_text", "thinking", "tool_call", "done", "cut"].includes(e.kind)) tui.commitStream();
1614
+ if (e.kind === "snapshot_failed" || e.kind === "undeclared_change" || e.kind === "stop_and_ask") {
1615
+ for (const line of formatEvent(e, app.io.palette, usableColumns(process.stdout.columns), {}, app.ui?.style)) app.io.say(line);
1616
+ }
1617
+ }
1618
+ continue;
1619
+ }
1620
+ if (e.kind === "text_delta" || e.kind === "thinking_delta") {
1621
+ if (tui !== undefined) {
1622
+ live = true;
1623
+ tui.pushDelta(e.kind === "thinking_delta" ? "thinking" : "text", e.text ?? "");
1624
+ }
1625
+ continue;
1626
+ }
1627
+ if (e.kind === "discard") {
1628
+ live = false;
1629
+ skipFinal = false;
1630
+ tui?.discardStream();
1631
+ continue;
1632
+ }
1633
+ // Per-round, not per-turn: a tool-using turn streams again after each
1634
+ // tool_result. A latch that stays set would skip commitStream on later
1635
+ // rounds and dropLive would then erase the only copy.
1636
+ if (live && tui !== undefined) {
1637
+ tui.commitStream();
1638
+ live = false;
1639
+ skipFinal = true;
1640
+ }
1641
+ if (skipFinal && (e.kind === "assistant_text" || e.kind === "thinking")) continue;
1642
+ if (skipFinal && e.kind !== "usage") skipFinal = false;
1643
+ if (
1644
+ tui !== undefined &&
1645
+ tui.pushEvent !== undefined &&
1646
+ (e.kind === "tool_call" || e.kind === "tool_result" || e.kind === "thinking")
1647
+ ) {
1648
+ tui.pushEvent(e);
1649
+ continue;
1650
+ }
1651
+ if (e.kind === "assistant_text" && app.io.answer !== undefined) {
1652
+ app.io.answer(e.text ?? "");
1653
+ continue;
1654
+ }
1655
+ for (const line of formatEvent(
1656
+ e,
1657
+ app.io.palette,
1658
+ usableColumns(process.stdout.columns),
1659
+ {},
1660
+ app.ui?.style,
1661
+ )) {
1662
+ app.io.say(line);
1663
+ }
1664
+ }
1665
+ }
1666
+
1667
+ // ---------------------------------------------------------------------------
1668
+ // TUI
1669
+ // ---------------------------------------------------------------------------
1670
+
1671
+ export class Tui {
1672
+ readonly term: Terminal;
1673
+ editor: Editor;
1674
+ /** Not yet delivered. Retracting a line must leave no trace in `.xeno/`. */
1675
+ queue: QueuedMessage[] = [];
1676
+ select: SelectList | null = null;
1677
+ settings: Settings | null = null;
1678
+ tree: TreeList | null = null;
1679
+ screen: TuiMainScreen;
1680
+ private readonly chat: Container;
1681
+ private noticeDir: string;
1682
+ private readonly transcript = new Transcript();
1683
+ private transcriptItems: TranscriptItem[] = [];
1684
+ private historyNode = -1;
1685
+ private historyDir = "";
1686
+ private ancestors: LogEvent[] = [];
1687
+ private historyLength = -1;
1688
+ private liveMarkdown: MarkdownBlock | null = null;
1689
+ private liveStyle: Style | null = null;
1690
+ private readonly live: Container;
1691
+ private readonly editorBox: Container;
1692
+ private readonly footerBox: Container;
1693
+ liveRows = 0;
1694
+ abort: AbortController | null = null;
1695
+ inflight = false;
1696
+ awaitingKeyFor: string | null = null;
1697
+ streamThinking = "";
1698
+ streamText = "";
1699
+ flushed = 0;
1700
+ toolsExpanded = false;
1701
+ thinkingExpanded = false;
1702
+ sessionName: string | null;
1703
+ private onPick: ((item: SelectItem) => Promise<void>) | null = null;
1704
+ private onTreePick: ((node: TreeNode) => Promise<void>) | null = null;
1705
+ /** Which selector Ctrl+S should persist. Other lists ignore Ctrl+S. */
1706
+ private selectKind: "model" | "thinking" | "other" = "other";
1707
+ private approvalResolve: ((choice: ApprovalChoice) => void) | null = null;
1708
+ private inputs: string[] = [];
1709
+ private wake: (() => void) | null = null;
1710
+ private attached = false;
1711
+ private lastInterrupt = 0;
1712
+ private lastEscape = 0;
1713
+ private pasting = false;
1714
+ private turn: Promise<void> | null = null;
1715
+ readonly app: App;
1716
+ /** Read once: a git spawn on every keystroke would stall the live region. */
1717
+ private readonly branch: string | null;
1718
+
1719
+ constructor(app: App, term?: Terminal) {
1720
+ this.app = app;
1721
+ this.historyDir = app.logDir;
1722
+ this.noticeDir = app.logDir;
1723
+ this.screen = createInteractiveTui(term);
1724
+ this.term = this.screen.terminal;
1725
+ this.editor = new Editor({ tui: this.screen });
1726
+ this.editor.bindings = editorBindings((app.ui?.keymap ?? defaultKeymap()).byAction);
1727
+ this.editor.setAutocompleteProvider(interactiveAutocomplete(app, async (refresh) => (await fileIndexFor(app, refresh)).paths));
1728
+ this.editor.onInput = (data) => { this.inputs.push(data); this.wake?.(); };
1729
+ this.chat = new Container();
1730
+ this.live = new Container();
1731
+ this.editorBox = new Container();
1732
+ 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);
1739
+ this.sessionName = app.args.sessionName || null;
1740
+ // Before attach: a hung git must not freeze an already-captured tty.
1741
+ this.branch = currentBranch(app.workspace);
1742
+ }
1743
+
1744
+ get style(): Style {
1745
+ return this.app.ui?.style ?? ANSI;
1746
+ }
1747
+
1748
+ get signal(): AbortSignal | undefined {
1749
+ return this.abort?.signal;
1750
+ }
1751
+
1752
+ writeLine(line: string): void {
1753
+ if (this.noticeDir !== this.app.logDir) { this.chat.clear(); this.noticeDir = this.app.logDir; }
1754
+ this.chat.addChild(new PiText(line, 0, 0));
1755
+ this.paint();
1756
+ }
1757
+
1758
+ dropLive(): void {
1759
+ this.live.clear();
1760
+ this.liveRows = 0;
1761
+ }
1762
+
1763
+ private markdownStyle(): Style {
1764
+ return this.app.io.palette.bold("\0") === "\0" ? IDENTITY : this.style;
1765
+ }
1766
+
1767
+ private clearStream(): void {
1768
+ this.streamThinking = "";
1769
+ this.streamText = "";
1770
+ this.flushed = 0;
1771
+ }
1772
+
1773
+ pushDelta(kind: "text" | "thinking", text: string): void {
1774
+ if (kind === "thinking") this.streamThinking += text;
1775
+ else this.streamText += text;
1776
+ this.paintStream();
1777
+ }
1778
+
1779
+ discardStream(): void {
1780
+ this.clearStream();
1781
+ this.dropLive();
1782
+ if (this.inflight) this.paintStream();
1783
+ else this.paint();
1784
+ }
1785
+
1786
+ commitStream(): void {
1787
+ // A stream is never durable history. syncTranscript replaces it from the log.
1788
+ this.clearStream();
1789
+ this.paint();
1790
+ }
1791
+
1792
+ async syncTranscript(): Promise<void> {
1793
+ const session = this.app.session;
1794
+ const dir = this.app.logDir;
1795
+ const node = session.state.node;
1796
+ if (dir !== this.historyDir || node !== this.historyNode) {
1797
+ const history = await readNodeHistory(dir, node);
1798
+ if (!history.ok) {
1799
+ this.chat.clear();
1800
+ this.chat.addChild(new PiText("Cannot load complete session history: " + history.error, 0, 0));
1801
+ this.paint();
1802
+ return;
1803
+ }
1804
+ if (this.app.session !== session || this.app.logDir !== dir) return;
1805
+ if (this.historyDir !== dir) {
1806
+ if (this.noticeDir !== dir) this.chat.clear();
1807
+ this.noticeDir = dir;
1808
+ this.queue = [];
1809
+ this.clearStream();
1810
+ this.editor.applyExternal("");
1811
+ this.sessionName = (await readCursor(dir))?.name ?? null;
1812
+ }
1813
+ this.ancestors = history.nodes.filter((entry) => entry.node !== node).flatMap((entry) => entry.events);
1814
+ this.historyDir = dir;
1815
+ this.historyNode = node;
1816
+ this.historyLength = -1;
1817
+ }
1818
+ if (this.historyLength !== session.state.events.length) {
1819
+ this.transcriptItems = projectTranscript([...this.ancestors, ...session.state.events]);
1820
+ this.historyLength = session.state.events.length;
1821
+ }
1822
+ this.paint();
1823
+ }
1824
+
1825
+ paintStream(): void {
1826
+ this.rebuildLive();
1827
+ this.rebuildChrome();
1828
+ this.renderScreen();
1829
+ }
1830
+
1831
+ paint(): void {
1832
+ const colored = this.markdownStyle() !== IDENTITY;
1833
+ this.editor.borderColor = thinkingBorderColor(this.app.providerConfig.effort, colored, this.style);
1834
+ this.rebuildLive();
1835
+ this.rebuildChrome();
1836
+ this.renderScreen();
1837
+ }
1838
+
1839
+ private renderScreen(): void {
1840
+ this.transcript.update(this.transcriptItems, this.markdownStyle(), this.toolsExpanded, this.thinkingExpanded);
1841
+ if (this.attached) this.screen.requestRender();
1842
+ else this.screen.renderNow();
1843
+ }
1844
+
1845
+ private rebuildLive(): void {
1846
+ this.live.clear();
1847
+ const style = this.markdownStyle();
1848
+ if (this.streamThinking.length > 0) {
1849
+ this.live.addChild(
1850
+ new Lines((width) =>
1851
+ renderToolView({ kind: "thinking", text: this.streamThinking }, this.thinkingExpanded, width, style),
1852
+ ),
1853
+ );
1854
+ }
1855
+ if (this.streamText.length > 0) {
1856
+ if (this.liveStyle !== style || this.liveMarkdown === null) {
1857
+ this.liveStyle = style;
1858
+ this.liveMarkdown = new MarkdownBlock(this.streamText, style);
1859
+ }
1860
+ this.liveMarkdown.setText(this.streamText);
1861
+ this.live.addChild(this.liveMarkdown);
1862
+ }
1863
+ }
1864
+
1865
+ private rebuildChrome(): void {
1866
+ const inner = this.tree ?? this.select ?? this.settings ?? this.editor;
1867
+ inner.setStyle(this.style);
1868
+ inner.onInput = this.editor.onInput;
1869
+ this.editorBox.clear();
1870
+ this.editorBox.addChild(inner);
1871
+ this.screen.setFocus(inner);
1872
+ this.footerBox.clear();
1873
+ this.footerBox.addChild(new Lines((width) => this.statusLines(width)));
1874
+ }
1875
+
1876
+ private statusLines(width: number): string[] {
1877
+ if (this.app.ui?.settings.statusBar === false) return [];
1878
+ const attrs = { ...PLAIN_ATTRS, status: true };
1879
+ return renderStatus(this.statusState(), width).map((line) => paint(line, attrs, this.style));
1880
+ }
1881
+
1882
+ private statusState(): StatusState {
1883
+ const s = this.app.session;
1884
+ const budget = contextBudget({
1885
+ frozen: s.config.frozen,
1886
+ handoff: s.state.handoff,
1887
+ events: s.state.events,
1888
+ anchor: s.state.anchor,
1889
+ atTurnBoundary: !this.inflight,
1890
+ ...(s.config.counter ? { counter: s.config.counter } : {}),
1891
+ ...(s.config.boundary ? { boundary: s.config.boundary } : {}),
1892
+ });
1893
+ const t = s.totals;
1894
+ const cost = reportedCost(t, this.app.providerConfig.spec.price);
1895
+ return {
1896
+ cwd: this.app.workspace,
1897
+ branch: this.branch,
1898
+ tokens: budget.used,
1899
+ triggerTokens: budget.trigger,
1900
+ model: this.app.providerConfig.spec.id,
1901
+ thinking: this.app.providerConfig.effort,
1902
+ cut: budget.decision.cut,
1903
+ reason: budget.decision.reason,
1904
+ ...(this.sessionName ? { sessionName: this.sessionName } : {}),
1905
+ home: homedir(),
1906
+ inputTokens: t.input_tokens,
1907
+ outputTokens: t.output_tokens,
1908
+ cacheRead: t.cache_read_input_tokens,
1909
+ cacheWrite: t.cache_creation_input_tokens,
1910
+ ...(t.prompt_tokens > 0 ? { cacheHitRate: t.cache_hit_rate * 100 } : {}),
1911
+ ...(cost.kind === "usd" ? { cost: cost.amount } : cost.kind === "partial" ? { cost: cost.known } : {}),
1912
+ ...(this.queue.length > 0 ? { queued: this.queue.length } : {}),
1913
+ };
1914
+ }
1915
+
1916
+ openSettings(field?: string): void {
1917
+ if (field !== undefined) { this.openSettingValues(field); return; }
1918
+ const settings = this.app.ui?.settings;
1919
+ this.settings = new Settings([
1920
+ { id: "theme", label: "Theme", currentValue: settings?.theme ?? "dark", values: ["dark", "light"], description: "Apply the palette to the editor, menus and complete history." },
1921
+ { 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
+ { id: "statusBar", label: "Status bar", currentValue: settings?.statusBar === false ? "off" : "on", values: ["on", "off"], description: "Workspace, usage and distance to the next context boundary." },
1923
+ ], this.style);
1924
+ this.paint();
1925
+ }
1926
+
1927
+ openLoginSelect(): void {
1928
+ const providers = [...new Set(this.app.catalog.map((model) => model.provider))];
1929
+ 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" })),
1932
+ ], { style: this.style, title: "Login · choose a provider" });
1933
+ this.onPick = async (item) => { await handleLine(this.app, "/login " + item.value, this); };
1934
+ this.paint();
1935
+ }
1936
+
1937
+ 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(); };
1942
+ this.paint();
1943
+ }
1944
+
1945
+ private openSettingValues(field: string): void {
1946
+ const key = field.toLowerCase();
1947
+ let items: SelectItem[] = [];
1948
+ if (key === "theme") {
1949
+ items = [
1950
+ { value: "dark", label: "dark" },
1951
+ { value: "light", label: "light" },
1952
+ ];
1953
+ } else if (key === "thinking") {
1954
+ items = ["low", "medium", "high", "xhigh", "max"].map((v) => ({ value: v, label: v }));
1955
+ } else if (key === "statusbar" || key === "status") {
1956
+ items = [
1957
+ { value: "on", label: "on" },
1958
+ { value: "off", label: "off" },
1959
+ ];
1960
+ } else {
1961
+ this.app.io.say(this.app.io.palette.yellow(` unknown setting: ${field}`));
1962
+ this.paint();
1963
+ return;
1964
+ }
1965
+ this.select = new SelectList(items, { style: this.style, title: "Setting · " + field });
1966
+ this.selectKind = "other";
1967
+ this.onPick = async (item) => {
1968
+ await commitSetting(this.app, key === "status" ? "statusBar" : field, item.value, this);
1969
+ };
1970
+ this.paint();
1971
+ }
1972
+
1973
+ openModelSelect(): void {
1974
+ const items: SelectItem[] = [];
1975
+ for (const spec of this.app.catalog) {
1976
+ const got = this.app.credentialOf(spec);
1977
+ const here = spec.id === this.app.providerConfig.spec.id;
1978
+ items.push({
1979
+ value: spec.id,
1980
+ label: spec.id,
1981
+ description: `${spec.provider} · ${spec.name}${here ? " ✓ current" : got.ok ? " ready" : " login required"}`,
1982
+ });
1983
+ }
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" });
1985
+ this.selectKind = "model";
1986
+ this.onPick = async (item) => {
1987
+ const spec = this.app.catalog.find((m) => m.id === item.value);
1988
+ if (spec !== undefined) await switchToModel(this.app, spec);
1989
+ };
1990
+ this.app.io.say(this.app.io.palette.dim(" switching cuts this node so the next model starts from a handoff"));
1991
+ this.paint();
1992
+ }
1993
+
1994
+ openThinkingSelect(): void {
1995
+ const spec = this.app.providerConfig.spec;
1996
+ const current = this.app.providerConfig.effort;
1997
+ const items: SelectItem[] = EFFORTS.map((level) => {
1998
+ const supported = effortSupported(spec, level);
1999
+ const here = level === current;
2000
+ return {
2001
+ value: level,
2002
+ label: level,
2003
+ description: !supported ? "unsupported" : here ? "now" : "",
2004
+ };
2005
+ });
2006
+ this.select = new SelectList(items, { style: this.style, title: "Thinking", selectedValue: current, hint: "↑↓ select · Enter apply · Ctrl+S save default · Esc cancel" });
2007
+ this.selectKind = "thinking";
2008
+ this.onPick = async (item) => {
2009
+ const effort = asEffort(item.value);
2010
+ if (effort === undefined) return;
2011
+ applyThinking(this.app, effort, this);
2012
+ };
2013
+ this.paint();
2014
+ }
2015
+
2016
+ private async saveSelectDefault(): Promise<void> {
2017
+ if (this.select === null) return;
2018
+ const item = this.select.state.items[this.select.state.selected];
2019
+ if (item === undefined) return;
2020
+ const C = this.app.io.palette;
2021
+ if (this.selectKind === "model") {
2022
+ // Patch only this key: dumping merged settings would freeze today's defaults.
2023
+ await writeUserSettings({ defaultModel: item.value });
2024
+ 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 };
2027
+ }
2028
+ this.app.io.say(C.green(` saved default model ${item.value}`));
2029
+ return;
2030
+ }
2031
+ if (this.selectKind === "thinking") {
2032
+ const effort = asEffort(item.value);
2033
+ if (!effort || !effortSupported(this.app.providerConfig.spec, effort)) {
2034
+ this.app.io.say(C.yellow(" Cannot save an unsupported thinking level."));
2035
+ return;
2036
+ }
2037
+ await writeUserSettings({ defaultThinking: item.value });
2038
+ 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 };
2041
+ }
2042
+ this.app.io.say(C.green(` saved default thinking ${item.value}`));
2043
+ }
2044
+ }
2045
+
2046
+ 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
+ );
2063
+ this.selectKind = "other";
2064
+ this.onPick = async (item) => {
2065
+ await resumeSession(this.app, item.value, this);
2066
+ };
2067
+ this.paint();
2068
+ }
2069
+
2070
+ /**
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,
2073
+ * the existing AbortSignal fires first — same path Escape already uses.
2074
+ */
2075
+ async openTreeSelect(): Promise<void> {
2076
+ this.select = null;
2077
+ this.onPick = null;
2078
+ const points = await this.app.session.restorePoints();
2079
+ const nodes = await treeNodesFor(this.app);
2080
+ this.tree = new TreeList(nodes, { style: this.style });
2081
+ this.onTreePick = async (node) => {
2082
+ const point = points.find((p) => `${p.node}:${p.atEvent}` === (node.target ?? node.id));
2083
+ if (point === undefined) return;
2084
+ if (this.inflight) { await this.settle(); this.returnQueueToEditor(); }
2085
+ const done = await this.app.session.rewind(point, "context");
2086
+ await this.syncTranscript();
2087
+ 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}`));
2097
+ this.app.io.say("");
2098
+ };
2099
+ this.paint();
2100
+ }
2101
+
2102
+ async nextInput(): Promise<string> {
2103
+ while (this.inputs.length === 0) {
2104
+ await new Promise<void>((resolve) => { this.wake = resolve; });
2105
+ this.wake = null;
2106
+ }
2107
+ return this.inputs.shift()!;
2108
+ }
2109
+
2110
+ attach(): void {
2111
+ this.attached = true;
2112
+ this.rebuildChrome();
2113
+ this.screen.start();
2114
+ }
2115
+
2116
+ detach(): void {
2117
+ this.attached = false;
2118
+ this.screen.stop();
2119
+ }
2120
+
2121
+ async settle(): Promise<void> {
2122
+ this.abort?.abort();
2123
+ await this.turn;
2124
+ }
2125
+
2126
+ ask(request: PermissionRequest): Promise<ApprovalChoice> {
2127
+ return new Promise((resolve) => {
2128
+ this.app.io.say(approvalHeading(request));
2129
+ this.select = new SelectList(approvalItems(request), { style: this.style, title: "Tool approval", search: false });
2130
+ this.approvalResolve = resolve;
2131
+ this.onPick = async (item) => {
2132
+ resolve(choiceFromValue(item.value));
2133
+ };
2134
+ this.paint();
2135
+ });
2136
+ }
2137
+
2138
+ private boundAction(data: string): ActionId | undefined {
2139
+ return Object.entries((this.app.ui?.keymap ?? defaultKeymap()).byKey)
2140
+ .find(([chord]) => keyMatches(data, chord))?.[1];
2141
+ }
2142
+
2143
+ private async openExternalEditor(): Promise<void> {
2144
+ const attached = this.attached;
2145
+ if (attached) this.screen.stop();
2146
+ if (attached && process.stdin.isTTY) process.stdin.setRawMode(false);
2147
+ try {
2148
+ const result = await editInExternalEditor({ content: this.editor.state.text, command: resolveEditorCommand() });
2149
+ if (result.ok) this.editor.applyExternal(result.content);
2150
+ else this.app.io.say(result.message);
2151
+ } finally {
2152
+ if (attached) this.screen.start();
2153
+ this.paint();
2154
+ }
2155
+ }
2156
+
2157
+ /** Programmatic compatibility; live stdin always enters as Pi raw input. */
2158
+ async applyKey(key: Key): Promise<"continue" | "quit"> { return this.applyInput(encodeKey(key)); }
2159
+
2160
+ async applyInput(data: string): Promise<"continue" | "quit"> {
2161
+ const action = this.boundAction(data);
2162
+ // Modal ownership precedes application shortcuts, including during a turn.
2163
+ 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;
2166
+ this.paint();
2167
+ return "continue";
2168
+ }
2169
+ if (this.tree) {
2170
+ const state = this.tree.acceptInput(data);
2171
+ if (state.cancelled || state.confirmed) {
2172
+ const pick = this.onTreePick;
2173
+ this.tree = null;
2174
+ this.onTreePick = null;
2175
+ if (state.confirmed && pick) await pick(state.confirmed);
2176
+ }
2177
+ this.paint();
2178
+ return "continue";
2179
+ }
2180
+ if (this.select) {
2181
+ if (action === "app.saveDefault" && !this.approvalResolve) await this.saveSelectDefault();
2182
+ else {
2183
+ const state = this.select.acceptInput(data);
2184
+ if (state.cancelled || state.confirmed) {
2185
+ const pick = this.onPick, approval = this.approvalResolve;
2186
+ this.select = null;
2187
+ this.onPick = null;
2188
+ this.approvalResolve = null;
2189
+ this.selectKind = "other";
2190
+ if (state.confirmed && pick) { await pick(state.confirmed); await this.syncTranscript(); }
2191
+ else approval?.("deny");
2192
+ }
2193
+ }
2194
+ this.paint();
2195
+ return "continue";
2196
+ }
2197
+ if (data.includes("\x1b[200~")) this.pasting = true;
2198
+ if (this.pasting) {
2199
+ this.editor.acceptInput(data);
2200
+ if (data.includes("\x1b[201~")) this.pasting = false;
2201
+ this.paint();
2202
+ return "continue";
2203
+ }
2204
+ if (this.editor.autocompleteVisible && (keyMatches(data, "escape") || keyMatches(data, "up") || keyMatches(data, "down") || keyMatches(data, "tab"))) {
2205
+ this.editor.acceptInput(data);
2206
+ this.paint();
2207
+ return "continue";
2208
+ }
2209
+ if (action === "app.exit" || keyMatches(data, "ctrl+c")) {
2210
+ if (this.inflight) { this.abort?.abort(); this.returnQueueToEditor(); }
2211
+ else if (this.editor.state.text.length > 0) this.editor.applyExternal("");
2212
+ else if (Date.now() - this.lastInterrupt < 1000) return "quit";
2213
+ else this.app.io.say(" Press Ctrl+C again to exit, or Ctrl+D on an empty prompt.");
2214
+ this.lastInterrupt = Date.now();
2215
+ this.paint();
2216
+ return "continue";
2217
+ }
2218
+ if (keyMatches(data, "ctrl+d") && this.editor.state.text.length === 0) return "quit";
2219
+ 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();
2226
+ this.lastEscape = Date.now();
2227
+ this.paint();
2228
+ return "continue";
2229
+ }
2230
+ if (action === "app.message.dequeue") { this.recallLast(); this.paint(); return "continue"; }
2231
+ 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"; }
2233
+ if (action === "app.externalEditor") { await this.openExternalEditor(); return "continue"; }
2234
+ if (!this.inflight) {
2235
+ if (action === "app.model") { this.openModelSelect(); return "continue"; }
2236
+ if (action === "app.cycleModel" || action === "app.cycleModelReverse") {
2237
+ await cycleScopedModel(this.app, action === "app.cycleModel" ? 1 : -1); this.paint(); return "continue";
2238
+ }
2239
+ if (action === "app.cycleThinking") { cycleThinking(this.app, this); return "continue"; }
2240
+ }
2241
+ if (this.inflight && action === "app.message.followUp") {
2242
+ const text = this.editor.state.text;
2243
+ this.editor.applyExternal("");
2244
+ return this.submitLine(text, "followup");
2245
+ }
2246
+ const state = this.editor.acceptInput(data);
2247
+ if (state.submitted !== null) return this.submitLine(state.submitted, "steering");
2248
+ this.paint();
2249
+ return "continue";
2250
+ }
2251
+
2252
+ private async submitLine(text: string, queueKind: QueueKind): Promise<"continue" | "quit"> {
2253
+ const line = text.trim();
2254
+ this.paint();
2255
+ if (this.awaitingKeyFor !== null) {
2256
+ await handleLine(this.app, line, this);
2257
+ return "continue";
2258
+ }
2259
+ if (!line) return "continue";
2260
+ const command = parseLine(this.app, line);
2261
+ if (command.kind !== "prompt") {
2262
+ if (this.inflight && commandDefinition(line)?.busy === "idle") {
2263
+ this.app.io.say(" This command needs an idle session. Press Esc to interrupt the current turn first.");
2264
+ this.editor.applyExternal(line);
2265
+ return "continue";
2266
+ }
2267
+ const result = await handleLine(this.app, line, this);
2268
+ await this.syncTranscript();
2269
+ this.paint();
2270
+ return result === "quit" ? "quit" : "continue";
2271
+ }
2272
+ if (this.inflight) this.enqueue({ kind: queueKind, text: command.text });
2273
+ else this.turn = this.runPromptTurn(command.text).catch((error: unknown) => {
2274
+ this.app.io.say(this.app.io.palette.red(describeError(error)));
2275
+ });
2276
+ this.paint();
2277
+ return "continue";
2278
+ }
2279
+
2280
+ private async runPromptTurn(text: string): Promise<void> {
2281
+ this.abort = new AbortController();
2282
+ this.inflight = true;
2283
+ try {
2284
+ await run(this.app, text, this.abort.signal, this);
2285
+ if (!this.abort.signal.aborted) await this.drainFollowUps();
2286
+ this.app.io.say("");
2287
+ } finally {
2288
+ await this.syncTranscript();
2289
+ this.commitStream();
2290
+ this.abort = null;
2291
+ fileIndexes.delete(this.app.workspace);
2292
+ this.inflight = false;
2293
+ this.clearStream();
2294
+ this.paint();
2295
+ }
2296
+ }
2297
+
2298
+ /**
2299
+ * send() calls this at after_tools. Expansion is here because the
2300
+ * files the user named may have changed while the line waited.
2301
+ */
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);
2306
+ this.paintStream();
2307
+ return this.expandQueued(item.text);
2308
+ }
2309
+
2310
+ private async expandQueued(text: string): Promise<string | null> {
2311
+ const needsIndex = fileReferences(text).length > 0;
2312
+ const index: FileIndex = needsIndex
2313
+ ? await fileIndexFor(this.app)
2314
+ : { paths: [], truncated: false };
2315
+ const expanded = await expandInput(text, {
2316
+ workspace: this.app.workspace,
2317
+ index,
2318
+ say: (line) => this.app.io.say(line),
2319
+ });
2320
+ return expanded.send ? expanded.message : null;
2321
+ }
2322
+
2323
+ private async drainFollowUps(): Promise<void> {
2324
+ 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);
2328
+ this.paintStream();
2329
+ await run(this.app, item.text, this.abort.signal, this);
2330
+ }
2331
+ }
2332
+
2333
+ private enqueue(item: QueuedMessage): void {
2334
+ this.queue.push(item);
2335
+ }
2336
+
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);
2343
+ }
2344
+
2345
+ private returnQueueToEditor(): void {
2346
+ if (this.queue.length === 0) return;
2347
+ const draft = this.editor.state.text;
2348
+ const parts = this.queue.map((m) => m.text);
2349
+ if (draft.trim().length > 0) parts.push(draft);
2350
+ this.queue = [];
2351
+ this.editor.applyExternal(parts.join("\n"));
2352
+ }
2353
+ }