atom-lite 0.1.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +58 -10
  3. package/dist/agent/loop.d.ts +11 -0
  4. package/dist/agent/loop.js +238 -47
  5. package/dist/agent/loop.js.map +1 -1
  6. package/dist/agent/model-turn.d.ts +37 -0
  7. package/dist/agent/model-turn.js +53 -0
  8. package/dist/agent/model-turn.js.map +1 -0
  9. package/dist/agent/turn-recorder.d.ts +33 -0
  10. package/dist/agent/turn-recorder.js +74 -0
  11. package/dist/agent/turn-recorder.js.map +1 -0
  12. package/dist/agent/types.d.ts +4 -0
  13. package/dist/cli/activity.d.ts +45 -0
  14. package/dist/cli/activity.js +141 -0
  15. package/dist/cli/activity.js.map +1 -0
  16. package/dist/cli/app.js +1234 -378
  17. package/dist/cli/app.js.map +1 -1
  18. package/dist/cli/approval-prompt.d.ts +24 -0
  19. package/dist/cli/approval-prompt.js +82 -0
  20. package/dist/cli/approval-prompt.js.map +1 -0
  21. package/dist/cli/banner.d.ts +5 -1
  22. package/dist/cli/banner.js +10 -4
  23. package/dist/cli/banner.js.map +1 -1
  24. package/dist/cli/commands.d.ts +92 -14
  25. package/dist/cli/commands.js +330 -52
  26. package/dist/cli/commands.js.map +1 -1
  27. package/dist/cli/diff.d.ts +1 -0
  28. package/dist/cli/diff.js +28 -0
  29. package/dist/cli/diff.js.map +1 -0
  30. package/dist/cli/input.d.ts +2 -0
  31. package/dist/cli/input.js +10 -0
  32. package/dist/cli/input.js.map +1 -0
  33. package/dist/cli/main.js +2 -2
  34. package/dist/cli/main.js.map +1 -1
  35. package/dist/cli/mouse.d.ts +54 -0
  36. package/dist/cli/mouse.js +86 -0
  37. package/dist/cli/mouse.js.map +1 -0
  38. package/dist/cli/palette.d.ts +41 -0
  39. package/dist/cli/palette.js +212 -0
  40. package/dist/cli/palette.js.map +1 -0
  41. package/dist/cli/panel.d.ts +29 -0
  42. package/dist/cli/panel.js +48 -0
  43. package/dist/cli/panel.js.map +1 -0
  44. package/dist/cli/picker.d.ts +8 -0
  45. package/dist/cli/picker.js +26 -28
  46. package/dist/cli/picker.js.map +1 -1
  47. package/dist/cli/status-line.d.ts +6 -0
  48. package/dist/cli/status-line.js +21 -7
  49. package/dist/cli/status-line.js.map +1 -1
  50. package/dist/cli/stream-text.d.ts +4 -8
  51. package/dist/cli/stream-text.js +26 -12
  52. package/dist/cli/stream-text.js.map +1 -1
  53. package/dist/cli/theme.d.ts +48 -0
  54. package/dist/cli/theme.js +160 -0
  55. package/dist/cli/theme.js.map +1 -0
  56. package/dist/cli/tool-box.d.ts +41 -0
  57. package/dist/cli/tool-box.js +177 -0
  58. package/dist/cli/tool-box.js.map +1 -0
  59. package/dist/cli/transcript.d.ts +24 -1
  60. package/dist/cli/transcript.js +65 -2
  61. package/dist/cli/transcript.js.map +1 -1
  62. package/dist/cli/trust-prompt.d.ts +24 -0
  63. package/dist/cli/trust-prompt.js +89 -0
  64. package/dist/cli/trust-prompt.js.map +1 -0
  65. package/dist/config/defaults.d.ts +12 -0
  66. package/dist/config/defaults.js +12 -0
  67. package/dist/config/defaults.js.map +1 -1
  68. package/dist/config/load.d.ts +2 -0
  69. package/dist/config/load.js +36 -0
  70. package/dist/config/load.js.map +1 -1
  71. package/dist/config/paths.d.ts +2 -0
  72. package/dist/config/paths.js +12 -1
  73. package/dist/config/paths.js.map +1 -1
  74. package/dist/config/schema.d.ts +53 -0
  75. package/dist/config/schema.js +33 -0
  76. package/dist/config/schema.js.map +1 -1
  77. package/dist/config/write.d.ts +2 -0
  78. package/dist/config/write.js +9 -0
  79. package/dist/config/write.js.map +1 -1
  80. package/dist/context/builder.d.ts +7 -0
  81. package/dist/context/builder.js +11 -0
  82. package/dist/context/builder.js.map +1 -0
  83. package/dist/context/manager.d.ts +17 -3
  84. package/dist/context/manager.js +129 -41
  85. package/dist/context/manager.js.map +1 -1
  86. package/dist/extensions/api.d.ts +101 -0
  87. package/dist/extensions/api.js +219 -0
  88. package/dist/extensions/api.js.map +1 -0
  89. package/dist/extensions/discovery.d.ts +42 -0
  90. package/dist/extensions/discovery.js +326 -0
  91. package/dist/extensions/discovery.js.map +1 -0
  92. package/dist/extensions/loader.d.ts +68 -0
  93. package/dist/extensions/loader.js +353 -0
  94. package/dist/extensions/loader.js.map +1 -0
  95. package/dist/extensions/session-manager.d.ts +44 -0
  96. package/dist/extensions/session-manager.js +137 -0
  97. package/dist/extensions/session-manager.js.map +1 -0
  98. package/dist/extensions/tool-adapter.d.ts +12 -0
  99. package/dist/extensions/tool-adapter.js +157 -0
  100. package/dist/extensions/tool-adapter.js.map +1 -0
  101. package/dist/extensions/trust.d.ts +6 -0
  102. package/dist/extensions/trust.js +55 -0
  103. package/dist/extensions/trust.js.map +1 -0
  104. package/dist/extensions/types.d.ts +94 -0
  105. package/dist/extensions/types.js +2 -0
  106. package/dist/extensions/types.js.map +1 -0
  107. package/dist/extensions/ui.d.ts +21 -0
  108. package/dist/extensions/ui.js +75 -0
  109. package/dist/extensions/ui.js.map +1 -0
  110. package/dist/index.d.ts +2 -0
  111. package/dist/index.js +2 -0
  112. package/dist/index.js.map +1 -1
  113. package/dist/mcp/client.d.ts +73 -0
  114. package/dist/mcp/client.js +363 -0
  115. package/dist/mcp/client.js.map +1 -0
  116. package/dist/mcp/format.d.ts +3 -0
  117. package/dist/mcp/format.js +37 -0
  118. package/dist/mcp/format.js.map +1 -0
  119. package/dist/mcp/index.d.ts +6 -0
  120. package/dist/mcp/index.js +7 -0
  121. package/dist/mcp/index.js.map +1 -0
  122. package/dist/mcp/manager.d.ts +53 -0
  123. package/dist/mcp/manager.js +349 -0
  124. package/dist/mcp/manager.js.map +1 -0
  125. package/dist/mcp/stdio-transport.d.ts +26 -0
  126. package/dist/mcp/stdio-transport.js +174 -0
  127. package/dist/mcp/stdio-transport.js.map +1 -0
  128. package/dist/mcp/tools.d.ts +22 -0
  129. package/dist/mcp/tools.js +162 -0
  130. package/dist/mcp/tools.js.map +1 -0
  131. package/dist/mcp/types.d.ts +92 -0
  132. package/dist/mcp/types.js +2 -0
  133. package/dist/mcp/types.js.map +1 -0
  134. package/dist/providers/factory.js +1 -0
  135. package/dist/providers/factory.js.map +1 -1
  136. package/dist/runtime/executor.d.ts +2 -0
  137. package/dist/runtime/executor.js +4 -0
  138. package/dist/runtime/executor.js.map +1 -0
  139. package/dist/runtime/scheduler.d.ts +4 -3
  140. package/dist/runtime/scheduler.js +7 -98
  141. package/dist/runtime/scheduler.js.map +1 -1
  142. package/dist/runtime/tool-executor.d.ts +18 -0
  143. package/dist/runtime/tool-executor.js +126 -0
  144. package/dist/runtime/tool-executor.js.map +1 -0
  145. package/dist/security/process.d.ts +10 -0
  146. package/dist/security/process.js +36 -0
  147. package/dist/security/process.js.map +1 -1
  148. package/dist/sessions/events.d.ts +9 -0
  149. package/dist/sessions/events.js +11 -6
  150. package/dist/sessions/events.js.map +1 -1
  151. package/dist/sessions/history.d.ts +18 -0
  152. package/dist/sessions/history.js +76 -0
  153. package/dist/sessions/history.js.map +1 -0
  154. package/dist/sessions/index.d.ts +2 -0
  155. package/dist/sessions/index.js +2 -0
  156. package/dist/sessions/index.js.map +1 -1
  157. package/dist/sessions/search.d.ts +16 -0
  158. package/dist/sessions/search.js +136 -0
  159. package/dist/sessions/search.js.map +1 -0
  160. package/dist/sessions/store.js +7 -4
  161. package/dist/sessions/store.js.map +1 -1
  162. package/dist/tools/bash.js +6 -3
  163. package/dist/tools/bash.js.map +1 -1
  164. package/dist/tools/edit.js +2 -1
  165. package/dist/tools/edit.js.map +1 -1
  166. package/dist/tools/types.d.ts +6 -0
  167. package/dist/types.d.ts +2 -1
  168. package/dist/types.js.map +1 -1
  169. package/package.json +3 -1
package/dist/cli/app.js CHANGED
@@ -1,21 +1,38 @@
1
1
  import { loadConfig } from "../config/load.js";
2
- import { saveProviderSelection } from "../config/write.js";
2
+ import { defaultAgentDir, userConfigDir } from "../config/paths.js";
3
+ import { saveProviderSelection, saveTheme, } from "../config/write.js";
3
4
  import { resolveApiKey, saveApiKey } from "../config/credentials.js";
5
+ import { join } from "node:path";
6
+ import { writeFileSync, existsSync } from "node:fs";
4
7
  import { execSync } from "node:child_process";
5
8
  import { openSearchablePicker } from "./picker.js";
9
+ import { openCommandPalette } from "./palette.js";
10
+ import { bareCommandName, createCommandRegistry } from "./commands.js";
11
+ import { resolveTheme, themeLabel, themeNames, markdownThemeFor, } from "./theme.js";
6
12
  import { SecretInput } from "./secret-input.js";
7
13
  import { StatusLine } from "./status-line.js";
14
+ import { Activity } from "./activity.js";
15
+ import { ToolBox } from "./tool-box.js";
16
+ import { ApprovalPrompt } from "./approval-prompt.js";
8
17
  import { Banner } from "./banner.js";
9
18
  import { formatContextUsage, knownContextWindow, } from "../providers/context-window.js";
10
19
  import { StreamText } from "./stream-text.js";
11
20
  import { Transcript } from "./transcript.js";
12
21
  import { AgentRuntime } from "../agent/loop.js";
13
22
  import { JsonlSessionStore } from "../sessions/store.js";
23
+ import { ExtensionLoader } from "../extensions/loader.js";
24
+ import { collectExtensionTools } from "../extensions/tool-adapter.js";
25
+ import { denyProject, trustProject } from "../extensions/trust.js";
26
+ import { ProjectTrustPrompt } from "./trust-prompt.js";
27
+ import { formatSessionList, formatSessionSearch, searchSessions, sessionTitleFromEvents, } from "../sessions/search.js";
28
+ import { providerSwitchNotice, sessionHistoryLines, sessionSelectionFromEvents, } from "../sessions/history.js";
29
+ import { blockAtLine, createMouseCapture, handleMouseEvent, parseSgrMouse, shouldEnableMouse, } from "./mouse.js";
14
30
  import { readTool, writeTool, editTool, bashTool, webfetchTool, websearchTool } from "../tools/index.js";
31
+ import { McpManager, formatMcpStatus, formatMcpDoctor, createMcpTools } from "../mcp/index.js";
15
32
  import { createProvider } from "../providers/factory.js";
16
33
  import { availableProviderNames, getPreset, listModelsForProvider, materializeProviderConfig, probeApiKey, providerLabel, PROVIDER_PRESETS, } from "../providers/catalog.js";
17
34
  import { ContextManager } from "../context/manager.js";
18
- import { ProcessTerminal, isKeyRelease, Key, matchesKey, } from "@earendil-works/pi-tui";
35
+ import { ProcessTerminal, isKeyRelease, isKeyRepeat, Key, matchesKey, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
19
36
  export async function run(args) {
20
37
  let providerOverride;
21
38
  let modelOverride;
@@ -23,10 +40,20 @@ export async function run(args) {
23
40
  let debug = false;
24
41
  let showVersion = false;
25
42
  let sessionOverride;
43
+ let themeOverride;
44
+ let noColor = false;
45
+ let listSessions = false;
46
+ let searchSessionsQuery;
26
47
  const positional = [];
48
+ const extensionPaths = [];
27
49
  for (let i = 0; i < args.length; i++) {
28
50
  const a = args[i];
29
- if (a === "--provider")
51
+ if (a === "-e" || a === "--extension") {
52
+ const path = args[++i];
53
+ if (path)
54
+ extensionPaths.push(path);
55
+ }
56
+ else if (a === "--provider")
30
57
  providerOverride = args[++i];
31
58
  else if (a === "--model")
32
59
  modelOverride = args[++i];
@@ -40,10 +67,18 @@ export async function run(args) {
40
67
  positional.push("__continue__");
41
68
  else if (a === "--session")
42
69
  sessionOverride = args[++i];
70
+ else if (a === "--list-sessions")
71
+ listSessions = true;
72
+ else if (a === "--search-sessions")
73
+ searchSessionsQuery = args[++i] ?? "";
74
+ else if (a === "--theme")
75
+ themeOverride = args[++i];
76
+ else if (a === "--no-color")
77
+ noColor = true;
43
78
  else if (a === "--no-tools")
44
79
  positional.push("__notools__");
45
80
  else if (a === "--help") {
46
- process.stdout.write("atom-lite [task] [--provider NAME] [--model MODEL] [--continue] [--session ID] [--no-tools] [--json] [--debug] [--version] [--help]\n");
81
+ process.stdout.write("atom-lite [task] [--provider NAME] [--model MODEL] [--continue] [--session ID] [--list-sessions] [--search-sessions QUERY] [--no-tools] [--json] [--debug] [--theme NAME] [--no-color] [-e PATH] [--version] [--help]\n");
47
82
  return;
48
83
  }
49
84
  else if (a.startsWith("--")) {
@@ -54,7 +89,24 @@ export async function run(args) {
54
89
  }
55
90
  }
56
91
  if (showVersion) {
57
- process.stdout.write("0.1.0\n");
92
+ process.stdout.write("0.3.1\n");
93
+ return;
94
+ }
95
+ if (listSessions || searchSessionsQuery !== undefined) {
96
+ const store = new JsonlSessionStore();
97
+ if (listSessions) {
98
+ process.stdout.write(`${formatSessionList(store.list())}\n`);
99
+ }
100
+ if (searchSessionsQuery !== undefined) {
101
+ const query = searchSessionsQuery.trim();
102
+ if (!query) {
103
+ process.stderr.write("atom-lite: --search-sessions requires a query\n");
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+ const matches = searchSessions(store.list(), query);
108
+ process.stdout.write(`${formatSessionSearch(matches)}\n`);
109
+ }
58
110
  return;
59
111
  }
60
112
  let config = loadConfig();
@@ -71,7 +123,9 @@ export async function run(args) {
71
123
  if (modelOverride)
72
124
  modelByProvider.set(provider.id, modelOverride);
73
125
  const useTools = !positional.includes("__notools__");
74
- const tools = useTools ? [readTool, writeTool, editTool, bashTool, webfetchTool, websearchTool] : [];
126
+ const builtInTools = useTools
127
+ ? [readTool, writeTool, editTool, bashTool, webfetchTool, websearchTool]
128
+ : [];
75
129
  const interactive = process.stdin.isTTY === true && !jsonMode;
76
130
  // one-shot task from positional or stdin
77
131
  let stdinTask = "";
@@ -84,39 +138,127 @@ export async function run(args) {
84
138
  const taskArgs = positional.filter((p) => !p.startsWith("__"));
85
139
  const oneShot = (taskArgs.join(" ").trim() || stdinTask).trim();
86
140
  const store = new JsonlSessionStore();
87
- const session = sessionOverride
141
+ let session = sessionOverride
88
142
  ? store.resume(sessionOverride) ?? store.create(process.cwd(), provider.id)
89
143
  : positional.includes("__continue__")
90
144
  ? store.resumeLatest(process.cwd()) ?? store.create(process.cwd(), provider.id)
91
145
  : store.create(process.cwd(), provider.id);
146
+ const mcpServers = (config.mcpServers ?? {});
147
+ const mcpManager = new McpManager({
148
+ servers: mcpServers,
149
+ onEvent: (event) => {
150
+ try {
151
+ session?.appendEvent?.(event);
152
+ }
153
+ catch {
154
+ // Observability must not break the session.
155
+ }
156
+ },
157
+ });
158
+ const extensionUICallbacks = {};
159
+ const extensionProviderNames = new Set();
160
+ function resolveExtensionApiKey(value) {
161
+ if (typeof value !== "string" || !value)
162
+ return undefined;
163
+ if (value.startsWith("!")) {
164
+ try {
165
+ const { execFileSync } = require("node:child_process");
166
+ const parts = value.slice(1).trim().split(/\s+/);
167
+ return execFileSync(parts[0], parts.slice(1), { encoding: "utf8" }).trim();
168
+ }
169
+ catch {
170
+ return undefined;
171
+ }
172
+ }
173
+ if (value.startsWith("$"))
174
+ return process.env[value.slice(1)] ?? value;
175
+ if (value.startsWith("${") && value.endsWith("}"))
176
+ return process.env[value.slice(2, -1)] ?? value;
177
+ return value;
178
+ }
179
+ const extensionLoader = new ExtensionLoader({
180
+ cliPaths: extensionPaths,
181
+ cwd: process.cwd(),
182
+ agentDir: defaultAgentDir(),
183
+ userDir: join(userConfigDir(), "extensions"),
184
+ projectDir: process.cwd(),
185
+ });
186
+ const pendingTrust = extensionLoader
187
+ .discover()
188
+ .filter((entry) => entry.source === "project" &&
189
+ !entry.trusted &&
190
+ entry.skipReason === "project is not trusted");
191
+ if (!interactive && pendingTrust.length > 0) {
192
+ process.stderr.write(`atom-lite: skipping ${pendingTrust.length} untrusted project extension(s); run interactively to review\n`);
193
+ }
194
+ await extensionLoader.loadAll().catch(() => {
195
+ // Extension failures are reported per extension.
196
+ });
197
+ // Remove any previously bridged extension providers before re-bridging.
198
+ for (const name of extensionProviderNames) {
199
+ delete config.providers[name];
200
+ }
201
+ extensionProviderNames.clear();
202
+ // Bridge Pi-style extension providers into Atom-lite's config so they
203
+ // appear in /provider, /model, and the command palette.
204
+ for (const loaded of extensionLoader.list()) {
205
+ for (const [name, provider] of (loaded.registries?.providers ?? new Map())) {
206
+ const cfg = provider?.config ?? {};
207
+ const firstModel = Array.isArray(cfg.models) && cfg.models.length > 0
208
+ ? (cfg.models[0].id ?? cfg.models[0].name ?? "default")
209
+ : "default";
210
+ const apiKey = resolveExtensionApiKey(cfg.apiKey);
211
+ const headers = { ...(cfg.headers ?? {}) };
212
+ if (cfg.authHeader && apiKey)
213
+ headers.Authorization = `Bearer ${apiKey}`;
214
+ config.providers[name] = {
215
+ type: "openai-compatible",
216
+ baseUrl: cfg.baseUrl,
217
+ model: firstModel,
218
+ apiKey: apiKey ?? null,
219
+ apiKeyEnv: null,
220
+ headers,
221
+ contextWindow: cfg.models?.[0]?.contextWindow,
222
+ };
223
+ extensionProviderNames.add(name);
224
+ }
225
+ }
226
+ const extensionTools = collectExtensionTools(extensionLoader);
227
+ const allTools = [...builtInTools, ...extensionTools];
228
+ const extensionDispatch = async (event, payload) => {
229
+ const results = [];
230
+ for (const loaded of extensionLoader.list()) {
231
+ const handlers = loaded.registries.handlers.get(event);
232
+ if (!handlers)
233
+ continue;
234
+ for (const handler of handlers) {
235
+ try {
236
+ results.push(await handler(payload, loaded.context ?? {}));
237
+ }
238
+ catch (error) {
239
+ // eslint-disable-next-line no-console
240
+ console.error(`[extension:${loaded.id}] handler error: ${error instanceof Error ? error.message : String(error)}`);
241
+ }
242
+ }
243
+ }
244
+ return results;
245
+ };
92
246
  const contextManager = new ContextManager({
93
247
  systemPrompt: "You are a coding assistant.",
94
248
  maxInputTokens: config.context.maxInputTokens ?? null,
95
249
  reserveOutputTokens: config.context.reserveOutputTokens,
96
250
  compactAtPercent: config.context.compactAtPercent,
97
251
  maxProjectInstructionBytes: config.context.maxProjectInstructionBytes,
252
+ compactEveryNTurns: config.context.compactEveryNTurns ?? 0,
98
253
  provider,
99
254
  model: modelOverride ?? "default",
100
255
  userInstructionsPath: undefined,
101
256
  });
102
257
  let sessionTrusted = false;
103
- let appendLineFn = () => { };
104
- let pendingApproval = null;
105
258
  const approvals = {
106
259
  mode: config.runtime.approvalMode ?? "ask",
107
- handler: interactive
108
- ? async (req) => {
109
- appendLineFn(`[approval] ${req.command}`);
110
- appendLineFn(` cwd: ${req.cwd} timeout: ${req.timeoutMs}ms sandboxed: ${req.sandboxed}`);
111
- appendLineFn(" type y (approve) / n (deny) / t (trust session)");
112
- return new Promise((resolve) => {
113
- pendingApproval = (d) => {
114
- pendingApproval = null;
115
- resolve(d);
116
- };
117
- });
118
- }
119
- : undefined,
260
+ // Installed below once the interactive UI helpers exist.
261
+ handler: undefined,
120
262
  isTrusted: () => sessionTrusted,
121
263
  trustSession: () => {
122
264
  sessionTrusted = true;
@@ -127,17 +269,25 @@ export async function run(args) {
127
269
  provider,
128
270
  model: modelOverride,
129
271
  workspace: process.cwd(),
130
- tools,
272
+ tools: allTools,
131
273
  approvals,
132
274
  contextManager,
275
+ extensionDispatch,
133
276
  bashConcurrency: config.runtime.bashConcurrency,
134
277
  });
278
+ mcpManager.onToolsChanged = () => {
279
+ runtime.setTools([...builtInTools, ...extensionTools, ...createMcpTools(mcpManager)]);
280
+ };
135
281
  if (oneShot || !interactive) {
136
282
  // non-interactive / one-shot path
137
283
  const controller = new AbortController();
138
284
  const onSigint = () => controller.abort();
139
285
  process.on("SIGINT", onSigint);
140
286
  try {
287
+ if (useTools) {
288
+ await mcpManager.syncTools();
289
+ runtime.setTools([...builtInTools, ...extensionTools, ...createMcpTools(mcpManager)]);
290
+ }
141
291
  const result = await runtime.runAgentTurn(oneShot || " ", session, controller.signal);
142
292
  if (jsonMode) {
143
293
  process.stdout.write(JSON.stringify({ result, sessionId: session.id }) + "\n");
@@ -154,22 +304,39 @@ export async function run(args) {
154
304
  process.exit(1);
155
305
  }
156
306
  finally {
307
+ await mcpManager.close().catch(() => { });
157
308
  process.off("SIGINT", onSigint);
158
309
  }
159
310
  return;
160
311
  }
161
312
  // ---- Interactive TUI (first-class) ----
162
- const { TuiMainScreen, Text, Input, Editor, Container, Spacer, ScrollView, Loader, Markdown, Box } = await import("@earendil-works/pi-tui");
313
+ const { TuiAltScreen, Text, Input, Editor, Container, Spacer, ScrollView, VStack, Loader, Markdown, Box } = await import("@earendil-works/pi-tui");
314
+ const mouseEnabled = shouldEnableMouse({
315
+ interactive,
316
+ configured: config.ui.mouse,
317
+ stdoutIsTTY: process.stdout.isTTY === true,
318
+ });
163
319
  const terminal = new ProcessTerminal();
164
- const tui = new TuiMainScreen(terminal);
320
+ const tui = new TuiAltScreen(terminal, false, undefined, { mouse: mouseEnabled });
321
+ const mouseCapture = createMouseCapture((data) => terminal.write(data));
165
322
  let effortLevel = "medium";
166
323
  runtime.setEffort(effortLevel);
167
- const transcript = new Transcript();
168
- transcript.add(new Banner());
324
+ let themeName = themeOverride ?? config.ui.theme;
325
+ if (noColor)
326
+ themeName = "ansi";
327
+ let terminalScheme;
328
+ let theme = resolveTheme(themeName, {
329
+ noColor,
330
+ env: process.env,
331
+ scheme: terminalScheme,
332
+ });
333
+ const transcript = new Transcript(theme);
334
+ const banner = new Banner(theme);
335
+ transcript.add(banner);
169
336
  transcript.add(new Spacer(1));
170
337
  const content = transcript.container;
171
338
  const output = new ScrollView(content, { follow: "end" });
172
- const streamText = new StreamText(transcript);
339
+ const streamText = new StreamText(transcript, 200, 200, markdownThemeFor(theme));
173
340
  function headerLabel() {
174
341
  const cwd = process.cwd();
175
342
  const home = process.env.HOME ?? "";
@@ -188,23 +355,27 @@ export async function run(args) {
188
355
  }
189
356
  return branch ? `${displayPath}:${branch}` : displayPath;
190
357
  }
191
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
192
358
  // paddingY = 0 keeps the workspace path directly under the status line.
193
- const header = new Text(dim(headerLabel()), 1, 0);
194
- const statusBar = new StatusLine();
359
+ const header = new Text(theme.tokens.dim(headerLabel()), 1, 0);
360
+ const statusBar = new StatusLine(theme);
361
+ const activity = new Activity({ theme, onChange: () => updateStatus() });
195
362
  statusBar.setParts({
196
- left: dim(`${provider.id}/${provider.model ?? "default"}`),
197
- tokens: dim("tokens: ?"),
198
- effort: dim("effort: medium"),
363
+ left: `${provider.id}/${provider.model ?? "default"}`,
364
+ tokens: "tokens: ?",
365
+ effort: "effort: medium",
366
+ activity: activity.text(),
199
367
  });
200
368
  const selectListTheme = {
201
- selectedPrefix: (s) => s,
202
- selectedText: (s) => `\x1b[7m${s}\x1b[0m`,
203
- description: (s) => s,
204
- scrollInfo: (s) => s,
205
- noMatch: (s) => s,
369
+ selectedPrefix: (s) => theme.tokens.accent(s),
370
+ selectedText: (s) => theme.tokens.selection(s),
371
+ description: (s) => theme.tokens.dim(s),
372
+ scrollInfo: (s) => theme.tokens.dim(s),
373
+ noMatch: (s) => theme.tokens.dim(s),
374
+ };
375
+ const editorTheme = {
376
+ borderColor: (s) => theme.tokens.border(s),
377
+ selectList: selectListTheme,
206
378
  };
207
- const editorTheme = { borderColor: (s) => s, selectList: selectListTheme };
208
379
  const input = new Editor(tui, editorTheme, {});
209
380
  const { CombinedAutocompleteProvider } = await import("@earendil-works/pi-tui");
210
381
  const { DefaultSkillRegistry, skillCacheSignature } = await import("../skills/registry.js");
@@ -218,80 +389,222 @@ export async function run(args) {
218
389
  }
219
390
  return skillReg;
220
391
  }
221
- const slashCommands = [
222
- { name: "help", description: "show commands" },
223
- { name: "model", description: "search/switch model" },
224
- { name: "provider", description: "add/select provider" },
225
- { name: "session", description: "session info" },
226
- { name: "compact", description: "compact history" },
227
- { name: "clear", description: "clear session" },
228
- { name: "approval", description: "approval mode" },
229
- { name: "doctor", description: "diagnostics" },
230
- { name: "effort", description: "set effort level" },
231
- { name: "skills", description: "list/select skills" },
232
- { name: "exit", description: "exit" },
233
- ];
234
- for (const s of skillReg.list()) {
235
- slashCommands.push({ name: s.name, description: s.description });
236
- }
237
- input.setAutocompleteProvider(new CombinedAutocompleteProvider(slashCommands, process.cwd()));
238
- tui.addChild(output);
239
- tui.addChild(new Spacer());
240
- tui.addChild(input);
241
- tui.addChild(statusBar);
392
+ let commandRegistry = createCommandRegistry({ skills: skillReg.list() });
393
+ let registrySkillSignature = skillSignature;
394
+ function refreshCommandRegistry() {
395
+ const sig = skillCacheSignature(process.cwd());
396
+ if (sig === registrySkillSignature)
397
+ return;
398
+ commandRegistry = createCommandRegistry({ skills: cachedSkillRegistry().list() });
399
+ registrySkillSignature = sig;
400
+ input.setAutocompleteProvider(new CombinedAutocompleteProvider(commandRegistry.autocomplete(), process.cwd()));
401
+ }
402
+ input.setAutocompleteProvider(new CombinedAutocompleteProvider(commandRegistry.autocomplete(), process.cwd()));
403
+ const root = new VStack([]);
404
+ root.addChild(output, { grow: 1 });
405
+ root.addChild(new Spacer(1));
406
+ root.addChild(input);
407
+ root.addChild(statusBar);
242
408
  // folder:branch info below the status line, dimmed
243
- tui.addChild(header);
409
+ root.addChild(header);
244
410
  const dropdownContainer = new Container();
245
- tui.addChild(dropdownContainer);
411
+ root.addChild(dropdownContainer);
412
+ tui.setLayoutRoot(root);
246
413
  tui.setFocus(input);
414
+ if (!noColor) {
415
+ try {
416
+ tui.setTerminalColorSchemeNotifications(true);
417
+ tui.onTerminalColorSchemeChange((scheme) => {
418
+ terminalScheme = scheme;
419
+ if (themeName === "auto")
420
+ applyTheme("auto", false);
421
+ });
422
+ }
423
+ catch {
424
+ // Terminals that do not support colour-scheme reports keep the
425
+ // COLORFGBG/dark fallback.
426
+ }
427
+ }
428
+ class ThinkingBlock {
429
+ buffer = "";
430
+ render(width) {
431
+ const lines = [theme.tokens.dim("💭 thinking")];
432
+ const sep = theme.tokens.dim("│ ");
433
+ const contentWidth = Math.max(1, width - 2);
434
+ const logicalLines = this.buffer.length > 0 ? this.buffer.split("\\n") : [""];
435
+ for (const logical of logicalLines) {
436
+ const wrapped = wrapTextWithAnsi(logical, contentWidth);
437
+ if (wrapped.length === 0) {
438
+ lines.push(sep);
439
+ }
440
+ else {
441
+ for (const part of wrapped)
442
+ lines.push(sep + theme.tokens.dim(part));
443
+ }
444
+ }
445
+ return lines;
446
+ }
447
+ invalidate() { }
448
+ }
247
449
  let thinkingText = null;
248
450
  let thinkingBuffer = "";
249
- let showThinking = true;
250
- let loader = null;
451
+ let showThinking = config.ui.showThinking;
251
452
  let expandTools = false;
252
453
  let uiModalOpen = false;
454
+ let closeActiveOverlay;
253
455
  const toolBoxes = [];
254
- approvals.handler = interactive
255
- ? async (req) => {
256
- appendLine("box: approval");
257
- appendLine(req.command);
258
- appendLine("type y / n / t");
259
- return new Promise((resolve) => {
260
- pendingApproval = (d) => {
261
- pendingApproval = null;
262
- resolve(d);
263
- };
264
- });
265
- }
266
- : undefined;
267
- function appendLine(text) {
456
+ /**
457
+ * Close whichever modal overlay owns the keyboard. Callers that clear the
458
+ * dropdown container directly must use this first, or the overlay's input
459
+ * listener would keep consuming keys after it disappears from the screen.
460
+ */
461
+ function closeOverlay() {
462
+ const close = closeActiveOverlay;
463
+ closeActiveOverlay = undefined;
464
+ close?.();
465
+ }
466
+ function appendLine(text, style = theme.tokens.text) {
268
467
  if (thinkingText) {
269
468
  thinkingText = null;
270
469
  thinkingBuffer = "";
271
470
  }
272
471
  streamText.finalize();
273
- transcript.add(new Text(text));
472
+ transcript.add(new Text(style(text)));
274
473
  tui.requestRender();
275
474
  }
276
475
  function appendDelta(delta) {
277
476
  streamText.append(delta);
278
477
  tui.requestRender();
279
478
  }
479
+ /** Review project-local extensions once per project decision. */
480
+ function maybeReviewProjectTrust() {
481
+ if (uiModalOpen)
482
+ return;
483
+ const pending = extensionLoader
484
+ .discover()
485
+ .filter((entry) => entry.source === "project" &&
486
+ !entry.trusted &&
487
+ entry.skipReason === "project is not trusted");
488
+ if (pending.length === 0)
489
+ return;
490
+ uiModalOpen = true;
491
+ closeOverlay();
492
+ dropdownContainer.clear();
493
+ const prompt = new ProjectTrustPrompt(pending.map((entry) => ({
494
+ id: entry.id,
495
+ entryPath: entry.entryPath,
496
+ version: entry.manifest?.version,
497
+ capabilities: entry.manifest?.capabilities ?? [],
498
+ })), process.cwd(), (decision) => {
499
+ uiModalOpen = false;
500
+ dropdownContainer.clear();
501
+ tui.setFocus(input);
502
+ if (decision === "approve") {
503
+ trustProject(process.cwd());
504
+ appendLine("approved project extensions");
505
+ }
506
+ else {
507
+ denyProject(process.cwd());
508
+ appendLine("denied project extensions");
509
+ }
510
+ void extensionLoader
511
+ .loadAll()
512
+ .then(() => {
513
+ runtime.setTools([
514
+ ...builtInTools,
515
+ ...collectExtensionTools(extensionLoader),
516
+ ...createMcpTools(mcpManager),
517
+ ]);
518
+ appendLine(`extensions: ${extensionLoader.list().length} processed (see /extensions)`);
519
+ tui.requestRender();
520
+ })
521
+ .catch((error) => {
522
+ appendLine(`extensions: reload failed: ${error instanceof Error ? error.message : String(error)}`);
523
+ });
524
+ });
525
+ dropdownContainer.addChild(prompt);
526
+ tui.setFocus(prompt);
527
+ tui.requestRender();
528
+ }
529
+ function thinkingBody() {
530
+ return thinkingBuffer
531
+ .split("\n")
532
+ .map((line) => theme.tokens.dim(`│ ${line}`))
533
+ .join("\n");
534
+ }
280
535
  function finalizeText() {
281
536
  streamText.finalize();
282
537
  }
538
+ let activeTurnController = null;
539
+ const messageQueue = [];
540
+ const steeringQueue = [];
541
+ runtime.setSteeringQueue(steeringQueue);
542
+ /** Run one interactive turn while keeping the status bar's activity fresh. */
543
+ async function runTurn(prompt) {
544
+ activity.start("formulating");
545
+ const controller = new AbortController();
546
+ activeTurnController = controller;
547
+ try {
548
+ if (useTools) {
549
+ await mcpManager.syncTools();
550
+ runtime.setTools([...builtInTools, ...extensionTools, ...createMcpTools(mcpManager)]);
551
+ }
552
+ await runtime.runAgentTurn(prompt, session, controller.signal);
553
+ }
554
+ catch (err) {
555
+ if (err instanceof Error && err.message === "USER_CANCELLED") {
556
+ appendLine("cancelled");
557
+ }
558
+ else {
559
+ appendLine(`atom-lite: ${err instanceof Error ? err.message : String(err)}`);
560
+ }
561
+ }
562
+ finally {
563
+ activeTurnController = null;
564
+ activity.stop();
565
+ if (messageQueue.length > 0) {
566
+ const next = messageQueue.shift();
567
+ appendLine(`> ${next}`, theme.tokens.user);
568
+ void runTurn(next);
569
+ }
570
+ }
571
+ }
283
572
  function updateStatus() {
284
573
  const usage = runtime.getContextUsage();
285
574
  statusBar.setParts({
286
- left: dim(`${provider.id}/${currentModelLabel()}`),
287
- tokens: dim(`tokens: ${formatContextUsage(usage.used, usage.window)}`),
288
- effort: dim(`effort: ${effortLevel}`),
575
+ left: `${provider.id}/${currentModelLabel()}`,
576
+ tokens: `tokens: ${formatContextUsage(usage.used, usage.window)}`,
577
+ effort: `effort: ${effortLevel}`,
578
+ activity: activity.text(),
289
579
  });
290
580
  tui.requestRender();
291
581
  }
292
582
  function currentModelLabel() {
293
583
  return modelOverride ?? provider.model ?? "default";
294
584
  }
585
+ function applyTheme(name, persist) {
586
+ themeName = name;
587
+ theme = resolveTheme(name, {
588
+ noColor,
589
+ env: process.env,
590
+ scheme: terminalScheme,
591
+ });
592
+ banner.setTheme(theme);
593
+ transcript.setTheme(theme);
594
+ statusBar.setTheme(theme);
595
+ activity.setTheme(theme);
596
+ header.setText(theme.tokens.dim(headerLabel()));
597
+ if (persist) {
598
+ try {
599
+ saveTheme(name);
600
+ }
601
+ catch (err) {
602
+ appendLine(`warning: could not persist theme: ${err instanceof Error ? err.message : String(err)}`);
603
+ }
604
+ }
605
+ updateStatus();
606
+ tui.requestRender();
607
+ }
295
608
  /**
296
609
  * Show the best-known window immediately, then refine it from live provider
297
610
  * metadata without blocking the TUI.
@@ -327,6 +640,17 @@ export async function run(args) {
327
640
  refreshContextWindow();
328
641
  const MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
329
642
  const modelCache = new Map();
643
+ for (const loaded of extensionLoader.list()) {
644
+ for (const [name, provider] of (loaded.registries?.providers ?? new Map())) {
645
+ const cfg = provider?.config ?? {};
646
+ const models = Array.isArray(cfg.models)
647
+ ? cfg.models.map((m) => m.id ?? m.name).filter(Boolean)
648
+ : [];
649
+ if (models.length > 0) {
650
+ modelCache.set(name, { models, at: Date.now() });
651
+ }
652
+ }
653
+ }
330
654
  function profileConfig(profile) {
331
655
  return config.providers[profile];
332
656
  }
@@ -420,6 +744,13 @@ export async function run(args) {
420
744
  });
421
745
  }
422
746
  }
747
+ /** Providers whose model list is missing or older than the cache TTL. */
748
+ function providersNeedingRefresh(names) {
749
+ return names.filter((name) => {
750
+ const cached = modelCache.get(name);
751
+ return !cached || Date.now() - cached.at >= MODEL_CACHE_TTL_MS;
752
+ });
753
+ }
423
754
  function switchToModel(profile, model) {
424
755
  const pcfg = ensureProfile(profile);
425
756
  if (!pcfg) {
@@ -441,6 +772,35 @@ export async function run(args) {
441
772
  refreshContextWindow();
442
773
  appendLine(`switched to ${profile}/${model}`);
443
774
  }
775
+ /** Interpret `/model` arguments: bare model, provider/model, or provider. */
776
+ function switchModelTarget(target) {
777
+ const rest = target.trim();
778
+ if (!rest) {
779
+ openModelPicker();
780
+ return;
781
+ }
782
+ const knownModels = modelsFor(provider.id);
783
+ if (knownModels.includes(rest)) {
784
+ switchToModel(provider.id, rest);
785
+ return;
786
+ }
787
+ const slash = rest.indexOf("/");
788
+ if (slash > 0) {
789
+ const profile = rest.slice(0, slash);
790
+ const model = rest.slice(slash + 1);
791
+ if (profileConfig(profile) || getPreset(profile)) {
792
+ switchToModel(profile, model);
793
+ return;
794
+ }
795
+ }
796
+ if (profileConfig(rest) || getPreset(rest)) {
797
+ ensureProfile(rest);
798
+ const pcfg = profileConfig(rest);
799
+ switchToModel(rest, String(pcfg?.model ?? modelOverride ?? "default"));
800
+ return;
801
+ }
802
+ switchToModel(provider.id, rest);
803
+ }
444
804
  function openModelPicker() {
445
805
  const names = availableProviderNames(config);
446
806
  if (!names.includes(provider.id) && profileConfig(provider.id)) {
@@ -451,6 +811,7 @@ export async function run(args) {
451
811
  tui,
452
812
  container: dropdownContainer,
453
813
  title: "Select model",
814
+ theme,
454
815
  items: buildModelItems(),
455
816
  maxVisible: 15,
456
817
  onSelect: (item) => {
@@ -466,9 +827,11 @@ export async function run(args) {
466
827
  },
467
828
  restoreFocus: () => tui.setFocus(input),
468
829
  });
469
- if (names.length) {
470
- picker.setStatus(`loading live models from ${names.length} provider${names.length === 1 ? "" : "s"}…`);
471
- refreshModelCache(names, () => {
830
+ closeActiveOverlay = picker.close;
831
+ const stale = providersNeedingRefresh(names);
832
+ if (stale.length) {
833
+ picker.setStatus(`loading live models from ${stale.length} provider${stale.length === 1 ? "" : "s"}…`);
834
+ refreshModelCache(stale, () => {
472
835
  picker.setStatus("");
473
836
  picker.updateItems(buildModelItems());
474
837
  });
@@ -521,6 +884,7 @@ export async function run(args) {
521
884
  appendLine("tip: /model searches every configured provider");
522
885
  }
523
886
  function closeApiKeyPrompt() {
887
+ closeOverlay();
524
888
  dropdownContainer.clear();
525
889
  uiModalOpen = false;
526
890
  tui.setFocus(input);
@@ -528,13 +892,14 @@ export async function run(args) {
528
892
  }
529
893
  function promptForApiKey(preset, existingKey) {
530
894
  uiModalOpen = true;
895
+ closeOverlay();
531
896
  dropdownContainer.clear();
532
897
  const wrapper = new Container();
533
- wrapper.addChild(new Text(`\x1b[36m${preset.label} API key\x1b[0m`));
898
+ wrapper.addChild(new Text(theme.tokens.accent(`${preset.label} API key`)));
534
899
  if (preset.keyUrl)
535
- wrapper.addChild(new Text(dim(`create one: ${preset.keyUrl}`)));
900
+ wrapper.addChild(new Text(theme.tokens.dim(`create one: ${preset.keyUrl}`)));
536
901
  if (existingKey)
537
- wrapper.addChild(new Text(dim("a key is already saved — press Enter to keep it")));
902
+ wrapper.addChild(new Text(theme.tokens.dim("a key is already saved — press Enter to keep it")));
538
903
  const secret = new SecretInput("API key", existingKey ? "hidden (Enter keeps saved key)" : "paste key (hidden)");
539
904
  const status = new Text("");
540
905
  wrapper.addChild(secret);
@@ -564,18 +929,18 @@ export async function run(args) {
564
929
  }
565
930
  return;
566
931
  }
567
- status.setText(dim("an API key is required"));
932
+ status.setText(theme.tokens.dim("an API key is required"));
568
933
  tui.requestRender();
569
934
  return;
570
935
  }
571
936
  busy = true;
572
- status.setText(dim("validating key…"));
937
+ status.setText(theme.tokens.dim("validating key…"));
573
938
  tui.requestRender();
574
939
  await new Promise((r) => setImmediate(r));
575
940
  const probe = await probeApiKey(preset, key);
576
941
  if (probe.invalid) {
577
942
  busy = false;
578
- status.setText(`\x1b[31m✗ key rejected by ${preset.label}\x1b[0m`);
943
+ status.setText(theme.tokens.error(`✗ key rejected by ${preset.label}`));
579
944
  secret.reset();
580
945
  tui.requestRender();
581
946
  return;
@@ -585,7 +950,7 @@ export async function run(args) {
585
950
  }
586
951
  catch (err) {
587
952
  busy = false;
588
- status.setText(`\x1b[31m✗ could not save key: ${err instanceof Error ? err.message : String(err)}\x1b[0m`);
953
+ status.setText(theme.tokens.error(`✗ could not save key: ${err instanceof Error ? err.message : String(err)}`));
589
954
  tui.requestRender();
590
955
  return;
591
956
  }
@@ -621,10 +986,11 @@ export async function run(args) {
621
986
  }
622
987
  function openProviderPicker() {
623
988
  uiModalOpen = true;
624
- openSearchablePicker({
989
+ const picker = openSearchablePicker({
625
990
  tui,
626
991
  container: dropdownContainer,
627
992
  title: "Select provider",
993
+ theme,
628
994
  items: providerItems(),
629
995
  maxVisible: 12,
630
996
  onSelect: (item) => {
@@ -637,69 +1003,554 @@ export async function run(args) {
637
1003
  },
638
1004
  restoreFocus: () => tui.setFocus(input),
639
1005
  });
1006
+ closeActiveOverlay = picker.close;
1007
+ }
1008
+ function openEffortPicker() {
1009
+ uiModalOpen = true;
1010
+ const items = [
1011
+ { value: "low", label: "low", description: "minimal reasoning" },
1012
+ { value: "medium", label: "medium", description: "balanced" },
1013
+ { value: "high", label: "high", description: "deep reasoning" },
1014
+ ];
1015
+ const picker = openSearchablePicker({
1016
+ tui,
1017
+ container: dropdownContainer,
1018
+ title: "Select effort",
1019
+ theme,
1020
+ items,
1021
+ maxVisible: 3,
1022
+ onSelect: (item) => {
1023
+ uiModalOpen = false;
1024
+ setEffortTarget(item.value);
1025
+ },
1026
+ onCancel: () => {
1027
+ uiModalOpen = false;
1028
+ appendLine("cancelled");
1029
+ },
1030
+ restoreFocus: () => tui.setFocus(input),
1031
+ });
1032
+ closeActiveOverlay = picker.close;
1033
+ picker.setStatus("applies to the next turn");
1034
+ }
1035
+ function setEffortTarget(level) {
1036
+ effortLevel = level;
1037
+ runtime.setEffort(level);
1038
+ updateStatus();
1039
+ appendLine(`effort set to ${effortLevel}`);
1040
+ }
1041
+ function themePickerItems() {
1042
+ return themeNames().map((name) => ({
1043
+ value: name,
1044
+ label: name,
1045
+ description: themeLabel(name),
1046
+ }));
1047
+ }
1048
+ function setThemeTarget(name) {
1049
+ if (!themeNames().includes(name)) {
1050
+ appendLine(`unknown theme: ${name} (available: ${themeNames().join(", ")})`);
1051
+ return;
1052
+ }
1053
+ applyTheme(name, true);
1054
+ appendLine(`theme: ${themeLabel(name)}`);
1055
+ }
1056
+ function openThemePicker() {
1057
+ uiModalOpen = true;
1058
+ const original = themeName;
1059
+ const picker = openSearchablePicker({
1060
+ tui,
1061
+ container: dropdownContainer,
1062
+ title: "Select theme",
1063
+ theme,
1064
+ items: themePickerItems(),
1065
+ maxVisible: 4,
1066
+ initialValue: themeName,
1067
+ onPreview: (item) => applyTheme(item.value, false),
1068
+ onSelect: (item) => {
1069
+ uiModalOpen = false;
1070
+ setThemeTarget(item.value);
1071
+ },
1072
+ onCancel: () => {
1073
+ uiModalOpen = false;
1074
+ applyTheme(original, false);
1075
+ appendLine("cancelled");
1076
+ },
1077
+ restoreFocus: () => tui.setFocus(input),
1078
+ });
1079
+ closeActiveOverlay = picker.close;
1080
+ }
1081
+ async function loadSkill(name, extra) {
1082
+ const registry = cachedSkillRegistry();
1083
+ const content = registry.load(name);
1084
+ if (!content) {
1085
+ appendLine(`skill not found: ${name}`);
1086
+ return false;
1087
+ }
1088
+ session.appendMessage({ role: "system", content });
1089
+ if (typeof session.appendEvent === "function") {
1090
+ session.appendEvent({ type: "skill_loaded", name });
1091
+ }
1092
+ appendLine(`Loaded skill: ${name}`);
1093
+ if (extra) {
1094
+ appendLine(`> ${extra}`);
1095
+ await runTurn(extra);
1096
+ }
1097
+ return true;
1098
+ }
1099
+ const commandContext = {
1100
+ get session() {
1101
+ return session;
1102
+ },
1103
+ runtime,
1104
+ approvals,
1105
+ appendLine,
1106
+ clearContent: () => {
1107
+ session.messages.length = 0;
1108
+ content.clear();
1109
+ tui.requestRender();
1110
+ },
1111
+ updateStatus,
1112
+ runDoctor,
1113
+ runMcp,
1114
+ runCompact,
1115
+ runExport,
1116
+ openModelPicker,
1117
+ openProviderPicker,
1118
+ openEffortPicker,
1119
+ openThemePicker,
1120
+ openSessionsPicker,
1121
+ searchSessions: searchSessionsInStore,
1122
+ renameSession,
1123
+ listExtensions: () => {
1124
+ const report = extensionLoader.report();
1125
+ if (report.length === 0)
1126
+ return "No extensions discovered.";
1127
+ return report
1128
+ .map((ext) => {
1129
+ const origin = ext.origin ? `${ext.source}:${ext.origin}` : ext.source;
1130
+ const version = ext.version ? `v${ext.version}` : "-";
1131
+ const counts = `${ext.toolCount} tools / ${ext.commandCount} commands`;
1132
+ const error = ext.error ? `\t${ext.error}` : "";
1133
+ return `${ext.id}\t${origin}\t${version}\t${ext.status}\t${counts}\t${ext.entryPath}${error}`;
1134
+ })
1135
+ .join("\n");
1136
+ },
1137
+ extensionDoctor: () => extensionLoader.doctor(),
1138
+ reloadExtensions,
1139
+ switchModel: switchModelTarget,
1140
+ startProviderSetup,
1141
+ setEffort: setEffortTarget,
1142
+ toggleThinking: () => {
1143
+ showThinking = !showThinking;
1144
+ return showThinking;
1145
+ },
1146
+ setTheme: setThemeTarget,
1147
+ listSkills: () => cachedSkillRegistry().list().map((skill) => ({
1148
+ name: skill.name,
1149
+ description: skill.description,
1150
+ })),
1151
+ loadSkill,
1152
+ runTurn,
1153
+ requestExit: () => {
1154
+ void mcpManager.close().finally(() => {
1155
+ tui.stop();
1156
+ process.exit(0);
1157
+ });
1158
+ },
1159
+ };
1160
+ function restoreTranscript(next) {
1161
+ streamText.finalize();
1162
+ transcript.reset();
1163
+ banner.setTheme(theme);
1164
+ transcript.add(banner);
1165
+ transcript.add(new Spacer(1));
1166
+ for (const line of sessionHistoryLines(next.messages)) {
1167
+ transcript.addLine(line);
1168
+ }
1169
+ tui.requestRender();
1170
+ }
1171
+ function resumeSession(id) {
1172
+ const next = store.resume(id);
1173
+ if (!next) {
1174
+ appendLine(`could not resume session: ${id}`);
1175
+ return;
1176
+ }
1177
+ const previous = session.id;
1178
+ session = next;
1179
+ session.appendEvent({ type: "session_resumed", from: previous });
1180
+ restoreTranscript(next);
1181
+ const title = sessionTitleFromEvents(next.events);
1182
+ appendLine(`resumed session ${session.id}: ${title} (${session.messages.length} messages)`);
1183
+ const notice = providerSwitchNotice({ provider: provider.id, model: currentModelLabel() }, sessionSelectionFromEvents(next.events));
1184
+ if (notice)
1185
+ appendLine(notice);
1186
+ updateStatus();
1187
+ tui.requestRender();
1188
+ }
1189
+ function renameSession(title) {
1190
+ const trimmed = title.trim();
1191
+ if (!trimmed) {
1192
+ appendLine("usage: /session rename <title>");
1193
+ return;
1194
+ }
1195
+ session.appendEvent({ type: "session_renamed", title: trimmed });
1196
+ appendLine(`session renamed: ${trimmed}`);
1197
+ tui.requestRender();
1198
+ }
1199
+ function searchSessionsInStore(query) {
1200
+ const matches = searchSessions(store.list(), query);
1201
+ if (matches.length === 0) {
1202
+ appendLine(`no sessions match “${query}”`);
1203
+ return;
1204
+ }
1205
+ appendLine(formatSessionSearch(matches));
1206
+ }
1207
+ function openSessionsPicker(initialScope) {
1208
+ uiModalOpen = true;
1209
+ let scope = initialScope;
1210
+ const buildItems = () => {
1211
+ const summaries = scope === "all" ? store.list() : store.list(process.cwd());
1212
+ const toggle = {
1213
+ value: "__scope__",
1214
+ label: scope === "all"
1215
+ ? "Show this workspace only"
1216
+ : "Show all workspaces",
1217
+ description: `scope: ${scope === "all" ? "all workspaces" : process.cwd()}`,
1218
+ };
1219
+ return [
1220
+ toggle,
1221
+ ...summaries.map((summary) => ({
1222
+ value: `session:${summary.id}`,
1223
+ label: summary.title ?? summary.id,
1224
+ description: `${summary.updatedAt ?? "unknown"} · ${summary.provider} · ${summary.messageCount ?? 0} messages · ${summary.workspace}`,
1225
+ })),
1226
+ ];
1227
+ };
1228
+ let picker;
1229
+ picker = openSearchablePicker({
1230
+ tui,
1231
+ container: dropdownContainer,
1232
+ title: "Resume session",
1233
+ theme,
1234
+ items: buildItems(),
1235
+ closeOnSelect: false,
1236
+ onSelect: (item) => {
1237
+ if (item.value === "__scope__") {
1238
+ scope = scope === "all" ? "workspace" : "all";
1239
+ picker.updateItems(buildItems());
1240
+ picker.setStatus(scope === "all" ? "showing all workspaces" : "showing this workspace");
1241
+ return;
1242
+ }
1243
+ picker.close();
1244
+ uiModalOpen = false;
1245
+ resumeSession(item.value.slice("session:".length));
1246
+ },
1247
+ onCancel: () => {
1248
+ uiModalOpen = false;
1249
+ appendLine("cancelled");
1250
+ },
1251
+ restoreFocus: () => tui.setFocus(input),
1252
+ });
1253
+ closeActiveOverlay = picker.close;
1254
+ picker.setStatus(scope === "all" ? "showing all workspaces" : "showing this workspace");
1255
+ }
1256
+ function buildPaletteItems() {
1257
+ const items = [];
1258
+ for (const command of commandRegistry.list()) {
1259
+ const hint = command.argumentHint ? ` ${command.argumentHint}` : "";
1260
+ items.push({
1261
+ id: `command:${command.name}`,
1262
+ label: `/${command.name}${hint}`,
1263
+ description: command.description,
1264
+ category: command.category,
1265
+ keywords: command.aliases,
1266
+ action: async () => {
1267
+ await commandRegistry.dispatch(`/${command.name}`, commandContext);
1268
+ },
1269
+ });
1270
+ }
1271
+ const profiles = new Set([
1272
+ ...availableProviderNames(config),
1273
+ ...Object.keys(config.providers),
1274
+ ]);
1275
+ for (const profile of profiles) {
1276
+ const preset = getPreset(profile);
1277
+ items.push({
1278
+ id: `provider:${profile}`,
1279
+ label: providerLabel(profile),
1280
+ description: preset?.description ?? "configured provider",
1281
+ category: "Providers",
1282
+ action: () => {
1283
+ if (preset)
1284
+ startProviderSetup(profile);
1285
+ else
1286
+ switchModelTarget(`${profile}/${String(profileConfig(profile)?.model ?? "default")}`);
1287
+ },
1288
+ });
1289
+ }
1290
+ for (const item of buildModelItems()) {
1291
+ const separator = item.value.indexOf("\u0000");
1292
+ const profile = item.value.slice(0, separator);
1293
+ const model = item.value.slice(separator + 1);
1294
+ items.push({
1295
+ id: `model:${item.value}`,
1296
+ label: item.label.replace(" (current)", ""),
1297
+ description: `${providerLabel(profile)}${item.label.includes("(current)") ? " · current" : ""}`,
1298
+ category: "Models",
1299
+ action: () => switchToModel(profile, model),
1300
+ });
1301
+ }
1302
+ for (const name of themeNames()) {
1303
+ items.push({
1304
+ id: `theme:${name}`,
1305
+ label: name,
1306
+ description: themeLabel(name),
1307
+ category: "Themes",
1308
+ action: () => setThemeTarget(name),
1309
+ });
1310
+ }
1311
+ for (const summary of store.list(process.cwd()).slice(0, 8)) {
1312
+ items.push({
1313
+ id: `session:${summary.id}`,
1314
+ label: summary.title ?? summary.id,
1315
+ description: `${summary.updatedAt ?? "unknown"} · ${summary.provider} · ${summary.messageCount ?? 0} messages`,
1316
+ category: "Sessions",
1317
+ keywords: [summary.provider, summary.workspace],
1318
+ action: () => resumeSession(summary.id),
1319
+ });
1320
+ }
1321
+ return items;
1322
+ }
1323
+ function openPalette() {
1324
+ if (uiModalOpen)
1325
+ return;
1326
+ refreshCommandRegistry();
1327
+ uiModalOpen = true;
1328
+ const palette = openCommandPalette({
1329
+ tui,
1330
+ container: dropdownContainer,
1331
+ items: buildPaletteItems(),
1332
+ theme,
1333
+ title: "Command palette",
1334
+ onSelect: () => {
1335
+ uiModalOpen = false;
1336
+ },
1337
+ onCancel: () => {
1338
+ uiModalOpen = false;
1339
+ appendLine("cancelled");
1340
+ },
1341
+ onError: (err) => {
1342
+ uiModalOpen = false;
1343
+ appendLine(`palette: ${err instanceof Error ? err.message : String(err)}`);
1344
+ },
1345
+ restoreFocus: () => tui.setFocus(input),
1346
+ });
1347
+ closeActiveOverlay = palette.close;
1348
+ // The palette lists models too; refresh stale caches in the background and
1349
+ // update the open list when live discovery finishes.
1350
+ const stale = providersNeedingRefresh(availableProviderNames(config));
1351
+ if (stale.length) {
1352
+ refreshModelCache(stale, () => {
1353
+ palette.updateItems(buildPaletteItems());
1354
+ });
1355
+ }
640
1356
  }
641
- appendLineFn = appendLine;
642
1357
  function toggleTools() {
643
1358
  expandTools = !expandTools;
644
1359
  for (const box of toolBoxes) {
645
- if (box.live)
646
- continue; // a running command keeps its live view
647
- box.output.clear();
648
- if (expandTools) {
649
- if (box.lines.length > 0) {
650
- box.output.addChild(new Text(box.lines.join("\n")));
1360
+ box.setExpanded(expandTools);
1361
+ }
1362
+ tui.requestRender();
1363
+ }
1364
+ async function reloadExtensions() {
1365
+ const previous = extensionLoader.list();
1366
+ // Emit session_shutdown and extension_unload before unloading.
1367
+ for (const loaded of previous) {
1368
+ const handlers = loaded.registries?.handlers?.get?.("session_shutdown");
1369
+ if (handlers) {
1370
+ for (const handler of handlers) {
1371
+ try {
1372
+ await handler({ type: "session_shutdown", reason: "reload" }, (loaded.context ?? {}));
1373
+ }
1374
+ catch (error) {
1375
+ appendLine(`extension shutdown error: ${error instanceof Error ? error.message : String(error)}`);
1376
+ }
651
1377
  }
652
1378
  }
653
- else {
654
- box.output.addChild(new Text(` (${box.lines.length} lines, ctrl+o to expand)`));
1379
+ }
1380
+ await extensionLoader.unloadAll();
1381
+ try {
1382
+ await extensionLoader.loadAll();
1383
+ }
1384
+ catch (error) {
1385
+ appendLine(`extensions: reload failed: ${error instanceof Error ? error.message : String(error)}`);
1386
+ tui.requestRender();
1387
+ return;
1388
+ }
1389
+ // Remove previously bridged providers, then re-bridge.
1390
+ for (const name of extensionProviderNames) {
1391
+ delete config.providers[name];
1392
+ modelCache.delete(name);
1393
+ }
1394
+ extensionProviderNames.clear();
1395
+ for (const loaded of extensionLoader.list()) {
1396
+ for (const [name, provider] of (loaded.registries?.providers ?? new Map())) {
1397
+ const cfg = provider?.config ?? {};
1398
+ const firstModel = Array.isArray(cfg.models) && cfg.models.length > 0
1399
+ ? (cfg.models[0].id ?? cfg.models[0].name ?? "default")
1400
+ : "default";
1401
+ const apiKey = resolveExtensionApiKey(cfg.apiKey);
1402
+ const headers = { ...(cfg.headers ?? {}) };
1403
+ if (cfg.authHeader && apiKey)
1404
+ headers.Authorization = `Bearer ${apiKey}`;
1405
+ config.providers[name] = {
1406
+ type: "openai-compatible",
1407
+ baseUrl: cfg.baseUrl,
1408
+ model: firstModel,
1409
+ apiKey: apiKey ?? null,
1410
+ apiKeyEnv: null,
1411
+ headers,
1412
+ contextWindow: cfg.models?.[0]?.contextWindow,
1413
+ };
1414
+ extensionProviderNames.add(name);
1415
+ const models = Array.isArray(cfg.models)
1416
+ ? cfg.models.map((m) => m.id ?? m.name).filter(Boolean)
1417
+ : [];
1418
+ if (models.length > 0)
1419
+ modelCache.set(name, { models, at: Date.now() });
1420
+ }
1421
+ }
1422
+ // Re-apply extension tools.
1423
+ try {
1424
+ await mcpManager.syncTools();
1425
+ }
1426
+ catch {
1427
+ // ignore
1428
+ }
1429
+ runtime.setTools([
1430
+ ...builtInTools,
1431
+ ...collectExtensionTools(extensionLoader),
1432
+ ...createMcpTools(mcpManager),
1433
+ ]);
1434
+ // Emit session_start with reason reload.
1435
+ for (const loaded of extensionLoader.list()) {
1436
+ const handlers = loaded.registries?.handlers?.get?.("session_start");
1437
+ if (handlers) {
1438
+ for (const handler of handlers) {
1439
+ try {
1440
+ await handler({ type: "session_start", reason: "reload" }, (loaded.context ?? {}));
1441
+ }
1442
+ catch (error) {
1443
+ appendLine(`extension start error: ${error instanceof Error ? error.message : String(error)}`);
1444
+ }
1445
+ }
655
1446
  }
656
1447
  }
1448
+ appendLine(`extensions: ${extensionLoader.list().length} reloaded (see /extensions)`);
657
1449
  tui.requestRender();
658
1450
  }
1451
+ // Wire interactive extension UI callbacks now that the TUI exists.
1452
+ extensionUICallbacks.notify = (message, type) => {
1453
+ const style = type === "error"
1454
+ ? theme.tokens.error
1455
+ : type === "warning"
1456
+ ? theme.tokens.warning
1457
+ : theme.tokens.accent;
1458
+ appendLine(`${style(`[ext]`)} ${message}`);
1459
+ };
1460
+ extensionUICallbacks.requestRender = () => tui.requestRender();
1461
+ extensionUICallbacks.getToolsExpanded = () => expandTools;
1462
+ extensionUICallbacks.setToolsExpanded = (expanded) => {
1463
+ if (expandTools !== expanded)
1464
+ toggleTools();
1465
+ };
1466
+ function toggleToolBoxAtScreenRow(screenY) {
1467
+ const width = terminal.columns;
1468
+ const outputHeight = Math.max(1, output.viewportHeight || output.render(width).length);
1469
+ if (screenY < 0 || screenY >= outputHeight)
1470
+ return false;
1471
+ const children = content.children;
1472
+ const contentBlocks = children.map((child, index) => ({
1473
+ id: `content:${index}`,
1474
+ height: child.render(width).length,
1475
+ }));
1476
+ const total = contentBlocks.reduce((sum, block) => sum + block.height, 0);
1477
+ const viewportTop = Math.max(0, total - outputHeight);
1478
+ const inner = blockAtLine(contentBlocks, viewportTop + screenY);
1479
+ if (!inner)
1480
+ return false;
1481
+ const child = children[Number(inner.id.slice("content:".length))];
1482
+ if (!(child instanceof ToolBox))
1483
+ return false;
1484
+ child.toggleExpanded();
1485
+ return true;
1486
+ }
1487
+ tui.addInputListener((data) => {
1488
+ const event = parseSgrMouse(data);
1489
+ if (!event)
1490
+ return undefined;
1491
+ // Let the alt-screen TUI own wheel scrolling; only intercept clicks.
1492
+ if (event.type === "wheel")
1493
+ return undefined;
1494
+ if (!mouseEnabled)
1495
+ return { consume: true };
1496
+ handleMouseEvent(event, {
1497
+ isModalOpen: () => uiModalOpen,
1498
+ scrollBy: (lines) => output.scrollBy(lines),
1499
+ focusInput: () => tui.setFocus(input),
1500
+ toggleToolBox: (screenY) => toggleToolBoxAtScreenRow(screenY),
1501
+ requestRender: () => tui.requestRender(),
1502
+ });
1503
+ return { consume: true };
1504
+ });
659
1505
  tui.addInputListener((data) => {
1506
+ // Releases and repeats must not toggle modal shortcuts twice.
1507
+ if (isKeyRelease(data) || isKeyRepeat(data))
1508
+ return undefined;
1509
+ // Ctrl+P opens the command palette unless a modal owns the keyboard.
1510
+ if (matchesKey(data, Key.ctrl("p")) && !uiModalOpen) {
1511
+ openPalette();
1512
+ return { consume: true };
1513
+ }
660
1514
  // Ctrl+O toggles tool expansion (unless a modal is capturing input).
661
1515
  if (matchesKey(data, Key.ctrl("o")) && !uiModalOpen) {
662
1516
  toggleTools();
663
1517
  return { consume: true };
664
1518
  }
1519
+ // Escape cancels the active turn.
1520
+ if (matchesKey(data, Key.escape) && !uiModalOpen && activeTurnController) {
1521
+ activeTurnController.abort();
1522
+ return { consume: true };
1523
+ }
665
1524
  return undefined;
666
1525
  });
667
1526
  runtime["onEvent"] = (e) => {
668
1527
  if (e.type === "thinking_delta") {
1528
+ activity.setPhase("thinking");
669
1529
  if (!showThinking)
670
1530
  return;
671
1531
  if (!thinkingText) {
672
- thinkingText = new Text("\x1b[2mThinking...\x1b[0m");
1532
+ thinkingText = new ThinkingBlock();
673
1533
  transcript.add(thinkingText);
674
1534
  thinkingBuffer = "";
675
1535
  }
676
1536
  thinkingBuffer += e.text;
677
- const prefixed = thinkingBuffer
678
- .split("\n")
679
- .map((l) => `\x1b[2m| ${l}\x1b[0m`)
680
- .join("\n");
681
- thinkingText.setText(`\x1b[2mThinking...\x1b[0m\n${prefixed}`);
1537
+ thinkingText.buffer = thinkingBuffer;
1538
+ transcript.invalidate();
682
1539
  tui.requestRender();
683
1540
  }
684
1541
  else if (e.type === "assistant_delta") {
1542
+ activity.setPhase("working");
685
1543
  if (thinkingText) {
686
- const prefixed = thinkingBuffer
687
- .split("\n")
688
- .map((l) => `\x1b[2m| ${l}\x1b[0m`)
689
- .join("\n");
690
- thinkingText.setText(`\x1b[2mThinking...\x1b[0m\n${prefixed}\n\x1b[2m| end\x1b[0m`);
1544
+ transcript.invalidate();
691
1545
  thinkingText = null;
692
1546
  thinkingBuffer = "";
693
1547
  }
694
1548
  appendDelta(e.text);
695
1549
  }
696
1550
  else if (e.type === "assistant_text") {
1551
+ activity.setPhase("working");
697
1552
  if (thinkingText) {
698
- const prefixed = thinkingBuffer
699
- .split("\n")
700
- .map((l) => `\x1b[2m| ${l}\x1b[0m`)
701
- .join("\n");
702
- thinkingText.setText(`\x1b[2mThinking...\x1b[0m\n${prefixed}\n\x1b[2m| end\x1b[0m`);
1553
+ transcript.invalidate();
703
1554
  thinkingText = null;
704
1555
  thinkingBuffer = "";
705
1556
  }
@@ -718,37 +1569,18 @@ export async function run(args) {
718
1569
  }
719
1570
  else if (e.type === "tool_call") {
720
1571
  finalizeText();
721
- const box = new Container();
722
- const header = new Text(`▸ ${e.name} ${JSON.stringify(e.args)}`);
723
- box.addChild(header);
724
- const output = new Container();
725
- box.addChild(output);
1572
+ activity.setPhase("working", e.name);
1573
+ const box = new ToolBox({ id: e.id, name: e.name, args: e.args });
1574
+ box.setExpanded(expandTools);
726
1575
  transcript.add(box);
727
- toolBoxes.push({
728
- id: e.id,
729
- header,
730
- output,
731
- container: box,
732
- lines: [],
733
- toolName: e.name,
734
- args: e.args,
735
- });
1576
+ toolBoxes.push(box);
736
1577
  tui.requestRender();
737
1578
  }
738
1579
  else if (e.type === "tool_output") {
739
1580
  const box = toolBoxes.find((b) => b.id === e.toolCallId);
740
1581
  if (box) {
741
- if (!box.live) {
742
- box.live = new Text("");
743
- box.output.addChild(box.live);
744
- }
745
- box.liveBuffer = (box.liveBuffer ?? "") + e.text;
746
- // Keep the live view bounded; the completed result carries the full
747
- // capped output, and the JSONL transcript keeps everything.
748
- if (box.liveBuffer.length > 60_000) {
749
- box.liveBuffer = box.liveBuffer.slice(-60_000);
750
- }
751
- box.live.setText(box.liveBuffer);
1582
+ activity.setPhase("working", e.name);
1583
+ box.appendLive(e.text);
752
1584
  tui.requestRender();
753
1585
  }
754
1586
  }
@@ -756,44 +1588,14 @@ export async function run(args) {
756
1588
  const last = toolBoxes.find((b) => b.id === e.toolCallId) ??
757
1589
  toolBoxes[toolBoxes.length - 1];
758
1590
  if (last) {
759
- last.live = null;
760
- last.liveBuffer = "";
761
- last.output.clear();
762
- if (e.diff) {
763
- const diffLines = e.diff;
764
- last.lines = diffLines;
765
- if (expandTools) {
766
- const colored = diffLines
767
- .map((l) => l.startsWith("+")
768
- ? `\x1b[32m${l}\x1b[0m`
769
- : l.startsWith("-")
770
- ? `\x1b[31m${l}\x1b[0m`
771
- : l)
772
- .join("\n");
773
- if (colored)
774
- last.output.addChild(new Text(colored));
775
- }
776
- else {
777
- const added = diffLines.filter((l) => l.startsWith("+")).length;
778
- const removed = diffLines.filter((l) => l.startsWith("-")).length;
779
- last.output.addChild(new Text(` +${added} -${removed} (ctrl+o to expand)`));
780
- }
781
- }
782
- else {
783
- const lines = e.content.split("\n");
784
- last.lines = lines;
785
- if (expandTools) {
786
- last.output.addChild(new Text(lines.join("\n")));
787
- }
788
- else {
789
- last.output.addChild(new Text(` (${lines.length} lines, ctrl+o to expand)`));
790
- }
791
- }
1591
+ last.setResult(e.content, { isError: e.isError, diff: e.diff });
1592
+ activity.setPhase("working");
792
1593
  tui.requestRender();
793
1594
  }
794
1595
  }
795
1596
  else if (e.type === "final") {
796
1597
  finalizeText();
1598
+ activity.stop();
797
1599
  }
798
1600
  };
799
1601
  async function runDoctor() {
@@ -812,249 +1614,303 @@ export async function run(args) {
812
1614
  catch (e) {
813
1615
  lines.push(` provider generate: FAIL ${e instanceof Error ? e.message : String(e)}`);
814
1616
  }
1617
+ lines.push(...formatMcpDoctor(mcpManager.status()));
815
1618
  return lines.join("\n");
816
1619
  }
817
- input.onSubmit = async (value) => {
818
- const trimmed = value.trim();
819
- input.setText("");
820
- if (pendingApproval) {
821
- const a = trimmed.toLowerCase();
822
- if (a === "y" || a === "yes")
823
- pendingApproval("approve");
824
- else if (a === "t" || a === "trust")
825
- pendingApproval("trust-session");
826
- else
827
- pendingApproval("deny");
828
- return;
829
- }
830
- if (!trimmed) {
831
- tui.requestRender();
832
- return;
833
- }
834
- const firstToken = trimmed.split(/\s+/)[0].replace(/^\//, "");
835
- const _skillReg = cachedSkillRegistry();
836
- const isSkill = _skillReg.list().some((s) => s.name === firstToken) || trimmed.startsWith("/skill ");
837
- if (isSkill) {
838
- const skillName = trimmed.startsWith("/skill ") ? trimmed.split(/\s+/)[1] : firstToken;
839
- const content = _skillReg.load(skillName);
840
- if (!content) {
841
- appendLine(`skill not found: ${skillName}`);
1620
+ async function runMcp(args) {
1621
+ const trimmed = (args ?? "").trim();
1622
+ if (trimmed === "reconnect" || trimmed.startsWith("reconnect ")) {
1623
+ const server = trimmed.slice("reconnect".length).trim();
1624
+ try {
1625
+ if (server)
1626
+ await mcpManager.reconnect(server);
1627
+ else
1628
+ await mcpManager.reconnect();
1629
+ }
1630
+ catch (e) {
1631
+ appendLine(`mcp reconnect failed: ${e instanceof Error ? e.message : String(e)}`);
842
1632
  return;
843
1633
  }
844
- session.appendMessage({ role: "system", content });
845
- if (typeof session.appendEvent === "function") {
846
- session.appendEvent({ type: "skill_loaded", name: skillName });
1634
+ if (useTools) {
1635
+ await mcpManager.syncTools();
1636
+ runtime.setTools([...builtInTools, ...extensionTools, ...createMcpTools(mcpManager)]);
847
1637
  }
848
- appendLine(`Loaded skill: ${skillName}`);
849
- const extra = trimmed.startsWith("/skill ")
850
- ? trimmed.split(/\s+/).slice(2).join(" ").trim()
851
- : trimmed.slice(firstToken.length + 1).trim();
852
- if (extra) {
853
- appendLine(`> ${extra}`);
854
- const controller = new AbortController();
855
- try {
856
- await runtime.runAgentTurn(extra, session, controller.signal);
857
- }
858
- catch (err) {
859
- appendLine(`atom-lite: ${err instanceof Error ? err.message : String(err)}`);
860
- }
861
- }
862
- return;
863
- }
864
- if (trimmed === "/exit") {
865
- tui.stop();
866
- process.exit(0);
1638
+ appendLine(formatMcpStatus(mcpManager.status()));
867
1639
  return;
868
1640
  }
869
- if (trimmed === "/help") {
870
- appendLine(["/help", "/model", "/provider", "/session", "/compact", "/clear", "/approval", "/doctor", "/effort", "/thinking", "/exit"].join("\n"));
1641
+ if (trimmed) {
1642
+ appendLine("usage: /mcp [reconnect [server]]");
871
1643
  return;
872
1644
  }
873
- if (trimmed === "/session") {
874
- appendLine(`session: ${session.id}\nworkspace: ${session.workspace}\nprovider: ${session.provider}\nmessages: ${session.messages.length}`);
875
- return;
876
- }
877
- if (trimmed === "/approval") {
878
- appendLine(`approval mode: ${approvals.mode}\ntrusted this session: ${approvals.isTrusted()}`);
879
- return;
1645
+ appendLine(formatMcpStatus(mcpManager.status()));
1646
+ }
1647
+ async function runExport(args) {
1648
+ const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
1649
+ let format = "md";
1650
+ let force = false;
1651
+ let redact = true;
1652
+ const positional = [];
1653
+ for (let i = 0; i < tokens.length; i++) {
1654
+ const t = tokens[i];
1655
+ if (t === "--format") {
1656
+ format = tokens[++i] ?? format;
1657
+ }
1658
+ else if (t.startsWith("--format=")) {
1659
+ format = t.slice("--format=".length);
1660
+ }
1661
+ else if (t === "--force") {
1662
+ force = true;
1663
+ }
1664
+ else if (t === "--no-redact") {
1665
+ redact = false;
1666
+ }
1667
+ else {
1668
+ positional.push(t);
1669
+ }
880
1670
  }
881
- if (trimmed === "/doctor") {
882
- const out = await runDoctor();
883
- appendLine(out);
1671
+ const isJson = format === "json";
1672
+ const ext = isJson ? "json" : "md";
1673
+ const outPath = positional[0] || `${session.id}.${ext}`;
1674
+ if (existsSync(outPath) && !force) {
1675
+ appendLine(`export refused: ${outPath} already exists (use --force)`);
884
1676
  return;
885
1677
  }
886
- if (trimmed === "/clear") {
887
- session.messages.length = 0;
888
- content.clear();
889
- tui.requestRender();
890
- appendLine("started new session");
891
- return;
1678
+ if (!redact) {
1679
+ appendLine("warning: redaction disabled; secrets may be written to disk");
892
1680
  }
893
- if (trimmed === "/compact") {
894
- appendLine("compaction is automatic");
1681
+ const secretPatterns = [
1682
+ /sk-[A-Za-z0-9_-]{8,}/g,
1683
+ /ghp_[A-Za-z0-9]{20,}/g,
1684
+ /gho_[A-Za-z0-9]{20,}/g,
1685
+ /github_pat_[A-Za-z0-9_]{20,}/g,
1686
+ /AKIA[0-9A-Z]{16}/g,
1687
+ /xox[baprs]-[A-Za-z0-9-]{10,}/g,
1688
+ /Bearer\s+[A-Za-z0-9._-]+/g,
1689
+ ];
1690
+ const redactText = (value) => {
1691
+ if (!redact)
1692
+ return value;
1693
+ let out = value;
1694
+ for (const pattern of secretPatterns) {
1695
+ out = out.replace(pattern, "[REDACTED]");
1696
+ }
1697
+ return out;
1698
+ };
1699
+ const messageText = (message) => {
1700
+ const content = message?.content;
1701
+ if (Array.isArray(content)) {
1702
+ return content
1703
+ .map((p) => (p?.type === "text" ? p.text : ""))
1704
+ .join("");
1705
+ }
1706
+ return typeof content === "string" ? content : "";
1707
+ };
1708
+ if (isJson) {
1709
+ const events = typeof session.events !== "undefined"
1710
+ ? session.events
1711
+ : [];
1712
+ const payload = {
1713
+ version: 1,
1714
+ session: {
1715
+ id: session.id,
1716
+ workspace: session.workspace,
1717
+ provider: session.provider,
1718
+ messageCount: session.messages.length,
1719
+ },
1720
+ messages: session.messages,
1721
+ events,
1722
+ };
1723
+ let json = JSON.stringify(payload, null, 2);
1724
+ if (redact)
1725
+ json = redactText(json);
1726
+ writeFileSync(outPath, json + "\n", "utf8");
1727
+ appendLine(`exported session to ${outPath}`);
895
1728
  return;
896
1729
  }
897
- if (trimmed === "/model" || trimmed.startsWith("/model ")) {
898
- const rest = trimmed.slice("/model".length).trim();
899
- if (!rest) {
900
- openModelPicker();
901
- return;
1730
+ const lines = [];
1731
+ lines.push(`# Session ${session.id}`);
1732
+ lines.push("");
1733
+ lines.push(`- Workspace: ${session.workspace}`);
1734
+ lines.push(`- Provider: ${session.provider}`);
1735
+ lines.push(`- Messages: ${session.messages.length}`);
1736
+ lines.push(`- Exported: ${new Date().toISOString()}`);
1737
+ lines.push("");
1738
+ lines.push("## Table of contents");
1739
+ lines.push("");
1740
+ let turn = 0;
1741
+ for (const message of session.messages) {
1742
+ if (message.role === "user") {
1743
+ turn++;
1744
+ const snippet = redactText(messageText(message)).split("\\n")[0].slice(0, 80);
1745
+ lines.push(`${turn}. ${snippet || "(empty)"}`);
902
1746
  }
903
- const knownModels = modelsFor(provider.id);
904
- if (knownModels.includes(rest)) {
905
- switchToModel(provider.id, rest);
906
- return;
1747
+ }
1748
+ lines.push("");
1749
+ lines.push("## Transcript");
1750
+ lines.push("");
1751
+ for (const message of session.messages) {
1752
+ if (message.role === "user") {
1753
+ lines.push("### User");
1754
+ lines.push("");
1755
+ lines.push(redactText(messageText(message)));
1756
+ lines.push("");
907
1757
  }
908
- const slash = rest.indexOf("/");
909
- if (slash > 0) {
910
- const profile = rest.slice(0, slash);
911
- const model = rest.slice(slash + 1);
912
- if (profileConfig(profile) || getPreset(profile)) {
913
- switchToModel(profile, model);
914
- return;
1758
+ else if (message.role === "assistant") {
1759
+ lines.push("### Assistant");
1760
+ lines.push("");
1761
+ const text = redactText(messageText(message));
1762
+ if (text.trim())
1763
+ lines.push(text);
1764
+ if (Array.isArray(message.toolCalls) && message.toolCalls.length) {
1765
+ lines.push("");
1766
+ lines.push("Tool calls:");
1767
+ for (const call of message.toolCalls) {
1768
+ lines.push(`- \`${call.name}\` ${JSON.stringify(call.arguments ?? {})}`);
1769
+ }
915
1770
  }
1771
+ lines.push("");
916
1772
  }
917
- if (profileConfig(rest) || getPreset(rest)) {
918
- ensureProfile(rest);
919
- const pcfg = profileConfig(rest);
920
- switchToModel(rest, String(pcfg?.model ?? modelOverride ?? "default"));
921
- return;
1773
+ else if (message.role === "tool") {
1774
+ lines.push(`### Tool result: ${message.name}`);
1775
+ lines.push("");
1776
+ const content = redactText(String(message.content ?? ""));
1777
+ const truncated = content.length > 4000
1778
+ ? content.slice(0, 4000) + "\\n… (truncated)"
1779
+ : content;
1780
+ lines.push("```");
1781
+ lines.push(truncated);
1782
+ lines.push("```");
1783
+ lines.push("");
922
1784
  }
923
- switchToModel(provider.id, rest);
1785
+ }
1786
+ writeFileSync(outPath, lines.join("\\n"), "utf8");
1787
+ appendLine(`exported session to ${outPath}`);
1788
+ }
1789
+ async function runCompact(args) {
1790
+ const focus = (args ?? "").trim();
1791
+ if (session.messages.length <= 6) {
1792
+ appendLine("nothing to compact");
924
1793
  return;
925
1794
  }
926
- if (trimmed === "/provider" || trimmed.startsWith("/provider ")) {
927
- const rest = trimmed.slice("/provider".length).trim();
928
- if (rest)
929
- startProviderSetup(rest);
930
- else
931
- openProviderPicker();
1795
+ const summary = await runtime.compactSession(session, focus || undefined);
1796
+ if (!summary) {
1797
+ appendLine("nothing to compact");
932
1798
  return;
933
1799
  }
934
- if (trimmed === "/skills") {
935
- const reg = new (await import("../skills/registry.js")).DefaultSkillRegistry(process.cwd());
936
- const skills = reg.list();
937
- if (skills.length === 0) {
938
- appendLine("no skills available");
939
- return;
940
- }
941
- appendLine(skills.map((sk) => `${sk.name} - ${sk.description}`).join("\n"));
1800
+ appendLine(`compacted: ${summary.slice(0, 200)}`);
1801
+ }
1802
+ input.onSubmit = async (value) => {
1803
+ const trimmed = value.trim();
1804
+ input.setText("");
1805
+ if (!trimmed) {
1806
+ tui.requestRender();
942
1807
  return;
943
1808
  }
944
- if (trimmed.startsWith("/skill ")) {
945
- const name = trimmed.split(/\s+/)[1];
946
- const reg = new (await import("../skills/registry.js")).DefaultSkillRegistry(process.cwd());
947
- const content = reg.load(name);
948
- if (!content) {
949
- appendLine(`skill not found: ${name}`);
1809
+ refreshCommandRegistry();
1810
+ if (trimmed === "/steer" || trimmed.startsWith("/steer ")) {
1811
+ const text = trimmed.slice("/steer".length).trim();
1812
+ if (!text) {
1813
+ appendLine("usage: /steer <message>");
1814
+ tui.requestRender();
950
1815
  return;
951
1816
  }
952
- session.appendMessage({ role: "system", content });
953
- if (typeof session.appendEvent === "function") {
954
- session.appendEvent({ type: "skill_loaded", name });
1817
+ if (activeTurnController) {
1818
+ steeringQueue.push(text);
1819
+ appendLine(`steering: ${text}`, theme.tokens.dim);
955
1820
  }
956
- appendLine(`Loaded skill: ${name}`);
957
- const extra = trimmed.split(/\s+/).slice(2).join(" ").trim();
958
- if (extra) {
959
- appendLine(`> ${extra}`);
960
- const controller = new AbortController();
961
- try {
962
- await runtime.runAgentTurn(extra, session, controller.signal);
963
- }
964
- catch (err) {
965
- appendLine(`atom-lite: ${err instanceof Error ? err.message : String(err)}`);
966
- }
1821
+ else {
1822
+ appendLine(`> ${text}`, theme.tokens.user);
1823
+ await runTurn(text);
967
1824
  }
1825
+ tui.requestRender();
968
1826
  return;
969
1827
  }
970
- if (trimmed === "/thinking") {
971
- showThinking = !showThinking;
972
- appendLine(`thinking display: ${showThinking ? "shown" : "hidden"}`);
973
- return;
974
- }
975
- if (trimmed.startsWith("/effort")) {
976
- const parts = trimmed.split(/\s+/);
977
- if (parts.length >= 2) {
978
- const lvl = parts[1];
979
- if (lvl === "low" || lvl === "medium" || lvl === "high") {
980
- effortLevel = lvl;
981
- runtime.setEffort(lvl);
982
- updateStatus();
983
- appendLine(`effort set to ${effortLevel}`);
984
- }
985
- else {
986
- appendLine("usage: /effort low|medium|high");
987
- }
1828
+ if (trimmed.startsWith("/")) {
1829
+ const result = await commandRegistry.dispatch(trimmed, commandContext);
1830
+ if (result.exit)
988
1831
  return;
1832
+ if (!result.handled) {
1833
+ appendLine(`unknown command: ${trimmed.split(/\s+/)[0]} (type /help for a list of commands)`);
989
1834
  }
990
- // Interactive dropdown
991
- const items = [
992
- { value: "low", label: "low", description: "minimal reasoning" },
993
- { value: "medium", label: "medium", description: "balanced" },
994
- { value: "high", label: "high", description: "deep reasoning" },
995
- ];
996
- const { SelectList } = await import("@earendil-works/pi-tui");
997
- const selectListTheme = {
998
- selectedPrefix: (s) => s,
999
- selectedText: (s) => `\x1b[7m${s}\x1b[0m`,
1000
- description: (s) => s,
1001
- scrollInfo: (s) => s,
1002
- noMatch: (s) => s,
1003
- };
1004
- const selectList = new SelectList(items, 3, selectListTheme);
1005
- dropdownContainer.addChild(selectList);
1006
- tui.requestRender();
1007
- const removeListener = tui.addInputListener((data) => {
1008
- // Skip kitty-protocol key-release events to avoid double moves.
1009
- if (isKeyRelease(data))
1010
- return { consume: true };
1011
- selectList.handleInput(data);
1012
- tui.requestRender();
1013
- return { consume: true };
1014
- });
1015
- selectList.onSelect = (item) => {
1016
- removeListener();
1017
- const lvl = item.value;
1018
- effortLevel = lvl;
1019
- runtime.setEffort(lvl);
1020
- dropdownContainer.clear();
1021
- tui.setFocus(input);
1022
- updateStatus();
1023
- appendLine(`effort set to ${effortLevel}`);
1024
- };
1025
- selectList.onCancel = () => {
1026
- removeListener();
1027
- dropdownContainer.clear();
1028
- tui.setFocus(input);
1029
- appendLine("cancelled");
1030
- };
1031
1835
  return;
1032
1836
  }
1033
- appendLine(`> ${trimmed}`);
1034
- const controller = new AbortController();
1035
- try {
1036
- await runtime.runAgentTurn(trimmed, session, controller.signal);
1837
+ // Skills remain invokable by bare name, but still run through the registry.
1838
+ const bare = bareCommandName(commandRegistry, trimmed);
1839
+ if (bare) {
1840
+ await commandRegistry.dispatch(`/${bare} ${trimmed.slice(bare.length)}`, commandContext);
1841
+ return;
1037
1842
  }
1038
- catch (err) {
1039
- appendLine(`atom-lite: ${err instanceof Error ? err.message : String(err)}`);
1843
+ if (activeTurnController) {
1844
+ messageQueue.push(trimmed);
1845
+ appendLine(`queued: ${trimmed}`, theme.tokens.dim);
1846
+ tui.requestRender();
1847
+ return;
1040
1848
  }
1849
+ appendLine(`> ${trimmed}`, theme.tokens.user);
1850
+ await runTurn(trimmed);
1041
1851
  };
1042
- // fix approvals handler to use appendLine (defined above via closure var)
1043
1852
  approvals.handler = interactive
1044
- ? async (req) => {
1045
- appendLine("┌─ approval ─────────────────────────────");
1046
- appendLine(`│ ${req.command}`);
1047
- appendLine(`│ cwd: ${req.cwd} timeout: ${req.timeoutMs}ms sandboxed: ${req.sandboxed}`);
1048
- appendLine("│ [y]es / [n]o / [t]rust session");
1049
- appendLine("└────────────────────────────────────────");
1050
- return new Promise((resolve) => {
1051
- pendingApproval = (d) => {
1052
- pendingApproval = null;
1053
- resolve(d);
1054
- };
1853
+ ? (req) => new Promise((resolve) => {
1854
+ uiModalOpen = true;
1855
+ closeOverlay();
1856
+ dropdownContainer.clear();
1857
+ activity.setPhase("waiting");
1858
+ const prompt = new ApprovalPrompt(req, (decision) => {
1859
+ uiModalOpen = false;
1860
+ dropdownContainer.clear();
1861
+ tui.setFocus(input);
1862
+ activity.setPhase("working");
1863
+ if (decision === "trust-session") {
1864
+ appendLine("trusted this session: shell commands will run without approval");
1865
+ }
1866
+ resolve(decision);
1055
1867
  });
1056
- }
1868
+ dropdownContainer.addChild(prompt);
1869
+ tui.setFocus(prompt);
1870
+ tui.requestRender();
1871
+ })
1057
1872
  : undefined;
1873
+ process.on("SIGTERM", () => {
1874
+ void mcpManager.close().finally(() => {
1875
+ tui.stop();
1876
+ process.exit(0);
1877
+ });
1878
+ });
1058
1879
  tui.start();
1880
+ maybeReviewProjectTrust();
1881
+ if (mouseEnabled) {
1882
+ process.once("exit", () => mouseCapture.disable());
1883
+ if (process.platform !== "win32") {
1884
+ process.on("SIGTSTP", () => {
1885
+ mouseCapture.disable();
1886
+ process.kill(process.pid, "SIGSTOP");
1887
+ });
1888
+ process.on("SIGCONT", () => {
1889
+ mouseCapture.enable();
1890
+ tui.requestRender();
1891
+ });
1892
+ }
1893
+ }
1894
+ // Some terminals answer OSC 11 but not the colour-scheme report. Query the
1895
+ // background once and derive light/dark from its luminance.
1896
+ if (!noColor && typeof tui.queryTerminalColors === "function") {
1897
+ void tui
1898
+ .queryTerminalColors({ timeoutMs: 250 })
1899
+ .then((colors) => {
1900
+ const background = colors.background;
1901
+ if (!background)
1902
+ return;
1903
+ const luminance = (0.2126 * background.r +
1904
+ 0.7152 * background.g +
1905
+ 0.0722 * background.b) /
1906
+ 255;
1907
+ terminalScheme = luminance > 0.5 ? "light" : "dark";
1908
+ if (themeName === "auto")
1909
+ applyTheme("auto", false);
1910
+ })
1911
+ .catch(() => {
1912
+ // Keep the static fallback.
1913
+ });
1914
+ }
1059
1915
  }
1060
1916
  //# sourceMappingURL=app.js.map