@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/README.md +2 -1
- package/dist/args.js +1 -1
- package/dist/args.js.map +1 -1
- package/dist/cli.js +36 -5
- package/dist/cli.js.map +1 -1
- package/dist/input.d.ts +5 -0
- package/dist/input.d.ts.map +1 -1
- package/dist/input.js +24 -3
- package/dist/input.js.map +1 -1
- package/dist/keyboard.d.ts +2 -0
- package/dist/keyboard.d.ts.map +1 -1
- package/dist/keyboard.js +14 -0
- package/dist/keyboard.js.map +1 -1
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +136 -2
- package/dist/markdown.js.map +1 -1
- package/dist/models.d.ts +37 -8
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +438 -90
- package/dist/models.js.map +1 -1
- package/dist/picker.d.ts.map +1 -1
- package/dist/picker.js +28 -13
- package/dist/picker.js.map +1 -1
- package/dist/screen.d.ts +10 -1
- package/dist/screen.d.ts.map +1 -1
- package/dist/screen.js +35 -15
- package/dist/screen.js.map +1 -1
- package/dist/skills.js +1 -1
- package/dist/skills.js.map +1 -1
- package/dist/store.d.ts +11 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/theme.d.ts +5 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +10 -0
- package/dist/theme.js.map +1 -1
- package/dist/tui.d.ts +6 -0
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +500 -126
- package/dist/tui.js.map +1 -1
- package/dist/turn.d.ts +11 -0
- package/dist/turn.d.ts.map +1 -1
- package/dist/turn.js +56 -24
- package/dist/turn.js.map +1 -1
- package/package.json +4 -4
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 {
|
|
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 {
|
|
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
|
|
220
|
-
this.
|
|
221
|
-
const
|
|
222
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
596
|
+
if (!this.deleteImageTokenBefore())
|
|
597
|
+
this.buffer.backspace();
|
|
559
598
|
break;
|
|
560
599
|
case "delete":
|
|
561
|
-
this.
|
|
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.
|
|
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
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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.
|
|
1060
|
+
` ${tone.mist("─".repeat(Math.max(8, width - 2)))}`,
|
|
968
1061
|
];
|
|
969
1062
|
}
|
|
970
1063
|
footerLines() {
|
|
971
1064
|
const width = this.contentWidth();
|
|
972
|
-
const
|
|
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 [
|
|
1077
|
+
return [rule, ...suggestions, ...rendered.lines, rule];
|
|
984
1078
|
}
|
|
985
1079
|
this.lastInputRender = null;
|
|
986
1080
|
if (this.status)
|
|
987
|
-
return [
|
|
1081
|
+
return [rule, this.statusLine(), rule];
|
|
988
1082
|
if (this.mode === "running") {
|
|
989
|
-
return [
|
|
1083
|
+
return [rule, ` ${tone.mist("// esc interrupts")}`, rule];
|
|
990
1084
|
}
|
|
991
1085
|
if (this.mode === "permission")
|
|
992
|
-
return [
|
|
1086
|
+
return [rule, ` ${this.permissionHint}`, rule];
|
|
993
1087
|
if (this.mode === "picker" && this.picker) {
|
|
994
|
-
return [
|
|
1088
|
+
return [rule, this.picker.footer(this.picker.itemCount()), rule];
|
|
995
1089
|
}
|
|
996
|
-
return [
|
|
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
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
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 (
|
|
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("
|
|
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
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
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
|
|
1248
|
-
|
|
1249
|
-
const
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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" && !
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
}
|