@quatcode/agent 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/dist/args.d.ts +13 -1
  4. package/dist/args.d.ts.map +1 -1
  5. package/dist/args.js +25 -5
  6. package/dist/args.js.map +1 -1
  7. package/dist/cli.js +130 -98
  8. package/dist/cli.js.map +1 -1
  9. package/dist/clipboard.d.ts +5 -0
  10. package/dist/clipboard.d.ts.map +1 -0
  11. package/dist/clipboard.js +82 -0
  12. package/dist/clipboard.js.map +1 -0
  13. package/dist/gate.d.ts.map +1 -1
  14. package/dist/gate.js +1 -7
  15. package/dist/gate.js.map +1 -1
  16. package/dist/input.d.ts +56 -0
  17. package/dist/input.d.ts.map +1 -0
  18. package/dist/input.js +283 -0
  19. package/dist/input.js.map +1 -0
  20. package/dist/keyboard.d.ts +68 -0
  21. package/dist/keyboard.d.ts.map +1 -0
  22. package/dist/keyboard.js +390 -0
  23. package/dist/keyboard.js.map +1 -0
  24. package/dist/logo.d.ts +16 -0
  25. package/dist/logo.d.ts.map +1 -0
  26. package/dist/logo.js +131 -0
  27. package/dist/logo.js.map +1 -0
  28. package/dist/markdown.d.ts +14 -0
  29. package/dist/markdown.d.ts.map +1 -0
  30. package/dist/markdown.js +244 -0
  31. package/dist/markdown.js.map +1 -0
  32. package/dist/mcp.d.ts +19 -0
  33. package/dist/mcp.d.ts.map +1 -0
  34. package/dist/mcp.js +206 -0
  35. package/dist/mcp.js.map +1 -0
  36. package/dist/models.d.ts +24 -0
  37. package/dist/models.d.ts.map +1 -0
  38. package/dist/models.js +188 -0
  39. package/dist/models.js.map +1 -0
  40. package/dist/picker.d.ts +40 -0
  41. package/dist/picker.d.ts.map +1 -0
  42. package/dist/picker.js +156 -0
  43. package/dist/picker.js.map +1 -0
  44. package/dist/render.d.ts.map +1 -1
  45. package/dist/render.js +3 -1
  46. package/dist/render.js.map +1 -1
  47. package/dist/screen.d.ts +28 -0
  48. package/dist/screen.d.ts.map +1 -0
  49. package/dist/screen.js +114 -0
  50. package/dist/screen.js.map +1 -0
  51. package/dist/skills.d.ts +21 -0
  52. package/dist/skills.d.ts.map +1 -0
  53. package/dist/skills.js +173 -0
  54. package/dist/skills.js.map +1 -0
  55. package/dist/store.d.ts +31 -1
  56. package/dist/store.d.ts.map +1 -1
  57. package/dist/store.js +192 -1
  58. package/dist/store.js.map +1 -1
  59. package/dist/theme.d.ts +86 -0
  60. package/dist/theme.d.ts.map +1 -0
  61. package/dist/theme.js +315 -0
  62. package/dist/theme.js.map +1 -0
  63. package/dist/tui.d.ts +16 -0
  64. package/dist/tui.d.ts.map +1 -0
  65. package/dist/tui.js +2456 -0
  66. package/dist/tui.js.map +1 -0
  67. package/dist/turn.d.ts +45 -0
  68. package/dist/turn.d.ts.map +1 -0
  69. package/dist/turn.js +198 -0
  70. package/dist/turn.js.map +1 -0
  71. package/dist/version.d.ts +2 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +13 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +22 -6
package/dist/tui.js ADDED
@@ -0,0 +1,2456 @@
1
+ import path from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { Session, ToolRegistry, addUsage, createProviderFromEnv, emptyUsage, estimateCost, runAgent, } from "@quatcode/core";
4
+ import { analyzeCommand, createProjectTools, McpManager } from "@quatcode/tools";
5
+ import { buildProjectContext, createBrainTools, scanProject } from "@quatcode/brain";
6
+ import { Keyboard } from "./keyboard.js";
7
+ import { LineBuffer, defaultPrompt } from "./input.js";
8
+ import { Picker } from "./picker.js";
9
+ import { Screen } from "./screen.js";
10
+ import { TurnController, fmtCost, fmtDuration, fmtTokens } from "./turn.js";
11
+ import { renderBanner } from "./logo.js";
12
+ import { renderMarkdown } from "./markdown.js";
13
+ import { CUSTOM_MODEL, PROVIDERS, buildProvider, defaultModelFor, effortsForModel, fetchProviderModels, hasKey, keyEnvFor, resolveProviderForModel, } from "./models.js";
14
+ import { activeThemeId, bg, bold, buildColorRemap, clipAnsi, fg, getThemes, glyphs, padEndTo, palette, remapColors, setTheme, stringWidth, stripAnsi, tone, } from "./theme.js";
15
+ import { listSessionsForDir, loadConfig, makeSessionId, saveConfig, saveSession, deleteSession } from "./store.js";
16
+ import { configFile, conversationKey, deleteConversation, displayTitle, latestPerConversation, messagesForStorage, sortConversations, updateConversation, } from "./store.js";
17
+ import { MCP_USAGE, isMcpUrl, mcpInstructionsSection, parseMcpAdd } from "./mcp.js";
18
+ import { copyToClipboard, readClipboardImage, readClipboardText, } from "./clipboard.js";
19
+ import { createLoadSkillTool, fetchSkillFromUrl, importSkillFile, listSkills, parseSkill, removeSkill, saveSkill, skillsRoot, skillsSystemSection, } from "./skills.js";
20
+ import { VERSION } from "./version.js";
21
+ const COMMAND_INFO = [
22
+ { name: "/help", desc: "show this help" },
23
+ { name: "/new", desc: "new conversation (clears the screen)" },
24
+ { name: "/model", args: "[id]", desc: "pick or set the model" },
25
+ { name: "/effort", args: "[level]", desc: "reasoning effort (minimal…high)" },
26
+ { name: "/key", desc: "enter the API key for the current provider" },
27
+ { name: "/dir", args: "[path]", desc: "change the project directory" },
28
+ { name: "/context", desc: "project summary" },
29
+ { name: "/usage", desc: "tokens, cost and time" },
30
+ { name: "/sessions", desc: "list and resume recent sessions" },
31
+ { name: "/timeline", desc: "jump to a sent message" },
32
+ { name: "/undo", desc: "take back the last message" },
33
+ { name: "/verbose", desc: "toggle full tool output" },
34
+ { name: "/skills", args: "[add|remove|list]", desc: "manage agent skills" },
35
+ { name: "/mcp", args: "[list|add|remove]", desc: "manage MCP servers" },
36
+ { name: "/theme", args: "[id]", desc: "change the color theme" },
37
+ { name: "/clear", desc: "clear the screen (ctrl+l)" },
38
+ { name: "/exit", desc: "quit" },
39
+ ];
40
+ const COMMANDS = COMMAND_INFO.map((command) => command.name);
41
+ const READ_ONLY_TOOLS = new Set([
42
+ "read_file",
43
+ "list_dir",
44
+ "search",
45
+ "git",
46
+ "inspect_project",
47
+ "load_skill",
48
+ "screenshot",
49
+ "list_servers",
50
+ ]);
51
+ export async function runTui(options) {
52
+ const input = options.io?.input ?? process.stdin;
53
+ const output = options.io?.output ?? process.stdout;
54
+ if (!input.isTTY || !output.isTTY) {
55
+ output.write("quatcode: the interactive interface needs a TTY.\nuse: quatcode \"<task>\" for headless mode.\n");
56
+ return 1;
57
+ }
58
+ const app = new TuiApp(options, input, output);
59
+ return app.run();
60
+ }
61
+ class TuiApp {
62
+ opts;
63
+ kb;
64
+ screen;
65
+ out;
66
+ startedAt = performance.now();
67
+ buffer = new LineBuffer();
68
+ dir;
69
+ provider = null;
70
+ providerId = "anthropic";
71
+ model = "";
72
+ effort = null;
73
+ config = {};
74
+ summary = null;
75
+ skills = [];
76
+ system = "";
77
+ tools = null;
78
+ mcp = new McpManager();
79
+ session = new Session("");
80
+ gateMode;
81
+ verbose = false;
82
+ conversationId = makeSessionId();
83
+ totalUsage = emptyUsage();
84
+ turns = 0;
85
+ transcript = [];
86
+ splashLines = 0;
87
+ liveBase = -1;
88
+ scrollOffset = 0;
89
+ status = null;
90
+ statusStartedAt = 0;
91
+ spinnerFrame = 0;
92
+ spinnerTimer = null;
93
+ picker = null;
94
+ mode = "idle";
95
+ inputPrompt = defaultPrompt();
96
+ inputMask = false;
97
+ inputPlaceholder = "ask anything, or /help";
98
+ permissionHint = "";
99
+ permissionOptionLine = null;
100
+ permissionSelected = 0;
101
+ permissionOptions = [];
102
+ permissionInner = 0;
103
+ permissionBorder = palette.steel;
104
+ lastVisibleStart = 0;
105
+ lastInputRender = null;
106
+ lastInputOffset = 1;
107
+ lastHeaderRows = 2;
108
+ lastViewport = 3;
109
+ suggestionIndex = 0;
110
+ resolveLine = null;
111
+ resolveChoice = null;
112
+ resolvePicker = null;
113
+ pickerAction = null;
114
+ abort = null;
115
+ abortRequested = false;
116
+ undoAfterAbort = false;
117
+ renderQueued = false;
118
+ pendingKeys = [];
119
+ padX = 2;
120
+ padTop = 1;
121
+ padBottom = 1;
122
+ agentMode = "build";
123
+ lastTurn = null;
124
+ turnUndo = null;
125
+ pendingInitial = null;
126
+ pendingImages = [];
127
+ userMarkers = [];
128
+ messageCursor = -1;
129
+ selection = null;
130
+ frameLines = [];
131
+ constructor(options, input, output) {
132
+ this.opts = options;
133
+ this.dir = path.resolve(options.dir);
134
+ this.gateMode = options.allowAll ? "always" : "ask";
135
+ this.out = output;
136
+ this.kb = new Keyboard(input, output);
137
+ this.screen = new Screen(output);
138
+ }
139
+ async run() {
140
+ this.kb.start();
141
+ this.screen.enter();
142
+ this.kb.setHandler(this.onKeys);
143
+ const cleanup = () => {
144
+ try {
145
+ this.stopSpinner();
146
+ this.kb.stop();
147
+ this.screen.exit();
148
+ }
149
+ catch {
150
+ // ignore
151
+ }
152
+ };
153
+ process.once("exit", cleanup);
154
+ process.once("SIGINT", () => {
155
+ cleanup();
156
+ process.exit(130);
157
+ });
158
+ process.once("SIGTERM", () => {
159
+ cleanup();
160
+ process.exit(143);
161
+ });
162
+ this.config = await loadConfig().catch(() => ({}));
163
+ for (const [name, value] of Object.entries(this.config.keys ?? {})) {
164
+ if (value && process.env[name] === undefined)
165
+ process.env[name] = value;
166
+ }
167
+ this.effort = this.opts.effort ?? this.config.effort ?? null;
168
+ setTheme(this.config.theme ?? "quatcode");
169
+ this.screen.applyBackground();
170
+ let configMeta = null;
171
+ try {
172
+ configMeta = await this.loadConfiguration();
173
+ }
174
+ catch (err) {
175
+ this.append([
176
+ ` ${tone.red(`// ${errorMessage(err)}`)}`,
177
+ ` ${tone.mist("// hint: run quatcode from a project directory or pass --dir <path>")}`,
178
+ ]);
179
+ this.renderNow();
180
+ await this.pause(1200);
181
+ this.shutdown();
182
+ return 1;
183
+ }
184
+ await this.splash();
185
+ try {
186
+ await this.bootStep("CONFIGURATION", () => configMeta ?? "");
187
+ await this.bootStep("PROJECT", () => this.loadProject());
188
+ const mcpConfigs = this.config.mcp ?? {};
189
+ if (Object.keys(mcpConfigs).length > 0) {
190
+ await this.bootStep("MCP SERVERS", () => this.connectMcpServers(mcpConfigs));
191
+ }
192
+ }
193
+ catch (err) {
194
+ this.append([
195
+ ` ${tone.red(`// ${errorMessage(err)}`)}`,
196
+ ` ${tone.mist("// hint: run quatcode from a project directory or pass --dir <path>")}`,
197
+ ]);
198
+ this.renderNow();
199
+ await this.pause(1200);
200
+ this.shutdown();
201
+ return 1;
202
+ }
203
+ this.append([` ${tone.mist("// type / to see commands · esc interrupts a run")}`, ""]);
204
+ if (this.opts.initialPrompt) {
205
+ const prompt = this.opts.initialPrompt;
206
+ this.buffer.addHistory(prompt);
207
+ this.clearSplash();
208
+ this.append(this.userBlockLines(prompt));
209
+ await this.turn(prompt);
210
+ }
211
+ for (;;) {
212
+ const result = await this.readLine();
213
+ if (result.kind === "interrupt" || result.kind === "eof")
214
+ break;
215
+ if (result.kind === "cancel") {
216
+ this.append([` ${tone.mist("// cancelled")}`]);
217
+ continue;
218
+ }
219
+ const input = result.text;
220
+ this.buffer.addHistory(input);
221
+ const images = this.pendingImages;
222
+ this.pendingImages = [];
223
+ const isCommand = input.startsWith("/");
224
+ if (!isCommand)
225
+ this.clearSplash();
226
+ const transcriptAt = this.transcript.length;
227
+ this.turnUndo = {
228
+ input,
229
+ snapshot: this.session.getMessages().map((message) => ({ ...message })),
230
+ transcriptLength: transcriptAt,
231
+ };
232
+ this.messageCursor = -1;
233
+ this.append(this.userBlockLines(input, images.length));
234
+ if (isCommand) {
235
+ this.turnUndo = null;
236
+ const shouldExit = await this.command(input);
237
+ if (shouldExit)
238
+ break;
239
+ continue;
240
+ }
241
+ this.userMarkers.push({ line: transcriptAt, text: input });
242
+ await this.turn(input, images);
243
+ }
244
+ this.printGoodbye();
245
+ this.renderNow();
246
+ await this.pause(120);
247
+ const summary = this.summaryLine();
248
+ this.shutdown();
249
+ this.out.write(`${summary}\n`);
250
+ return 0;
251
+ }
252
+ shutdown() {
253
+ this.stopSpinner();
254
+ this.kb.stop();
255
+ this.screen.exit();
256
+ void this.mcp.closeAll().catch(() => undefined);
257
+ }
258
+ pause(ms) {
259
+ return new Promise((resolve) => setTimeout(resolve, ms));
260
+ }
261
+ async splash() {
262
+ const width = this.contentWidth();
263
+ const info = { version: VERSION, dir: this.dir, mode: this.agentMode };
264
+ const animate = !process.env.QUATCODE_NO_ANIM && !process.env.CI;
265
+ if (!animate) {
266
+ this.applySplash(info, width, null);
267
+ this.renderNow();
268
+ return;
269
+ }
270
+ for (let sweep = -6; sweep <= 84; sweep += 3) {
271
+ this.applySplash(info, width, sweep);
272
+ this.renderNow();
273
+ await this.pause(16);
274
+ }
275
+ this.applySplash(info, width, null);
276
+ this.renderNow();
277
+ }
278
+ applySplash(info, width, sweep) {
279
+ const lines = renderBanner(info, width, sweep);
280
+ this.transcript = [...lines, ""];
281
+ this.splashLines = lines.length + 1;
282
+ }
283
+ clearSplash() {
284
+ if (this.splashLines <= 0)
285
+ return;
286
+ const removed = Math.min(this.splashLines, this.transcript.length);
287
+ if (removed > 0)
288
+ this.transcript.splice(0, removed);
289
+ this.splashLines = 0;
290
+ this.scheduleRender();
291
+ }
292
+ async bootStep(label, work) {
293
+ this.setStatus({ label });
294
+ try {
295
+ const meta = await work();
296
+ this.setStatus(null);
297
+ this.append([` ${tone.accent(glyphs.diamond)} ${padEndTo(tone.bone(label), 20)}${meta ?? ""}`]);
298
+ }
299
+ catch (err) {
300
+ this.setStatus(null);
301
+ this.append([` ${tone.red(glyphs.cross)} ${padEndTo(tone.red(label), 20)}`]);
302
+ throw err;
303
+ }
304
+ }
305
+ onKeys = (keys) => {
306
+ for (const key of keys) {
307
+ if (key.type === "resize") {
308
+ this.scheduleRender();
309
+ continue;
310
+ }
311
+ if (key.type === "mouse") {
312
+ if (key.kind === "wheelup" || key.kind === "wheeldown") {
313
+ if (this.mode === "editing" && this.suggestionMatches().length > 0) {
314
+ this.moveSuggestion(key.kind === "wheelup" ? -1 : 1);
315
+ continue;
316
+ }
317
+ const delta = key.kind === "wheelup" ? 3 : -3;
318
+ if (this.mode === "picker" && this.picker) {
319
+ const dir = key.kind === "wheelup" ? "up" : "down";
320
+ for (let i = 0; i < 3; i++)
321
+ this.picker.handleKey({ type: "nav", dir, ctrl: false });
322
+ this.scheduleRender();
323
+ }
324
+ else {
325
+ this.scroll(delta);
326
+ }
327
+ continue;
328
+ }
329
+ if (key.kind === "press") {
330
+ this.selection = {
331
+ startRow: key.y - 1,
332
+ startCol: key.x - 1,
333
+ endRow: key.y - 1,
334
+ endCol: key.x - 1,
335
+ dragging: true,
336
+ };
337
+ this.scheduleRender();
338
+ continue;
339
+ }
340
+ if (key.kind === "drag") {
341
+ if (this.selection?.dragging) {
342
+ this.selection.endRow = key.y - 1;
343
+ this.selection.endCol = key.x - 1;
344
+ this.scheduleRender();
345
+ }
346
+ continue;
347
+ }
348
+ if (key.kind === "release") {
349
+ if (this.selection?.dragging) {
350
+ const moved = this.selection.startRow !== key.y - 1 || this.selection.startCol !== key.x - 1;
351
+ this.selection.dragging = false;
352
+ if (!moved) {
353
+ this.selection = null;
354
+ this.handleClick(key.y - 1, key.x - 1);
355
+ }
356
+ else {
357
+ this.scheduleRender();
358
+ }
359
+ }
360
+ continue;
361
+ }
362
+ continue;
363
+ }
364
+ if (key.type === "hover") {
365
+ this.handleHover(key.y - 1, key.x - 1);
366
+ continue;
367
+ }
368
+ if (key.type === "control" && key.ch === "c" && this.selection && !this.selection.dragging) {
369
+ this.copySelection();
370
+ continue;
371
+ }
372
+ if (this.selection) {
373
+ this.selection = null;
374
+ this.scheduleRender();
375
+ }
376
+ if (this.mode === "editing")
377
+ this.handleEditKey(key);
378
+ else if (this.mode === "running")
379
+ this.handleRunKey(key);
380
+ else if (this.mode === "permission")
381
+ this.handleConfirmKey(key);
382
+ else if (this.mode === "picker")
383
+ this.handlePickerKey(key);
384
+ else
385
+ this.pendingKeys.push(key);
386
+ }
387
+ };
388
+ handleRunKey(key) {
389
+ if (key.type === "control" && key.ch === "z") {
390
+ if (this.undoAfterAbort)
391
+ return;
392
+ this.undoAfterAbort = true;
393
+ this.abortRequested = true;
394
+ this.append([` ${tone.mist("// UNDOING…")}`]);
395
+ this.abort?.abort();
396
+ return;
397
+ }
398
+ const abortKey = key.type === "escape" || (key.type === "control" && key.ch === "c");
399
+ if (!abortKey || this.abortRequested)
400
+ return;
401
+ this.abortRequested = true;
402
+ this.append([` ${tone.mist("// INTERRUPTING…")}`]);
403
+ this.abort?.abort();
404
+ }
405
+ handleEditKey(key) {
406
+ if (key.type === "text" || key.type === "backspace" || key.type === "delete") {
407
+ this.suggestionIndex = 0;
408
+ }
409
+ switch (key.type) {
410
+ case "text":
411
+ this.buffer.insert(key.text);
412
+ break;
413
+ case "control":
414
+ switch (key.ch) {
415
+ case "c":
416
+ if (this.buffer.isEmpty())
417
+ this.finishLine({ kind: "interrupt" });
418
+ else
419
+ this.buffer.clearLine();
420
+ break;
421
+ case "d":
422
+ if (this.buffer.isEmpty())
423
+ this.finishLine({ kind: "eof" });
424
+ else
425
+ this.buffer.deleteForward();
426
+ break;
427
+ case "j":
428
+ this.buffer.insert("\n");
429
+ break;
430
+ case "space":
431
+ this.buffer.insert("\n");
432
+ break;
433
+ case "v":
434
+ void this.pasteFromClipboard();
435
+ return;
436
+ case "z":
437
+ if (this.buffer.isEmpty()) {
438
+ void this.undoTurn("buffer");
439
+ return;
440
+ }
441
+ break;
442
+ case "a":
443
+ this.buffer.home();
444
+ break;
445
+ case "e":
446
+ this.buffer.end();
447
+ break;
448
+ case "b":
449
+ this.buffer.move(-1);
450
+ break;
451
+ case "f":
452
+ this.buffer.move(1);
453
+ break;
454
+ case "u":
455
+ this.buffer.clearLine();
456
+ break;
457
+ case "k":
458
+ this.buffer.killToEnd();
459
+ break;
460
+ case "w":
461
+ this.buffer.deleteWordBefore();
462
+ break;
463
+ case "p":
464
+ this.buffer.historyPrev();
465
+ break;
466
+ case "n":
467
+ this.buffer.historyNext();
468
+ break;
469
+ case "l":
470
+ this.clearTranscript();
471
+ return;
472
+ default:
473
+ break;
474
+ }
475
+ break;
476
+ case "meta":
477
+ if (key.ch === "b" || key.ch === "B")
478
+ this.buffer.moveWord(-1);
479
+ else if (key.ch === "f" || key.ch === "F")
480
+ this.buffer.moveWord(1);
481
+ else if (key.ch === "d" || key.ch === "D")
482
+ this.buffer.deleteWordAfter();
483
+ break;
484
+ case "nav":
485
+ if ((key.dir === "up" || key.dir === "down") &&
486
+ this.suggestionMatches().length > 0) {
487
+ this.moveSuggestion(key.dir === "up" ? -1 : 1);
488
+ return;
489
+ }
490
+ if (key.dir === "up") {
491
+ if (!this.buffer.moveVertical(-1))
492
+ this.buffer.historyPrev();
493
+ }
494
+ else if (key.dir === "down") {
495
+ if (!this.buffer.moveVertical(1))
496
+ this.buffer.historyNext();
497
+ }
498
+ else if (key.dir === "left") {
499
+ if (key.ctrl)
500
+ this.buffer.moveWord(-1);
501
+ else
502
+ this.buffer.move(-1);
503
+ }
504
+ else if (key.dir === "right") {
505
+ if (key.ctrl)
506
+ this.buffer.moveWord(1);
507
+ else
508
+ this.buffer.move(1);
509
+ }
510
+ else if (key.dir === "home") {
511
+ this.buffer.home();
512
+ }
513
+ else if (key.dir === "end") {
514
+ this.buffer.end();
515
+ }
516
+ else if (key.dir === "pageup") {
517
+ this.buffer.historyPrev();
518
+ }
519
+ else if (key.dir === "pagedown") {
520
+ this.buffer.historyNext();
521
+ }
522
+ break;
523
+ case "newline":
524
+ this.buffer.insert("\n");
525
+ break;
526
+ case "enter": {
527
+ let text = this.buffer.text();
528
+ if (text.trim() === "")
529
+ return;
530
+ const matches = this.suggestionMatches();
531
+ if (matches.length > 0) {
532
+ const index = Math.max(0, Math.min(this.suggestionIndex, matches.length - 1));
533
+ const selected = matches[index];
534
+ if (selected) {
535
+ this.finishLine({ kind: "submit", text: selected.name });
536
+ return;
537
+ }
538
+ }
539
+ if (this.buffer.continueLine()) {
540
+ this.scheduleRender();
541
+ return;
542
+ }
543
+ if (/^\/\S*$/.test(text)) {
544
+ const unique = COMMAND_INFO.filter((command) => command.name.startsWith(text));
545
+ if (unique.length === 1)
546
+ text = unique[0].name;
547
+ }
548
+ this.finishLine({ kind: "submit", text });
549
+ return;
550
+ }
551
+ case "tab":
552
+ if (this.suggestionMatches().length > 0)
553
+ this.completeCommand();
554
+ else
555
+ this.toggleMode();
556
+ break;
557
+ case "backspace":
558
+ this.buffer.backspace();
559
+ break;
560
+ case "delete":
561
+ this.buffer.deleteForward();
562
+ break;
563
+ case "escape":
564
+ this.finishLine({ kind: "cancel" });
565
+ return;
566
+ default:
567
+ break;
568
+ }
569
+ this.scheduleRender();
570
+ }
571
+ finishLine(result) {
572
+ const resolve = this.resolveLine;
573
+ this.resolveLine = null;
574
+ this.mode = "idle";
575
+ this.scheduleRender();
576
+ resolve?.(result);
577
+ }
578
+ readLine(overrides = {}) {
579
+ const initial = overrides.initial ?? this.pendingInitial ?? "";
580
+ this.pendingInitial = null;
581
+ if (initial)
582
+ this.buffer.setText(initial);
583
+ else
584
+ this.buffer.reset();
585
+ this.inputPrompt = overrides.prompt ?? defaultPrompt();
586
+ this.inputMask = overrides.mask ?? false;
587
+ this.inputPlaceholder = overrides.placeholder ?? "ask anything, or /help";
588
+ this.suggestionIndex = 0;
589
+ this.mode = "editing";
590
+ this.scheduleRender();
591
+ const promise = new Promise((resolve) => {
592
+ this.resolveLine = resolve;
593
+ });
594
+ if (this.pendingKeys.length > 0) {
595
+ const pending = this.pendingKeys;
596
+ this.pendingKeys = [];
597
+ for (const key of pending) {
598
+ if (this.mode !== "editing")
599
+ break;
600
+ this.handleEditKey(key);
601
+ }
602
+ }
603
+ return promise;
604
+ }
605
+ completeCommand() {
606
+ const text = this.buffer.text();
607
+ if (text.includes("\n") || !text.startsWith("/"))
608
+ return;
609
+ const candidates = COMMANDS.filter((command) => command.startsWith(text));
610
+ if (candidates.length === 0)
611
+ return;
612
+ if (candidates.length === 1) {
613
+ this.buffer.insert(candidates[0].slice(text.length));
614
+ return;
615
+ }
616
+ const common = longestCommonPrefix(candidates);
617
+ if (common.length > text.length)
618
+ this.buffer.insert(common.slice(text.length));
619
+ }
620
+ suggestionMatches() {
621
+ if (this.inputMask)
622
+ return [];
623
+ const text = this.buffer.text();
624
+ if (text.includes("\n") || !/^\/\S*$/.test(text))
625
+ return [];
626
+ const matches = COMMAND_INFO.filter((command) => command.name.startsWith(text));
627
+ if (matches.length === 0)
628
+ return [];
629
+ if (matches.length === 1 && matches[0].name === text)
630
+ return [];
631
+ return matches;
632
+ }
633
+ moveSuggestion(delta) {
634
+ const matches = this.suggestionMatches();
635
+ if (matches.length === 0)
636
+ return;
637
+ this.suggestionIndex = Math.max(0, Math.min(this.suggestionIndex + delta, matches.length - 1));
638
+ this.scheduleRender();
639
+ }
640
+ suggestions() {
641
+ const matches = this.suggestionMatches();
642
+ if (matches.length === 0)
643
+ return [];
644
+ this.suggestionIndex = Math.max(0, Math.min(this.suggestionIndex, matches.length - 1));
645
+ const lines = matches.map((command, index) => {
646
+ const plain = `${command.name}${command.args ? ` ${command.args}` : ""}`;
647
+ const styled = `${tone.bone(command.name)}${command.args ? tone.mist(` ${command.args}`) : ""}`;
648
+ if (index === this.suggestionIndex) {
649
+ return ` ${tone.accent(glyphs.prompt)} ${tone.onGraphite(` ${padEndTo(plain, 21)} `)}${tone.mist(command.desc)}`;
650
+ }
651
+ return ` ${padEndTo(styled, 23)}${tone.mist(command.desc)}`;
652
+ });
653
+ if (matches.length > 1) {
654
+ lines.push(` ${tone.mist("↑↓ select · enter run · tab complete")}`);
655
+ }
656
+ return lines;
657
+ }
658
+ handleHover(row, col) {
659
+ if (this.mode === "permission") {
660
+ const index = this.permissionOptionAt(row, col);
661
+ if (index !== null && index !== this.permissionSelected) {
662
+ this.permissionSelected = index;
663
+ this.updatePermissionOptions();
664
+ }
665
+ return;
666
+ }
667
+ if (this.mode === "editing") {
668
+ const matches = this.suggestionMatches();
669
+ if (matches.length === 0)
670
+ return;
671
+ const top = this.lastHeaderRows + this.lastViewport + 1;
672
+ const index = row - top;
673
+ if (index >= 0 && index < matches.length && index !== this.suggestionIndex) {
674
+ this.suggestionIndex = index;
675
+ this.scheduleRender();
676
+ }
677
+ return;
678
+ }
679
+ if (this.mode === "picker" && this.picker) {
680
+ if (this.picker.hoverAt(row - this.lastHeaderRows))
681
+ this.scheduleRender();
682
+ }
683
+ }
684
+ handleClick(row, col) {
685
+ if (this.mode === "permission") {
686
+ const index = this.permissionOptionAt(row, col);
687
+ if (index !== null) {
688
+ this.permissionSelected = index;
689
+ this.updatePermissionOptions();
690
+ const choice = this.permissionOptions[index]?.choice;
691
+ if (choice)
692
+ this.resolvePermission(choice);
693
+ }
694
+ return;
695
+ }
696
+ if (this.mode === "editing") {
697
+ const matches = this.suggestionMatches();
698
+ if (matches.length > 0) {
699
+ const top = this.lastHeaderRows + this.lastViewport + 1;
700
+ const index = row - top;
701
+ if (index >= 0 && index < matches.length) {
702
+ this.suggestionIndex = index;
703
+ this.finishLine({ kind: "submit", text: matches[index].name });
704
+ }
705
+ }
706
+ return;
707
+ }
708
+ if (this.mode === "picker" && this.picker) {
709
+ const value = this.picker.pickAt(row - this.lastHeaderRows);
710
+ if (value !== null)
711
+ this.closePicker(value);
712
+ else
713
+ this.scheduleRender();
714
+ }
715
+ }
716
+ scroll(delta) {
717
+ this.scrollOffset = Math.max(0, this.scrollOffset + delta);
718
+ this.scheduleRender();
719
+ }
720
+ async undoTurn(target = "next") {
721
+ const last = this.lastTurn;
722
+ if (!last) {
723
+ this.append([` ${tone.mist("// nothing to undo")}`]);
724
+ return;
725
+ }
726
+ this.lastTurn = null;
727
+ this.session = new Session(this.system, last.snapshot);
728
+ this.transcript = this.transcript.slice(0, last.transcriptLength);
729
+ this.userMarkers = this.userMarkers.filter((marker) => marker.line < last.transcriptLength);
730
+ this.messageCursor = -1;
731
+ this.scrollOffset = 0;
732
+ if (last.recordId)
733
+ await deleteSession(last.recordId).catch(() => false);
734
+ if (target === "buffer") {
735
+ this.buffer.setText(last.input);
736
+ this.scheduleRender();
737
+ }
738
+ else {
739
+ this.pendingInitial = last.input;
740
+ }
741
+ this.append([
742
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist("// UNDONE — message restored to the input")}`,
743
+ ]);
744
+ }
745
+ async pasteFromClipboard() {
746
+ const image = readClipboardImage();
747
+ if (image) {
748
+ this.pendingImages.push(image);
749
+ const kb = Math.max(1, Math.round((image.data.length * 3) / 4 / 1024));
750
+ this.append([
751
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// image attached (${image.mediaType} · ${kb} KB) · it will be sent with the next message`)}`,
752
+ ]);
753
+ return;
754
+ }
755
+ const text = readClipboardText();
756
+ if (text && text.length > 0) {
757
+ this.buffer.insert(text.replace(/\r\n?/g, "\n"));
758
+ this.scheduleRender();
759
+ return;
760
+ }
761
+ this.append([` ${tone.mist("// clipboard is empty or unavailable")}`]);
762
+ }
763
+ copySelection() {
764
+ const text = this.selectionText();
765
+ this.selection = null;
766
+ if (!text) {
767
+ this.scheduleRender();
768
+ return;
769
+ }
770
+ const ok = copyToClipboard(text);
771
+ this.append([
772
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// ${ok ? "copied" : "copy attempted"} · ${text.length} chars`)}`,
773
+ ]);
774
+ this.scheduleRender();
775
+ }
776
+ selectionText() {
777
+ if (!this.selection)
778
+ return "";
779
+ const s = this.selection;
780
+ const forward = s.startRow < s.endRow || (s.startRow === s.endRow && s.startCol <= s.endCol);
781
+ const startRow = forward ? s.startRow : s.endRow;
782
+ const startCol = forward ? s.startCol : s.endCol;
783
+ const endRow = forward ? s.endRow : s.startRow;
784
+ const endCol = forward ? s.endCol : s.startCol;
785
+ const lines = [];
786
+ for (let row = startRow; row <= endRow; row++) {
787
+ const raw = stripAnsi(this.frameLines[row] ?? "");
788
+ const chars = Array.from(raw);
789
+ const from = row === startRow ? startCol : 0;
790
+ const to = row === endRow ? endCol : chars.length - 1;
791
+ lines.push(chars
792
+ .slice(from, to + 1)
793
+ .join("")
794
+ .replace(/\s+$/, ""));
795
+ }
796
+ return lines.join("\n").replace(/^\n+|\n+$/g, "");
797
+ }
798
+ handleConfirmKey(key) {
799
+ if (key.type === "nav" && (key.dir === "left" || key.dir === "right")) {
800
+ const count = this.permissionOptions.length;
801
+ if (count > 0) {
802
+ this.permissionSelected =
803
+ (this.permissionSelected + (key.dir === "right" ? 1 : count - 1)) % count;
804
+ this.updatePermissionOptions();
805
+ }
806
+ return;
807
+ }
808
+ let choice = null;
809
+ if (key.type === "escape" || (key.type === "control" && key.ch === "c"))
810
+ choice = "deny";
811
+ else if (key.type === "enter") {
812
+ choice = this.permissionOptions[this.permissionSelected]?.choice ?? "deny";
813
+ }
814
+ else if (key.type === "text") {
815
+ const ch = key.text.toLowerCase();
816
+ if (ch === "y")
817
+ choice = "allow";
818
+ else if (ch === "a")
819
+ choice = "always";
820
+ else if (ch === "n")
821
+ choice = "deny";
822
+ }
823
+ if (!choice)
824
+ return;
825
+ this.resolvePermission(choice);
826
+ }
827
+ permissionOptionAt(row, col) {
828
+ if (this.permissionOptionLine === null)
829
+ return null;
830
+ const contentIndex = row - this.lastHeaderRows;
831
+ if (contentIndex < 0 || contentIndex >= this.lastViewport)
832
+ return null;
833
+ const transcriptIndex = this.lastVisibleStart + contentIndex;
834
+ if (transcriptIndex !== this.permissionOptionLine)
835
+ return null;
836
+ const line = stripAnsi(this.frameLines[row] ?? "");
837
+ for (let i = 0; i < this.permissionOptions.length; i++) {
838
+ const label = this.permissionOptions[i].label;
839
+ const at = line.indexOf(label);
840
+ if (at === -1)
841
+ continue;
842
+ const start = at - 3;
843
+ const end = at + label.length + 1;
844
+ if (col >= start && col <= end)
845
+ return i;
846
+ }
847
+ return null;
848
+ }
849
+ handlePickerKey(key) {
850
+ if (!this.picker)
851
+ return;
852
+ const action = this.picker.handleKey(key);
853
+ if (action === "select") {
854
+ this.closePicker(this.picker.selectedValue());
855
+ return;
856
+ }
857
+ if (action === "cancel") {
858
+ this.closePicker(null);
859
+ return;
860
+ }
861
+ if (action === "rename" || action === "pin" || action === "delete") {
862
+ this.pickerAction = action;
863
+ this.closePicker(this.picker.selectedValue());
864
+ return;
865
+ }
866
+ this.scheduleRender();
867
+ }
868
+ openPicker(picker) {
869
+ this.picker = picker;
870
+ this.pickerAction = null;
871
+ this.mode = "picker";
872
+ this.scheduleRender();
873
+ return new Promise((resolve) => {
874
+ this.resolvePicker = resolve;
875
+ });
876
+ }
877
+ closePicker(value) {
878
+ const resolve = this.resolvePicker;
879
+ this.resolvePicker = null;
880
+ this.picker = null;
881
+ this.mode = "idle";
882
+ this.scheduleRender();
883
+ resolve?.(value);
884
+ }
885
+ scheduleRender() {
886
+ if (this.renderQueued)
887
+ return;
888
+ this.renderQueued = true;
889
+ setImmediate(() => {
890
+ this.renderQueued = false;
891
+ this.render();
892
+ });
893
+ }
894
+ renderNow() {
895
+ this.renderQueued = false;
896
+ this.render();
897
+ }
898
+ render() {
899
+ const rows = this.screen.rows;
900
+ const header = this.headerLines();
901
+ const footer = this.footerLines();
902
+ const viewport = Math.max(3, rows - header.length - footer.length - this.padTop - this.padBottom);
903
+ this.lastHeaderRows = header.length + this.padTop;
904
+ this.lastViewport = viewport;
905
+ let content;
906
+ if (this.mode === "picker" && this.picker) {
907
+ content = this.picker.render(this.contentWidth() - 2, viewport);
908
+ }
909
+ else {
910
+ content = this.visibleTranscript(viewport);
911
+ }
912
+ const lines = [""];
913
+ lines.push(...header, ...content);
914
+ while (lines.length < this.padTop + header.length + viewport)
915
+ lines.push("");
916
+ lines.push(...footer);
917
+ while (lines.length < rows)
918
+ lines.push("");
919
+ let cursor = null;
920
+ if (this.mode === "editing" && this.lastInputRender) {
921
+ const inputTop = this.padTop + header.length + viewport + this.lastInputOffset;
922
+ cursor = {
923
+ row: inputTop + this.lastInputRender.cursorRow,
924
+ col: this.padX + this.lastInputRender.cursorCol,
925
+ };
926
+ }
927
+ const padded = lines.map((line) => `${" ".repeat(this.padX)}${line}`);
928
+ this.frameLines = padded.slice(0, rows);
929
+ let selection = null;
930
+ if (this.selection) {
931
+ const s = this.selection;
932
+ const collapsed = s.startRow === s.endRow && s.startCol === s.endCol;
933
+ if (!collapsed) {
934
+ const forward = s.startRow < s.endRow || (s.startRow === s.endRow && s.startCol <= s.endCol);
935
+ selection = forward
936
+ ? { startRow: s.startRow, startCol: s.startCol, endRow: s.endRow, endCol: s.endCol }
937
+ : { startRow: s.endRow, startCol: s.endCol, endRow: s.startRow, endCol: s.startCol };
938
+ }
939
+ }
940
+ this.screen.render({ lines: this.frameLines, cursor, selection });
941
+ }
942
+ contentWidth() {
943
+ return Math.max(40, this.screen.width - this.padX * 2);
944
+ }
945
+ headerLines() {
946
+ const width = this.contentWidth();
947
+ const effort = this.activeEffort();
948
+ const modelLabel = this.model
949
+ ? `${this.providerId} · ${this.model}${effort ? ` · ${effort}` : ""}`
950
+ : "";
951
+ const badge = this.agentMode === "plan"
952
+ ? fg(palette.amber, bg(palette.graphite, " PLAN "))
953
+ : tone.onGraphite(" BUILD ");
954
+ const left = [
955
+ bold(tone.bone("quatcode")),
956
+ modelLabel ? tone.steel("·") : "",
957
+ modelLabel ? tone.silver(modelLabel) : "",
958
+ badge,
959
+ ]
960
+ .filter(Boolean)
961
+ .join(" ");
962
+ const scrolled = this.scrollOffset > 0 ? tone.mist("↑ ") : "";
963
+ const right = `${scrolled}${tone.mist(`v${VERSION}`)}`;
964
+ const gap = Math.max(1, width - 2 - stringWidth(left) - stringWidth(right));
965
+ return [
966
+ ` ${left}${" ".repeat(gap)}${right}`,
967
+ ` ${tone.steel("─".repeat(Math.max(8, width - 2)))}`,
968
+ ];
969
+ }
970
+ footerLines() {
971
+ const width = this.contentWidth();
972
+ const bar = ` ${tone.steel("─".repeat(Math.max(8, width - 2)))}`;
973
+ if (this.mode === "editing") {
974
+ const rendered = this.buffer.render({
975
+ width,
976
+ prompt: this.inputPrompt,
977
+ placeholder: this.inputPlaceholder,
978
+ mask: this.inputMask,
979
+ });
980
+ this.lastInputRender = rendered;
981
+ const suggestions = this.suggestions();
982
+ this.lastInputOffset = 1 + suggestions.length;
983
+ return [bar, ...suggestions, ...rendered.lines];
984
+ }
985
+ this.lastInputRender = null;
986
+ if (this.status)
987
+ return [bar, this.statusLine()];
988
+ if (this.mode === "running") {
989
+ return [bar, ` ${tone.mist("// esc interrupts")}`];
990
+ }
991
+ if (this.mode === "permission")
992
+ return [bar, ` ${this.permissionHint}`];
993
+ if (this.mode === "picker" && this.picker) {
994
+ return [bar, this.picker.footer(this.picker.itemCount())];
995
+ }
996
+ return [bar, ` ${tone.mist(`// ${shortenHome(this.dir)}`)}`];
997
+ }
998
+ statusLine() {
999
+ const spin = tone.accent(glyphs.spinner[this.spinnerFrame % glyphs.spinner.length] ?? "-");
1000
+ const elapsed = ((performance.now() - this.statusStartedAt) / 1000).toFixed(1);
1001
+ const meta = [this.status?.meta, `${elapsed}s`].filter(Boolean).join(" · ");
1002
+ return ` ${spin} ${this.status?.label ?? ""} ${tone.mist(meta)}`;
1003
+ }
1004
+ visibleTranscript(viewport) {
1005
+ const total = this.transcript.length;
1006
+ const maxOffset = Math.max(0, total - viewport);
1007
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
1008
+ const end = total - this.scrollOffset;
1009
+ const start = Math.max(0, end - viewport);
1010
+ this.lastVisibleStart = start;
1011
+ return this.transcript.slice(start, end);
1012
+ }
1013
+ append(lines) {
1014
+ const flat = lines.map((line) => line.replace(/\r?\n/g, " "));
1015
+ this.transcript.push(...flat);
1016
+ if (this.transcript.length > 2000) {
1017
+ const removed = this.transcript.length - 2000;
1018
+ this.transcript.splice(0, removed);
1019
+ this.userMarkers = this.userMarkers
1020
+ .map((marker) => ({ ...marker, line: marker.line - removed }))
1021
+ .filter((marker) => marker.line >= 0);
1022
+ }
1023
+ this.scheduleRender();
1024
+ }
1025
+ clearTranscript() {
1026
+ this.transcript = [];
1027
+ this.scrollOffset = 0;
1028
+ this.append([` ${tone.mist("// screen cleared")}`, ""]);
1029
+ }
1030
+ beginLive() {
1031
+ this.liveBase = this.transcript.length;
1032
+ this.scrollOffset = 0;
1033
+ }
1034
+ pushLive(text) {
1035
+ if (this.liveBase < 0)
1036
+ this.liveBase = this.transcript.length;
1037
+ const width = this.contentWidth() - 2;
1038
+ const rendered = renderMarkdown(text, { width });
1039
+ this.transcript = this.transcript.slice(0, this.liveBase).concat(rendered);
1040
+ this.scheduleRender();
1041
+ }
1042
+ endLive() {
1043
+ this.liveBase = -1;
1044
+ }
1045
+ setStatus(status) {
1046
+ this.status = status;
1047
+ if (status) {
1048
+ this.statusStartedAt = performance.now();
1049
+ this.startSpinner();
1050
+ }
1051
+ else {
1052
+ this.stopSpinner();
1053
+ }
1054
+ this.scheduleRender();
1055
+ }
1056
+ startSpinner() {
1057
+ if (this.spinnerTimer)
1058
+ return;
1059
+ this.spinnerFrame = 0;
1060
+ this.spinnerTimer = setInterval(() => {
1061
+ this.spinnerFrame += 1;
1062
+ this.scheduleRender();
1063
+ }, 90);
1064
+ if (typeof this.spinnerTimer.unref === "function")
1065
+ this.spinnerTimer.unref();
1066
+ }
1067
+ stopSpinner() {
1068
+ if (this.spinnerTimer)
1069
+ clearInterval(this.spinnerTimer);
1070
+ this.spinnerTimer = null;
1071
+ }
1072
+ async loadConfiguration() {
1073
+ this.rebuildTools();
1074
+ const explicit = this.opts.model;
1075
+ if (explicit) {
1076
+ this.providerId = resolveProviderForModel(explicit) ?? this.detectProvider() ?? "anthropic";
1077
+ this.model = explicit;
1078
+ }
1079
+ else if (this.config.model) {
1080
+ this.providerId =
1081
+ this.config.provider ??
1082
+ resolveProviderForModel(this.config.model) ??
1083
+ this.detectProvider() ??
1084
+ "anthropic";
1085
+ this.model = this.config.model;
1086
+ }
1087
+ else {
1088
+ const detected = this.detectEnvSetup();
1089
+ if (detected) {
1090
+ this.providerId = detected.id;
1091
+ this.model = detected.model;
1092
+ }
1093
+ else {
1094
+ const chosen = await this.changeModel();
1095
+ if (!chosen) {
1096
+ this.providerId = "anthropic";
1097
+ this.model = defaultModelFor("anthropic");
1098
+ }
1099
+ }
1100
+ }
1101
+ const keyState = hasKey(this.providerId)
1102
+ ? ` ${tone.silver("· key set")}`
1103
+ : ` ${tone.bone("· no key")} ${tone.mist(`(${keyEnvFor(this.providerId)})`)}`;
1104
+ return `${tone.silver(`${this.providerId} · ${this.model}`)}${keyState}`;
1105
+ }
1106
+ detectProvider() {
1107
+ if (process.env.ANTHROPIC_API_KEY)
1108
+ return "anthropic";
1109
+ if (process.env.OPENAI_API_KEY)
1110
+ return "openai";
1111
+ if (process.env.GEMINI_API_KEY)
1112
+ return "gemini";
1113
+ return null;
1114
+ }
1115
+ detectEnvSetup() {
1116
+ const override = process.env.QUATCODE_PROVIDER?.toLowerCase();
1117
+ if (override === "anthropic" || override === "openai" || override === "gemini") {
1118
+ const envModel = {
1119
+ anthropic: process.env.ANTHROPIC_MODEL,
1120
+ openai: process.env.OPENAI_MODEL,
1121
+ gemini: process.env.GEMINI_MODEL,
1122
+ }[override];
1123
+ return { id: override, model: envModel ?? defaultModelFor(override) };
1124
+ }
1125
+ if (process.env.ANTHROPIC_MODEL)
1126
+ return { id: "anthropic", model: process.env.ANTHROPIC_MODEL };
1127
+ if (process.env.OPENAI_MODEL)
1128
+ return { id: "openai", model: process.env.OPENAI_MODEL };
1129
+ if (process.env.GEMINI_MODEL)
1130
+ return { id: "gemini", model: process.env.GEMINI_MODEL };
1131
+ try {
1132
+ const provider = createProviderFromEnv();
1133
+ const id = provider.id;
1134
+ if (provider.defaultModel &&
1135
+ (id === "anthropic" || id === "openai" || id === "gemini")) {
1136
+ return { id, model: provider.defaultModel };
1137
+ }
1138
+ }
1139
+ catch {
1140
+ return null;
1141
+ }
1142
+ return null;
1143
+ }
1144
+ async loadProject() {
1145
+ const summary = await scanProject(this.dir);
1146
+ this.summary = summary;
1147
+ this.system = this.buildSystem("");
1148
+ this.skills = await listSkills().catch(() => []);
1149
+ const recent = await listSessionsForDir(this.dir, 50).catch(() => []);
1150
+ for (const record of [...recent].reverse())
1151
+ this.buffer.addHistory(record.task);
1152
+ const stack = summary.frameworks.slice(0, 3).join("+") ||
1153
+ summary.languages
1154
+ .slice(0, 2)
1155
+ .map((l) => l.ext.replace(".", ""))
1156
+ .join("+");
1157
+ const parts = [
1158
+ summary.name,
1159
+ stack,
1160
+ `${summary.fileCount} files`,
1161
+ summary.packageManager ?? "",
1162
+ ].filter(Boolean);
1163
+ return tone.mist(parts.join(" · "));
1164
+ }
1165
+ buildSystem(query) {
1166
+ if (!this.opts.context || !this.summary)
1167
+ return this.opts.system ?? "";
1168
+ const context = buildProjectContext(this.summary, { query }).system;
1169
+ return this.opts.system ? `${context}\n\n${this.opts.system}` : context;
1170
+ }
1171
+ rebuildTools() {
1172
+ this.tools?.dispose();
1173
+ const full = createProjectTools(this.dir);
1174
+ for (const tool of createBrainTools(this.dir).list())
1175
+ full.register(tool);
1176
+ full.register(createLoadSkillTool());
1177
+ for (const tool of this.mcp.tools())
1178
+ full.register(tool);
1179
+ if (this.agentMode === "plan") {
1180
+ const readOnlyMcp = this.mcp.readOnlyToolNames();
1181
+ const filtered = new ToolRegistry();
1182
+ for (const tool of full.list()) {
1183
+ if (READ_ONLY_TOOLS.has(tool.name) || readOnlyMcp.has(tool.name))
1184
+ filtered.register(tool);
1185
+ }
1186
+ filtered.onDispose(() => full.dispose());
1187
+ this.tools = filtered;
1188
+ return;
1189
+ }
1190
+ this.tools = full;
1191
+ }
1192
+ async connectMcpServers(configs) {
1193
+ const statuses = await this.mcp.connectAll(configs);
1194
+ this.rebuildTools();
1195
+ const connected = statuses.filter((status) => status.state === "connected");
1196
+ const failed = statuses.filter((status) => status.state === "error");
1197
+ const disabled = statuses.filter((status) => status.state === "disabled");
1198
+ const toolCount = connected.reduce((sum, status) => sum + status.tools.length, 0);
1199
+ for (const status of failed) {
1200
+ this.append([` ${tone.red(`// MCP ${status.name}: ${status.error ?? "connection failed"}`)}`]);
1201
+ }
1202
+ const parts = [
1203
+ `${connected.length}/${statuses.length} connected`,
1204
+ `${toolCount} tool${toolCount === 1 ? "" : "s"}`,
1205
+ ];
1206
+ if (disabled.length > 0)
1207
+ parts.push(`${disabled.length} disabled`);
1208
+ if (failed.length > 0)
1209
+ parts.push(`${failed.length} failed`);
1210
+ return tone.mist(parts.join(" · "));
1211
+ }
1212
+ toggleMode() {
1213
+ this.agentMode = this.agentMode === "build" ? "plan" : "build";
1214
+ this.rebuildTools();
1215
+ this.append([
1216
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// MODE → ${this.agentMode.toUpperCase()}`)}${this.agentMode === "plan" ? ` ${tone.amber("read-only: no edits, no commands")}` : ""}`,
1217
+ ]);
1218
+ }
1219
+ ensureProvider() {
1220
+ if (this.provider)
1221
+ return this.provider;
1222
+ try {
1223
+ this.provider = buildProvider(this.providerId, this.model);
1224
+ return this.provider;
1225
+ }
1226
+ catch {
1227
+ return null;
1228
+ }
1229
+ }
1230
+ async collectModelEntries() {
1231
+ const result = [];
1232
+ for (const provider of PROVIDERS) {
1233
+ let models = provider.models;
1234
+ if (hasKey(provider.id)) {
1235
+ this.setStatus({ label: `LOADING ${provider.label.toUpperCase()} MODELS` });
1236
+ const live = await fetchProviderModels(provider.id);
1237
+ if (live.length > 0)
1238
+ models = live;
1239
+ }
1240
+ for (const model of models) {
1241
+ result.push({ providerId: provider.id, id: model.id, label: model.label });
1242
+ }
1243
+ }
1244
+ this.setStatus(null);
1245
+ return result;
1246
+ }
1247
+ async changeModel() {
1248
+ this.setStatus({ label: "LOADING MODELS" });
1249
+ const entries = await this.collectModelEntries();
1250
+ this.setStatus(null);
1251
+ const pickerEntries = [];
1252
+ for (const provider of PROVIDERS) {
1253
+ const models = entries.filter((entry) => entry.providerId === provider.id);
1254
+ if (models.length === 0)
1255
+ continue;
1256
+ pickerEntries.push({
1257
+ kind: "header",
1258
+ label: `${provider.label} · ${hasKey(provider.id) ? "key set" : "no key"}`,
1259
+ });
1260
+ for (const model of models) {
1261
+ pickerEntries.push({
1262
+ kind: "item",
1263
+ value: `${provider.id}:${model.id}`,
1264
+ label: model.id,
1265
+ hint: model.label,
1266
+ });
1267
+ }
1268
+ }
1269
+ pickerEntries.push({ kind: "item", value: CUSTOM_MODEL, label: "custom model id…" });
1270
+ const chosen = await this.openPicker(new Picker({ title: "select a model", entries: pickerEntries, filterable: true }));
1271
+ if (!chosen)
1272
+ return false;
1273
+ if (chosen === CUSTOM_MODEL) {
1274
+ const result = await this.readLine({
1275
+ prompt: ` ${tone.bone("MODEL ID")} ${tone.mist(glyphs.prompt)} `,
1276
+ placeholder: "e.g. claude-sonnet-5-5",
1277
+ });
1278
+ if (result.kind !== "submit" || !result.text.trim())
1279
+ return false;
1280
+ const id = result.text.trim();
1281
+ this.applyModel(resolveProviderForModel(id) ?? this.providerId, id);
1282
+ await this.chooseEffortForModel(id);
1283
+ return true;
1284
+ }
1285
+ const separator = chosen.indexOf(":");
1286
+ const model = chosen.slice(separator + 1);
1287
+ this.applyModel(chosen.slice(0, separator), model);
1288
+ await this.chooseEffortForModel(model);
1289
+ return true;
1290
+ }
1291
+ effortEntries(efforts, current) {
1292
+ const entries = [
1293
+ { kind: "item", value: "default", label: "default", hint: "no effort parameter" },
1294
+ ];
1295
+ for (const level of efforts) {
1296
+ entries.push({
1297
+ kind: "item",
1298
+ value: level,
1299
+ label: level,
1300
+ hint: level === current ? "current" : undefined,
1301
+ });
1302
+ }
1303
+ return entries;
1304
+ }
1305
+ async chooseEffortForModel(model) {
1306
+ const efforts = effortsForModel(model);
1307
+ if (!efforts || efforts.length === 0)
1308
+ return;
1309
+ const chosen = await this.openPicker(new Picker({
1310
+ title: `select effort · ${model}`,
1311
+ entries: this.effortEntries(efforts, this.activeEffort()),
1312
+ filterable: true,
1313
+ }));
1314
+ if (chosen === null)
1315
+ return;
1316
+ this.setEffort(chosen === "default" ? null : chosen);
1317
+ this.append([
1318
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// EFFORT → ${this.activeEffort() ?? "default"}`)}`,
1319
+ ]);
1320
+ }
1321
+ async effortCommand(arg) {
1322
+ const value = arg.trim().toLowerCase();
1323
+ const efforts = effortsForModel(this.model);
1324
+ if (!efforts || efforts.length === 0) {
1325
+ this.append([
1326
+ ` ${tone.mist(`// ${this.model || "this model"} does not support effort`)}`,
1327
+ ]);
1328
+ return;
1329
+ }
1330
+ if (value === "off" || value === "default" || value === "none" || value === "auto") {
1331
+ this.setEffort(null);
1332
+ this.append([` ${tone.mist("// EFFORT → default")}`]);
1333
+ return;
1334
+ }
1335
+ if (value) {
1336
+ if (!efforts.includes(value)) {
1337
+ this.append([
1338
+ ` ${tone.red(`// unknown effort: ${value}`)}`,
1339
+ ` ${tone.mist(`// available: ${efforts.join(", ")} · default`)}`,
1340
+ ]);
1341
+ return;
1342
+ }
1343
+ this.setEffort(value);
1344
+ this.append([` ${tone.mist(`// EFFORT → ${value}`)}`]);
1345
+ return;
1346
+ }
1347
+ const chosen = await this.openPicker(new Picker({
1348
+ title: "select effort",
1349
+ entries: this.effortEntries(efforts, this.activeEffort()),
1350
+ filterable: true,
1351
+ }));
1352
+ if (chosen === null)
1353
+ return;
1354
+ this.setEffort(chosen === "default" ? null : chosen);
1355
+ this.append([
1356
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// EFFORT → ${this.activeEffort() ?? "default"}`)}`,
1357
+ ]);
1358
+ }
1359
+ activeEffort() {
1360
+ if (!this.effort)
1361
+ return null;
1362
+ const efforts = effortsForModel(this.model);
1363
+ return efforts && efforts.includes(this.effort) ? this.effort : null;
1364
+ }
1365
+ setEffort(value) {
1366
+ this.effort = value;
1367
+ if (value)
1368
+ this.config.effort = value;
1369
+ else
1370
+ delete this.config.effort;
1371
+ void saveConfig({ ...this.config }).catch(() => undefined);
1372
+ }
1373
+ applyModel(providerId, model) {
1374
+ this.providerId = providerId;
1375
+ this.model = model;
1376
+ this.provider = null;
1377
+ this.config.provider = providerId;
1378
+ this.config.model = model;
1379
+ void saveConfig({ ...this.config, provider: providerId, model }).catch(() => undefined);
1380
+ }
1381
+ async permissionCard(req) {
1382
+ this.setStatus(null);
1383
+ let warning = null;
1384
+ if (req.tool === "run_command") {
1385
+ const command = req.input && typeof req.input === "object" && "command" in req.input
1386
+ ? String(req.input.command)
1387
+ : "";
1388
+ if (command)
1389
+ warning = analyzeCommand(command);
1390
+ }
1391
+ const title = warning?.dangerous
1392
+ ? "DANGER"
1393
+ : warning?.sensitive
1394
+ ? "SENSITIVE"
1395
+ : "PERMISSION";
1396
+ const border = warning?.dangerous ? palette.red : palette.steel;
1397
+ const inner = Math.min(Math.max(44, this.contentWidth() - 8), 66);
1398
+ const body = [bold(tone.bone(req.tool))];
1399
+ const summary = permissionSummary(req);
1400
+ if (summary)
1401
+ body.push(tone.silver(clipAnsi(summary, inner - 2)));
1402
+ if (warning?.dangerous)
1403
+ body.push(tone.red(`danger: ${warning.reasons.join(", ")}`));
1404
+ else if (warning?.sensitive)
1405
+ body.push(tone.amber(`sensitive: ${warning.reasons.join(", ")}`));
1406
+ body.push("");
1407
+ this.permissionOptions = [
1408
+ { choice: "allow", key: "Y", label: "allow" },
1409
+ { choice: "always", key: "A", label: "always" },
1410
+ { choice: "deny", key: "N", label: "deny" },
1411
+ ];
1412
+ this.permissionSelected = 0;
1413
+ this.permissionInner = inner;
1414
+ this.permissionBorder = border;
1415
+ const optionRowIndex = body.length;
1416
+ body.push(this.renderPermissionOptions());
1417
+ const card = box(title, body, inner, border);
1418
+ this.append(card);
1419
+ this.permissionOptionLine = this.transcript.length - card.length + 1 + optionRowIndex;
1420
+ this.permissionHint = ` ${tone.bone("← →")} ${tone.mist("select")} ${tone.bone("ENTER")} ${tone.mist("confirm")} ${tone.bone("Y/A/N")} ${tone.mist("direct")} ${tone.bone("ESC")} ${tone.mist("deny")} ${tone.mist("(mouse works too)")}`;
1421
+ this.mode = "permission";
1422
+ this.scheduleRender();
1423
+ const choice = await new Promise((resolve) => {
1424
+ this.resolveChoice = resolve;
1425
+ });
1426
+ const granted = choice === "allow" || choice === "always";
1427
+ const marker = granted ? tone.bone(glyphs.diamond) : tone.red(glyphs.cross);
1428
+ this.append([
1429
+ ` ${marker} ${tone.silver(req.tool)} ${tone.mist(granted ? "ALLOWED" : "DENIED")}`,
1430
+ ]);
1431
+ return choice;
1432
+ }
1433
+ renderPermissionOptions() {
1434
+ return this.permissionOptions
1435
+ .map((option, index) => {
1436
+ const text = `${option.key} ${option.label}`;
1437
+ return index === this.permissionSelected
1438
+ ? tone.onGraphite(` ${text} `)
1439
+ : tone.silver(text);
1440
+ })
1441
+ .join(" ");
1442
+ }
1443
+ updatePermissionOptions() {
1444
+ if (this.permissionOptionLine === null)
1445
+ return;
1446
+ const b = glyphs.box;
1447
+ const content = padEndTo(this.renderPermissionOptions(), this.permissionInner - 2);
1448
+ this.transcript[this.permissionOptionLine] =
1449
+ ` ${fg(this.permissionBorder, b.v)} ${content} ${fg(this.permissionBorder, b.v)}`;
1450
+ this.scheduleRender();
1451
+ }
1452
+ resolvePermission(choice) {
1453
+ const resolve = this.resolveChoice;
1454
+ this.resolveChoice = null;
1455
+ this.permissionOptionLine = null;
1456
+ this.mode = "running";
1457
+ this.scheduleRender();
1458
+ resolve?.(choice);
1459
+ }
1460
+ buildGate() {
1461
+ return {
1462
+ request: async (req) => {
1463
+ if (this.agentMode === "plan" && !READ_ONLY_TOOLS.has(req.tool)) {
1464
+ this.append([
1465
+ ` ${tone.amber(glyphs.diamondOpen)} ${tone.mist(`// ${req.tool} blocked in PLAN mode (Tab switches to BUILD)`)}`,
1466
+ ]);
1467
+ return { allowed: false };
1468
+ }
1469
+ if (this.gateMode === "always")
1470
+ return { allowed: true };
1471
+ const choice = await this.permissionCard(req);
1472
+ if (choice === "always") {
1473
+ this.gateMode = "always";
1474
+ return { allowed: true };
1475
+ }
1476
+ return { allowed: choice === "allow" };
1477
+ },
1478
+ };
1479
+ }
1480
+ async promptForKey(keyEnv) {
1481
+ this.append([` ${tone.mist(`// ${keyEnv} IS NOT SET`)}`]);
1482
+ const result = await this.readLine({
1483
+ prompt: ` ${tone.bone(keyEnv)} ${tone.mist(glyphs.prompt)} `,
1484
+ mask: true,
1485
+ placeholder: "",
1486
+ });
1487
+ if (result.kind !== "submit" || !result.text.trim()) {
1488
+ this.append([` ${tone.mist("// request not sent")}`]);
1489
+ return false;
1490
+ }
1491
+ const value = result.text.trim();
1492
+ process.env[keyEnv] = value;
1493
+ this.config.keys = { ...(this.config.keys ?? {}), [keyEnv]: value };
1494
+ void saveConfig({ ...this.config }).catch(() => undefined);
1495
+ this.append([
1496
+ ` ${tone.bone(glyphs.diamond)} ${tone.mist(`${keyEnv} SAVED · ~/.quatcode/config.json (and this session)`)}`,
1497
+ ]);
1498
+ return true;
1499
+ }
1500
+ async turn(input, images = []) {
1501
+ if (!this.tools)
1502
+ return;
1503
+ let provider = this.ensureProvider();
1504
+ if (!provider) {
1505
+ const keyEnv = keyEnvFor(this.providerId);
1506
+ this.append([
1507
+ ` ${tone.red(glyphs.cross)} ${tone.bone(`${this.providerId} NEEDS ${keyEnv}`)}`,
1508
+ ]);
1509
+ const entered = await this.promptForKey(keyEnv);
1510
+ if (!entered)
1511
+ return;
1512
+ provider = this.ensureProvider();
1513
+ if (!provider) {
1514
+ this.append([` ${tone.red(`// ${keyEnv} IS STILL NOT SET`)}`]);
1515
+ return;
1516
+ }
1517
+ }
1518
+ if (this.opts.context && this.summary)
1519
+ this.system = this.buildSystem(input);
1520
+ const skillsSection = skillsSystemSection(this.skills);
1521
+ let system = skillsSection
1522
+ ? `${this.system ? `${this.system}\n\n` : ""}${skillsSection}`
1523
+ : this.system;
1524
+ const mcpSection = mcpInstructionsSection(this.mcp.instructions());
1525
+ if (mcpSection)
1526
+ system = system ? `${system}\n\n${mcpSection}` : mcpSection;
1527
+ if (this.agentMode === "plan") {
1528
+ system = `${system ? `${system}\n\n` : ""}# mode\nYou are in PLAN mode: analyze the codebase and propose a plan. Do not modify files or run commands. When the plan is ready, present it and tell the user to press Tab to switch to BUILD mode.`;
1529
+ }
1530
+ this.abort = new AbortController();
1531
+ this.abortRequested = false;
1532
+ this.undoAfterAbort = false;
1533
+ this.mode = "running";
1534
+ this.scrollOffset = 0;
1535
+ const controller = new TurnController({ model: this.model, maxSteps: this.opts.maxSteps }, {
1536
+ width: () => this.contentWidth() - 2,
1537
+ append: (lines) => this.append(lines),
1538
+ beginLive: () => this.beginLive(),
1539
+ pushLive: (text) => this.pushLive(text),
1540
+ endLive: () => this.endLive(),
1541
+ setStatus: (status) => this.setStatus(status),
1542
+ verbose: () => this.verbose,
1543
+ aborted: () => this.abortRequested,
1544
+ });
1545
+ let result = null;
1546
+ try {
1547
+ result = await runAgent(input, {
1548
+ provider,
1549
+ model: this.model,
1550
+ system,
1551
+ tools: this.tools,
1552
+ permissions: this.buildGate(),
1553
+ maxSteps: this.opts.maxSteps,
1554
+ reasoningEffort: this.activeEffort() ?? undefined,
1555
+ session: this.session,
1556
+ stream: true,
1557
+ signal: this.abort.signal,
1558
+ images,
1559
+ onEvent: controller.handle,
1560
+ });
1561
+ }
1562
+ catch (err) {
1563
+ this.append([` ${tone.red(`// ${errorMessage(err)}`)}`]);
1564
+ }
1565
+ finally {
1566
+ this.endLive();
1567
+ this.setStatus(null);
1568
+ this.mode = "idle";
1569
+ this.abort = null;
1570
+ }
1571
+ if (!result) {
1572
+ this.scheduleRender();
1573
+ return;
1574
+ }
1575
+ if (this.undoAfterAbort) {
1576
+ this.undoAfterAbort = false;
1577
+ this.abortRequested = false;
1578
+ const undo = this.turnUndo;
1579
+ this.turnUndo = null;
1580
+ this.lastTurn = null;
1581
+ if (undo) {
1582
+ this.session = new Session(this.system, undo.snapshot);
1583
+ this.transcript = this.transcript.slice(0, undo.transcriptLength);
1584
+ this.userMarkers = this.userMarkers.filter((marker) => marker.line < undo.transcriptLength);
1585
+ this.pendingInitial = undo.input;
1586
+ }
1587
+ this.messageCursor = -1;
1588
+ this.scrollOffset = 0;
1589
+ this.append([
1590
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist("// UNDONE — message restored to the input")}`,
1591
+ ]);
1592
+ this.scheduleRender();
1593
+ return;
1594
+ }
1595
+ this.totalUsage = addUsage(this.totalUsage, result.usage);
1596
+ this.turns += 1;
1597
+ const status = this.abortRequested
1598
+ ? "interrupted"
1599
+ : result.status === "complete"
1600
+ ? "complete"
1601
+ : result.status === "max_steps"
1602
+ ? "max steps"
1603
+ : "failed";
1604
+ controller.footer(status);
1605
+ const recordId = await this.saveTurn(input, result, controller.durationMs);
1606
+ if (this.turnUndo) {
1607
+ this.lastTurn = { ...this.turnUndo, recordId };
1608
+ this.turnUndo = null;
1609
+ }
1610
+ this.scheduleRender();
1611
+ }
1612
+ async command(line) {
1613
+ const body = line.slice(1).trim();
1614
+ const space = body.search(/\s/);
1615
+ const name = (space === -1 ? body : body.slice(0, space)).toLowerCase();
1616
+ const arg = space === -1 ? "" : body.slice(space).trim();
1617
+ switch (name) {
1618
+ case "help":
1619
+ this.printHelp();
1620
+ return false;
1621
+ case "new":
1622
+ this.session = new Session(this.system);
1623
+ this.conversationId = makeSessionId();
1624
+ this.lastTurn = null;
1625
+ this.transcript = [];
1626
+ this.userMarkers = [];
1627
+ this.messageCursor = -1;
1628
+ this.scrollOffset = 0;
1629
+ this.append([` ${tone.mist("// NEW CONVERSATION")}`, ""]);
1630
+ return false;
1631
+ case "undo":
1632
+ await this.undoTurn();
1633
+ return false;
1634
+ case "model":
1635
+ if (arg) {
1636
+ this.applyModel(resolveProviderForModel(arg) ?? this.providerId, arg);
1637
+ this.append([` ${tone.mist(`// MODEL → ${this.providerId} · ${this.model}`)}`]);
1638
+ }
1639
+ else {
1640
+ const changed = await this.changeModel();
1641
+ if (changed) {
1642
+ this.append([` ${tone.mist(`// MODEL → ${this.providerId} · ${this.model}`)}`]);
1643
+ }
1644
+ }
1645
+ return false;
1646
+ case "effort":
1647
+ await this.effortCommand(arg);
1648
+ return false;
1649
+ case "key": {
1650
+ const keyEnv = keyEnvFor(this.providerId);
1651
+ if (hasKey(this.providerId)) {
1652
+ this.append([` ${tone.mist(`// ${keyEnv} IS SET`)}`]);
1653
+ }
1654
+ else {
1655
+ await this.promptForKey(keyEnv);
1656
+ }
1657
+ return false;
1658
+ }
1659
+ case "dir":
1660
+ await this.changeDir(arg);
1661
+ return false;
1662
+ case "context":
1663
+ await this.printContext();
1664
+ return false;
1665
+ case "usage":
1666
+ case "cost":
1667
+ this.printUsage();
1668
+ return false;
1669
+ case "sessions":
1670
+ await this.sessionsCommand(arg);
1671
+ return false;
1672
+ case "timeline":
1673
+ await this.timelineCommand();
1674
+ return false;
1675
+ case "verbose":
1676
+ this.verbose = !this.verbose;
1677
+ this.append([
1678
+ ` ${tone.mist(`// VERBOSE ${this.verbose ? "ON — FULL TOOL OUTPUT" : "OFF"}`)}`,
1679
+ ]);
1680
+ return false;
1681
+ case "theme":
1682
+ await this.changeTheme(arg);
1683
+ return false;
1684
+ case "skills":
1685
+ await this.skillsCommand(arg);
1686
+ return false;
1687
+ case "mcp":
1688
+ await this.mcpCommand(arg);
1689
+ return false;
1690
+ case "clear":
1691
+ this.clearTranscript();
1692
+ return false;
1693
+ case "exit":
1694
+ case "quit":
1695
+ return true;
1696
+ default:
1697
+ this.append([` ${tone.mist(`// unknown command: /${name} — try /help`)}`]);
1698
+ return false;
1699
+ }
1700
+ }
1701
+ async changeTheme(arg) {
1702
+ const current = activeThemeId();
1703
+ if (arg) {
1704
+ this.applyTheme(arg);
1705
+ return;
1706
+ }
1707
+ const entries = getThemes().map((theme) => ({
1708
+ kind: "item",
1709
+ value: theme.id,
1710
+ label: theme.label,
1711
+ hint: theme.id === current ? "current" : undefined,
1712
+ }));
1713
+ const chosen = await this.openPicker(new Picker({ title: "select a theme", entries, filterable: true }));
1714
+ if (!chosen || chosen === current)
1715
+ return;
1716
+ this.applyTheme(chosen);
1717
+ }
1718
+ applyTheme(id) {
1719
+ const before = { ...palette };
1720
+ const theme = setTheme(id);
1721
+ const pairs = buildColorRemap(before, palette);
1722
+ this.transcript = this.transcript.map((line) => remapColors(line, pairs));
1723
+ this.config.theme = theme.id;
1724
+ void saveConfig({ ...this.config, theme: theme.id }).catch(() => undefined);
1725
+ this.screen.applyBackground();
1726
+ this.append([
1727
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// THEME → ${theme.label.toUpperCase()}`)}`,
1728
+ "",
1729
+ ]);
1730
+ }
1731
+ async sessionsCommand(arg) {
1732
+ const limit = Number.parseInt(arg, 10);
1733
+ const max = Number.isFinite(limit) && limit > 0 ? limit : 40;
1734
+ for (;;) {
1735
+ const records = await listSessionsForDir(this.dir, max);
1736
+ const conversations = sortConversations(latestPerConversation(records));
1737
+ if (conversations.length === 0) {
1738
+ this.append([` ${tone.mist(`// NO SESSIONS YET IN ${shortenHome(this.dir).toUpperCase()}`)}`]);
1739
+ return;
1740
+ }
1741
+ const labelWidth = Math.max(20, this.contentWidth() - 62);
1742
+ const entries = conversations.map((record) => ({
1743
+ kind: "item",
1744
+ value: conversationKey(record),
1745
+ label: `${record.pinned ? `${glyphs.pin} ` : ""}${clipAnsi(displayTitle(record), labelWidth)}`,
1746
+ hint: `${record.pinned ? "pinned · " : ""}${record.createdAt.slice(0, 16).replace("T", " ")} · ${record.model} · ${record.status} · ${record.steps} steps`,
1747
+ }));
1748
+ const chosen = await this.openPicker(new Picker({
1749
+ title: "sessions · enter resumes",
1750
+ entries,
1751
+ filterable: true,
1752
+ actions: ["rename", "pin", "delete"],
1753
+ }));
1754
+ if (!chosen)
1755
+ return;
1756
+ const record = conversations.find((item) => conversationKey(item) === chosen);
1757
+ if (!record)
1758
+ return;
1759
+ const action = this.pickerAction;
1760
+ if (action === "rename") {
1761
+ await this.renameConversation(record);
1762
+ continue;
1763
+ }
1764
+ if (action === "pin") {
1765
+ const pinned = !record.pinned;
1766
+ await updateConversation(chosen, { pinned });
1767
+ this.append([
1768
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// ${pinned ? "PINNED" : "UNPINNED"} → ${clipAnsi(displayTitle(record), 60)}`)}`,
1769
+ ]);
1770
+ continue;
1771
+ }
1772
+ if (action === "delete") {
1773
+ const confirmed = await this.confirm(`delete session "${clipAnsi(displayTitle(record), 60)}"?`);
1774
+ if (!confirmed) {
1775
+ this.append([` ${tone.mist("// cancelled")}`]);
1776
+ continue;
1777
+ }
1778
+ const removed = await deleteConversation(chosen);
1779
+ this.append([
1780
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// SESSION DELETED → ${clipAnsi(displayTitle(record), 60)} · ${removed} record${removed === 1 ? "" : "s"}`)}`,
1781
+ ]);
1782
+ continue;
1783
+ }
1784
+ this.resumeSession(record);
1785
+ return;
1786
+ }
1787
+ }
1788
+ async renameConversation(record) {
1789
+ const result = await this.readLine({
1790
+ prompt: ` ${tone.bone("SESSION NAME")} ${tone.mist(glyphs.prompt)} `,
1791
+ placeholder: clipAnsi(displayTitle(record), 60),
1792
+ initial: record.title ?? "",
1793
+ });
1794
+ if (result.kind !== "submit") {
1795
+ this.append([` ${tone.mist("// cancelled")}`]);
1796
+ return;
1797
+ }
1798
+ const title = result.text.replace(/\s+/g, " ").trim();
1799
+ if (!title)
1800
+ return;
1801
+ await updateConversation(conversationKey(record), { title });
1802
+ this.append([
1803
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// SESSION RENAMED → ${clipAnsi(title, 60)}`)}`,
1804
+ ]);
1805
+ }
1806
+ resumeSession(record) {
1807
+ const messages = record.messages ?? [];
1808
+ this.session = new Session(this.system, messages);
1809
+ this.conversationId = conversationKey(record);
1810
+ if (record.model) {
1811
+ this.providerId = resolveProviderForModel(record.model) ?? this.providerId;
1812
+ this.model = record.model;
1813
+ this.provider = null;
1814
+ }
1815
+ const task = record.task.replace(/\s+/g, " ");
1816
+ this.append([
1817
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// RESUMED → ${clipAnsi(task, this.contentWidth() - 20)}`)}`,
1818
+ ` ${tone.silver(`${messages.length} message${messages.length === 1 ? "" : "s"} · ${record.model} · ${record.createdAt.slice(0, 16).replace("T", " ")}`)}`,
1819
+ ]);
1820
+ if (record.text) {
1821
+ this.append([` ${tone.mist("// LAST ANSWER")}`]);
1822
+ this.append(renderMarkdown(record.text, { width: this.contentWidth() - 2 }));
1823
+ }
1824
+ if (messages.length === 0) {
1825
+ this.append([` ${tone.mist("// no stored messages for this session (saved before resume support)")}`]);
1826
+ }
1827
+ this.append([""]);
1828
+ }
1829
+ async skillsCommand(arg) {
1830
+ const parts = arg.split(/\s+/).filter(Boolean);
1831
+ const sub = (parts[0] ?? "").toLowerCase();
1832
+ const rest = parts.slice(1).join(" ").trim();
1833
+ if (!sub || sub === "list") {
1834
+ this.printSkills();
1835
+ return;
1836
+ }
1837
+ if (sub === "add") {
1838
+ if (!rest) {
1839
+ await this.addSkillPasted();
1840
+ return;
1841
+ }
1842
+ if (/^https?:\/\//i.test(rest)) {
1843
+ await this.addSkillFromUrl(rest);
1844
+ return;
1845
+ }
1846
+ await this.addSkillFromPath(rest);
1847
+ return;
1848
+ }
1849
+ if (sub === "remove" || sub === "rm") {
1850
+ if (!rest) {
1851
+ this.append([` ${tone.mist("// usage: /skills remove <name>")}`]);
1852
+ return;
1853
+ }
1854
+ const needle = rest.toLowerCase();
1855
+ const target = this.skills.find((skill) => skill.slug === needle || skill.name.toLowerCase() === needle);
1856
+ if (!target) {
1857
+ this.append([` ${tone.red(`// no skill named ${rest}`)}`]);
1858
+ return;
1859
+ }
1860
+ const confirmed = await this.confirm(`remove skill "${target.name}"?`);
1861
+ if (!confirmed) {
1862
+ this.append([` ${tone.mist("// cancelled")}`]);
1863
+ return;
1864
+ }
1865
+ const removed = await removeSkill(target.slug);
1866
+ await this.refreshSkills();
1867
+ this.append([
1868
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// SKILL REMOVED → ${removed?.name ?? target.name}`)}`,
1869
+ ]);
1870
+ return;
1871
+ }
1872
+ this.append([
1873
+ ` ${tone.mist("// usage: /skills [list] · /skills add <path|url> · /skills add (paste) · /skills remove <name>")}`,
1874
+ ]);
1875
+ }
1876
+ printSkills() {
1877
+ const lines = [];
1878
+ if (this.skills.length === 0) {
1879
+ lines.push(` ${tone.mist("// NO SKILLS INSTALLED")}`);
1880
+ }
1881
+ else {
1882
+ lines.push(` ${tone.mist(`// SKILLS (${this.skills.length})`)}`);
1883
+ for (const skill of this.skills) {
1884
+ lines.push(` ${tone.bone(skill.name)}${skill.description ? ` ${tone.mist(skill.description)}` : ""}`);
1885
+ lines.push(` ${tone.silver(shortenHome(skill.path))}`);
1886
+ }
1887
+ }
1888
+ lines.push("");
1889
+ lines.push(` ${tone.mist("// storage")} ${tone.silver(shortenHome(path.join(skillsRoot(), "<name>", "SKILL.md")))}`);
1890
+ lines.push(` ${tone.mist("// add: /skills add <path|url> · /skills add (paste markdown) · /skills remove <name>")}`);
1891
+ this.append(lines);
1892
+ }
1893
+ async addSkillFromPath(sourcePath) {
1894
+ try {
1895
+ const skill = await importSkillFile(sourcePath);
1896
+ await this.refreshSkills();
1897
+ this.append([
1898
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// SKILL ADDED → ${skill.name}`)}`,
1899
+ ` ${tone.silver(shortenHome(skill.path))}`,
1900
+ ]);
1901
+ }
1902
+ catch (err) {
1903
+ this.append([` ${tone.red(`// could not import: ${errorMessage(err)}`)}`]);
1904
+ }
1905
+ }
1906
+ async addSkillFromUrl(url) {
1907
+ this.setStatus({ label: "DOWNLOADING SKILL" });
1908
+ try {
1909
+ const skill = await fetchSkillFromUrl(url);
1910
+ await this.refreshSkills();
1911
+ this.append([
1912
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// SKILL ADDED → ${skill.name}`)}`,
1913
+ ` ${tone.silver(shortenHome(skill.path))}`,
1914
+ ]);
1915
+ }
1916
+ catch (err) {
1917
+ this.append([` ${tone.red(`// download failed: ${errorMessage(err)}`)}`]);
1918
+ }
1919
+ finally {
1920
+ this.setStatus(null);
1921
+ }
1922
+ }
1923
+ async addSkillPasted() {
1924
+ this.append([
1925
+ ` ${tone.mist("// paste SKILL.md content below · enter to save · esc to cancel")}`,
1926
+ ]);
1927
+ const result = await this.readLine({
1928
+ prompt: ` ${tone.bone("SKILL")} ${tone.mist(glyphs.prompt)} `,
1929
+ placeholder: "paste markdown, then press enter",
1930
+ });
1931
+ if (result.kind !== "submit") {
1932
+ this.append([` ${tone.mist("// cancelled")}`]);
1933
+ return;
1934
+ }
1935
+ const content = result.text.trim();
1936
+ if (!content) {
1937
+ this.append([` ${tone.mist("// cancelled")}`]);
1938
+ return;
1939
+ }
1940
+ let name = parseSkill(content, "").name;
1941
+ if (!name) {
1942
+ const nameResult = await this.readLine({
1943
+ prompt: ` ${tone.bone("SKILL NAME")} ${tone.mist(glyphs.prompt)} `,
1944
+ placeholder: "e.g. code-review",
1945
+ });
1946
+ if (nameResult.kind !== "submit" || !nameResult.text.trim()) {
1947
+ this.append([` ${tone.mist("// cancelled")}`]);
1948
+ return;
1949
+ }
1950
+ name = nameResult.text.trim();
1951
+ }
1952
+ const skill = await saveSkill(name, content);
1953
+ await this.refreshSkills();
1954
+ this.append([
1955
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// SKILL ADDED → ${skill.name}`)}`,
1956
+ ` ${tone.silver(shortenHome(skill.path))}`,
1957
+ ]);
1958
+ }
1959
+ async refreshSkills() {
1960
+ this.skills = await listSkills().catch(() => []);
1961
+ }
1962
+ async mcpCommand(arg) {
1963
+ const parts = arg.split(/\s+/).filter(Boolean);
1964
+ const sub = (parts[0] ?? "").toLowerCase();
1965
+ const rest = parts.slice(1).join(" ").trim();
1966
+ if (isMcpUrl(arg)) {
1967
+ await this.addMcpServer(arg.trim());
1968
+ return;
1969
+ }
1970
+ if (!sub || sub === "list" || sub === "ls" || sub === "status") {
1971
+ this.printMcp();
1972
+ return;
1973
+ }
1974
+ if (sub === "add") {
1975
+ await this.addMcpServer(rest);
1976
+ return;
1977
+ }
1978
+ if (sub === "remove" || sub === "rm" || sub === "delete") {
1979
+ await this.removeMcpServer(rest);
1980
+ return;
1981
+ }
1982
+ if (sub === "enable" || sub === "disable") {
1983
+ await this.toggleMcpServer(rest, sub === "enable");
1984
+ return;
1985
+ }
1986
+ if (sub === "reconnect" || sub === "reload" || sub === "restart") {
1987
+ await this.reconnectMcp(rest);
1988
+ return;
1989
+ }
1990
+ if (sub === "tools") {
1991
+ this.printMcpTools(rest);
1992
+ return;
1993
+ }
1994
+ this.append([` ${tone.mist(`// usage: ${MCP_USAGE}`)}`]);
1995
+ }
1996
+ printMcp() {
1997
+ const statuses = this.mcp.statuses();
1998
+ const lines = [];
1999
+ if (statuses.length === 0) {
2000
+ lines.push(` ${tone.mist("// NO MCP SERVERS CONFIGURED")}`);
2001
+ }
2002
+ else {
2003
+ lines.push(` ${tone.mist(`// MCP SERVERS (${statuses.length})`)}`);
2004
+ for (const status of statuses) {
2005
+ const badge = status.state === "connected"
2006
+ ? tone.accent(glyphs.check)
2007
+ : status.state === "error"
2008
+ ? tone.red(glyphs.cross)
2009
+ : tone.mist(glyphs.bullet);
2010
+ const state = status.state === "connected"
2011
+ ? tone.mist(`${status.tools.length} tool${status.tools.length === 1 ? "" : "s"}${status.serverName ? ` · ${status.serverName}${status.serverVersion ? ` ${status.serverVersion}` : ""}` : ""}`)
2012
+ : status.state === "error"
2013
+ ? tone.red(clipAnsi(status.error ?? "connection failed", this.contentWidth() - 30))
2014
+ : tone.mist("disabled");
2015
+ lines.push(` ${badge} ${padEndTo(tone.bone(status.name), 20)}${state}`);
2016
+ lines.push(` ${tone.silver(status.kind)} ${tone.mist(clipAnsi(status.target, Math.max(20, this.contentWidth() - 16)))}`);
2017
+ }
2018
+ }
2019
+ lines.push("");
2020
+ lines.push(` ${tone.mist("// config")} ${tone.silver(shortenHome(configFile()))}`);
2021
+ lines.push(` ${tone.mist(`// ${MCP_USAGE}`)}`);
2022
+ this.append(lines);
2023
+ }
2024
+ async addMcpServer(rest) {
2025
+ let spec = rest;
2026
+ if (!spec) {
2027
+ const nameResult = await this.readLine({
2028
+ prompt: ` ${tone.bone("MCP NAME")} ${tone.mist(glyphs.prompt)} `,
2029
+ placeholder: "e.g. filesystem — or paste a service url",
2030
+ });
2031
+ if (nameResult.kind !== "submit" || !nameResult.text.trim()) {
2032
+ this.append([` ${tone.mist("// cancelled")}`]);
2033
+ return;
2034
+ }
2035
+ const answer = nameResult.text.trim();
2036
+ if (isMcpUrl(answer)) {
2037
+ spec = answer;
2038
+ }
2039
+ else {
2040
+ const targetResult = await this.readLine({
2041
+ prompt: ` ${tone.bone("MCP SERVER")} ${tone.mist(glyphs.prompt)} `,
2042
+ placeholder: "npx -y @modelcontextprotocol/server-filesystem . · https://host/mcp",
2043
+ });
2044
+ if (targetResult.kind !== "submit" || !targetResult.text.trim()) {
2045
+ this.append([` ${tone.mist("// cancelled")}`]);
2046
+ return;
2047
+ }
2048
+ spec = `${answer} ${targetResult.text.trim()}`;
2049
+ }
2050
+ }
2051
+ const parsed = parseMcpAdd(spec);
2052
+ if (!parsed.ok) {
2053
+ this.append([
2054
+ ` ${tone.red(`// ${parsed.error}`)}`,
2055
+ ` ${tone.mist(`// usage: ${MCP_USAGE}`)}`,
2056
+ ]);
2057
+ return;
2058
+ }
2059
+ if (this.config.mcp?.[parsed.name]) {
2060
+ const confirmed = await this.confirm(`replace MCP server "${parsed.name}"?`);
2061
+ if (!confirmed) {
2062
+ this.append([` ${tone.mist("// cancelled")}`]);
2063
+ return;
2064
+ }
2065
+ }
2066
+ this.config.mcp = { ...(this.config.mcp ?? {}), [parsed.name]: parsed.config };
2067
+ await saveConfig({ ...this.config }).catch(() => undefined);
2068
+ this.setStatus({ label: `MCP · CONNECTING ${parsed.name.toUpperCase()}` });
2069
+ const status = await this.mcp.connect(parsed.name, parsed.config);
2070
+ this.setStatus(null);
2071
+ this.rebuildTools();
2072
+ this.reportMcpStatus(status);
2073
+ }
2074
+ async removeMcpServer(rest) {
2075
+ const name = rest.split(/\s+/).filter(Boolean)[0];
2076
+ if (!name) {
2077
+ this.append([` ${tone.mist("// usage: /mcp remove <name>")}`]);
2078
+ return;
2079
+ }
2080
+ const configs = this.config.mcp ?? {};
2081
+ if (!(name in configs)) {
2082
+ this.append([` ${tone.red(`// no MCP server named ${name}`)}`]);
2083
+ return;
2084
+ }
2085
+ const confirmed = await this.confirm(`remove MCP server "${name}"?`);
2086
+ if (!confirmed) {
2087
+ this.append([` ${tone.mist("// cancelled")}`]);
2088
+ return;
2089
+ }
2090
+ await this.mcp.remove(name);
2091
+ const next = { ...configs };
2092
+ delete next[name];
2093
+ this.config.mcp = next;
2094
+ await saveConfig({ ...this.config }).catch(() => undefined);
2095
+ this.rebuildTools();
2096
+ this.append([
2097
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// MCP REMOVED → ${name}`)}`,
2098
+ ]);
2099
+ }
2100
+ async toggleMcpServer(rest, enabled) {
2101
+ const name = rest.split(/\s+/).filter(Boolean)[0];
2102
+ if (!name) {
2103
+ this.append([` ${tone.mist(`// usage: /mcp ${enabled ? "enable" : "disable"} <name>`)}`]);
2104
+ return;
2105
+ }
2106
+ const configs = this.config.mcp ?? {};
2107
+ const config = configs[name];
2108
+ if (!config) {
2109
+ this.append([` ${tone.red(`// no MCP server named ${name}`)}`]);
2110
+ return;
2111
+ }
2112
+ const wasEnabled = config.enabled !== false;
2113
+ if (wasEnabled === enabled) {
2114
+ this.append([` ${tone.mist(`// MCP ${name} IS ALREADY ${enabled ? "ENABLED" : "DISABLED"}`)}`]);
2115
+ return;
2116
+ }
2117
+ const updated = { ...config, enabled };
2118
+ this.config.mcp = { ...configs, [name]: updated };
2119
+ await saveConfig({ ...this.config }).catch(() => undefined);
2120
+ if (enabled)
2121
+ this.setStatus({ label: `MCP · CONNECTING ${name.toUpperCase()}` });
2122
+ const status = await this.mcp.connect(name, updated);
2123
+ this.setStatus(null);
2124
+ this.rebuildTools();
2125
+ this.reportMcpStatus(status);
2126
+ }
2127
+ async reconnectMcp(rest) {
2128
+ const name = rest.split(/\s+/).filter(Boolean)[0];
2129
+ const configs = this.config.mcp ?? {};
2130
+ if (name) {
2131
+ const config = configs[name];
2132
+ if (!config) {
2133
+ this.append([` ${tone.red(`// no MCP server named ${name}`)}`]);
2134
+ return;
2135
+ }
2136
+ this.setStatus({ label: `MCP · RECONNECTING ${name.toUpperCase()}` });
2137
+ const status = await this.mcp.connect(name, config);
2138
+ this.setStatus(null);
2139
+ this.rebuildTools();
2140
+ this.reportMcpStatus(status);
2141
+ return;
2142
+ }
2143
+ const names = Object.keys(configs);
2144
+ if (names.length === 0) {
2145
+ this.append([` ${tone.mist("// NO MCP SERVERS CONFIGURED")}`]);
2146
+ return;
2147
+ }
2148
+ this.setStatus({ label: "MCP · RECONNECTING" });
2149
+ const statuses = await this.mcp.connectAll(configs);
2150
+ this.setStatus(null);
2151
+ this.rebuildTools();
2152
+ const failed = statuses.filter((status) => status.state === "error").length;
2153
+ this.append([
2154
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// MCP RECONNECTED · ${statuses.length - failed}/${statuses.length} connected`)}`,
2155
+ ]);
2156
+ this.printMcp();
2157
+ }
2158
+ printMcpTools(rest) {
2159
+ const name = rest.split(/\s+/).filter(Boolean)[0];
2160
+ if (name && !this.mcp.has(name)) {
2161
+ this.append([` ${tone.red(`// no MCP server named ${name}`)}`]);
2162
+ return;
2163
+ }
2164
+ const statuses = name ? [this.mcp.status(name)] : this.mcp.statuses();
2165
+ const lines = [];
2166
+ let printed = false;
2167
+ for (const status of statuses) {
2168
+ if (status.tools.length === 0)
2169
+ continue;
2170
+ printed = true;
2171
+ lines.push(` ${tone.mist(`// ${status.name} (${status.tools.length})`)}`);
2172
+ for (const tool of status.tools) {
2173
+ const hint = tool.readOnly ? tone.mist(" · read-only") : "";
2174
+ const descWidth = Math.max(20, this.contentWidth() - 48);
2175
+ lines.push(` ${padEndTo(tone.bone(tool.name), 42)}${tone.silver(clipAnsi(tool.description, descWidth))}${hint}`);
2176
+ }
2177
+ }
2178
+ if (!printed)
2179
+ lines.push(` ${tone.mist("// NO MCP TOOLS")}`);
2180
+ this.append(lines);
2181
+ }
2182
+ reportMcpStatus(status) {
2183
+ if (status.state === "connected") {
2184
+ const version = status.serverName
2185
+ ? ` · ${status.serverName}${status.serverVersion ? ` ${status.serverVersion}` : ""}`
2186
+ : "";
2187
+ const lines = [
2188
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// MCP → ${status.name} CONNECTED${version} · ${status.tools.length} tool${status.tools.length === 1 ? "" : "s"}`)}`,
2189
+ ];
2190
+ if (status.tools.length > 0) {
2191
+ const names = status.tools.map((tool) => tool.name).join(", ");
2192
+ lines.push(` ${tone.silver(clipAnsi(names, this.contentWidth() - 6))}`);
2193
+ }
2194
+ this.append(lines);
2195
+ return;
2196
+ }
2197
+ if (status.state === "disabled") {
2198
+ this.append([` ${tone.mist(`// MCP → ${status.name} DISABLED`)}`]);
2199
+ return;
2200
+ }
2201
+ this.append([
2202
+ ` ${tone.red(`// MCP → ${status.name} FAILED: ${clipAnsi(status.error ?? "connection failed", Math.max(20, this.contentWidth() - 24))}`)}`,
2203
+ ]);
2204
+ }
2205
+ async confirm(question) {
2206
+ this.permissionOptions = [];
2207
+ this.permissionOptionLine = null;
2208
+ this.append([` ${tone.amber(question)}`]);
2209
+ this.permissionHint = ` ${tone.bone("Y")} ${tone.mist("yes")} ${tone.bone("N")} ${tone.mist("no")}`;
2210
+ this.mode = "permission";
2211
+ this.scheduleRender();
2212
+ const choice = await new Promise((resolve) => {
2213
+ this.resolveChoice = resolve;
2214
+ });
2215
+ this.mode = "idle";
2216
+ this.scheduleRender();
2217
+ return choice === "allow" || choice === "always";
2218
+ }
2219
+ async changeDir(arg) {
2220
+ if (!arg) {
2221
+ this.append([` ${tone.mist(`// DIR ${this.dir}`)}`]);
2222
+ return;
2223
+ }
2224
+ const next = path.resolve(this.dir, arg);
2225
+ if (next === this.dir) {
2226
+ this.append([` ${tone.mist("// already in this directory")}`]);
2227
+ return;
2228
+ }
2229
+ this.dir = next;
2230
+ this.rebuildTools();
2231
+ this.session = new Session("");
2232
+ try {
2233
+ const meta = await this.loadProject();
2234
+ this.session = new Session(this.system);
2235
+ this.append([
2236
+ ` ${tone.mist(`// DIR → ${shortenHome(this.dir)}`)}`,
2237
+ ` ${tone.mist("// PROJECT")} ${meta}`,
2238
+ ]);
2239
+ }
2240
+ catch (err) {
2241
+ this.append([` ${tone.red(`// failed to scan project: ${errorMessage(err)}`)}`]);
2242
+ }
2243
+ }
2244
+ async printContext() {
2245
+ if (!this.summary) {
2246
+ try {
2247
+ this.summary = await scanProject(this.dir);
2248
+ }
2249
+ catch (err) {
2250
+ this.append([` ${tone.red(`// ${errorMessage(err)}`)}`]);
2251
+ return;
2252
+ }
2253
+ }
2254
+ const summary = this.summary;
2255
+ const rows = [
2256
+ ["name", summary.name],
2257
+ ["stack", summary.frameworks.join(", ") || "—"],
2258
+ [
2259
+ "languages",
2260
+ summary.languages
2261
+ .slice(0, 4)
2262
+ .map((l) => `${l.ext} (${l.count})`)
2263
+ .join(", ") || "—",
2264
+ ],
2265
+ ["files", `${summary.fileCount} (${summary.sourceFileCount} source)`],
2266
+ ["package manager", summary.packageManager ?? "—"],
2267
+ ["dependencies", `${summary.dependencyCount} + ${summary.devDependencyCount} dev`],
2268
+ ];
2269
+ const lines = [` ${tone.mist("// CONTEXT")}`];
2270
+ for (const [key, value] of rows) {
2271
+ lines.push(` ${padEndTo(tone.bone(key), 18)}${tone.silver(clipAnsi(value, this.contentWidth() - 24))}`);
2272
+ }
2273
+ if (summary.keyFiles.length > 0) {
2274
+ lines.push(` ${padEndTo(tone.bone("key files"), 18)}`);
2275
+ for (const file of summary.keyFiles.slice(0, 6)) {
2276
+ lines.push(` ${tone.silver(file.path)} ${tone.mist(`(${file.kind})`)}`);
2277
+ }
2278
+ }
2279
+ this.append(lines);
2280
+ }
2281
+ printUsage() {
2282
+ const cost = estimateCost(this.totalUsage, this.model, this.providerId);
2283
+ const rows = [
2284
+ ["turns", String(this.turns)],
2285
+ [
2286
+ "tokens",
2287
+ `${fmtTokens(this.totalUsage.inputTokens)}↑ ${fmtTokens(this.totalUsage.outputTokens)}↓`,
2288
+ ],
2289
+ ["cost", fmtCost(cost)],
2290
+ ["elapsed", fmtDuration(performance.now() - this.startedAt)],
2291
+ ["model", `${this.providerId} · ${this.model}`],
2292
+ ["effort", this.activeEffort() ?? "default"],
2293
+ ];
2294
+ const lines = [` ${tone.mist("// SESSION USAGE")}`];
2295
+ for (const [key, value] of rows) {
2296
+ lines.push(` ${padEndTo(tone.bone(key), 12)}${tone.silver(value)}`);
2297
+ }
2298
+ this.append(lines);
2299
+ }
2300
+ async timelineCommand() {
2301
+ if (this.userMarkers.length === 0) {
2302
+ this.append([` ${tone.mist("// NO MESSAGES YET")}`]);
2303
+ return;
2304
+ }
2305
+ const width = Math.max(20, this.contentWidth() - 40);
2306
+ const total = this.userMarkers.length;
2307
+ const entries = this.userMarkers.map((marker, index) => ({
2308
+ kind: "item",
2309
+ value: String(index),
2310
+ label: clipAnsi(marker.text.replace(/\s+/g, " ").trim() || "(empty)", width),
2311
+ hint: `${index + 1}/${total}`,
2312
+ }));
2313
+ const chosen = await this.openPicker(new Picker({ title: "timeline · enter jumps to the message", entries, filterable: true }));
2314
+ if (!chosen)
2315
+ return;
2316
+ const marker = this.userMarkers[Number.parseInt(chosen, 10)];
2317
+ if (!marker)
2318
+ return;
2319
+ this.focusTranscriptLine(marker.line);
2320
+ }
2321
+ focusTranscriptLine(line) {
2322
+ const total = this.transcript.length;
2323
+ const viewport = Math.max(3, this.lastViewport);
2324
+ this.scrollOffset = Math.max(0, total - line - viewport);
2325
+ this.scheduleRender();
2326
+ }
2327
+ printHelp() {
2328
+ const keys = [
2329
+ ["enter", "send"],
2330
+ ["ctrl+enter", "newline (shift+enter, ctrl+space, ctrl+j, \\ + enter too)"],
2331
+ ["ctrl+v", "paste text or an image"],
2332
+ ["mouse drag", "select text · ctrl+c copies"],
2333
+ ["permission card", "← → select · enter confirm · mouse click"],
2334
+ ["sessions picker", "ctrl+r rename · ctrl+p pin · ctrl+d delete"],
2335
+ ["↑ ↓", "input history — previous / next message"],
2336
+ ["wheel", "scroll the conversation"],
2337
+ ["ctrl+p/ctrl+n", "input history (same as ↑ ↓)"],
2338
+ ["tab", "plan/build mode · complete commands"],
2339
+ ["ctrl+z", "undo the last message (works while thinking)"],
2340
+ ["esc", "interrupt a run"],
2341
+ ["ctrl+c", "clear input / exit"],
2342
+ ];
2343
+ const lines = [` ${tone.mist("// COMMANDS")}`];
2344
+ for (const command of COMMAND_INFO) {
2345
+ const name = `${command.name}${command.args ? ` ${command.args}` : ""}`;
2346
+ lines.push(` ${padEndTo(tone.bone(name), 20)}${tone.silver(command.desc)}`);
2347
+ }
2348
+ lines.push("", ` ${tone.mist("// KEYS")}`);
2349
+ for (const [name, desc] of keys) {
2350
+ lines.push(` ${padEndTo(tone.bone(name), 18)}${tone.silver(desc)}`);
2351
+ }
2352
+ this.append(lines);
2353
+ }
2354
+ userBlockLines(input, imageCount = 0) {
2355
+ const lines = input.split("\n");
2356
+ const head = ` ${tone.accent(glyphs.prompt)} ${bold(tone.bone(lines[0] ?? ""))}`;
2357
+ const rest = lines.slice(1).map((line) => ` ${tone.bone(line)}`);
2358
+ const images = imageCount > 0
2359
+ ? [` ${tone.mist(`[${imageCount} image${imageCount === 1 ? "" : "s"} attached]`)}`]
2360
+ : [];
2361
+ return [head, ...rest, ...images];
2362
+ }
2363
+ async saveTurn(input, result, durationMs) {
2364
+ const cost = estimateCost(result.usage, this.model, this.providerId);
2365
+ const record = {
2366
+ id: makeSessionId(),
2367
+ createdAt: new Date().toISOString(),
2368
+ cwd: this.dir,
2369
+ task: input,
2370
+ model: this.model,
2371
+ status: result.status,
2372
+ steps: result.steps,
2373
+ usage: result.usage,
2374
+ costCents: cost?.totalCents,
2375
+ durationMs,
2376
+ text: result.text,
2377
+ error: result.error,
2378
+ events: result.events
2379
+ .filter((event) => event.type !== "text.delta")
2380
+ .map((event) => event.type === "tool.result" && event.images ? { ...event, images: undefined } : event),
2381
+ conversationId: this.conversationId,
2382
+ messages: messagesForStorage(result.session.getMessages()),
2383
+ };
2384
+ try {
2385
+ await saveSession(record);
2386
+ }
2387
+ catch (err) {
2388
+ this.append([` ${tone.mist(`// could not save session: ${errorMessage(err)}`)}`]);
2389
+ }
2390
+ return record.id;
2391
+ }
2392
+ printGoodbye() {
2393
+ this.append(["", ` ${tone.mist(`// SESSION CLOSED · ${this.summaryLine()}`)}`]);
2394
+ }
2395
+ summaryLine() {
2396
+ const cost = estimateCost(this.totalUsage, this.model, this.providerId);
2397
+ const parts = [
2398
+ `${this.turns} turn${this.turns === 1 ? "" : "s"}`,
2399
+ `${fmtTokens(this.totalUsage.inputTokens)}↑ ${fmtTokens(this.totalUsage.outputTokens)}↓`,
2400
+ fmtCost(cost),
2401
+ ];
2402
+ return `quatcode: session closed · ${parts.join(" · ")}`;
2403
+ }
2404
+ }
2405
+ function box(title, body, inner, borderHex) {
2406
+ const b = glyphs.box;
2407
+ const label = ` ${title} `;
2408
+ const fill = Math.max(0, inner - 1 - stringWidth(label));
2409
+ const lines = [
2410
+ ` ${fg(borderHex, b.tl + b.h)}${tone.mist(label)}${fg(borderHex, b.h.repeat(fill))}${fg(borderHex, b.tr)}`,
2411
+ ];
2412
+ for (const row of body) {
2413
+ const content = padEndTo(clipAnsi(row, inner - 2), inner - 2);
2414
+ lines.push(` ${fg(borderHex, b.v)} ${content} ${fg(borderHex, b.v)}`);
2415
+ }
2416
+ lines.push(` ${fg(borderHex, b.bl + b.h.repeat(inner) + b.br)}`);
2417
+ return lines;
2418
+ }
2419
+ function permissionSummary(req) {
2420
+ if (req.input === null || req.input === undefined)
2421
+ return "";
2422
+ if (typeof req.input !== "object")
2423
+ return String(req.input);
2424
+ const record = req.input;
2425
+ const candidate = record.command ?? record.path ?? record.pattern ?? record.url ?? record.query ?? "";
2426
+ if (typeof candidate === "string") {
2427
+ return candidate.replace(/\s+/g, " ").slice(0, 400);
2428
+ }
2429
+ try {
2430
+ return JSON.stringify(req.input).slice(0, 400);
2431
+ }
2432
+ catch {
2433
+ return "";
2434
+ }
2435
+ }
2436
+ function shortenHome(dir) {
2437
+ const home = homedir();
2438
+ return dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
2439
+ }
2440
+ function errorMessage(err) {
2441
+ return err instanceof Error ? err.message : String(err);
2442
+ }
2443
+ function longestCommonPrefix(items) {
2444
+ if (items.length === 0)
2445
+ return "";
2446
+ let prefix = items[0];
2447
+ for (const item of items.slice(1)) {
2448
+ while (!item.startsWith(prefix)) {
2449
+ prefix = prefix.slice(0, -1);
2450
+ if (prefix === "")
2451
+ return "";
2452
+ }
2453
+ }
2454
+ return prefix;
2455
+ }
2456
+ //# sourceMappingURL=tui.js.map