miii-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/dist/cli.js +1860 -0
- package/package.json +51 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1860 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.tsx
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
import { createElement } from "react";
|
|
6
|
+
|
|
7
|
+
// src/ui/App.tsx
|
|
8
|
+
import { useState as useState4, useEffect as useEffect3 } from "react";
|
|
9
|
+
import { Box as Box9, Text as Text9, useApp } from "ink";
|
|
10
|
+
import { homedir as homedir2 } from "os";
|
|
11
|
+
import { sep } from "path";
|
|
12
|
+
|
|
13
|
+
// src/ollama/client.ts
|
|
14
|
+
import { Ollama } from "ollama";
|
|
15
|
+
var ollama = new Ollama({
|
|
16
|
+
host: process.env.OLLAMA_HOST ?? "http://localhost:11434"
|
|
17
|
+
});
|
|
18
|
+
var OLLAMA_NOT_RUNNING = "Ollama is not running. Start it with: ollama serve";
|
|
19
|
+
var HARMONY_RE = /<\|?\/?(?:channel|message|start|end|return|constrain|assistant|user|system|developer|tool|tool_call|tool_response|final|analysis|commentary)\|?>/gi;
|
|
20
|
+
var CHANNEL_LABEL_RE = /^(?:analysis|commentary|final)\s*(?=\w)/i;
|
|
21
|
+
function stripHarmony(s) {
|
|
22
|
+
if (s == null) return s;
|
|
23
|
+
let out = s.replace(HARMONY_RE, "");
|
|
24
|
+
out = out.replace(CHANNEL_LABEL_RE, "");
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function isConnectionError(err) {
|
|
28
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
29
|
+
return msg.includes("ECONNREFUSED") || msg.includes("fetch failed") || msg.includes("connect");
|
|
30
|
+
}
|
|
31
|
+
async function listModels() {
|
|
32
|
+
try {
|
|
33
|
+
const { models } = await ollama.list();
|
|
34
|
+
return models.map((m) => m.name);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (isConnectionError(err)) {
|
|
37
|
+
throw new Error(OLLAMA_NOT_RUNNING);
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function modelContext(model) {
|
|
43
|
+
try {
|
|
44
|
+
const info = await ollama.show({ model });
|
|
45
|
+
const modelInfo = info.model_info;
|
|
46
|
+
if (modelInfo) {
|
|
47
|
+
const ctxKey = Object.keys(modelInfo).find((k) => k.includes("context_length"));
|
|
48
|
+
if (ctxKey) {
|
|
49
|
+
const val = Number(modelInfo[ctxKey]);
|
|
50
|
+
if (!isNaN(val) && val > 0) return val;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return 2048;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (isConnectionError(err)) {
|
|
56
|
+
throw new Error(OLLAMA_NOT_RUNNING);
|
|
57
|
+
}
|
|
58
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
59
|
+
if (msg.toLowerCase().includes("not found") || msg.toLowerCase().includes("unknown model")) {
|
|
60
|
+
throw new Error(`Model "${model}" not found. Run: ollama pull ${model}`);
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function* chat(model, messages, tools, opts) {
|
|
66
|
+
if (opts?.signal?.aborted) return;
|
|
67
|
+
const signal = opts?.signal;
|
|
68
|
+
const client = signal ? new Ollama({
|
|
69
|
+
host: process.env.OLLAMA_HOST ?? "http://localhost:11434",
|
|
70
|
+
fetch: ((input, init) => fetch(input, { ...init, signal }))
|
|
71
|
+
}) : ollama;
|
|
72
|
+
let stream;
|
|
73
|
+
const onAbort = () => {
|
|
74
|
+
try {
|
|
75
|
+
client.abort();
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
80
|
+
try {
|
|
81
|
+
const numPredict = opts?.num_predict;
|
|
82
|
+
const options = {
|
|
83
|
+
temperature: opts?.temperature ?? 0.2,
|
|
84
|
+
num_ctx: opts?.num_ctx ?? 8192
|
|
85
|
+
};
|
|
86
|
+
if (numPredict !== void 0 && numPredict > 0) options.num_predict = numPredict;
|
|
87
|
+
stream = await client.chat({
|
|
88
|
+
model,
|
|
89
|
+
messages,
|
|
90
|
+
tools,
|
|
91
|
+
stream: true,
|
|
92
|
+
think: true,
|
|
93
|
+
keep_alive: opts?.keep_alive ?? "10m",
|
|
94
|
+
options
|
|
95
|
+
});
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (signal?.aborted) return;
|
|
98
|
+
if (isConnectionError(err)) {
|
|
99
|
+
throw new Error(OLLAMA_NOT_RUNNING);
|
|
100
|
+
}
|
|
101
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
102
|
+
if (msg.toLowerCase().includes("not found") || msg.toLowerCase().includes("unknown model")) {
|
|
103
|
+
throw new Error(`Model "${model}" not found. Run: ollama pull ${model}`);
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
for await (const chunk of stream) {
|
|
109
|
+
if (signal?.aborted) break;
|
|
110
|
+
yield {
|
|
111
|
+
content: stripHarmony(chunk.message.content),
|
|
112
|
+
thinking: stripHarmony(chunk.message.thinking),
|
|
113
|
+
done: chunk.done,
|
|
114
|
+
tool_calls: chunk.message.tool_calls,
|
|
115
|
+
prompt_eval_count: chunk.prompt_eval_count,
|
|
116
|
+
eval_count: chunk.eval_count
|
|
117
|
+
};
|
|
118
|
+
if (opts?.signal?.aborted) break;
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if (opts?.signal?.aborted) return;
|
|
122
|
+
if (isConnectionError(err)) {
|
|
123
|
+
throw new Error(OLLAMA_NOT_RUNNING);
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
} finally {
|
|
127
|
+
if (opts?.signal) opts.signal.removeEventListener("abort", onAbort);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/config.ts
|
|
132
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
133
|
+
import { join } from "path";
|
|
134
|
+
import { homedir } from "os";
|
|
135
|
+
var CONFIG_DIR = join(homedir(), ".miii");
|
|
136
|
+
var CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
137
|
+
function loadConfig() {
|
|
138
|
+
if (!existsSync(CONFIG_PATH)) return {};
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
141
|
+
} catch {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function saveConfig(config) {
|
|
146
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
147
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
|
|
148
|
+
}
|
|
149
|
+
function setModel(model) {
|
|
150
|
+
saveConfig({ ...loadConfig(), model });
|
|
151
|
+
}
|
|
152
|
+
function setEffort(effort) {
|
|
153
|
+
saveConfig({ ...loadConfig(), effort });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/ui/WelcomeBlock.tsx
|
|
157
|
+
import { Box, Text } from "ink";
|
|
158
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
159
|
+
function WelcomeBlock({ model, activeCtx, effort, cwd }) {
|
|
160
|
+
const ctxLabel = activeCtx != null ? `${Math.round(activeCtx / 1024)}k ctx` : "\u2014 ctx";
|
|
161
|
+
return /* @__PURE__ */ jsxs(
|
|
162
|
+
Box,
|
|
163
|
+
{
|
|
164
|
+
flexDirection: "column",
|
|
165
|
+
borderStyle: "round",
|
|
166
|
+
borderColor: "gray",
|
|
167
|
+
paddingX: 2,
|
|
168
|
+
marginBottom: 1,
|
|
169
|
+
children: [
|
|
170
|
+
/* @__PURE__ */ jsxs(Box, { gap: 2, children: [
|
|
171
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "blue", children: "MIII CLI" }),
|
|
172
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "\xB7" }),
|
|
173
|
+
/* @__PURE__ */ jsx(Text, { children: model ?? "/models" }),
|
|
174
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "\xB7" }),
|
|
175
|
+
/* @__PURE__ */ jsx(Text, { children: ctxLabel }),
|
|
176
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "\xB7" }),
|
|
177
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
178
|
+
effort,
|
|
179
|
+
" effort"
|
|
180
|
+
] })
|
|
181
|
+
] }),
|
|
182
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: cwd })
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/ui/ModelList.tsx
|
|
189
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
190
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
191
|
+
function ModelList({ models, cursor, activeModel, showActive }) {
|
|
192
|
+
if (models.length === 0) {
|
|
193
|
+
return /* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
194
|
+
"no models found. run: ollama pull ",
|
|
195
|
+
"<model>"
|
|
196
|
+
] });
|
|
197
|
+
}
|
|
198
|
+
return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: models.map((m, i) => /* @__PURE__ */ jsxs2(Text2, { color: i === cursor ? "white" : void 0, dimColor: i !== cursor, children: [
|
|
199
|
+
i === cursor ? "\u25B6 " : " ",
|
|
200
|
+
m,
|
|
201
|
+
showActive && m === activeModel ? /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: " (active)" }) : null
|
|
202
|
+
] }, m)) });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/ui/InputBar.tsx
|
|
206
|
+
import { useEffect, useState } from "react";
|
|
207
|
+
import { Box as Box3, Text as Text3 } from "ink";
|
|
208
|
+
import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
209
|
+
var SPIN = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
210
|
+
function InputBar({ input, disabled, processingLabel }) {
|
|
211
|
+
const [frame, setFrame] = useState(0);
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!disabled) return;
|
|
214
|
+
const t = setInterval(() => setFrame((f) => (f + 1) % SPIN.length), 150);
|
|
215
|
+
return () => clearInterval(t);
|
|
216
|
+
}, [disabled]);
|
|
217
|
+
return /* @__PURE__ */ jsx3(
|
|
218
|
+
Box3,
|
|
219
|
+
{
|
|
220
|
+
borderStyle: "single",
|
|
221
|
+
borderTop: true,
|
|
222
|
+
borderBottom: true,
|
|
223
|
+
borderLeft: false,
|
|
224
|
+
borderRight: false,
|
|
225
|
+
borderColor: disabled ? "yellow" : "white dim",
|
|
226
|
+
paddingX: 1,
|
|
227
|
+
children: disabled ? /* @__PURE__ */ jsxs3(Fragment, { children: [
|
|
228
|
+
/* @__PURE__ */ jsx3(Text3, { color: "yellow", children: SPIN[frame] + " " }),
|
|
229
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, italic: true, children: processingLabel ?? "processing\u2026" }),
|
|
230
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " (esc to cancel)" })
|
|
231
|
+
] }) : /* @__PURE__ */ jsxs3(Fragment, { children: [
|
|
232
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "> " }),
|
|
233
|
+
/* @__PURE__ */ jsx3(Text3, { children: input }),
|
|
234
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "\u258C" })
|
|
235
|
+
] })
|
|
236
|
+
}
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/ui/ModelsView.tsx
|
|
241
|
+
import { Box as Box4, Text as Text4 } from "ink";
|
|
242
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
243
|
+
function ModelsView({ models, cursor, model, ollamaHost, effort }) {
|
|
244
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginLeft: 2, children: [
|
|
245
|
+
/* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", marginBottom: 1, children: [
|
|
246
|
+
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "config" }),
|
|
247
|
+
/* @__PURE__ */ jsxs4(Box4, { marginTop: 1, flexDirection: "column", children: [
|
|
248
|
+
/* @__PURE__ */ jsxs4(Text4, { children: [
|
|
249
|
+
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "model " }),
|
|
250
|
+
/* @__PURE__ */ jsx4(Text4, { children: model ?? "\u2014" })
|
|
251
|
+
] }),
|
|
252
|
+
/* @__PURE__ */ jsxs4(Text4, { children: [
|
|
253
|
+
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "host " }),
|
|
254
|
+
/* @__PURE__ */ jsx4(Text4, { children: ollamaHost ?? "http://localhost:11434" })
|
|
255
|
+
] }),
|
|
256
|
+
/* @__PURE__ */ jsxs4(Text4, { children: [
|
|
257
|
+
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "effort " }),
|
|
258
|
+
/* @__PURE__ */ jsx4(Text4, { children: effort }),
|
|
259
|
+
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " (\u2190 \u2192)" })
|
|
260
|
+
] })
|
|
261
|
+
] })
|
|
262
|
+
] }),
|
|
263
|
+
/* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "switch model" }),
|
|
264
|
+
/* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(ModelList, { models, cursor, activeModel: model, showActive: true }) }),
|
|
265
|
+
/* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "\u2191\u2193 navigate enter switch \u2190\u2192 effort esc close" }) })
|
|
266
|
+
] });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/ui/CommandPalette.tsx
|
|
270
|
+
import { Box as Box5, Text as Text5 } from "ink";
|
|
271
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
272
|
+
var COMMANDS = [
|
|
273
|
+
{ name: "/models", description: "switch model or adjust effort" },
|
|
274
|
+
{ name: "/clear", description: "clear chat and reset context" },
|
|
275
|
+
{ name: "/exit", description: "quit miii" }
|
|
276
|
+
];
|
|
277
|
+
function CommandPalette({ filter, cursor }) {
|
|
278
|
+
const filtered = COMMANDS.filter((c) => c.name.startsWith(filter));
|
|
279
|
+
if (filtered.length === 0) return null;
|
|
280
|
+
return /* @__PURE__ */ jsxs5(
|
|
281
|
+
Box5,
|
|
282
|
+
{
|
|
283
|
+
flexDirection: "column",
|
|
284
|
+
borderStyle: "single",
|
|
285
|
+
borderColor: "white dim",
|
|
286
|
+
marginX: 1,
|
|
287
|
+
marginBottom: 0,
|
|
288
|
+
paddingX: 1,
|
|
289
|
+
children: [
|
|
290
|
+
filtered.map((cmd, i) => {
|
|
291
|
+
const active = i === cursor;
|
|
292
|
+
return /* @__PURE__ */ jsxs5(Box5, { gap: 2, children: [
|
|
293
|
+
/* @__PURE__ */ jsx5(Text5, { bold: active, color: active ? "white" : void 0, dimColor: !active, children: cmd.name }),
|
|
294
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: cmd.description })
|
|
295
|
+
] }, cmd.name);
|
|
296
|
+
}),
|
|
297
|
+
/* @__PURE__ */ jsx5(Box5, { marginTop: 0, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "\u2191\u2193 navigate tab/enter autocomplete esc dismiss" }) })
|
|
298
|
+
]
|
|
299
|
+
}
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
function filteredCommands(filter) {
|
|
303
|
+
return COMMANDS.filter((c) => c.name.startsWith(filter));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/ui/FilePicker.tsx
|
|
307
|
+
import { Box as Box6, Text as Text6 } from "ink";
|
|
308
|
+
import { readdirSync } from "fs";
|
|
309
|
+
import { join as join2, relative } from "path";
|
|
310
|
+
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
311
|
+
var IGNORE = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", ".miii"]);
|
|
312
|
+
var MAX_RESULTS = 10;
|
|
313
|
+
var MAX_SCAN = 2e3;
|
|
314
|
+
var cache = null;
|
|
315
|
+
function listFiles(cwd) {
|
|
316
|
+
if (cache && cache.cwd === cwd) return cache.files;
|
|
317
|
+
const out = [];
|
|
318
|
+
const stack = [cwd];
|
|
319
|
+
while (stack.length && out.length < MAX_SCAN) {
|
|
320
|
+
const dir = stack.pop();
|
|
321
|
+
let entries;
|
|
322
|
+
try {
|
|
323
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
324
|
+
} catch {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
for (const e of entries) {
|
|
328
|
+
if (IGNORE.has(e.name) || e.name.startsWith(".")) continue;
|
|
329
|
+
const full = join2(dir, e.name);
|
|
330
|
+
if (e.isDirectory()) stack.push(full);
|
|
331
|
+
else if (e.isFile()) out.push(relative(cwd, full));
|
|
332
|
+
if (out.length >= MAX_SCAN) break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
cache = { cwd, files: out };
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
function parseMention(input) {
|
|
339
|
+
const m = input.match(/(?:^|\s)@([^\s]*)$/);
|
|
340
|
+
if (!m) return null;
|
|
341
|
+
return { query: m[1], start: input.length - m[1].length - 1 };
|
|
342
|
+
}
|
|
343
|
+
function searchFiles(cwd, query) {
|
|
344
|
+
const files = listFiles(cwd);
|
|
345
|
+
const q = query.toLowerCase();
|
|
346
|
+
if (!q) return files.slice(0, MAX_RESULTS);
|
|
347
|
+
const scored = [];
|
|
348
|
+
for (const f of files) {
|
|
349
|
+
const lf = f.toLowerCase();
|
|
350
|
+
const idx = lf.indexOf(q);
|
|
351
|
+
if (idx === -1) continue;
|
|
352
|
+
const base = lf.split("/").pop() ?? lf;
|
|
353
|
+
const baseIdx = base.indexOf(q);
|
|
354
|
+
const score = baseIdx === 0 ? 0 : baseIdx > -1 ? 1 : 2 + idx;
|
|
355
|
+
scored.push([score, f]);
|
|
356
|
+
if (scored.length > 500) break;
|
|
357
|
+
}
|
|
358
|
+
scored.sort((a, b) => a[0] - b[0] || a[1].length - b[1].length);
|
|
359
|
+
return scored.slice(0, MAX_RESULTS).map(([, f]) => f);
|
|
360
|
+
}
|
|
361
|
+
function FilePicker({ matches, cursor }) {
|
|
362
|
+
if (matches.length === 0) return null;
|
|
363
|
+
return /* @__PURE__ */ jsxs6(
|
|
364
|
+
Box6,
|
|
365
|
+
{
|
|
366
|
+
flexDirection: "column",
|
|
367
|
+
borderStyle: "single",
|
|
368
|
+
borderColor: "white dim",
|
|
369
|
+
marginX: 1,
|
|
370
|
+
marginBottom: 0,
|
|
371
|
+
paddingX: 1,
|
|
372
|
+
children: [
|
|
373
|
+
matches.map((f, i) => {
|
|
374
|
+
const active = i === cursor;
|
|
375
|
+
return /* @__PURE__ */ jsx6(Box6, { children: /* @__PURE__ */ jsxs6(Text6, { bold: active, color: active ? "white" : void 0, dimColor: !active, children: [
|
|
376
|
+
active ? "\u203A " : " ",
|
|
377
|
+
f
|
|
378
|
+
] }) }, f);
|
|
379
|
+
}),
|
|
380
|
+
/* @__PURE__ */ jsx6(Box6, { marginTop: 0, children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "\u2191\u2193 navigate tab insert esc dismiss" }) })
|
|
381
|
+
]
|
|
382
|
+
}
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/ui/ChatView.tsx
|
|
387
|
+
import { Box as Box8, Text as Text8 } from "ink";
|
|
388
|
+
|
|
389
|
+
// src/ui/ThinkingBlock.tsx
|
|
390
|
+
import { useState as useState2, useEffect as useEffect2 } from "react";
|
|
391
|
+
import { Box as Box7, Text as Text7 } from "ink";
|
|
392
|
+
import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
393
|
+
var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
394
|
+
var globalThinkingVisible = false;
|
|
395
|
+
var listeners = /* @__PURE__ */ new Set();
|
|
396
|
+
function toggleThinkingVisible() {
|
|
397
|
+
globalThinkingVisible = !globalThinkingVisible;
|
|
398
|
+
listeners.forEach((fn) => fn());
|
|
399
|
+
}
|
|
400
|
+
function useThinkingVisible() {
|
|
401
|
+
const [visible, setVisible] = useState2(globalThinkingVisible);
|
|
402
|
+
useEffect2(() => {
|
|
403
|
+
const handler = () => setVisible(globalThinkingVisible);
|
|
404
|
+
listeners.add(handler);
|
|
405
|
+
return () => {
|
|
406
|
+
listeners.delete(handler);
|
|
407
|
+
};
|
|
408
|
+
}, []);
|
|
409
|
+
return visible;
|
|
410
|
+
}
|
|
411
|
+
function ThinkingBlock({ content }) {
|
|
412
|
+
const [frame, setFrame] = useState2(0);
|
|
413
|
+
const visible = useThinkingVisible();
|
|
414
|
+
useEffect2(() => {
|
|
415
|
+
const t = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
|
|
416
|
+
return () => clearInterval(t);
|
|
417
|
+
}, []);
|
|
418
|
+
return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", marginLeft: 2, marginBottom: 1, children: [
|
|
419
|
+
/* @__PURE__ */ jsxs7(Box7, { children: [
|
|
420
|
+
/* @__PURE__ */ jsxs7(Text7, { color: "blue", children: [
|
|
421
|
+
FRAMES[frame],
|
|
422
|
+
" "
|
|
423
|
+
] }),
|
|
424
|
+
/* @__PURE__ */ jsx7(Text7, { dimColor: true, italic: true, children: "thinking\u2026" }),
|
|
425
|
+
/* @__PURE__ */ jsxs7(Text7, { dimColor: true, children: [
|
|
426
|
+
" \xB7 ctrl+t to ",
|
|
427
|
+
visible ? "hide" : "show",
|
|
428
|
+
" thoughts"
|
|
429
|
+
] })
|
|
430
|
+
] }),
|
|
431
|
+
visible && content ? /* @__PURE__ */ jsx7(Box7, { marginLeft: 2, children: /* @__PURE__ */ jsx7(Text7, { dimColor: true, italic: true, children: content }) }) : null
|
|
432
|
+
] });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/ui/ChatView.tsx
|
|
436
|
+
import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
|
|
437
|
+
function formatTokens(n) {
|
|
438
|
+
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k";
|
|
439
|
+
return String(n);
|
|
440
|
+
}
|
|
441
|
+
function formatDuration(ms) {
|
|
442
|
+
const totalSec = ms / 1e3;
|
|
443
|
+
if (totalSec < 60) return `${totalSec.toFixed(1)}s`;
|
|
444
|
+
const m = Math.floor(totalSec / 60);
|
|
445
|
+
const s = Math.round(totalSec - m * 60);
|
|
446
|
+
return `${m}m ${s}s`;
|
|
447
|
+
}
|
|
448
|
+
function countLines(s) {
|
|
449
|
+
if (!s) return 0;
|
|
450
|
+
return s.split("\n").length;
|
|
451
|
+
}
|
|
452
|
+
function FileEditBlock({
|
|
453
|
+
label,
|
|
454
|
+
path,
|
|
455
|
+
added,
|
|
456
|
+
removed,
|
|
457
|
+
previewLines
|
|
458
|
+
}) {
|
|
459
|
+
const MAX = 16;
|
|
460
|
+
const shown = previewLines.slice(0, MAX);
|
|
461
|
+
const extra = previewLines.length - shown.length;
|
|
462
|
+
return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginLeft: 2, children: [
|
|
463
|
+
/* @__PURE__ */ jsxs8(Box8, { children: [
|
|
464
|
+
/* @__PURE__ */ jsx8(Text8, { color: "yellow", children: "\u25CF " }),
|
|
465
|
+
/* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
|
|
466
|
+
label,
|
|
467
|
+
" "
|
|
468
|
+
] }),
|
|
469
|
+
/* @__PURE__ */ jsx8(Text8, { children: "(" }),
|
|
470
|
+
/* @__PURE__ */ jsx8(Text8, { bold: true, children: path }),
|
|
471
|
+
/* @__PURE__ */ jsx8(Text8, { children: ")" })
|
|
472
|
+
] }),
|
|
473
|
+
/* @__PURE__ */ jsx8(Box8, { marginLeft: 2, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
|
|
474
|
+
"\u23BF ",
|
|
475
|
+
removed > 0 ? `Added ${added} lines, removed ${removed} lines` : `Added ${added} lines`
|
|
476
|
+
] }) }),
|
|
477
|
+
shown.map((ln, i) => /* @__PURE__ */ jsx8(Box8, { marginLeft: 4, children: /* @__PURE__ */ jsxs8(Text8, { color: ln.sign === "+" ? "green" : ln.sign === "-" ? "red" : void 0, dimColor: ln.sign === " ", children: [
|
|
478
|
+
ln.sign,
|
|
479
|
+
" ",
|
|
480
|
+
ln.text
|
|
481
|
+
] }) }, i)),
|
|
482
|
+
extra > 0 && /* @__PURE__ */ jsx8(Box8, { marginLeft: 4, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
|
|
483
|
+
"\u2026 ",
|
|
484
|
+
extra,
|
|
485
|
+
" more lines"
|
|
486
|
+
] }) })
|
|
487
|
+
] });
|
|
488
|
+
}
|
|
489
|
+
var TOOL_LABEL = {
|
|
490
|
+
write_file: "Write",
|
|
491
|
+
edit_file: "Update",
|
|
492
|
+
read_file: "Read",
|
|
493
|
+
run_bash: "Bash",
|
|
494
|
+
glob: "Glob",
|
|
495
|
+
grep: "Grep"
|
|
496
|
+
};
|
|
497
|
+
function truncate(s, max) {
|
|
498
|
+
if (s.length <= max) return s;
|
|
499
|
+
return s.slice(0, max - 1) + "\u2026";
|
|
500
|
+
}
|
|
501
|
+
function toolHeader(use) {
|
|
502
|
+
const label = TOOL_LABEL[use.name] ?? use.name;
|
|
503
|
+
const input = use.input ?? {};
|
|
504
|
+
let arg = "";
|
|
505
|
+
switch (use.name) {
|
|
506
|
+
case "write_file":
|
|
507
|
+
case "edit_file":
|
|
508
|
+
case "read_file":
|
|
509
|
+
arg = String(input.path ?? input.file_path ?? "");
|
|
510
|
+
break;
|
|
511
|
+
case "run_bash": {
|
|
512
|
+
const cmd = String(input.command ?? "").replace(/\s+/g, " ");
|
|
513
|
+
arg = truncate(cmd, 120);
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
case "glob":
|
|
517
|
+
case "grep":
|
|
518
|
+
arg = truncate(String(input.pattern ?? ""), 120);
|
|
519
|
+
break;
|
|
520
|
+
default: {
|
|
521
|
+
arg = truncate(JSON.stringify(input), 80);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return { label, arg };
|
|
525
|
+
}
|
|
526
|
+
function summarizeResult(res, toolName) {
|
|
527
|
+
const content = res.content ?? "";
|
|
528
|
+
const lines = content.split("\n");
|
|
529
|
+
if (!res.is_error) {
|
|
530
|
+
if (toolName === "read_file") {
|
|
531
|
+
const total = lines.length;
|
|
532
|
+
return `Read ${total} line${total === 1 ? "" : "s"}`;
|
|
533
|
+
}
|
|
534
|
+
if (toolName === "grep") {
|
|
535
|
+
if (content === "No matches.") return "No matches";
|
|
536
|
+
const n = lines.filter(Boolean).length;
|
|
537
|
+
return `${n} match${n === 1 ? "" : "es"}`;
|
|
538
|
+
}
|
|
539
|
+
if (toolName === "glob") {
|
|
540
|
+
if (content === "No files matched.") return "No files";
|
|
541
|
+
const n = lines.filter(Boolean).length;
|
|
542
|
+
return `${n} file${n === 1 ? "" : "s"}`;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const firstNonEmpty = lines.find((l) => l.trim().length > 0) ?? "";
|
|
546
|
+
const extra = lines.length - 1;
|
|
547
|
+
const head = firstNonEmpty.length > 100 ? firstNonEmpty.slice(0, 97) + "..." : firstNonEmpty;
|
|
548
|
+
return extra > 0 ? `${head} (+${extra} lines)` : head;
|
|
549
|
+
}
|
|
550
|
+
function ToolResultBlock({ result, toolName }) {
|
|
551
|
+
const content = result.content ?? "";
|
|
552
|
+
const lines = content.split("\n");
|
|
553
|
+
const showMulti = (toolName === "run_bash" || toolName === "grep" || toolName === "glob" || result.is_error) && lines.length > 1;
|
|
554
|
+
if (!showMulti) {
|
|
555
|
+
return /* @__PURE__ */ jsx8(Box8, { marginLeft: 2, children: /* @__PURE__ */ jsxs8(Text8, { color: result.is_error ? "red" : void 0, dimColor: !result.is_error, children: [
|
|
556
|
+
"\u23BF ",
|
|
557
|
+
summarizeResult(result, toolName)
|
|
558
|
+
] }) });
|
|
559
|
+
}
|
|
560
|
+
const MAX_LINES = 10;
|
|
561
|
+
const MAX_LINE_WIDTH = 200;
|
|
562
|
+
const shown = lines.slice(0, MAX_LINES).map((l) => truncate(l, MAX_LINE_WIDTH));
|
|
563
|
+
const extra = lines.length - shown.length;
|
|
564
|
+
return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginLeft: 2, children: [
|
|
565
|
+
/* @__PURE__ */ jsxs8(Text8, { color: result.is_error ? "red" : void 0, dimColor: !result.is_error, children: [
|
|
566
|
+
"\u23BF ",
|
|
567
|
+
summarizeResult(result, toolName)
|
|
568
|
+
] }),
|
|
569
|
+
shown.map((ln, i) => /* @__PURE__ */ jsx8(Box8, { marginLeft: 4, children: /* @__PURE__ */ jsx8(Text8, { color: result.is_error ? "red" : void 0, dimColor: true, children: ln || " " }) }, i)),
|
|
570
|
+
extra > 0 && /* @__PURE__ */ jsx8(Box8, { marginLeft: 4, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
|
|
571
|
+
"\u2026 ",
|
|
572
|
+
extra,
|
|
573
|
+
" more lines"
|
|
574
|
+
] }) })
|
|
575
|
+
] });
|
|
576
|
+
}
|
|
577
|
+
function ToolUseLine({ use, result }) {
|
|
578
|
+
if (use.name === "write_file" && !result?.is_error) {
|
|
579
|
+
const input = use.input;
|
|
580
|
+
const content = input.content ?? "";
|
|
581
|
+
const added = countLines(content);
|
|
582
|
+
const preview = content.split("\n").map((t) => ({ sign: "+", text: t }));
|
|
583
|
+
return /* @__PURE__ */ jsx8(FileEditBlock, { label: "Write", path: input.path ?? "", added, removed: 0, previewLines: preview });
|
|
584
|
+
}
|
|
585
|
+
if (use.name === "edit_file" && !result?.is_error) {
|
|
586
|
+
const input = use.input;
|
|
587
|
+
const oldS = input.old_str ?? "";
|
|
588
|
+
const newS = input.new_str ?? "";
|
|
589
|
+
const added = countLines(newS);
|
|
590
|
+
const removed = countLines(oldS);
|
|
591
|
+
const preview = [
|
|
592
|
+
...oldS.split("\n").map((t) => ({ sign: "-", text: t })),
|
|
593
|
+
...newS.split("\n").map((t) => ({ sign: "+", text: t }))
|
|
594
|
+
];
|
|
595
|
+
return /* @__PURE__ */ jsx8(FileEditBlock, { label: "Update", path: input.path ?? "", added, removed, previewLines: preview });
|
|
596
|
+
}
|
|
597
|
+
const { label, arg } = toolHeader(use);
|
|
598
|
+
return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginLeft: 2, children: [
|
|
599
|
+
/* @__PURE__ */ jsxs8(Box8, { children: [
|
|
600
|
+
/* @__PURE__ */ jsx8(Text8, { color: "yellow", children: "\u25CF " }),
|
|
601
|
+
/* @__PURE__ */ jsxs8(Text8, { color: "yellow", children: [
|
|
602
|
+
label,
|
|
603
|
+
" "
|
|
604
|
+
] }),
|
|
605
|
+
/* @__PURE__ */ jsx8(Text8, { children: "(" }),
|
|
606
|
+
/* @__PURE__ */ jsx8(Text8, { bold: true, children: arg }),
|
|
607
|
+
/* @__PURE__ */ jsx8(Text8, { children: ")" })
|
|
608
|
+
] }),
|
|
609
|
+
result && /* @__PURE__ */ jsx8(ToolResultBlock, { result, toolName: use.name })
|
|
610
|
+
] });
|
|
611
|
+
}
|
|
612
|
+
function AssistantMessage({ msg }) {
|
|
613
|
+
return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginBottom: 1, children: [
|
|
614
|
+
msg.content && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "row", children: [
|
|
615
|
+
/* @__PURE__ */ jsx8(Text8, { color: "white", children: "\u25CF " }),
|
|
616
|
+
/* @__PURE__ */ jsx8(Box8, { flexGrow: 1, children: /* @__PURE__ */ jsx8(Text8, { children: msg.content }) })
|
|
617
|
+
] }),
|
|
618
|
+
msg.tool_uses?.map((u) => {
|
|
619
|
+
const r = msg.tool_results?.find((x) => x.tool_use_id === u.id);
|
|
620
|
+
return /* @__PURE__ */ jsx8(ToolUseLine, { use: u, result: r }, u.id);
|
|
621
|
+
}),
|
|
622
|
+
msg.tokens && /* @__PURE__ */ jsx8(Box8, { marginLeft: 2, children: /* @__PURE__ */ jsxs8(Text8, { dimColor: true, children: [
|
|
623
|
+
`\u21B3 Completed \xB7 ${formatTokens(msg.tokens.prompt_eval + msg.tokens.eval)} tokens`,
|
|
624
|
+
msg.duration != null ? ` \xB7 ${formatDuration(msg.duration)}` : ""
|
|
625
|
+
] }) })
|
|
626
|
+
] });
|
|
627
|
+
}
|
|
628
|
+
function summarizeInput(input) {
|
|
629
|
+
if (!input || typeof input !== "object") return "";
|
|
630
|
+
const obj = input;
|
|
631
|
+
const priority = ["path", "file_path", "command", "pattern", "query"];
|
|
632
|
+
for (const k of priority) {
|
|
633
|
+
const v = obj[k];
|
|
634
|
+
if (typeof v === "string" && v.length > 0) return `${k}: ${v}`;
|
|
635
|
+
}
|
|
636
|
+
const first = Object.entries(obj).find(([, v]) => typeof v === "string");
|
|
637
|
+
if (first) {
|
|
638
|
+
const [k, v] = first;
|
|
639
|
+
const trimmed = v.length > 80 ? v.slice(0, 80) + "\u2026" : v;
|
|
640
|
+
return `${k}: ${trimmed}`;
|
|
641
|
+
}
|
|
642
|
+
return "";
|
|
643
|
+
}
|
|
644
|
+
function PermissionPrompt({ req, cursor }) {
|
|
645
|
+
const options = [
|
|
646
|
+
{ label: "Yes", key: "yes" },
|
|
647
|
+
{ label: "No, and tell me what to do differently", key: "no" }
|
|
648
|
+
];
|
|
649
|
+
const summary = summarizeInput(req.input);
|
|
650
|
+
return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginBottom: 1, borderStyle: "round", borderColor: "blue", paddingX: 1, children: [
|
|
651
|
+
/* @__PURE__ */ jsx8(Text8, { color: "blue", bold: true, children: "Tool use" }),
|
|
652
|
+
/* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text8, { children: [
|
|
653
|
+
"Allow ",
|
|
654
|
+
/* @__PURE__ */ jsx8(Text8, { bold: true, children: req.toolName }),
|
|
655
|
+
"?"
|
|
656
|
+
] }) }),
|
|
657
|
+
summary && /* @__PURE__ */ jsx8(Box8, { marginLeft: 2, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: summary }) }),
|
|
658
|
+
/* @__PURE__ */ jsx8(Box8, { flexDirection: "column", marginTop: 1, children: options.map((opt, i) => /* @__PURE__ */ jsxs8(Text8, { color: i === cursor ? "blue" : void 0, children: [
|
|
659
|
+
i === cursor ? "\u276F " : " ",
|
|
660
|
+
i + 1,
|
|
661
|
+
". ",
|
|
662
|
+
opt.label
|
|
663
|
+
] }, opt.key)) })
|
|
664
|
+
] });
|
|
665
|
+
}
|
|
666
|
+
function ChatView({
|
|
667
|
+
messages,
|
|
668
|
+
streaming,
|
|
669
|
+
streamingContent,
|
|
670
|
+
thinking,
|
|
671
|
+
thinkingContent,
|
|
672
|
+
error,
|
|
673
|
+
pendingPermission,
|
|
674
|
+
permissionCursor = 0,
|
|
675
|
+
activeToolUses,
|
|
676
|
+
activeToolResults
|
|
677
|
+
}) {
|
|
678
|
+
return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginLeft: 1, marginBottom: 1, children: [
|
|
679
|
+
messages.map(
|
|
680
|
+
(msg, i) => msg.role === "user" ? /* @__PURE__ */ jsxs8(Box8, { flexDirection: "row", marginBottom: 1, children: [
|
|
681
|
+
/* @__PURE__ */ jsx8(Text8, { color: "blue", children: "\u25CF " }),
|
|
682
|
+
/* @__PURE__ */ jsx8(Box8, { flexGrow: 1, children: /* @__PURE__ */ jsx8(Text8, { children: msg.content }) })
|
|
683
|
+
] }, i) : /* @__PURE__ */ jsx8(AssistantMessage, { msg }, i)
|
|
684
|
+
),
|
|
685
|
+
thinking && /* @__PURE__ */ jsx8(ThinkingBlock, { content: thinkingContent }),
|
|
686
|
+
streaming && streamingContent && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "row", marginBottom: 1, children: [
|
|
687
|
+
/* @__PURE__ */ jsx8(Text8, { color: "white", children: "\u25CF " }),
|
|
688
|
+
/* @__PURE__ */ jsx8(Box8, { flexGrow: 1, children: /* @__PURE__ */ jsx8(Text8, { children: streamingContent }) })
|
|
689
|
+
] }),
|
|
690
|
+
activeToolUses?.map((u) => {
|
|
691
|
+
const r = activeToolResults?.find((x) => x.tool_use_id === u.id);
|
|
692
|
+
return /* @__PURE__ */ jsx8(ToolUseLine, { use: u, result: r }, u.id);
|
|
693
|
+
}),
|
|
694
|
+
pendingPermission && /* @__PURE__ */ jsx8(PermissionPrompt, { req: pendingPermission, cursor: permissionCursor }),
|
|
695
|
+
error && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "row", marginBottom: 1, children: [
|
|
696
|
+
/* @__PURE__ */ jsx8(Text8, { color: "red", children: "\u25CF " }),
|
|
697
|
+
/* @__PURE__ */ jsx8(Text8, { color: "red", children: error })
|
|
698
|
+
] })
|
|
699
|
+
] });
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/ui/hooks/useAgentRunner.ts
|
|
703
|
+
import { useState as useState3, useRef } from "react";
|
|
704
|
+
|
|
705
|
+
// src/tools/edit_file.ts
|
|
706
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
707
|
+
var edit_file = {
|
|
708
|
+
name: "edit_file",
|
|
709
|
+
description: "Replace an exact string in a file. old_str must be unique.",
|
|
710
|
+
input_schema: {
|
|
711
|
+
type: "object",
|
|
712
|
+
properties: {
|
|
713
|
+
path: { type: "string", description: "File path" },
|
|
714
|
+
old_str: { type: "string", description: "Exact text to replace" },
|
|
715
|
+
new_str: { type: "string", description: "Replacement text" }
|
|
716
|
+
},
|
|
717
|
+
required: ["path", "old_str", "new_str"]
|
|
718
|
+
},
|
|
719
|
+
handler: ({ path, old_str, new_str }) => {
|
|
720
|
+
const src = readFileSync2(path, "utf-8");
|
|
721
|
+
const first = src.indexOf(old_str);
|
|
722
|
+
if (first === -1) {
|
|
723
|
+
return { content: `old_str not found in ${path}`, is_error: true };
|
|
724
|
+
}
|
|
725
|
+
if (src.indexOf(old_str, first + 1) !== -1) {
|
|
726
|
+
return { content: `old_str not unique in ${path}`, is_error: true };
|
|
727
|
+
}
|
|
728
|
+
writeFileSync2(path, src.slice(0, first) + new_str + src.slice(first + old_str.length), "utf-8");
|
|
729
|
+
return { content: `Edited ${path}` };
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// src/tools/read_file.ts
|
|
734
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
735
|
+
var read_file = {
|
|
736
|
+
name: "read_file",
|
|
737
|
+
description: "Read entire file contents as UTF-8 text.",
|
|
738
|
+
input_schema: {
|
|
739
|
+
type: "object",
|
|
740
|
+
properties: {
|
|
741
|
+
path: { type: "string", description: "File path" }
|
|
742
|
+
},
|
|
743
|
+
required: ["path"]
|
|
744
|
+
},
|
|
745
|
+
handler: ({ path }) => {
|
|
746
|
+
try {
|
|
747
|
+
const MAX = 2e5;
|
|
748
|
+
const raw = readFileSync3(path, "utf-8");
|
|
749
|
+
const truncated = raw.length > MAX;
|
|
750
|
+
const body = truncated ? raw.slice(0, MAX) + `
|
|
751
|
+
[truncated: ${raw.length - MAX} more chars]` : raw;
|
|
752
|
+
return { content: body };
|
|
753
|
+
} catch (err) {
|
|
754
|
+
return { content: err instanceof Error ? err.message : String(err), is_error: true };
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
// src/tools/write_file.ts
|
|
760
|
+
import { writeFileSync as writeFileSync3, mkdirSync as mkdirSync2 } from "fs";
|
|
761
|
+
import { dirname } from "path";
|
|
762
|
+
var write_file = {
|
|
763
|
+
name: "write_file",
|
|
764
|
+
description: "Create or overwrite a file with the given content. Parent dirs auto-created.",
|
|
765
|
+
input_schema: {
|
|
766
|
+
type: "object",
|
|
767
|
+
properties: {
|
|
768
|
+
path: { type: "string", description: "File path" },
|
|
769
|
+
content: { type: "string", description: "Full file content" }
|
|
770
|
+
},
|
|
771
|
+
required: ["path", "content"]
|
|
772
|
+
},
|
|
773
|
+
handler: ({ path, content }) => {
|
|
774
|
+
try {
|
|
775
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
776
|
+
writeFileSync3(path, content, "utf-8");
|
|
777
|
+
return { content: `Wrote ${path} (${content.length} bytes)` };
|
|
778
|
+
} catch (err) {
|
|
779
|
+
return { content: err instanceof Error ? err.message : String(err), is_error: true };
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
// src/tools/run_bash.ts
|
|
785
|
+
import { execa } from "execa";
|
|
786
|
+
var run_bash = {
|
|
787
|
+
name: "run_bash",
|
|
788
|
+
description: "Execute a shell command (bash on Unix, cmd on Windows). Returns stdout+stderr. Non-interactive only.",
|
|
789
|
+
input_schema: {
|
|
790
|
+
type: "object",
|
|
791
|
+
properties: {
|
|
792
|
+
command: { type: "string", description: "Shell command to run" },
|
|
793
|
+
timeout_ms: { type: "number", description: "Timeout in ms (default 30000)" }
|
|
794
|
+
},
|
|
795
|
+
required: ["command"]
|
|
796
|
+
},
|
|
797
|
+
handler: async ({ command, timeout_ms }) => {
|
|
798
|
+
try {
|
|
799
|
+
const isWin = process.platform === "win32";
|
|
800
|
+
const shell = isWin ? "cmd" : "bash";
|
|
801
|
+
const shellArgs = isWin ? ["/c", command] : ["-c", command];
|
|
802
|
+
const { stdout, stderr, exitCode } = await execa(shell, shellArgs, {
|
|
803
|
+
timeout: timeout_ms ?? 3e4,
|
|
804
|
+
reject: false,
|
|
805
|
+
all: false
|
|
806
|
+
});
|
|
807
|
+
const out = [stdout, stderr].filter(Boolean).join("\n");
|
|
808
|
+
const is_error = exitCode !== 0;
|
|
809
|
+
const body = out || (is_error ? `(no output)` : "");
|
|
810
|
+
const content = is_error ? `${body}
|
|
811
|
+
[exit ${exitCode}]` : body;
|
|
812
|
+
return {
|
|
813
|
+
content: content.slice(0, 32e3),
|
|
814
|
+
is_error
|
|
815
|
+
};
|
|
816
|
+
} catch (err) {
|
|
817
|
+
return { content: err instanceof Error ? err.message : String(err), is_error: true };
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
// src/tools/grep.ts
|
|
823
|
+
import { execa as execa2 } from "execa";
|
|
824
|
+
var grep = {
|
|
825
|
+
name: "grep",
|
|
826
|
+
description: "Search file contents for a regex pattern. Uses ripgrep if available, falls back to grep -R.",
|
|
827
|
+
input_schema: {
|
|
828
|
+
type: "object",
|
|
829
|
+
properties: {
|
|
830
|
+
pattern: { type: "string", description: "Regex pattern" },
|
|
831
|
+
path: { type: "string", description: "Root path to search (default cwd)" },
|
|
832
|
+
glob: { type: "string", description: 'File glob filter, e.g. "*.ts"' },
|
|
833
|
+
case_insensitive: { type: "string", description: 'Set "true" for case-insensitive' },
|
|
834
|
+
max_results: { type: "number", description: "Max matching lines (default 200)" }
|
|
835
|
+
},
|
|
836
|
+
required: ["pattern"]
|
|
837
|
+
},
|
|
838
|
+
handler: async ({ pattern, path, glob: glob2, case_insensitive, max_results }) => {
|
|
839
|
+
const root = path ?? process.cwd();
|
|
840
|
+
const limit = max_results ?? 200;
|
|
841
|
+
const ci = case_insensitive === true || String(case_insensitive) === "true";
|
|
842
|
+
const tryRg = async () => {
|
|
843
|
+
const args = ["--line-number", "--no-heading", "--color=never", "-m", String(limit)];
|
|
844
|
+
if (ci) args.push("-i");
|
|
845
|
+
if (glob2) args.push("--glob", glob2);
|
|
846
|
+
args.push("--", pattern, root);
|
|
847
|
+
return execa2("rg", args, { reject: false, timeout: 2e4 });
|
|
848
|
+
};
|
|
849
|
+
const tryGrep = async () => {
|
|
850
|
+
const args = ["-R", "-n", "--color=never"];
|
|
851
|
+
if (ci) args.push("-i");
|
|
852
|
+
if (glob2) args.push("--include", glob2);
|
|
853
|
+
args.push("--", pattern, root);
|
|
854
|
+
return execa2("grep", args, { reject: false, timeout: 2e4 });
|
|
855
|
+
};
|
|
856
|
+
try {
|
|
857
|
+
let res;
|
|
858
|
+
try {
|
|
859
|
+
res = await tryRg();
|
|
860
|
+
if (res.exitCode === 127 || (res.stderr ?? "").includes("command not found")) {
|
|
861
|
+
res = await tryGrep();
|
|
862
|
+
}
|
|
863
|
+
} catch {
|
|
864
|
+
res = await tryGrep();
|
|
865
|
+
}
|
|
866
|
+
const lines = (res.stdout ?? "").split("\n").slice(0, limit);
|
|
867
|
+
const out = lines.join("\n");
|
|
868
|
+
const code = res.exitCode ?? 0;
|
|
869
|
+
if (!out && code === 1) return { content: "No matches." };
|
|
870
|
+
return { content: out || res.stderr || "No matches.", is_error: code > 1 };
|
|
871
|
+
} catch (err) {
|
|
872
|
+
return { content: err instanceof Error ? err.message : String(err), is_error: true };
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// src/tools/glob.ts
|
|
878
|
+
import { execa as execa3 } from "execa";
|
|
879
|
+
function globToFindName(glob2) {
|
|
880
|
+
return glob2;
|
|
881
|
+
}
|
|
882
|
+
var glob = {
|
|
883
|
+
name: "glob",
|
|
884
|
+
description: 'List files matching a glob pattern (e.g. "**/*.ts"). Uses ripgrep --files if available.',
|
|
885
|
+
input_schema: {
|
|
886
|
+
type: "object",
|
|
887
|
+
properties: {
|
|
888
|
+
pattern: { type: "string", description: 'Glob pattern, e.g. "**/*.ts"' },
|
|
889
|
+
path: { type: "string", description: "Root path (default cwd)" },
|
|
890
|
+
max_results: { type: "number", description: "Max paths returned (default 500)" }
|
|
891
|
+
},
|
|
892
|
+
required: ["pattern"]
|
|
893
|
+
},
|
|
894
|
+
handler: async ({ pattern, path, max_results }) => {
|
|
895
|
+
const root = path ?? process.cwd();
|
|
896
|
+
const limit = max_results ?? 500;
|
|
897
|
+
const tryRg = () => execa3("rg", ["--files", "--hidden", "--glob", pattern, root], {
|
|
898
|
+
reject: false,
|
|
899
|
+
timeout: 2e4
|
|
900
|
+
});
|
|
901
|
+
const tryFind = () => {
|
|
902
|
+
const name = globToFindName(pattern.replace(/^\*\*\//, ""));
|
|
903
|
+
return execa3("find", [root, "-type", "f", "-name", name], {
|
|
904
|
+
reject: false,
|
|
905
|
+
timeout: 2e4
|
|
906
|
+
});
|
|
907
|
+
};
|
|
908
|
+
try {
|
|
909
|
+
let res;
|
|
910
|
+
try {
|
|
911
|
+
res = await tryRg();
|
|
912
|
+
if (res.exitCode === 127 || (res.stderr ?? "").includes("command not found")) {
|
|
913
|
+
res = await tryFind();
|
|
914
|
+
}
|
|
915
|
+
} catch {
|
|
916
|
+
res = await tryFind();
|
|
917
|
+
}
|
|
918
|
+
const lines = (res.stdout ?? "").split("\n").filter(Boolean).slice(0, limit);
|
|
919
|
+
if (lines.length === 0) return { content: "No files matched." };
|
|
920
|
+
return { content: lines.join("\n") };
|
|
921
|
+
} catch (err) {
|
|
922
|
+
return { content: err instanceof Error ? err.message : String(err), is_error: true };
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
// src/tools/registry.ts
|
|
928
|
+
var TOOLS = [
|
|
929
|
+
edit_file,
|
|
930
|
+
read_file,
|
|
931
|
+
write_file,
|
|
932
|
+
run_bash,
|
|
933
|
+
grep,
|
|
934
|
+
glob
|
|
935
|
+
];
|
|
936
|
+
function getTool(name) {
|
|
937
|
+
return TOOLS.find((t) => t.name === name);
|
|
938
|
+
}
|
|
939
|
+
function toOllamaTools(tools = TOOLS) {
|
|
940
|
+
return tools.map((t) => ({
|
|
941
|
+
type: "function",
|
|
942
|
+
function: {
|
|
943
|
+
name: t.name,
|
|
944
|
+
description: t.description,
|
|
945
|
+
parameters: {
|
|
946
|
+
type: "object",
|
|
947
|
+
properties: t.input_schema.properties,
|
|
948
|
+
required: t.input_schema.required
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// src/prompt/system.ts
|
|
955
|
+
function buildSystemPrompt(tools, cwd) {
|
|
956
|
+
const toolLines = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
|
|
957
|
+
return `You are miii, a senior software engineer running in a terminal.
|
|
958
|
+
|
|
959
|
+
Working directory: ${cwd}
|
|
960
|
+
|
|
961
|
+
# Goal Understanding (read this first, every turn)
|
|
962
|
+
Before acting on any request, extract and hold three things:
|
|
963
|
+
GOAL: what the user ultimately wants (outcome, not steps)
|
|
964
|
+
CRITERION: how you will know the goal is met
|
|
965
|
+
GAPS: anything unclear that would force you to guess
|
|
966
|
+
|
|
967
|
+
If GAPS is non-empty, ask the minimum questions needed to fill them \u2014 one message, numbered list \u2014 before touching any file or running any command. Do not guess. Do not act on assumptions.
|
|
968
|
+
|
|
969
|
+
Re-read GOAL before every tool call. If a tool call does not move toward GOAL, skip it.
|
|
970
|
+
|
|
971
|
+
# Attention: re-attend to goal at each step
|
|
972
|
+
After each tool result, answer silently: "Does this result move me toward GOAL?"
|
|
973
|
+
YES \u2192 continue
|
|
974
|
+
NO \u2192 stop, re-derive plan from GOAL, explain the correction in one line
|
|
975
|
+
|
|
976
|
+
This prevents drift. Each step attends to the original goal, not just the previous step.
|
|
977
|
+
|
|
978
|
+
# Output format
|
|
979
|
+
- Always reply in plain text. Never use Markdown syntax: no \`#\` headings, no \`**bold**\`, no \`-\` bullet lists, no fenced \`\`\` code blocks, no inline backticks.
|
|
980
|
+
- Quote code, paths, and identifiers inline as plain text. Do not wrap them.
|
|
981
|
+
- Keep prose terse.
|
|
982
|
+
|
|
983
|
+
# Engineering mindset
|
|
984
|
+
- Treat every request as one of: bug, feature, or fix. Name which one before you start.
|
|
985
|
+
- Apply first principles: decompose unclear tasks into smallest concrete sub-problems, solve each explicitly, compose the result.
|
|
986
|
+
- Never guess. If a fact (file path, function signature, current behavior) is unknown, read or search for it first.
|
|
987
|
+
|
|
988
|
+
# Clarifying questions \u2014 when to ask
|
|
989
|
+
Ask BEFORE acting when:
|
|
990
|
+
- The goal has more than one valid interpretation
|
|
991
|
+
- Success criterion is ambiguous (e.g. "make it better" \u2014 better how?)
|
|
992
|
+
- Required context is missing (which file? which behavior? which user?)
|
|
993
|
+
- Two reasonable approaches have different tradeoffs the user should choose
|
|
994
|
+
|
|
995
|
+
Do NOT ask when:
|
|
996
|
+
- The answer is findable by reading the codebase
|
|
997
|
+
- There is only one sensible interpretation
|
|
998
|
+
- The user has already answered this implicitly
|
|
999
|
+
|
|
1000
|
+
Ask in a numbered list. One round of questions per turn. Then wait.
|
|
1001
|
+
|
|
1002
|
+
# Tool calls
|
|
1003
|
+
- When you need a tool, emit the tool call directly. No preamble, no narration, no "I will use X".
|
|
1004
|
+
- Never describe a tool call instead of emitting it. If you cannot emit the call, answer in plain text.
|
|
1005
|
+
- After a tool result, move directly to the next tool call or the final answer. Do not restate what the previous tool did.
|
|
1006
|
+
|
|
1007
|
+
# Tools
|
|
1008
|
+
You have access to the following tools. Call them via the function-calling interface.
|
|
1009
|
+
${toolLines}
|
|
1010
|
+
|
|
1011
|
+
# Loop semantics
|
|
1012
|
+
- When you need to act on the filesystem or run a command, emit a tool call.
|
|
1013
|
+
- After each tool result, decide: more tool calls, or a final plain-text answer.
|
|
1014
|
+
- Stop emitting tool calls when GOAL is met. Reply with a concise plain-text final message confirming CRITERION is satisfied.
|
|
1015
|
+
|
|
1016
|
+
# Rules
|
|
1017
|
+
- Prefer editing existing files over creating new ones.
|
|
1018
|
+
- For edit_file, ensure old_str is unique within the target file.
|
|
1019
|
+
- Never invent file paths. Read, glob, or grep before editing.
|
|
1020
|
+
- No filler, no pleasantries, no apologies.
|
|
1021
|
+
|
|
1022
|
+
# Testing and verification
|
|
1023
|
+
- Always test the code after a change. Run the project's tests (e.g. npm test, pytest, go test) or the relevant script via run_bash before declaring a task done.
|
|
1024
|
+
- If no test exists for the change, run the affected entry point via run_bash to verify it behaves correctly.
|
|
1025
|
+
- Treat a green test run or a successful command as the completion signal. If it fails, fix and re-run.
|
|
1026
|
+
|
|
1027
|
+
# Permissions
|
|
1028
|
+
- When a new bash command pattern, file path, or glob pattern is needed, ask the user once; on approval it persists as a Tool(pattern) rule (e.g. Bash(npm test *), WriteFile(src/*)).
|
|
1029
|
+
`;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// src/permissions/policy.ts
|
|
1033
|
+
var DEFAULT_ALLOW = /* @__PURE__ */ new Set(["read_file"]);
|
|
1034
|
+
async function check(toolName, input, ctx) {
|
|
1035
|
+
if (DEFAULT_ALLOW.has(toolName)) return "allow";
|
|
1036
|
+
const answer = await ctx.ask(toolName, input);
|
|
1037
|
+
return answer === "no" ? "deny" : "allow";
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/agent/adapter.ts
|
|
1041
|
+
function mintToolUseId() {
|
|
1042
|
+
const rand = Math.random().toString(36).slice(2, 14);
|
|
1043
|
+
return `toolu_${rand}`;
|
|
1044
|
+
}
|
|
1045
|
+
function toOllamaMessages(history, system) {
|
|
1046
|
+
const out = [{ role: "system", content: system }];
|
|
1047
|
+
for (const msg of history) {
|
|
1048
|
+
if (typeof msg.content === "string") {
|
|
1049
|
+
out.push({ role: msg.role === "system" ? "system" : msg.role, content: msg.content });
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
if (msg.role === "assistant") {
|
|
1053
|
+
const text = msg.content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
1054
|
+
const tool_uses = msg.content.filter((b) => b.type === "tool_use");
|
|
1055
|
+
const ollamaMsg = { role: "assistant", content: text };
|
|
1056
|
+
if (tool_uses.length > 0) {
|
|
1057
|
+
ollamaMsg.tool_calls = tool_uses.map((u) => ({
|
|
1058
|
+
function: { name: u.name, arguments: u.input }
|
|
1059
|
+
}));
|
|
1060
|
+
}
|
|
1061
|
+
out.push(ollamaMsg);
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
if (msg.role === "user") {
|
|
1065
|
+
const tool_results = msg.content.filter((b) => b.type === "tool_result");
|
|
1066
|
+
const texts = msg.content.filter((b) => b.type === "text");
|
|
1067
|
+
for (const tr of tool_results) {
|
|
1068
|
+
out.push({ role: "tool", content: tr.content });
|
|
1069
|
+
}
|
|
1070
|
+
if (texts.length > 0) {
|
|
1071
|
+
out.push({ role: "user", content: texts.map((t) => t.text).join("") });
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return out;
|
|
1076
|
+
}
|
|
1077
|
+
function parseTextToolCalls(text, knownToolNames) {
|
|
1078
|
+
if (!text) return { calls: [], cleanedText: text };
|
|
1079
|
+
const calls = [];
|
|
1080
|
+
let cleaned = text;
|
|
1081
|
+
const tagRe = /<\|?tool_call\|?>\s*([\s\S]*?)\s*<\|?\/?tool_call\|?>/g;
|
|
1082
|
+
cleaned = cleaned.replace(tagRe, (_m, body) => {
|
|
1083
|
+
const c = tryParse(body, knownToolNames);
|
|
1084
|
+
if (c) calls.push(c);
|
|
1085
|
+
return "";
|
|
1086
|
+
});
|
|
1087
|
+
const fenceRe = /```(?:json|tool_call)?\s*([\s\S]*?)```/g;
|
|
1088
|
+
cleaned = cleaned.replace(fenceRe, (_m, body) => {
|
|
1089
|
+
const c = tryParse(body, knownToolNames);
|
|
1090
|
+
if (c) {
|
|
1091
|
+
calls.push(c);
|
|
1092
|
+
return "";
|
|
1093
|
+
}
|
|
1094
|
+
return _m;
|
|
1095
|
+
});
|
|
1096
|
+
if (calls.length === 0) {
|
|
1097
|
+
const candidate = extractFirstJsonObject(cleaned);
|
|
1098
|
+
if (candidate) {
|
|
1099
|
+
const c = tryParse(candidate.json, knownToolNames);
|
|
1100
|
+
if (c) {
|
|
1101
|
+
calls.push(c);
|
|
1102
|
+
cleaned = (cleaned.slice(0, candidate.start) + cleaned.slice(candidate.end)).trim();
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return { calls, cleanedText: cleaned.trim() };
|
|
1107
|
+
}
|
|
1108
|
+
function tryParse(raw, knownToolNames) {
|
|
1109
|
+
const s = raw.trim();
|
|
1110
|
+
if (!s.startsWith("{")) return null;
|
|
1111
|
+
try {
|
|
1112
|
+
const obj = JSON.parse(s);
|
|
1113
|
+
const name = typeof obj.name === "string" ? obj.name : void 0;
|
|
1114
|
+
const args = obj.arguments ?? obj.parameters ?? obj.input ?? {};
|
|
1115
|
+
if (!name || !knownToolNames.includes(name)) return null;
|
|
1116
|
+
return { function: { name, arguments: args } };
|
|
1117
|
+
} catch {
|
|
1118
|
+
return null;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function extractFirstJsonObject(s) {
|
|
1122
|
+
const start = s.indexOf("{");
|
|
1123
|
+
if (start === -1) return null;
|
|
1124
|
+
let depth = 0;
|
|
1125
|
+
let inStr = false;
|
|
1126
|
+
let esc = false;
|
|
1127
|
+
for (let i = start; i < s.length; i++) {
|
|
1128
|
+
const ch = s[i];
|
|
1129
|
+
if (inStr) {
|
|
1130
|
+
if (esc) esc = false;
|
|
1131
|
+
else if (ch === "\\") esc = true;
|
|
1132
|
+
else if (ch === '"') inStr = false;
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
if (ch === '"') {
|
|
1136
|
+
inStr = true;
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
if (ch === "{") depth++;
|
|
1140
|
+
else if (ch === "}") {
|
|
1141
|
+
depth--;
|
|
1142
|
+
if (depth === 0) return { json: s.slice(start, i + 1), start, end: i + 1 };
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
function blocksFromOllama(text, tool_calls, knownToolNames = []) {
|
|
1148
|
+
const blocks = [];
|
|
1149
|
+
let finalText = text;
|
|
1150
|
+
let finalCalls = tool_calls ?? [];
|
|
1151
|
+
if (finalCalls.length === 0 && knownToolNames.length > 0) {
|
|
1152
|
+
const parsed = parseTextToolCalls(text, knownToolNames);
|
|
1153
|
+
if (parsed.calls.length > 0) {
|
|
1154
|
+
finalCalls = parsed.calls;
|
|
1155
|
+
finalText = parsed.cleanedText;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (finalText) blocks.push({ type: "text", text: finalText });
|
|
1159
|
+
for (const tc of finalCalls) {
|
|
1160
|
+
blocks.push({
|
|
1161
|
+
type: "tool_use",
|
|
1162
|
+
id: mintToolUseId(),
|
|
1163
|
+
name: tc.function.name,
|
|
1164
|
+
input: tc.function.arguments ?? {}
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
return blocks;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/agent/loop.ts
|
|
1171
|
+
var MAX_TURNS = 25;
|
|
1172
|
+
var NUM_PREDICT = 4096;
|
|
1173
|
+
var REPEAT_TAIL = 120;
|
|
1174
|
+
var REPEAT_KILL = 4;
|
|
1175
|
+
async function* runAgent(opts) {
|
|
1176
|
+
const { model, cwd, permissions, hooks, signal, num_ctx } = opts;
|
|
1177
|
+
const startTime = Date.now();
|
|
1178
|
+
const system = buildSystemPrompt(TOOLS, cwd);
|
|
1179
|
+
const ollamaTools = toOllamaTools(TOOLS);
|
|
1180
|
+
const history = [
|
|
1181
|
+
...opts.history,
|
|
1182
|
+
{ role: "user", content: opts.userText }
|
|
1183
|
+
];
|
|
1184
|
+
let promptTokens = 0;
|
|
1185
|
+
let evalTokens = 0;
|
|
1186
|
+
let lastAssistantSig = "";
|
|
1187
|
+
let repeatCount = 0;
|
|
1188
|
+
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
|
1189
|
+
let text = "";
|
|
1190
|
+
let tool_calls;
|
|
1191
|
+
let lastTail = "";
|
|
1192
|
+
let tailRepeats = 0;
|
|
1193
|
+
let streamLooped = false;
|
|
1194
|
+
const ac = new AbortController();
|
|
1195
|
+
const composedSignal = signal ? AbortSignal.any ? AbortSignal.any([signal, ac.signal]) : ac.signal : ac.signal;
|
|
1196
|
+
if (signal) signal.addEventListener("abort", () => ac.abort(), { once: true });
|
|
1197
|
+
try {
|
|
1198
|
+
for await (const chunk of chat(model, toOllamaMessages(history, system), ollamaTools, { signal: composedSignal, num_ctx, num_predict: NUM_PREDICT })) {
|
|
1199
|
+
if (signal?.aborted) break;
|
|
1200
|
+
if (chunk.content) {
|
|
1201
|
+
text += chunk.content;
|
|
1202
|
+
yield { type: "text-delta", text: chunk.content };
|
|
1203
|
+
if (text.length >= REPEAT_TAIL) {
|
|
1204
|
+
const tail = text.slice(-REPEAT_TAIL);
|
|
1205
|
+
if (tail === lastTail) {
|
|
1206
|
+
tailRepeats++;
|
|
1207
|
+
if (tailRepeats >= REPEAT_KILL) {
|
|
1208
|
+
streamLooped = true;
|
|
1209
|
+
ac.abort();
|
|
1210
|
+
break;
|
|
1211
|
+
}
|
|
1212
|
+
} else {
|
|
1213
|
+
tailRepeats = 0;
|
|
1214
|
+
lastTail = tail;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
if (chunk.thinking) {
|
|
1219
|
+
yield { type: "thinking-delta", text: chunk.thinking };
|
|
1220
|
+
}
|
|
1221
|
+
if (chunk.tool_calls && chunk.tool_calls.length > 0) {
|
|
1222
|
+
tool_calls = chunk.tool_calls;
|
|
1223
|
+
}
|
|
1224
|
+
if (chunk.done) {
|
|
1225
|
+
promptTokens += chunk.prompt_eval_count ?? 0;
|
|
1226
|
+
evalTokens += chunk.eval_count ?? 0;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
} catch (err) {
|
|
1230
|
+
if (streamLooped) {
|
|
1231
|
+
yield { type: "error", message: "Model stuck in repetition. Aborted stream. Try a different model or shorten context." };
|
|
1232
|
+
return history;
|
|
1233
|
+
}
|
|
1234
|
+
yield { type: "error", message: err instanceof Error ? err.message : String(err) };
|
|
1235
|
+
return history;
|
|
1236
|
+
}
|
|
1237
|
+
if (streamLooped) {
|
|
1238
|
+
yield { type: "error", message: "Model stuck in repetition. Aborted stream. Try a different model or shorten context." };
|
|
1239
|
+
return history;
|
|
1240
|
+
}
|
|
1241
|
+
if (signal?.aborted) {
|
|
1242
|
+
yield {
|
|
1243
|
+
type: "aborted",
|
|
1244
|
+
prompt_tokens: promptTokens,
|
|
1245
|
+
eval_tokens: evalTokens,
|
|
1246
|
+
duration_ms: Date.now() - startTime
|
|
1247
|
+
};
|
|
1248
|
+
return history;
|
|
1249
|
+
}
|
|
1250
|
+
const blocks = blocksFromOllama(text, tool_calls, TOOLS.map((t) => t.name));
|
|
1251
|
+
const tool_uses = blocks.filter((b) => b.type === "tool_use");
|
|
1252
|
+
history.push({ role: "assistant", content: blocks });
|
|
1253
|
+
if (tool_uses.length === 0) {
|
|
1254
|
+
yield { type: "turn-end", stop_reason: "end_turn" };
|
|
1255
|
+
break;
|
|
1256
|
+
}
|
|
1257
|
+
const sig = JSON.stringify(
|
|
1258
|
+
blocks.map(
|
|
1259
|
+
(b) => b.type === "tool_use" ? { t: "u", n: b.name, i: b.input } : b.type === "text" ? { t: "t", x: b.text.trim() } : b
|
|
1260
|
+
)
|
|
1261
|
+
);
|
|
1262
|
+
if (sig === lastAssistantSig) {
|
|
1263
|
+
repeatCount++;
|
|
1264
|
+
if (repeatCount >= 2) {
|
|
1265
|
+
yield { type: "error", message: "Agent loop detected: assistant produced identical output 3 turns in a row" };
|
|
1266
|
+
return history;
|
|
1267
|
+
}
|
|
1268
|
+
} else {
|
|
1269
|
+
repeatCount = 0;
|
|
1270
|
+
lastAssistantSig = sig;
|
|
1271
|
+
}
|
|
1272
|
+
for (const u of tool_uses) yield { type: "tool-use", block: u };
|
|
1273
|
+
const results = [];
|
|
1274
|
+
for (const use of tool_uses) {
|
|
1275
|
+
const tool = getTool(use.name);
|
|
1276
|
+
if (!tool) {
|
|
1277
|
+
const r2 = {
|
|
1278
|
+
type: "tool_result",
|
|
1279
|
+
tool_use_id: use.id,
|
|
1280
|
+
content: `Unknown tool: ${use.name}`,
|
|
1281
|
+
is_error: true
|
|
1282
|
+
};
|
|
1283
|
+
results.push(r2);
|
|
1284
|
+
yield { type: "tool-result", block: r2 };
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
const decision = await check(use.name, use.input, permissions);
|
|
1288
|
+
if (decision === "deny") {
|
|
1289
|
+
const r2 = {
|
|
1290
|
+
type: "tool_result",
|
|
1291
|
+
tool_use_id: use.id,
|
|
1292
|
+
content: `Permission denied for ${use.name}.`,
|
|
1293
|
+
is_error: true
|
|
1294
|
+
};
|
|
1295
|
+
results.push(r2);
|
|
1296
|
+
yield { type: "permission-denied", toolName: use.name, tool_use_id: use.id };
|
|
1297
|
+
yield { type: "tool-result", block: r2 };
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
await hooks?.firePre(use);
|
|
1301
|
+
let r;
|
|
1302
|
+
try {
|
|
1303
|
+
const out = await tool.handler(use.input);
|
|
1304
|
+
r = {
|
|
1305
|
+
type: "tool_result",
|
|
1306
|
+
tool_use_id: use.id,
|
|
1307
|
+
content: out.content,
|
|
1308
|
+
is_error: out.is_error
|
|
1309
|
+
};
|
|
1310
|
+
} catch (err) {
|
|
1311
|
+
r = {
|
|
1312
|
+
type: "tool_result",
|
|
1313
|
+
tool_use_id: use.id,
|
|
1314
|
+
content: err instanceof Error ? err.message : String(err),
|
|
1315
|
+
is_error: true
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
await hooks?.firePost(use, r);
|
|
1319
|
+
results.push(r);
|
|
1320
|
+
yield { type: "tool-result", block: r };
|
|
1321
|
+
}
|
|
1322
|
+
history.push({ role: "user", content: results });
|
|
1323
|
+
yield { type: "turn-end", stop_reason: "tool_use" };
|
|
1324
|
+
}
|
|
1325
|
+
yield { type: "done", prompt_tokens: promptTokens, eval_tokens: evalTokens };
|
|
1326
|
+
return history;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// src/ui/hooks/useAgentRunner.ts
|
|
1330
|
+
var FLUSH_MS = 100;
|
|
1331
|
+
function useAgentRunner(model, activeCtx) {
|
|
1332
|
+
const [messages, setMessages] = useState3([]);
|
|
1333
|
+
const [thinking, setThinking] = useState3(false);
|
|
1334
|
+
const [thinkingContent, setThinkingContent] = useState3("");
|
|
1335
|
+
const [streaming, setStreaming] = useState3(false);
|
|
1336
|
+
const [streamingContent, setStreamingContent] = useState3("");
|
|
1337
|
+
const [error, setError] = useState3(null);
|
|
1338
|
+
const [busy, setBusy] = useState3(false);
|
|
1339
|
+
const [processingLabel, setProcessingLabel] = useState3(void 0);
|
|
1340
|
+
const [agentHistory, setAgentHistory] = useState3([]);
|
|
1341
|
+
const [pendingPermission, setPendingPermission] = useState3(null);
|
|
1342
|
+
const [permissionCursor, setPermissionCursor] = useState3(0);
|
|
1343
|
+
const [activeToolUses, setActiveToolUses] = useState3([]);
|
|
1344
|
+
const [activeToolResults, setActiveToolResults] = useState3([]);
|
|
1345
|
+
const busyRef = useRef(false);
|
|
1346
|
+
const abortRef = useRef(null);
|
|
1347
|
+
const pendingPermissionRef = useRef(null);
|
|
1348
|
+
function askPermission(toolName, input) {
|
|
1349
|
+
return new Promise((resolve) => {
|
|
1350
|
+
const req = { toolName, input, resolve };
|
|
1351
|
+
pendingPermissionRef.current = req;
|
|
1352
|
+
setPermissionCursor(0);
|
|
1353
|
+
setPendingPermission(req);
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
function resolvePermission(cursor) {
|
|
1357
|
+
const req = pendingPermissionRef.current;
|
|
1358
|
+
if (!req) return;
|
|
1359
|
+
const answers = ["yes", "no"];
|
|
1360
|
+
pendingPermissionRef.current = null;
|
|
1361
|
+
setPendingPermission(null);
|
|
1362
|
+
req.resolve(answers[cursor]);
|
|
1363
|
+
}
|
|
1364
|
+
async function sendMessage(text) {
|
|
1365
|
+
if (busyRef.current || !model) return;
|
|
1366
|
+
busyRef.current = true;
|
|
1367
|
+
setBusy(true);
|
|
1368
|
+
setProcessingLabel("thinking\u2026");
|
|
1369
|
+
setError(null);
|
|
1370
|
+
setMessages((prev) => [...prev, { role: "user", content: text }]);
|
|
1371
|
+
setThinking(true);
|
|
1372
|
+
let accumulated = "";
|
|
1373
|
+
let thinkingAcc = "";
|
|
1374
|
+
let firstToken = true;
|
|
1375
|
+
setThinkingContent("");
|
|
1376
|
+
let streamFlushAt = 0;
|
|
1377
|
+
let thinkFlushAt = 0;
|
|
1378
|
+
const flushStream = (force = false) => {
|
|
1379
|
+
const now = Date.now();
|
|
1380
|
+
if (force || now - streamFlushAt >= FLUSH_MS) {
|
|
1381
|
+
streamFlushAt = now;
|
|
1382
|
+
setStreamingContent(accumulated);
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
const flushThink = (force = false) => {
|
|
1386
|
+
const now = Date.now();
|
|
1387
|
+
if (force || now - thinkFlushAt >= FLUSH_MS) {
|
|
1388
|
+
thinkFlushAt = now;
|
|
1389
|
+
setThinkingContent(thinkingAcc);
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
let turnUses = [];
|
|
1393
|
+
let turnResults = [];
|
|
1394
|
+
const startTime = Date.now();
|
|
1395
|
+
const flushTurn = (final) => {
|
|
1396
|
+
const msg = {
|
|
1397
|
+
role: "assistant",
|
|
1398
|
+
content: accumulated,
|
|
1399
|
+
tool_uses: turnUses.length ? turnUses : void 0,
|
|
1400
|
+
tool_results: turnResults.length ? turnResults : void 0
|
|
1401
|
+
};
|
|
1402
|
+
if (final) {
|
|
1403
|
+
msg.tokens = { prompt_eval: final.prompt, eval: final.eval };
|
|
1404
|
+
msg.duration = Date.now() - startTime;
|
|
1405
|
+
}
|
|
1406
|
+
setMessages((prev) => [...prev, msg]);
|
|
1407
|
+
accumulated = "";
|
|
1408
|
+
turnUses = [];
|
|
1409
|
+
turnResults = [];
|
|
1410
|
+
setStreamingContent("");
|
|
1411
|
+
setActiveToolUses([]);
|
|
1412
|
+
setActiveToolResults([]);
|
|
1413
|
+
};
|
|
1414
|
+
const controller = new AbortController();
|
|
1415
|
+
abortRef.current = controller;
|
|
1416
|
+
try {
|
|
1417
|
+
const gen = runAgent({
|
|
1418
|
+
model,
|
|
1419
|
+
cwd: process.cwd(),
|
|
1420
|
+
history: agentHistory,
|
|
1421
|
+
userText: text,
|
|
1422
|
+
permissions: { ask: askPermission },
|
|
1423
|
+
signal: controller.signal,
|
|
1424
|
+
num_ctx: activeCtx ?? void 0
|
|
1425
|
+
});
|
|
1426
|
+
let finalTokens = { prompt: 0, eval: 0 };
|
|
1427
|
+
let result;
|
|
1428
|
+
while (true) {
|
|
1429
|
+
result = await gen.next();
|
|
1430
|
+
if (result.done) {
|
|
1431
|
+
setAgentHistory(result.value);
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1434
|
+
const ev = result.value;
|
|
1435
|
+
switch (ev.type) {
|
|
1436
|
+
case "text-delta": {
|
|
1437
|
+
if (firstToken) {
|
|
1438
|
+
firstToken = false;
|
|
1439
|
+
setStreaming(true);
|
|
1440
|
+
}
|
|
1441
|
+
setThinking(false);
|
|
1442
|
+
setProcessingLabel("responding\u2026");
|
|
1443
|
+
accumulated += ev.text;
|
|
1444
|
+
flushStream();
|
|
1445
|
+
break;
|
|
1446
|
+
}
|
|
1447
|
+
case "thinking-delta": {
|
|
1448
|
+
thinkingAcc += ev.text;
|
|
1449
|
+
setThinking(true);
|
|
1450
|
+
setProcessingLabel("thinking\u2026");
|
|
1451
|
+
flushThink();
|
|
1452
|
+
break;
|
|
1453
|
+
}
|
|
1454
|
+
case "tool-use": {
|
|
1455
|
+
turnUses.push({ id: ev.block.id, name: ev.block.name, input: ev.block.input });
|
|
1456
|
+
setActiveToolUses([...turnUses]);
|
|
1457
|
+
setProcessingLabel(`running ${ev.block.name}\u2026`);
|
|
1458
|
+
break;
|
|
1459
|
+
}
|
|
1460
|
+
case "tool-result": {
|
|
1461
|
+
turnResults.push({
|
|
1462
|
+
tool_use_id: ev.block.tool_use_id,
|
|
1463
|
+
content: ev.block.content,
|
|
1464
|
+
is_error: ev.block.is_error
|
|
1465
|
+
});
|
|
1466
|
+
setActiveToolResults([...turnResults]);
|
|
1467
|
+
setProcessingLabel("thinking\u2026");
|
|
1468
|
+
break;
|
|
1469
|
+
}
|
|
1470
|
+
case "turn-end": {
|
|
1471
|
+
flushStream(true);
|
|
1472
|
+
flushThink(true);
|
|
1473
|
+
setStreaming(false);
|
|
1474
|
+
if (ev.stop_reason === "tool_use") {
|
|
1475
|
+
flushTurn(null);
|
|
1476
|
+
setThinking(true);
|
|
1477
|
+
thinkingAcc = "";
|
|
1478
|
+
setThinkingContent("");
|
|
1479
|
+
firstToken = true;
|
|
1480
|
+
}
|
|
1481
|
+
break;
|
|
1482
|
+
}
|
|
1483
|
+
case "done": {
|
|
1484
|
+
finalTokens = { prompt: ev.prompt_tokens, eval: ev.eval_tokens };
|
|
1485
|
+
break;
|
|
1486
|
+
}
|
|
1487
|
+
case "aborted": {
|
|
1488
|
+
finalTokens = { prompt: ev.prompt_tokens, eval: ev.eval_tokens };
|
|
1489
|
+
setStreaming(false);
|
|
1490
|
+
setThinking(false);
|
|
1491
|
+
flushTurn(finalTokens);
|
|
1492
|
+
setError(`Aborted \xB7 ${ev.prompt_tokens + ev.eval_tokens} tokens \xB7 ${(ev.duration_ms / 1e3).toFixed(1)}s`);
|
|
1493
|
+
break;
|
|
1494
|
+
}
|
|
1495
|
+
case "error": {
|
|
1496
|
+
setError(ev.message);
|
|
1497
|
+
break;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
setStreaming(false);
|
|
1502
|
+
setThinking(false);
|
|
1503
|
+
if (accumulated || turnUses.length || turnResults.length) flushTurn(finalTokens);
|
|
1504
|
+
} catch (err) {
|
|
1505
|
+
const aborted = controller.signal.aborted;
|
|
1506
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1507
|
+
setThinking(false);
|
|
1508
|
+
setStreaming(false);
|
|
1509
|
+
if (accumulated || turnUses.length || turnResults.length) flushTurn(null);
|
|
1510
|
+
setError(aborted ? `Aborted \xB7 ${((Date.now() - startTime) / 1e3).toFixed(1)}s` : msg);
|
|
1511
|
+
}
|
|
1512
|
+
abortRef.current = null;
|
|
1513
|
+
busyRef.current = false;
|
|
1514
|
+
setBusy(false);
|
|
1515
|
+
setProcessingLabel(void 0);
|
|
1516
|
+
}
|
|
1517
|
+
return {
|
|
1518
|
+
// state
|
|
1519
|
+
messages,
|
|
1520
|
+
setMessages,
|
|
1521
|
+
thinking,
|
|
1522
|
+
thinkingContent,
|
|
1523
|
+
setThinkingContent,
|
|
1524
|
+
streaming,
|
|
1525
|
+
streamingContent,
|
|
1526
|
+
setStreamingContent,
|
|
1527
|
+
error,
|
|
1528
|
+
setError,
|
|
1529
|
+
busy,
|
|
1530
|
+
processingLabel,
|
|
1531
|
+
agentHistory,
|
|
1532
|
+
setAgentHistory,
|
|
1533
|
+
pendingPermission,
|
|
1534
|
+
permissionCursor,
|
|
1535
|
+
setPermissionCursor,
|
|
1536
|
+
activeToolUses,
|
|
1537
|
+
setActiveToolUses,
|
|
1538
|
+
activeToolResults,
|
|
1539
|
+
setActiveToolResults,
|
|
1540
|
+
// refs (for keyboard handler)
|
|
1541
|
+
busyRef,
|
|
1542
|
+
abortRef,
|
|
1543
|
+
pendingPermissionRef,
|
|
1544
|
+
// actions
|
|
1545
|
+
sendMessage,
|
|
1546
|
+
resolvePermission
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// src/ui/hooks/useKeyboard.ts
|
|
1551
|
+
import { useInput } from "ink";
|
|
1552
|
+
var EFFORTS = ["low", "medium", "high"];
|
|
1553
|
+
function useKeyboard(opts) {
|
|
1554
|
+
const {
|
|
1555
|
+
exit,
|
|
1556
|
+
state,
|
|
1557
|
+
setState,
|
|
1558
|
+
models,
|
|
1559
|
+
cursor,
|
|
1560
|
+
setCursor,
|
|
1561
|
+
contexts,
|
|
1562
|
+
cfg,
|
|
1563
|
+
setCfg,
|
|
1564
|
+
setActiveCtx,
|
|
1565
|
+
pendingPermissionRef,
|
|
1566
|
+
permissionCursor,
|
|
1567
|
+
setPermissionCursor,
|
|
1568
|
+
resolvePermission,
|
|
1569
|
+
busyRef,
|
|
1570
|
+
abortRef,
|
|
1571
|
+
input,
|
|
1572
|
+
setInput,
|
|
1573
|
+
paletteCursor,
|
|
1574
|
+
setPaletteCursor,
|
|
1575
|
+
filePickerCursor,
|
|
1576
|
+
setFilePickerCursor,
|
|
1577
|
+
sendMessage,
|
|
1578
|
+
setMessages,
|
|
1579
|
+
setAgentHistory,
|
|
1580
|
+
setStreamingContent,
|
|
1581
|
+
setThinkingContent,
|
|
1582
|
+
setActiveToolUses,
|
|
1583
|
+
setActiveToolResults,
|
|
1584
|
+
setError
|
|
1585
|
+
} = opts;
|
|
1586
|
+
const effort = cfg.effort ?? "medium";
|
|
1587
|
+
useInput((char, key) => {
|
|
1588
|
+
if (key.ctrl && char === "c") {
|
|
1589
|
+
exit();
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (key.ctrl && char === "t") {
|
|
1593
|
+
toggleThinkingVisible();
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (key.escape && busyRef.current && abortRef.current) {
|
|
1597
|
+
abortRef.current.abort();
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
if (state === "select-model" || state === "models") {
|
|
1601
|
+
if (key.upArrow) {
|
|
1602
|
+
setCursor((i) => Math.max(0, i - 1));
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
if (key.downArrow) {
|
|
1606
|
+
setCursor((i) => Math.min(models.length - 1, i + 1));
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
if (key.return && models[cursor]) {
|
|
1610
|
+
const chosen = models[cursor];
|
|
1611
|
+
setModel(chosen);
|
|
1612
|
+
setCfg((c) => ({ ...c, model: chosen }));
|
|
1613
|
+
if (contexts[chosen]) setActiveCtx(contexts[chosen]);
|
|
1614
|
+
setState("ready");
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (state === "models") {
|
|
1618
|
+
if (key.rightArrow) {
|
|
1619
|
+
const next = EFFORTS[Math.min(EFFORTS.indexOf(effort) + 1, EFFORTS.length - 1)];
|
|
1620
|
+
setEffort(next);
|
|
1621
|
+
setCfg((c) => ({ ...c, effort: next }));
|
|
1622
|
+
} else if (key.leftArrow) {
|
|
1623
|
+
const next = EFFORTS[Math.max(EFFORTS.indexOf(effort) - 1, 0)];
|
|
1624
|
+
setEffort(next);
|
|
1625
|
+
setCfg((c) => ({ ...c, effort: next }));
|
|
1626
|
+
} else if (key.escape) {
|
|
1627
|
+
setState("ready");
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
if (state === "ready" && pendingPermissionRef.current) {
|
|
1633
|
+
if (key.upArrow) {
|
|
1634
|
+
setPermissionCursor((i) => Math.max(0, i - 1));
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
if (key.downArrow) {
|
|
1638
|
+
setPermissionCursor((i) => Math.min(1, i + 1));
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
if (key.return) {
|
|
1642
|
+
resolvePermission(permissionCursor);
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (state === "ready") {
|
|
1648
|
+
if (busyRef.current) return;
|
|
1649
|
+
const paletteOpen = input.startsWith("/");
|
|
1650
|
+
const matches = paletteOpen ? filteredCommands(input) : [];
|
|
1651
|
+
const mention = !paletteOpen ? parseMention(input) : null;
|
|
1652
|
+
const fileMatches = mention ? searchFiles(process.cwd(), mention.query) : [];
|
|
1653
|
+
const fileOpen = mention !== null && fileMatches.length > 0;
|
|
1654
|
+
if (paletteOpen && key.upArrow) {
|
|
1655
|
+
setPaletteCursor((i) => Math.max(0, i - 1));
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
if (paletteOpen && key.downArrow) {
|
|
1659
|
+
setPaletteCursor((i) => Math.min(matches.length - 1, i + 1));
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
if (paletteOpen && (key.tab || key.return) && matches[paletteCursor] && input !== matches[paletteCursor].name) {
|
|
1663
|
+
setInput(() => matches[paletteCursor].name);
|
|
1664
|
+
setPaletteCursor(() => 0);
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
if (paletteOpen && key.escape) {
|
|
1668
|
+
setInput(() => "");
|
|
1669
|
+
setPaletteCursor(() => 0);
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
if (fileOpen && key.upArrow) {
|
|
1673
|
+
setFilePickerCursor((i) => Math.max(0, i - 1));
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
if (fileOpen && key.downArrow) {
|
|
1677
|
+
setFilePickerCursor((i) => Math.min(fileMatches.length - 1, i + 1));
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
if (fileOpen && key.tab && fileMatches[filePickerCursor]) {
|
|
1681
|
+
const picked = fileMatches[filePickerCursor];
|
|
1682
|
+
setInput((s) => s.slice(0, mention.start) + "@" + picked + " ");
|
|
1683
|
+
setFilePickerCursor(() => 0);
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
if (fileOpen && key.escape) {
|
|
1687
|
+
setFilePickerCursor(() => 0);
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
if (key.return) {
|
|
1691
|
+
const trimmed = input.trim();
|
|
1692
|
+
if (trimmed === "/models") {
|
|
1693
|
+
setCursor(() => Math.max(0, models.findIndex((m) => m === cfg.model)));
|
|
1694
|
+
setState("models");
|
|
1695
|
+
} else if (trimmed === "/clear") {
|
|
1696
|
+
setMessages(() => []);
|
|
1697
|
+
setAgentHistory([]);
|
|
1698
|
+
setStreamingContent("");
|
|
1699
|
+
setThinkingContent("");
|
|
1700
|
+
setActiveToolUses([]);
|
|
1701
|
+
setActiveToolResults([]);
|
|
1702
|
+
setError(null);
|
|
1703
|
+
} else if (trimmed === "/exit") {
|
|
1704
|
+
exit();
|
|
1705
|
+
} else if (trimmed) {
|
|
1706
|
+
sendMessage(trimmed);
|
|
1707
|
+
}
|
|
1708
|
+
setInput(() => "");
|
|
1709
|
+
setPaletteCursor(() => 0);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
if (key.backspace || key.delete) {
|
|
1713
|
+
setInput((s) => {
|
|
1714
|
+
setPaletteCursor(() => 0);
|
|
1715
|
+
setFilePickerCursor(() => 0);
|
|
1716
|
+
return s.slice(0, -1);
|
|
1717
|
+
});
|
|
1718
|
+
} else if (char && !key.ctrl && !key.meta && !key.tab) {
|
|
1719
|
+
setInput((s) => {
|
|
1720
|
+
setPaletteCursor(() => 0);
|
|
1721
|
+
setFilePickerCursor(() => 0);
|
|
1722
|
+
return s + char;
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// src/ui/App.tsx
|
|
1730
|
+
import { Fragment as Fragment2, jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
|
|
1731
|
+
function App() {
|
|
1732
|
+
const { exit } = useApp();
|
|
1733
|
+
const cwd = process.cwd().replace(homedir2(), "~").split(sep).join("/");
|
|
1734
|
+
const [cfg, setCfg] = useState4(loadConfig());
|
|
1735
|
+
const [models, setModels] = useState4([]);
|
|
1736
|
+
const [contexts, setContexts] = useState4({});
|
|
1737
|
+
const [activeCtx, setActiveCtx] = useState4(null);
|
|
1738
|
+
const [state, setState] = useState4("loading");
|
|
1739
|
+
const [cursor, setCursor] = useState4(0);
|
|
1740
|
+
const [input, setInput] = useState4("");
|
|
1741
|
+
const [paletteCursor, setPaletteCursor] = useState4(0);
|
|
1742
|
+
const [filePickerCursor, setFilePickerCursor] = useState4(0);
|
|
1743
|
+
const agent = useAgentRunner(cfg.model, activeCtx);
|
|
1744
|
+
useEffect3(() => {
|
|
1745
|
+
listModels().then((m) => {
|
|
1746
|
+
setModels(m);
|
|
1747
|
+
setState(cfg.model ? "ready" : "select-model");
|
|
1748
|
+
Promise.all(m.map((name) => modelContext(name).then((ctx) => [name, ctx]))).then((pairs) => {
|
|
1749
|
+
const map = Object.fromEntries(pairs);
|
|
1750
|
+
setContexts(map);
|
|
1751
|
+
const active = cfg.model ?? m[0];
|
|
1752
|
+
if (active && map[active]) setActiveCtx(map[active]);
|
|
1753
|
+
}).catch(() => {
|
|
1754
|
+
});
|
|
1755
|
+
}).catch((err) => {
|
|
1756
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1757
|
+
agent.setError(msg);
|
|
1758
|
+
setModels([]);
|
|
1759
|
+
setState(cfg.model ? "ready" : "select-model");
|
|
1760
|
+
});
|
|
1761
|
+
}, []);
|
|
1762
|
+
useKeyboard({
|
|
1763
|
+
exit,
|
|
1764
|
+
state,
|
|
1765
|
+
setState,
|
|
1766
|
+
models,
|
|
1767
|
+
cursor,
|
|
1768
|
+
setCursor,
|
|
1769
|
+
contexts,
|
|
1770
|
+
cfg,
|
|
1771
|
+
setCfg,
|
|
1772
|
+
setActiveCtx,
|
|
1773
|
+
pendingPermissionRef: agent.pendingPermissionRef,
|
|
1774
|
+
permissionCursor: agent.permissionCursor,
|
|
1775
|
+
setPermissionCursor: agent.setPermissionCursor,
|
|
1776
|
+
resolvePermission: agent.resolvePermission,
|
|
1777
|
+
busyRef: agent.busyRef,
|
|
1778
|
+
abortRef: agent.abortRef,
|
|
1779
|
+
input,
|
|
1780
|
+
setInput,
|
|
1781
|
+
paletteCursor,
|
|
1782
|
+
setPaletteCursor,
|
|
1783
|
+
filePickerCursor,
|
|
1784
|
+
setFilePickerCursor,
|
|
1785
|
+
sendMessage: agent.sendMessage,
|
|
1786
|
+
setMessages: agent.setMessages,
|
|
1787
|
+
setAgentHistory: agent.setAgentHistory,
|
|
1788
|
+
setStreamingContent: agent.setStreamingContent,
|
|
1789
|
+
setThinkingContent: agent.setThinkingContent,
|
|
1790
|
+
setActiveToolUses: agent.setActiveToolUses,
|
|
1791
|
+
setActiveToolResults: agent.setActiveToolResults,
|
|
1792
|
+
setError: agent.setError
|
|
1793
|
+
});
|
|
1794
|
+
const effort = cfg.effort ?? "medium";
|
|
1795
|
+
const contextWarning = (() => {
|
|
1796
|
+
if (!activeCtx) return null;
|
|
1797
|
+
const last = [...agent.messages].reverse().find((m) => m.role === "assistant" && m.tokens);
|
|
1798
|
+
const used = last?.tokens ? last.tokens.prompt_eval + last.tokens.eval : 0;
|
|
1799
|
+
if (used < activeCtx * 0.7) return null;
|
|
1800
|
+
return Math.round(used / activeCtx * 100);
|
|
1801
|
+
})();
|
|
1802
|
+
return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", paddingX: 1, children: [
|
|
1803
|
+
/* @__PURE__ */ jsx9(WelcomeBlock, { model: cfg.model, activeCtx, effort, cwd, error: agent.error }),
|
|
1804
|
+
state === "loading" && !agent.error && /* @__PURE__ */ jsx9(Box9, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "connecting to ollama\u2026" }) }),
|
|
1805
|
+
agent.error && state !== "ready" && /* @__PURE__ */ jsx9(
|
|
1806
|
+
ChatView,
|
|
1807
|
+
{
|
|
1808
|
+
messages: [],
|
|
1809
|
+
streaming: false,
|
|
1810
|
+
streamingContent: "",
|
|
1811
|
+
thinking: false,
|
|
1812
|
+
error: agent.error
|
|
1813
|
+
}
|
|
1814
|
+
),
|
|
1815
|
+
state === "select-model" && /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", marginLeft: 2, children: [
|
|
1816
|
+
/* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "no model configured \u2014 select one" }),
|
|
1817
|
+
/* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(ModelList, { models, cursor }) }),
|
|
1818
|
+
models.length > 0 && /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "\u2191\u2193 navigate enter select ctrl+c quit" }) })
|
|
1819
|
+
] }),
|
|
1820
|
+
state === "models" && /* @__PURE__ */ jsx9(
|
|
1821
|
+
ModelsView,
|
|
1822
|
+
{
|
|
1823
|
+
models,
|
|
1824
|
+
cursor,
|
|
1825
|
+
model: cfg.model,
|
|
1826
|
+
ollamaHost: cfg.ollamaHost,
|
|
1827
|
+
effort
|
|
1828
|
+
}
|
|
1829
|
+
),
|
|
1830
|
+
state === "ready" && /* @__PURE__ */ jsxs9(Fragment2, { children: [
|
|
1831
|
+
/* @__PURE__ */ jsx9(
|
|
1832
|
+
ChatView,
|
|
1833
|
+
{
|
|
1834
|
+
messages: agent.messages,
|
|
1835
|
+
streaming: agent.streaming,
|
|
1836
|
+
streamingContent: agent.streamingContent,
|
|
1837
|
+
thinking: agent.thinking,
|
|
1838
|
+
thinkingContent: agent.thinkingContent,
|
|
1839
|
+
error: agent.error,
|
|
1840
|
+
pendingPermission: agent.pendingPermission,
|
|
1841
|
+
permissionCursor: agent.permissionCursor,
|
|
1842
|
+
activeToolUses: agent.activeToolUses,
|
|
1843
|
+
activeToolResults: agent.activeToolResults
|
|
1844
|
+
}
|
|
1845
|
+
),
|
|
1846
|
+
input.startsWith("/") && /* @__PURE__ */ jsx9(CommandPalette, { filter: input, cursor: paletteCursor }),
|
|
1847
|
+
contextWarning !== null && /* @__PURE__ */ jsx9(Box9, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx9(Text9, { color: "yellow", children: `\u26A0 context ${contextWarning}% full \u2014 run /clear and start fresh` }) }),
|
|
1848
|
+
!input.startsWith("/") && (() => {
|
|
1849
|
+
const m = parseMention(input);
|
|
1850
|
+
if (!m) return null;
|
|
1851
|
+
return /* @__PURE__ */ jsx9(FilePicker, { matches: searchFiles(process.cwd(), m.query), cursor: filePickerCursor });
|
|
1852
|
+
})(),
|
|
1853
|
+
/* @__PURE__ */ jsx9(InputBar, { input, disabled: agent.busy, processingLabel: agent.processingLabel }),
|
|
1854
|
+
!agent.busy && /* @__PURE__ */ jsx9(Box9, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "type / to see commands" }) })
|
|
1855
|
+
] })
|
|
1856
|
+
] });
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// src/cli.tsx
|
|
1860
|
+
render(createElement(App));
|