@quatcode/agent 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js CHANGED
@@ -1,17 +1,18 @@
1
1
  import path from "node:path";
2
2
  import { homedir } from "node:os";
3
- import { Session, ToolRegistry, addUsage, createProviderFromEnv, emptyUsage, estimateCost, runAgent, } from "@quatcode/core";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { Session, ToolRegistry, addUsage, emptyUsage, estimateCost, runAgent, } from "@quatcode/core";
4
5
  import { analyzeCommand, createProjectTools, McpManager } from "@quatcode/tools";
5
6
  import { buildProjectContext, createBrainTools, scanProject } from "@quatcode/brain";
6
7
  import { Keyboard } from "./keyboard.js";
7
8
  import { LineBuffer, defaultPrompt } from "./input.js";
8
9
  import { Picker } from "./picker.js";
9
10
  import { Screen } from "./screen.js";
10
- import { TurnController, fmtCost, fmtDuration, fmtTokens } from "./turn.js";
11
+ import { TurnController, fmtCost, fmtDuration, fmtTokens, toolArg, toolResultBlock } from "./turn.js";
11
12
  import { renderBanner } from "./logo.js";
12
13
  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";
14
+ import { ADD_PROVIDER, allProviders, buildProvider, defaultModelFor, effortsForModel, hasKey, keyEnvFor, probeProviderModels, providerEnvKey, providerHasModel, providerMeta, providerSlug, registerCustomProvider, removeCustomProvider, resolveProviderForModel, } from "./models.js";
15
+ import { activeThemeId, bg, bold, buildColorRemap, clipAnsi, fg, getThemes, glyphs, padEndTo, palette, remapColors, setTheme, stringWidth, stripAnsi, tone, toolGlyph, } from "./theme.js";
15
16
  import { listSessionsForDir, loadConfig, makeSessionId, saveConfig, saveSession, deleteSession } from "./store.js";
16
17
  import { configFile, conversationKey, deleteConversation, displayTitle, latestPerConversation, messagesForStorage, sortConversations, updateConversation, } from "./store.js";
17
18
  import { MCP_USAGE, isMcpUrl, mcpInstructionsSection, parseMcpAdd } from "./mcp.js";
@@ -22,6 +23,7 @@ const COMMAND_INFO = [
22
23
  { name: "/help", desc: "show this help" },
23
24
  { name: "/new", desc: "new conversation (clears the screen)" },
24
25
  { name: "/model", args: "[id]", desc: "pick or set the model" },
26
+ { name: "/provider", args: "[add|list|remove]", desc: "connect a provider by API key" },
25
27
  { name: "/effort", args: "[level]", desc: "reasoning effort (minimal…high)" },
26
28
  { name: "/key", desc: "enter the API key for the current provider" },
27
29
  { name: "/dir", args: "[path]", desc: "change the project directory" },
@@ -48,6 +50,7 @@ const READ_ONLY_TOOLS = new Set([
48
50
  "screenshot",
49
51
  "list_servers",
50
52
  ]);
53
+ const PLAN_ALLOWED_TOOLS = new Set([...READ_ONLY_TOOLS, "run_command"]);
51
54
  export async function runTui(options) {
52
55
  const input = options.io?.input ?? process.stdin;
53
56
  const output = options.io?.output ?? process.stdout;
@@ -80,6 +83,7 @@ class TuiApp {
80
83
  gateMode;
81
84
  verbose = false;
82
85
  conversationId = makeSessionId();
86
+ providerSessionId = makeSessionId();
83
87
  totalUsage = emptyUsage();
84
88
  turns = 0;
85
89
  transcript = [];
@@ -123,11 +127,14 @@ class TuiApp {
123
127
  lastTurn = null;
124
128
  turnUndo = null;
125
129
  pendingInitial = null;
126
- pendingImages = [];
130
+ pendingImages = new Map();
127
131
  userMarkers = [];
128
132
  messageCursor = -1;
129
133
  selection = null;
130
134
  frameLines = [];
135
+ modeBadgeStart = -1;
136
+ modeBadgeWidth = 0;
137
+ modeBadgeHover = false;
131
138
  constructor(options, input, output) {
132
139
  this.opts = options;
133
140
  this.dir = path.resolve(options.dir);
@@ -140,6 +147,11 @@ class TuiApp {
140
147
  this.kb.start();
141
148
  this.screen.enter();
142
149
  this.kb.setHandler(this.onKeys);
150
+ this.kb.onSizeReport = (row, col) => {
151
+ if (this.screen.applySizeReport(row, col))
152
+ this.scheduleRender();
153
+ };
154
+ this.screen.requestSize();
143
155
  const cleanup = () => {
144
156
  try {
145
157
  this.stopSpinner();
@@ -164,9 +176,19 @@ class TuiApp {
164
176
  if (value && process.env[name] === undefined)
165
177
  process.env[name] = value;
166
178
  }
179
+ for (const [id, custom] of Object.entries(this.config.customProviders ?? {})) {
180
+ registerCustomProvider({
181
+ id,
182
+ label: custom.label,
183
+ envKey: custom.envKey,
184
+ kind: "openai",
185
+ baseUrl: custom.baseUrl,
186
+ defaultModel: custom.models[0]?.id ?? "",
187
+ models: custom.models,
188
+ });
189
+ }
167
190
  this.effort = this.opts.effort ?? this.config.effort ?? null;
168
191
  setTheme(this.config.theme ?? "quatcode");
169
- this.screen.applyBackground();
170
192
  let configMeta = null;
171
193
  try {
172
194
  configMeta = await this.loadConfiguration();
@@ -216,10 +238,12 @@ class TuiApp {
216
238
  this.append([` ${tone.mist("// cancelled")}`]);
217
239
  continue;
218
240
  }
219
- const input = result.text;
220
- this.buffer.addHistory(input);
221
- const images = this.pendingImages;
222
- this.pendingImages = [];
241
+ const extracted = extractInputImages(result.text, this.pendingImages);
242
+ this.pendingImages = new Map();
243
+ const input = extracted.text;
244
+ const images = extracted.images;
245
+ if (input)
246
+ this.buffer.addHistory(input);
223
247
  const isCommand = input.startsWith("/");
224
248
  if (!isCommand)
225
249
  this.clearSplash();
@@ -305,7 +329,14 @@ class TuiApp {
305
329
  onKeys = (keys) => {
306
330
  for (const key of keys) {
307
331
  if (key.type === "resize") {
332
+ this.screen.requestSize();
308
333
  this.scheduleRender();
334
+ const settle = setTimeout(() => {
335
+ this.screen.requestSize();
336
+ this.scheduleRender();
337
+ }, 150);
338
+ if (typeof settle.unref === "function")
339
+ settle.unref();
309
340
  continue;
310
341
  }
311
342
  if (key.type === "mouse") {
@@ -458,7 +489,8 @@ class TuiApp {
458
489
  this.buffer.killToEnd();
459
490
  break;
460
491
  case "w":
461
- this.buffer.deleteWordBefore();
492
+ if (!this.deleteImageTokenBefore())
493
+ this.buffer.deleteWordBefore();
462
494
  break;
463
495
  case "p":
464
496
  this.buffer.historyPrev();
@@ -478,8 +510,14 @@ class TuiApp {
478
510
  this.buffer.moveWord(-1);
479
511
  else if (key.ch === "f" || key.ch === "F")
480
512
  this.buffer.moveWord(1);
481
- else if (key.ch === "d" || key.ch === "D")
482
- this.buffer.deleteWordAfter();
513
+ else if (key.ch === "d" || key.ch === "D") {
514
+ if (!this.deleteImageTokenAfter())
515
+ this.buffer.deleteWordAfter();
516
+ }
517
+ else if (key.ch === "v" || key.ch === "V") {
518
+ void this.pasteFromClipboard();
519
+ return;
520
+ }
483
521
  break;
484
522
  case "nav":
485
523
  if ((key.dir === "up" || key.dir === "down") &&
@@ -555,10 +593,12 @@ class TuiApp {
555
593
  this.toggleMode();
556
594
  break;
557
595
  case "backspace":
558
- this.buffer.backspace();
596
+ if (!this.deleteImageTokenBefore())
597
+ this.buffer.backspace();
559
598
  break;
560
599
  case "delete":
561
- this.buffer.deleteForward();
600
+ if (!this.deleteImageTokenAfter())
601
+ this.buffer.deleteForward();
562
602
  break;
563
603
  case "escape":
564
604
  this.finishLine({ kind: "cancel" });
@@ -655,7 +695,26 @@ class TuiApp {
655
695
  }
656
696
  return lines;
657
697
  }
698
+ badgeHit(col) {
699
+ if (this.modeBadgeStart < 0 || this.modeBadgeWidth <= 0)
700
+ return false;
701
+ const start = this.padX + this.modeBadgeStart;
702
+ return col >= start && col < start + this.modeBadgeWidth;
703
+ }
658
704
  handleHover(row, col) {
705
+ if (row === 1) {
706
+ const hover = this.badgeHit(col);
707
+ if (hover !== this.modeBadgeHover) {
708
+ this.modeBadgeHover = hover;
709
+ this.scheduleRender();
710
+ }
711
+ if (hover)
712
+ return;
713
+ }
714
+ else if (this.modeBadgeHover) {
715
+ this.modeBadgeHover = false;
716
+ this.scheduleRender();
717
+ }
659
718
  if (this.mode === "permission") {
660
719
  const index = this.permissionOptionAt(row, col);
661
720
  if (index !== null && index !== this.permissionSelected) {
@@ -682,6 +741,12 @@ class TuiApp {
682
741
  }
683
742
  }
684
743
  handleClick(row, col) {
744
+ if (row === 1 &&
745
+ (this.mode === "editing" || this.mode === "idle") &&
746
+ this.badgeHit(col)) {
747
+ this.toggleMode();
748
+ return;
749
+ }
685
750
  if (this.mode === "permission") {
686
751
  const index = this.permissionOptionAt(row, col);
687
752
  if (index !== null) {
@@ -745,21 +810,44 @@ class TuiApp {
745
810
  async pasteFromClipboard() {
746
811
  const image = readClipboardImage();
747
812
  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
- ]);
813
+ this.attachImageMarker(image);
753
814
  return;
754
815
  }
755
816
  const text = readClipboardText();
756
817
  if (text && text.length > 0) {
818
+ const fromPath = await imageFromPath(text);
819
+ if (fromPath) {
820
+ this.attachImageMarker(fromPath);
821
+ return;
822
+ }
757
823
  this.buffer.insert(text.replace(/\r\n?/g, "\n"));
758
824
  this.scheduleRender();
759
825
  return;
760
826
  }
761
827
  this.append([` ${tone.mist("// clipboard is empty or unavailable")}`]);
762
828
  }
829
+ deleteImageTokenBefore() {
830
+ const length = imageTokenBefore(this.buffer.cursorLineBefore());
831
+ if (length <= 0)
832
+ return false;
833
+ this.buffer.deleteBefore(length);
834
+ return true;
835
+ }
836
+ deleteImageTokenAfter() {
837
+ const length = imageTokenAfter(this.buffer.cursorLineAfter());
838
+ if (length <= 0)
839
+ return false;
840
+ this.buffer.deleteAfter(length);
841
+ return true;
842
+ }
843
+ attachImageMarker(image) {
844
+ const index = nextImageIndex(this.buffer.text());
845
+ this.pendingImages.set(index, image);
846
+ const current = this.buffer.text();
847
+ const needsSpace = current.length > 0 && !/\s$/.test(current);
848
+ this.buffer.insert(`${needsSpace ? " " : ""}[Image ${index}] `);
849
+ this.scheduleRender();
850
+ }
763
851
  copySelection() {
764
852
  const text = this.selectionText();
765
853
  this.selection = null;
@@ -948,52 +1036,58 @@ class TuiApp {
948
1036
  const modelLabel = this.model
949
1037
  ? `${this.providerId} · ${this.model}${effort ? ` · ${effort}` : ""}`
950
1038
  : "";
951
- const badge = this.agentMode === "plan"
952
- ? fg(palette.amber, bg(palette.graphite, " PLAN "))
953
- : tone.onGraphite(" BUILD ");
954
- const left = [
1039
+ const badgeText = this.agentMode === "plan" ? " PLAN " : " BUILD ";
1040
+ const badge = this.modeBadgeHover
1041
+ ? fg(palette.bone, bg(palette.steel, badgeText))
1042
+ : this.agentMode === "plan"
1043
+ ? fg(palette.amber, bg(palette.graphite, badgeText))
1044
+ : tone.onGraphite(badgeText);
1045
+ const before = [
955
1046
  bold(tone.bone("quatcode")),
956
1047
  modelLabel ? tone.steel("·") : "",
957
1048
  modelLabel ? tone.silver(modelLabel) : "",
958
- badge,
959
1049
  ]
960
1050
  .filter(Boolean)
961
1051
  .join(" ");
1052
+ this.modeBadgeStart = 2 + stringWidth(before) + 2;
1053
+ this.modeBadgeWidth = stringWidth(badge);
1054
+ const left = `${before} ${badge}`;
962
1055
  const scrolled = this.scrollOffset > 0 ? tone.mist("↑ ") : "";
963
1056
  const right = `${scrolled}${tone.mist(`v${VERSION}`)}`;
964
1057
  const gap = Math.max(1, width - 2 - stringWidth(left) - stringWidth(right));
965
1058
  return [
966
1059
  ` ${left}${" ".repeat(gap)}${right}`,
967
- ` ${tone.steel("─".repeat(Math.max(8, width - 2)))}`,
1060
+ ` ${tone.mist("─".repeat(Math.max(8, width - 2)))}`,
968
1061
  ];
969
1062
  }
970
1063
  footerLines() {
971
1064
  const width = this.contentWidth();
972
- const bar = ` ${tone.steel("─".repeat(Math.max(8, width - 2)))}`;
1065
+ const rule = ` ${tone.mist("─".repeat(Math.max(8, width - 2)))}`;
973
1066
  if (this.mode === "editing") {
974
1067
  const rendered = this.buffer.render({
975
1068
  width,
976
1069
  prompt: this.inputPrompt,
977
1070
  placeholder: this.inputPlaceholder,
978
1071
  mask: this.inputMask,
1072
+ style: styleInputLine,
979
1073
  });
980
1074
  this.lastInputRender = rendered;
981
1075
  const suggestions = this.suggestions();
982
1076
  this.lastInputOffset = 1 + suggestions.length;
983
- return [bar, ...suggestions, ...rendered.lines];
1077
+ return [rule, ...suggestions, ...rendered.lines, rule];
984
1078
  }
985
1079
  this.lastInputRender = null;
986
1080
  if (this.status)
987
- return [bar, this.statusLine()];
1081
+ return [rule, this.statusLine(), rule];
988
1082
  if (this.mode === "running") {
989
- return [bar, ` ${tone.mist("// esc interrupts")}`];
1083
+ return [rule, ` ${tone.mist("// esc interrupts")}`, rule];
990
1084
  }
991
1085
  if (this.mode === "permission")
992
- return [bar, ` ${this.permissionHint}`];
1086
+ return [rule, ` ${this.permissionHint}`, rule];
993
1087
  if (this.mode === "picker" && this.picker) {
994
- return [bar, this.picker.footer(this.picker.itemCount())];
1088
+ return [rule, this.picker.footer(this.picker.itemCount()), rule];
995
1089
  }
996
- return [bar, ` ${tone.mist(`// ${shortenHome(this.dir)}`)}`];
1090
+ return [rule, ` ${tone.mist(`// ${shortenHome(this.dir)}`)}`, rule];
997
1091
  }
998
1092
  statusLine() {
999
1093
  const spin = tone.accent(glyphs.spinner[this.spinnerFrame % glyphs.spinner.length] ?? "-");
@@ -1104,40 +1198,33 @@ class TuiApp {
1104
1198
  return `${tone.silver(`${this.providerId} · ${this.model}`)}${keyState}`;
1105
1199
  }
1106
1200
  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";
1201
+ for (const provider of allProviders()) {
1202
+ if (process.env[provider.envKey])
1203
+ return provider.id;
1204
+ }
1113
1205
  return null;
1114
1206
  }
1115
1207
  detectEnvSetup() {
1208
+ const envModelFor = (id) => {
1209
+ const meta = providerMeta(id);
1210
+ if (!meta)
1211
+ return undefined;
1212
+ return process.env[`${meta.envKey.replace(/_API_KEY$/, "")}_MODEL`];
1213
+ };
1214
+ const pick = (id) => ({
1215
+ id,
1216
+ model: envModelFor(id) ?? defaultModelFor(id),
1217
+ });
1116
1218
  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
- }
1219
+ if (override && providerMeta(override))
1220
+ return pick(override);
1221
+ for (const provider of allProviders()) {
1222
+ if (envModelFor(provider.id))
1223
+ return pick(provider.id);
1138
1224
  }
1139
- catch {
1140
- return null;
1225
+ for (const provider of allProviders()) {
1226
+ if (process.env[provider.envKey])
1227
+ return pick(provider.id);
1141
1228
  }
1142
1229
  return null;
1143
1230
  }
@@ -1180,7 +1267,7 @@ class TuiApp {
1180
1267
  const readOnlyMcp = this.mcp.readOnlyToolNames();
1181
1268
  const filtered = new ToolRegistry();
1182
1269
  for (const tool of full.list()) {
1183
- if (READ_ONLY_TOOLS.has(tool.name) || readOnlyMcp.has(tool.name))
1270
+ if (PLAN_ALLOWED_TOOLS.has(tool.name) || readOnlyMcp.has(tool.name))
1184
1271
  filtered.register(tool);
1185
1272
  }
1186
1273
  filtered.onDispose(() => full.dispose());
@@ -1213,79 +1300,214 @@ class TuiApp {
1213
1300
  this.agentMode = this.agentMode === "build" ? "plan" : "build";
1214
1301
  this.rebuildTools();
1215
1302
  this.append([
1216
- ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// MODE → ${this.agentMode.toUpperCase()}`)}${this.agentMode === "plan" ? ` ${tone.amber("read-only: no edits, no commands")}` : ""}`,
1303
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// MODE → ${this.agentMode.toUpperCase()}`)}${this.agentMode === "plan" ? ` ${tone.amber("no edits · run_command allowed (with approval)")}` : ""}`,
1217
1304
  ]);
1218
1305
  }
1219
1306
  ensureProvider() {
1220
1307
  if (this.provider)
1221
1308
  return this.provider;
1222
1309
  try {
1223
- this.provider = buildProvider(this.providerId, this.model);
1310
+ this.provider = buildProvider(this.providerId, this.model, {
1311
+ sessionId: this.providerSessionId,
1312
+ });
1224
1313
  return this.provider;
1225
1314
  }
1226
1315
  catch {
1227
1316
  return null;
1228
1317
  }
1229
1318
  }
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;
1319
+ async changeModel() {
1320
+ for (;;) {
1321
+ const pickerEntries = [
1322
+ { kind: "header", label: "setup" },
1323
+ {
1324
+ kind: "item",
1325
+ value: ADD_PROVIDER,
1326
+ label: "add a provider…",
1327
+ hint: "connect by API key",
1328
+ },
1329
+ ];
1330
+ for (const provider of allProviders()) {
1331
+ if (provider.models.length === 0)
1332
+ continue;
1333
+ pickerEntries.push({
1334
+ kind: "header",
1335
+ label: `${provider.label} · ${hasKey(provider.id) ? "key set" : "no key"}${provider.custom ? " · added" : ""}`,
1336
+ });
1337
+ for (const model of provider.models) {
1338
+ pickerEntries.push({
1339
+ kind: "item",
1340
+ value: `${provider.id}:${model.id}`,
1341
+ label: model.id,
1342
+ hint: model.label,
1343
+ });
1344
+ }
1239
1345
  }
1240
- for (const model of models) {
1241
- result.push({ providerId: provider.id, id: model.id, label: model.label });
1346
+ const chosen = await this.openPicker(new Picker({ title: "select a model", entries: pickerEntries, filterable: true }));
1347
+ if (!chosen)
1348
+ return false;
1349
+ if (chosen === ADD_PROVIDER) {
1350
+ const added = await this.addProviderFlow();
1351
+ if (added) {
1352
+ this.renderNow();
1353
+ await this.pause(650);
1354
+ }
1355
+ continue;
1242
1356
  }
1357
+ const separator = chosen.indexOf(":");
1358
+ const model = chosen.slice(separator + 1);
1359
+ this.applyModel(chosen.slice(0, separator), model);
1360
+ await this.chooseEffortForModel(model);
1361
+ return true;
1243
1362
  }
1244
- this.setStatus(null);
1245
- return result;
1246
1363
  }
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
- });
1364
+ async providerCommand(arg) {
1365
+ const parts = arg.split(/\s+/).filter(Boolean);
1366
+ const sub = (parts[0] ?? "").toLowerCase();
1367
+ const rest = parts.slice(1).join(" ").trim();
1368
+ if (!sub || sub === "add" || sub === "connect") {
1369
+ await this.addProviderFlow();
1370
+ return;
1371
+ }
1372
+ if (sub === "list" || sub === "ls") {
1373
+ const lines = [` ${tone.mist("// PROVIDERS")}`];
1374
+ for (const provider of allProviders()) {
1375
+ const state = hasKey(provider.id) ? tone.accent("key set") : tone.mist("no key");
1376
+ lines.push(` ${padEndTo(tone.bone(provider.label), 18)}${tone.silver(`${provider.models.length} models`)} ${state}${provider.custom ? tone.mist(" · added") : ""}`);
1377
+ lines.push(` ${tone.mist(`${provider.envKey}${provider.baseUrl ? ` · ${provider.baseUrl}` : ""}`)}`);
1267
1378
  }
1379
+ lines.push("");
1380
+ lines.push(` ${tone.mist("// add: /provider add (or /model → add a provider…) · remove: /provider remove <name>")}`);
1381
+ this.append(lines);
1382
+ return;
1268
1383
  }
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)
1384
+ if (sub === "remove" || sub === "rm") {
1385
+ if (!rest) {
1386
+ this.append([` ${tone.mist("// usage: /provider remove <name>")}`]);
1387
+ return;
1388
+ }
1389
+ const meta = providerMeta(rest);
1390
+ if (!meta) {
1391
+ this.append([` ${tone.red(`// no provider named ${rest}`)}`]);
1392
+ return;
1393
+ }
1394
+ if (!meta.custom) {
1395
+ this.append([` ${tone.red(`// ${rest} is a built-in provider and cannot be removed`)}`]);
1396
+ return;
1397
+ }
1398
+ const confirmed = await this.confirm(`remove provider "${rest}"?`);
1399
+ if (!confirmed) {
1400
+ this.append([` ${tone.mist("// cancelled")}`]);
1401
+ return;
1402
+ }
1403
+ removeCustomProvider(rest);
1404
+ if (meta.envKey) {
1405
+ const keys = { ...(this.config.keys ?? {}) };
1406
+ if (keys[meta.envKey]) {
1407
+ delete keys[meta.envKey];
1408
+ this.config.keys = keys;
1409
+ }
1410
+ delete process.env[meta.envKey];
1411
+ }
1412
+ if (this.config.customProviders?.[rest]) {
1413
+ const next = { ...this.config.customProviders };
1414
+ delete next[rest];
1415
+ this.config.customProviders = next;
1416
+ }
1417
+ if (this.providerId === rest) {
1418
+ this.applyModel("anthropic", defaultModelFor("anthropic"));
1419
+ this.append([` ${tone.mist(`// MODEL → ${this.providerId} · ${this.model}`)}`]);
1420
+ }
1421
+ await saveConfig({ ...this.config }).catch(() => undefined);
1422
+ this.append([
1423
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// PROVIDER REMOVED → ${rest}`)}`,
1424
+ ]);
1425
+ return;
1426
+ }
1427
+ this.append([` ${tone.mist("// usage: /provider [add|list|remove <name>]")}`]);
1428
+ }
1429
+ async addProviderFlow() {
1430
+ const nameResult = await this.readLine({
1431
+ prompt: ` ${tone.bone("PROVIDER NAME")} ${tone.mist(glyphs.prompt)} `,
1432
+ placeholder: "e.g. together, fireworks, cerebras",
1433
+ });
1434
+ if (nameResult.kind !== "submit" || !nameResult.text.trim()) {
1435
+ this.append([` ${tone.mist("// cancelled")}`]);
1436
+ return false;
1437
+ }
1438
+ const id = providerSlug(nameResult.text);
1439
+ const existing = providerMeta(id);
1440
+ if (existing && !existing.custom) {
1441
+ this.append([` ${tone.red(`// ${id} is already a built-in provider — pick it from the list`)}`]);
1442
+ return false;
1443
+ }
1444
+ const urlResult = await this.readLine({
1445
+ prompt: ` ${tone.bone("BASE URL")} ${tone.mist(glyphs.prompt)} `,
1446
+ placeholder: "https://api.example.com/v1 — the API base, not the website",
1447
+ });
1448
+ if (urlResult.kind !== "submit" || !urlResult.text.trim()) {
1449
+ this.append([` ${tone.mist("// cancelled")}`]);
1272
1450
  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
1451
  }
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);
1452
+ const rawBase = urlResult.text.trim().replace(/\/+$/, "");
1453
+ if (!/^https?:\/\//i.test(rawBase)) {
1454
+ this.append([` ${tone.red("// base url must start with http:// or https://")}`]);
1455
+ return false;
1456
+ }
1457
+ const keyResult = await this.readLine({
1458
+ prompt: ` ${tone.bone("API KEY")} ${tone.mist(glyphs.prompt)} `,
1459
+ mask: true,
1460
+ placeholder: "",
1461
+ });
1462
+ if (keyResult.kind !== "submit" || !keyResult.text.trim()) {
1463
+ this.append([` ${tone.mist("// cancelled")}`]);
1464
+ return false;
1465
+ }
1466
+ const apiKey = keyResult.text.trim();
1467
+ this.setStatus({ label: `CHECKING ${id.toUpperCase()}` });
1468
+ let models = [];
1469
+ let baseUrl = rawBase;
1470
+ try {
1471
+ const probe = await probeProviderModels(rawBase, apiKey);
1472
+ baseUrl = probe.baseUrl;
1473
+ models = probe.models;
1474
+ }
1475
+ catch (err) {
1476
+ this.setStatus(null);
1477
+ this.append([
1478
+ ` ${tone.red(`// ${errorMessage(err)}`)}`,
1479
+ ` ${tone.mist("// the provider must expose an OpenAI-compatible GET /models (usually <base>/v1)")}`,
1480
+ ]);
1481
+ return false;
1482
+ }
1483
+ this.setStatus(null);
1484
+ if (models.length === 0) {
1485
+ this.append([` ${tone.red("// no chat models found at this endpoint")}`]);
1486
+ return false;
1487
+ }
1488
+ const envKey = providerEnvKey(id);
1489
+ registerCustomProvider({
1490
+ id,
1491
+ label: id,
1492
+ envKey,
1493
+ kind: "openai",
1494
+ baseUrl,
1495
+ defaultModel: models[0]?.id ?? "",
1496
+ models,
1497
+ custom: true,
1498
+ });
1499
+ process.env[envKey] = apiKey;
1500
+ this.config.keys = { ...(this.config.keys ?? {}), [envKey]: apiKey };
1501
+ this.config.customProviders = {
1502
+ ...(this.config.customProviders ?? {}),
1503
+ [id]: { label: id, baseUrl, envKey, models },
1504
+ };
1505
+ await saveConfig({ ...this.config }).catch(() => undefined);
1506
+ this.append([
1507
+ ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// PROVIDER ADDED → ${id} · ${models.length} model${models.length === 1 ? "" : "s"}`)}`,
1508
+ ` ${tone.silver(baseUrl)}`,
1509
+ ` ${tone.mist(clipAnsi(models.slice(0, 6).map((model) => model.id).join(" · "), Math.max(20, this.contentWidth() - 6)))}`,
1510
+ ]);
1289
1511
  return true;
1290
1512
  }
1291
1513
  effortEntries(efforts, current) {
@@ -1460,12 +1682,14 @@ class TuiApp {
1460
1682
  buildGate() {
1461
1683
  return {
1462
1684
  request: async (req) => {
1463
- if (this.agentMode === "plan" && !READ_ONLY_TOOLS.has(req.tool)) {
1685
+ if (this.agentMode === "plan" && !PLAN_ALLOWED_TOOLS.has(req.tool)) {
1464
1686
  this.append([
1465
1687
  ` ${tone.amber(glyphs.diamondOpen)} ${tone.mist(`// ${req.tool} blocked in PLAN mode (Tab switches to BUILD)`)}`,
1466
1688
  ]);
1467
1689
  return { allowed: false };
1468
1690
  }
1691
+ if (this.agentMode === "plan" && READ_ONLY_TOOLS.has(req.tool))
1692
+ return { allowed: true };
1469
1693
  if (this.gateMode === "always")
1470
1694
  return { allowed: true };
1471
1695
  const choice = await this.permissionCard(req);
@@ -1525,7 +1749,7 @@ class TuiApp {
1525
1749
  if (mcpSection)
1526
1750
  system = system ? `${system}\n\n${mcpSection}` : mcpSection;
1527
1751
  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.`;
1752
+ system = `${system ? `${system}\n\n` : ""}# mode\nYou are in PLAN mode: analyze the codebase and propose a plan. You may run read-only commands with run_command to inspect the project (each command is approved by the user), but never modify files, install packages or run mutating commands. When the plan is ready, present it and tell the user to press Tab to switch to BUILD mode.`;
1529
1753
  }
1530
1754
  this.abort = new AbortController();
1531
1755
  this.abortRequested = false;
@@ -1602,6 +1826,11 @@ class TuiApp {
1602
1826
  ? "max steps"
1603
1827
  : "failed";
1604
1828
  controller.footer(status);
1829
+ if (result.status === "max_steps" && !this.abortRequested) {
1830
+ this.append([
1831
+ ` ${tone.amber(glyphs.diamondOpen)} ${tone.mist(`// STEP LIMIT REACHED (${this.opts.maxSteps}) — send any message to continue where it left off, or start with --max-steps <n>`)}`,
1832
+ ]);
1833
+ }
1605
1834
  const recordId = await this.saveTurn(input, result, controller.durationMs);
1606
1835
  if (this.turnUndo) {
1607
1836
  this.lastTurn = { ...this.turnUndo, recordId };
@@ -1621,7 +1850,9 @@ class TuiApp {
1621
1850
  case "new":
1622
1851
  this.session = new Session(this.system);
1623
1852
  this.conversationId = makeSessionId();
1853
+ this.providerSessionId = makeSessionId();
1624
1854
  this.lastTurn = null;
1855
+ this.pendingImages = new Map();
1625
1856
  this.transcript = [];
1626
1857
  this.userMarkers = [];
1627
1858
  this.messageCursor = -1;
@@ -1643,6 +1874,9 @@ class TuiApp {
1643
1874
  }
1644
1875
  }
1645
1876
  return false;
1877
+ case "provider":
1878
+ await this.providerCommand(arg);
1879
+ return false;
1646
1880
  case "effort":
1647
1881
  await this.effortCommand(arg);
1648
1882
  return false;
@@ -1722,7 +1956,6 @@ class TuiApp {
1722
1956
  this.transcript = this.transcript.map((line) => remapColors(line, pairs));
1723
1957
  this.config.theme = theme.id;
1724
1958
  void saveConfig({ ...this.config, theme: theme.id }).catch(() => undefined);
1725
- this.screen.applyBackground();
1726
1959
  this.append([
1727
1960
  ` ${tone.accent(glyphs.diamond)} ${tone.mist(`// THEME → ${theme.label.toUpperCase()}`)}`,
1728
1961
  "",
@@ -1743,7 +1976,7 @@ class TuiApp {
1743
1976
  kind: "item",
1744
1977
  value: conversationKey(record),
1745
1978
  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`,
1979
+ hint: `${record.pinned ? "pinned · " : ""}${record.createdAt.slice(0, 16).replace("T", " ")} · ${record.provider ? `${record.provider} · ` : ""}${record.model} · ${record.status} · ${record.steps} steps`,
1747
1980
  }));
1748
1981
  const chosen = await this.openPicker(new Picker({
1749
1982
  title: "sessions · enter resumes",
@@ -1807,25 +2040,91 @@ class TuiApp {
1807
2040
  const messages = record.messages ?? [];
1808
2041
  this.session = new Session(this.system, messages);
1809
2042
  this.conversationId = conversationKey(record);
2043
+ this.providerSessionId = makeSessionId();
1810
2044
  if (record.model) {
1811
- this.providerId = resolveProviderForModel(record.model) ?? this.providerId;
2045
+ const resolved = record.provider && providerHasModel(record.provider, record.model)
2046
+ ? record.provider
2047
+ : providerHasModel(this.providerId, record.model)
2048
+ ? this.providerId
2049
+ : resolveProviderForModel(record.model);
2050
+ if (resolved)
2051
+ this.providerId = resolved;
1812
2052
  this.model = record.model;
1813
2053
  this.provider = null;
1814
2054
  }
1815
2055
  const task = record.task.replace(/\s+/g, " ");
1816
2056
  this.append([
1817
2057
  ` ${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", " ")}`)}`,
2058
+ ` ${tone.silver(`${messages.length} message${messages.length === 1 ? "" : "s"} · ${record.provider ? `${record.provider} · ` : ""}${record.model} · ${record.createdAt.slice(0, 16).replace("T", " ")}`)}`,
1819
2059
  ]);
1820
- if (record.text) {
2060
+ if (messages.length > 0) {
2061
+ this.append(this.replaySession(messages));
2062
+ }
2063
+ else if (record.text) {
1821
2064
  this.append([` ${tone.mist("// LAST ANSWER")}`]);
1822
2065
  this.append(renderMarkdown(record.text, { width: this.contentWidth() - 2 }));
1823
2066
  }
1824
- if (messages.length === 0) {
2067
+ else {
1825
2068
  this.append([` ${tone.mist("// no stored messages for this session (saved before resume support)")}`]);
1826
2069
  }
1827
2070
  this.append([""]);
1828
2071
  }
2072
+ replaySession(messages) {
2073
+ const width = Math.max(40, this.contentWidth() - 2);
2074
+ const calls = new Map();
2075
+ const results = new Set();
2076
+ for (const message of messages) {
2077
+ if (message.role === "assistant") {
2078
+ for (const part of message.content) {
2079
+ if (part.type === "tool_call")
2080
+ calls.set(part.id, { name: part.name, input: part.input });
2081
+ }
2082
+ }
2083
+ else if (message.role === "tool") {
2084
+ results.add(message.toolCallId);
2085
+ }
2086
+ }
2087
+ const lines = [];
2088
+ for (const message of messages) {
2089
+ if (message.role === "user") {
2090
+ const text = message.content
2091
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
2092
+ .join("\n");
2093
+ const images = message.content.filter((part) => part.type === "image").length;
2094
+ if (text.trim() || images > 0)
2095
+ lines.push(...this.userBlockLines(text, images));
2096
+ continue;
2097
+ }
2098
+ if (message.role === "assistant") {
2099
+ const text = message.content
2100
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
2101
+ .join("");
2102
+ if (text.trim()) {
2103
+ lines.push(` ${tone.mist("// AGENT")}`);
2104
+ lines.push(...renderMarkdown(text, { width }));
2105
+ }
2106
+ for (const part of message.content) {
2107
+ if (part.type !== "tool_call" || results.has(part.id))
2108
+ continue;
2109
+ const arg = toolArg(part.input, Math.max(20, width - 28));
2110
+ lines.push("", clipAnsi(` ${tone.mist(toolGlyph(part.name))} ${bold(part.name)}${arg ? ` ${tone.silver(arg)}` : ""} ${tone.mist("// no result stored")}`, width - 1));
2111
+ }
2112
+ continue;
2113
+ }
2114
+ const call = calls.get(message.toolCallId);
2115
+ const name = call?.name ?? "tool";
2116
+ const arg = call ? toolArg(call.input, Math.max(20, width - 28)) : "";
2117
+ lines.push(...toolResultBlock({
2118
+ tool: name,
2119
+ arg,
2120
+ content: message.content,
2121
+ isError: Boolean(message.isError),
2122
+ width,
2123
+ verbose: this.verbose,
2124
+ }));
2125
+ }
2126
+ return lines;
2127
+ }
1829
2128
  async skillsCommand(arg) {
1830
2129
  const parts = arg.split(/\s+/).filter(Boolean);
1831
2130
  const sub = (parts[0] ?? "").toLowerCase();
@@ -2328,14 +2627,14 @@ class TuiApp {
2328
2627
  const keys = [
2329
2628
  ["enter", "send"],
2330
2629
  ["ctrl+enter", "newline (shift+enter, ctrl+space, ctrl+j, \\ + enter too)"],
2331
- ["ctrl+v", "paste text or an image"],
2630
+ ["ctrl+v / alt+v", "paste text or an image — alt+v works even if the terminal eats ctrl+v"],
2332
2631
  ["mouse drag", "select text · ctrl+c copies"],
2333
2632
  ["permission card", "← → select · enter confirm · mouse click"],
2334
2633
  ["sessions picker", "ctrl+r rename · ctrl+p pin · ctrl+d delete"],
2335
2634
  ["↑ ↓", "input history — previous / next message"],
2336
2635
  ["wheel", "scroll the conversation"],
2337
2636
  ["ctrl+p/ctrl+n", "input history (same as ↑ ↓)"],
2338
- ["tab", "plan/build mode · complete commands"],
2637
+ ["tab / badge", "switch plan/build · click the header badge · complete commands"],
2339
2638
  ["ctrl+z", "undo the last message (works while thinking)"],
2340
2639
  ["esc", "interrupt a run"],
2341
2640
  ["ctrl+c", "clear input / exit"],
@@ -2367,6 +2666,7 @@ class TuiApp {
2367
2666
  createdAt: new Date().toISOString(),
2368
2667
  cwd: this.dir,
2369
2668
  task: input,
2669
+ provider: this.providerId,
2370
2670
  model: this.model,
2371
2671
  status: result.status,
2372
2672
  steps: result.steps,
@@ -2437,6 +2737,80 @@ function shortenHome(dir) {
2437
2737
  const home = homedir();
2438
2738
  return dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir;
2439
2739
  }
2740
+ const IMAGE_EXTENSIONS = {
2741
+ ".png": "image/png",
2742
+ ".jpg": "image/jpeg",
2743
+ ".jpeg": "image/jpeg",
2744
+ ".webp": "image/webp",
2745
+ ".gif": "image/gif",
2746
+ ".bmp": "image/bmp",
2747
+ };
2748
+ const IMAGE_MARKER_RE = /\[Image (\d+)\]/g;
2749
+ export function nextImageIndex(text) {
2750
+ const used = new Set();
2751
+ for (const match of text.matchAll(IMAGE_MARKER_RE)) {
2752
+ const index = Number(match[1]);
2753
+ if (Number.isInteger(index) && index > 0)
2754
+ used.add(index);
2755
+ }
2756
+ let index = 1;
2757
+ while (used.has(index))
2758
+ index += 1;
2759
+ return index;
2760
+ }
2761
+ export function extractInputImages(text, images) {
2762
+ const used = [];
2763
+ const cleaned = text
2764
+ .replace(IMAGE_MARKER_RE, (match, raw) => {
2765
+ const image = images.get(Number(raw));
2766
+ if (!image)
2767
+ return match;
2768
+ used.push(image);
2769
+ return "";
2770
+ })
2771
+ .replace(/[ \t]{2,}/g, " ")
2772
+ .replace(/[ \t]+\n/g, "\n")
2773
+ .trim();
2774
+ return { text: cleaned, images: used };
2775
+ }
2776
+ function imageTokenBefore(before) {
2777
+ const match = /(?:\[Image \d+\][ \t]*)+$/.exec(before);
2778
+ return match ? match[0].length : 0;
2779
+ }
2780
+ function imageTokenAfter(after) {
2781
+ const match = /^(?:\[Image \d+\][ \t]*)+/.exec(after);
2782
+ return match ? match[0].length : 0;
2783
+ }
2784
+ function styleInputLine(text) {
2785
+ const parts = text.split(/(\[Image \d+\])/);
2786
+ if (parts.length === 1)
2787
+ return tone.bone(text);
2788
+ return parts
2789
+ .map((part) => /^\[Image \d+\]$/.test(part) ? tone.onBone(part) : tone.bone(part))
2790
+ .join("");
2791
+ }
2792
+ async function imageFromPath(raw) {
2793
+ const lines = raw
2794
+ .split(/\r?\n/)
2795
+ .map((line) => line.trim().replace(/^["']|["']$/g, "").trim())
2796
+ .filter(Boolean);
2797
+ if (lines.length !== 1)
2798
+ return null;
2799
+ const file = lines[0];
2800
+ const mediaType = IMAGE_EXTENSIONS[path.extname(file).toLowerCase()];
2801
+ if (!mediaType)
2802
+ return null;
2803
+ try {
2804
+ const info = await stat(file);
2805
+ if (!info.isFile() || info.size === 0 || info.size > 12 * 1024 * 1024)
2806
+ return null;
2807
+ const data = await readFile(file);
2808
+ return { type: "image", mediaType, data: data.toString("base64") };
2809
+ }
2810
+ catch {
2811
+ return null;
2812
+ }
2813
+ }
2440
2814
  function errorMessage(err) {
2441
2815
  return err instanceof Error ? err.message : String(err);
2442
2816
  }