amness 0.1.2 → 0.1.4
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 -2
- package/dist/cli/amness.js +4 -4
- package/dist/cli/config.d.ts +22 -1
- package/dist/cli/config.js +102 -40
- package/dist/cli/evalcli.d.ts +18 -1
- package/dist/cli/evalcli.js +53 -4
- package/dist/cli/models.d.ts +8 -0
- package/dist/cli/models.js +31 -0
- package/dist/cli/tui/app.d.ts +3 -1
- package/dist/cli/tui/app.js +34 -7
- package/dist/cli/tui/argcomplete.d.ts +12 -0
- package/dist/cli/tui/argcomplete.js +55 -0
- package/dist/cli/tui/cards.d.ts +2 -0
- package/dist/cli/tui/cards.js +4 -3
- package/dist/cli/tui/commands.js +98 -26
- package/dist/cli/tui/complete.d.ts +23 -3
- package/dist/cli/tui/complete.js +38 -3
- package/dist/cli/tui/full.d.ts +4 -0
- package/dist/cli/tui/full.js +42 -12
- package/dist/cli/tui/looppanel.d.ts +71 -0
- package/dist/cli/tui/looppanel.js +218 -0
- package/dist/cli/tui/sidebar.d.ts +4 -18
- package/dist/cli/tui/sidebar.js +8 -10
- package/dist/src/evals.d.ts +23 -2
- package/dist/src/evals.js +19 -5
- package/docs/agent-loop.md +15 -2
- package/docs/evaluation.md +32 -0
- package/docs/providers.md +13 -1
- package/docs/safety.md +3 -3
- package/docs/usage.md +51 -4
- package/package.json +1 -1
package/dist/cli/tui/cards.js
CHANGED
|
@@ -123,7 +123,8 @@ export function renderBlock(b, c) {
|
|
|
123
123
|
// ---------------------------------------------------------------- tool card
|
|
124
124
|
const sub = !!b.agent && b.agent !== "lead", failed = b.status === "fail", edit = b.edit;
|
|
125
125
|
const hue = failed ? t.err : ({ read_file: t.tool.read, list_files: t.tool.read, grep: t.tool.read, write_file: t.tool.edit, edit_file: t.tool.edit, apply_patch: t.tool.edit, sh: t.tool.shell, web_search: t.tool.web, fetch_url: t.tool.web, memory_save: t.tool.memory, memory_forget: t.tool.memory, memory_search: t.tool.read, todo_write: t.tool.plan, exit_plan_mode: t.tool.plan, task: t.tool.explore })[b.name] ?? (b.name.includes("__") ? t.tool.web : t.tool.generic);
|
|
126
|
-
const expanded = c.expandAll ? true : b.expanded ?? (!!edit || failed);
|
|
126
|
+
const expanded = c.expandAll ? true : b.expanded ?? (!!edit || failed || (!!c.last && !sub && b.name !== "read_file" && b.name !== "task"));
|
|
127
|
+
const preview = !c.expandAll && b.expanded === undefined && !edit && !failed && !!c.last && b.name !== "read_file" && b.name !== "task"; // opened automatically, not by the user: show a short preview only
|
|
127
128
|
const bg = failed ? mix(t.surface, t.err, 0.14) : t.surface, spinner = SPIN[c.frame % SPIN.length];
|
|
128
129
|
const label = BOLD(F(failed ? t.err : t.text, toolLabel(b.name))), args = F(t.dim, argSummary(b.name, b.args));
|
|
129
130
|
const status = b.status === "running"
|
|
@@ -163,7 +164,7 @@ export function renderBlock(b, c) {
|
|
|
163
164
|
}
|
|
164
165
|
else {
|
|
165
166
|
const res = b.result ?? "", hl = (k, s) => F(k === "plain" ? t.syn.plain : t.syn[k], s);
|
|
166
|
-
let lines = res.split("\n"), cap = c.expandAll ? 200 : 60;
|
|
167
|
+
let lines = res.split("\n"), cap = c.expandAll ? 200 : preview ? 20 : 60;
|
|
167
168
|
if (b.name === "sh") {
|
|
168
169
|
let cmd = "";
|
|
169
170
|
try {
|
|
@@ -191,7 +192,7 @@ export function renderBlock(b, c) {
|
|
|
191
192
|
body(w); // only the lines that look like errors are red
|
|
192
193
|
}
|
|
193
194
|
if (lines.length > cap)
|
|
194
|
-
body(F(t.faint, `… ${lines.length - cap} more lines
|
|
195
|
+
body(F(t.faint, `… ${lines.length - cap} more line${lines.length - cap === 1 ? "" : "s"} · /output shows all of it${preview ? "" : " · ctrl+o expands everything"}`));
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
return done();
|
package/dist/cli/tui/commands.js
CHANGED
|
@@ -3,8 +3,10 @@ import { relatives } from "./session.js";
|
|
|
3
3
|
import { SHELL_CHOICES, STRATEGIES, chooseShell, currentShell, loadPrompts, setShell, shellFallbackNote } from "../../src/index.js";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { collectFiles, effortsFor, loadMcp, readBundle, writeBundle } from "../../src/index.js";
|
|
6
|
-
import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS } from "../config.js";
|
|
7
|
-
import { where } from "../keystore.js";
|
|
6
|
+
import { CLAUDE_MODELS, OPENAI_MODELS, PROVIDERS, connections, keyFor, storeKey } from "../config.js";
|
|
7
|
+
import { noStore, where } from "../keystore.js";
|
|
8
|
+
import { fetchModels } from "../models.js";
|
|
9
|
+
import { STAGE_GLYPH, roundOf, stagesOf } from "./looppanel.js";
|
|
8
10
|
import { bold, cyan, dim, green, red, yellow } from "../ui.js";
|
|
9
11
|
import { estimateCost, fmtCost, fmtDuration, fmtTokens } from "./pricing.js";
|
|
10
12
|
import { codeBlocks } from "./clipboard.js";
|
|
@@ -16,6 +18,29 @@ import { THEMES, getTheme, themeNames } from "./themes.js";
|
|
|
16
18
|
import { helpLines, renderDiff, toolCall, todoList } from "./view.js";
|
|
17
19
|
const INIT_PROMPT = "Explore this repository and write an AGENTS.md at the repo root for future coding sessions: what the project is, how to build/test/run it (exact commands), the code layout, conventions you can verify from the code, and gotchas. Keep it under 80 lines and do not invent anything you cannot confirm from the files.";
|
|
18
20
|
const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
21
|
+
const keyStatus = (c) => !c.needsKey ? "local, no key needed" : c.source === "env" ? `connected · from ${c.env}` : c.source === "saved" ? `connected · ${noStore() ? "this session only" : where()}` : "not connected";
|
|
22
|
+
/** Make `id` the active provider, using its saved key (or asking for one). Returns false if the user gave up. */
|
|
23
|
+
async function switchProvider(a, id) {
|
|
24
|
+
let apiKey = keyFor(id).key;
|
|
25
|
+
if (PROVIDERS[id].needsKey && !apiKey) {
|
|
26
|
+
apiKey = await a.askText(`${PROVIDERS[id].label} API key (hidden; ${where()})`, { secret: true });
|
|
27
|
+
if (!apiKey)
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
Object.assign(a.cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model, apiKey });
|
|
31
|
+
if (id === "custom" || id === "ollama") {
|
|
32
|
+
const m = await a.askText("Model id", { initial: a.cfg.model });
|
|
33
|
+
if (m)
|
|
34
|
+
a.cfg.model = m;
|
|
35
|
+
const u = await a.askText("Base URL", { initial: a.cfg.baseUrl });
|
|
36
|
+
if (u)
|
|
37
|
+
a.cfg.baseUrl = u;
|
|
38
|
+
}
|
|
39
|
+
a.saveConfig();
|
|
40
|
+
a.buildAgent();
|
|
41
|
+
a.say(`provider → ${cyan(PROVIDERS[id].label)}, model ${cyan(a.cfg.model)}`, dim);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
19
44
|
export function buildCommands() {
|
|
20
45
|
return [
|
|
21
46
|
{ name: "help", alias: "?", help: "keys and commands", live: true, run: (a) => a.openPager(helpLines(a.cmds, (a.io.output.columns || 80) - 1), "help") },
|
|
@@ -23,7 +48,10 @@ export function buildCommands() {
|
|
|
23
48
|
let id = arg;
|
|
24
49
|
if (!id) {
|
|
25
50
|
const list = a.cfg.provider === "anthropic" ? CLAUDE_MODELS : a.cfg.provider === "openai" ? OPENAI_MODELS : [{ id: a.cfg.model, hint: "current" }];
|
|
26
|
-
const
|
|
51
|
+
const have = new Set(list.map((m) => m.id));
|
|
52
|
+
a.note("asking your provider for its models…", 7000);
|
|
53
|
+
const extra = (await fetchModels({ provider: a.cfg.provider, baseUrl: a.cfg.baseUrl, apiKey: a.cfg.apiKey })).filter((m) => !have.has(m)).map((m) => ({ id: m, hint: "from your provider" }));
|
|
54
|
+
const known = [...list, ...extra].map((m) => ({ label: m.id, value: m.id, hint: m.hint }));
|
|
27
55
|
id = await a.pick("Model", [...known, { label: "Other…", value: "" }], Math.max(0, known.findIndex((m) => m.value === a.cfg.model)));
|
|
28
56
|
if (id === undefined)
|
|
29
57
|
return;
|
|
@@ -42,29 +70,54 @@ export function buildCommands() {
|
|
|
42
70
|
a.buildAgent();
|
|
43
71
|
a.say(`model → ${cyan(id)}`, dim);
|
|
44
72
|
} },
|
|
45
|
-
{ name: "provider", help: "switch provider (OpenAI, Claude, Ollama…)", run: async (a) => {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
73
|
+
{ name: "provider", help: "switch provider (OpenAI, Claude, Ollama…); each provider keeps its own saved key", run: async (a) => {
|
|
74
|
+
const list = connections();
|
|
75
|
+
const id = await a.pick("Provider", list.map((c) => ({ label: `${!c.needsKey || c.source !== "none" ? "●" : "○"} ${c.label}${c.id === a.cfg.provider ? " (in use)" : ""}`, value: c.id, hint: keyStatus(c) })));
|
|
76
|
+
if (id)
|
|
77
|
+
await switchProvider(a, id);
|
|
78
|
+
} },
|
|
79
|
+
{ name: "connect", alias: "keys", help: "add, replace or remove API keys for several providers and see which are connected", run: async (a) => {
|
|
80
|
+
for (;;) {
|
|
81
|
+
const list = connections();
|
|
82
|
+
const id = await a.pick("Providers ● connected ○ not connected", list.map((c) => ({ label: `${!c.needsKey || c.source !== "none" ? "●" : "○"} ${c.label}${c.id === a.cfg.provider ? " (in use)" : ""}`, value: c.id, hint: keyStatus(c) })));
|
|
83
|
+
if (!id)
|
|
54
84
|
return;
|
|
85
|
+
const c = list.find((x) => x.id === id);
|
|
86
|
+
if (!c.needsKey) {
|
|
87
|
+
a.say(`${c.label} needs no key. Use /provider to switch to it.`, dim);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const act = c.source === "none" ? "add" : await a.pick("What do you want to do?", [
|
|
91
|
+
...(id === a.cfg.provider ? [] : [{ label: "Use this provider now", value: "use" }]),
|
|
92
|
+
{ label: c.source === "env" ? `Also save a key (${c.env} still wins while it is set)` : "Replace the key", value: "add" },
|
|
93
|
+
...(c.source === "saved" ? [{ label: "Remove the saved key", value: "remove" }] : []),
|
|
94
|
+
]);
|
|
95
|
+
if (!act)
|
|
96
|
+
continue;
|
|
97
|
+
if (act === "use") {
|
|
98
|
+
await switchProvider(a, id);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (act === "add") {
|
|
102
|
+
const key = await a.askText(`${c.label} API key (hidden; ${where()})`, { secret: true });
|
|
103
|
+
if (!key)
|
|
104
|
+
continue;
|
|
105
|
+
storeKey(id, key);
|
|
106
|
+
if (id === a.cfg.provider) {
|
|
107
|
+
a.cfg.apiKey = keyFor(id).key;
|
|
108
|
+
a.buildAgent();
|
|
109
|
+
}
|
|
110
|
+
a.say(`${green("✓")} ${c.label} connected (${noStore() ? "this session only" : where()})`, dim);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
storeKey(id, undefined);
|
|
114
|
+
if (id === a.cfg.provider) {
|
|
115
|
+
a.cfg.apiKey = keyFor(id).key;
|
|
116
|
+
a.buildAgent();
|
|
117
|
+
}
|
|
118
|
+
a.say(`${c.label}: saved key removed`, dim);
|
|
119
|
+
}
|
|
55
120
|
}
|
|
56
|
-
Object.assign(a.cfg, { provider: id, baseUrl: PROVIDERS[id].baseUrl, model: PROVIDERS[id].model, apiKey });
|
|
57
|
-
if (id === "custom" || id === "ollama") {
|
|
58
|
-
const m = await a.askText("Model id", { initial: a.cfg.model });
|
|
59
|
-
if (m)
|
|
60
|
-
a.cfg.model = m;
|
|
61
|
-
const u = await a.askText("Base URL", { initial: a.cfg.baseUrl });
|
|
62
|
-
if (u)
|
|
63
|
-
a.cfg.baseUrl = u;
|
|
64
|
-
}
|
|
65
|
-
a.saveConfig();
|
|
66
|
-
a.buildAgent();
|
|
67
|
-
a.say(`provider → ${cyan(PROVIDERS[id].label)}, model ${cyan(a.cfg.model)}`, dim);
|
|
68
121
|
} },
|
|
69
122
|
{ name: "effort", help: "reasoning effort for the current model", args: "[level]", run: async (a, arg) => {
|
|
70
123
|
const levels = a.cfg.provider === "anthropic" ? [...EFFORTS] : effortsFor(a.cfg.model);
|
|
@@ -106,6 +159,23 @@ export function buildCommands() {
|
|
|
106
159
|
return void a.say(`unknown mode "${m}"`, red);
|
|
107
160
|
a.setMode(m);
|
|
108
161
|
} },
|
|
162
|
+
{ name: "output", alias: "last", help: "the full output of the last tool call in a pager (/output 2 for the one before)", args: "[n]", live: true, run: (a, arg) => {
|
|
163
|
+
const calls = a.blocks.blocks.filter((b) => b.kind === "tool" && (!b.agent || b.agent === "lead") && !!b.result && b.name !== "todo_write");
|
|
164
|
+
if (!calls.length)
|
|
165
|
+
return void a.say("no tool output yet", dim);
|
|
166
|
+
const n = Math.max(1, Math.floor(Number(arg) || 1)), b = calls[calls.length - n];
|
|
167
|
+
if (!b)
|
|
168
|
+
return void a.say(`only ${calls.length} tool call${calls.length === 1 ? "" : "s"} so far`, dim);
|
|
169
|
+
const body = b.result.split("\n");
|
|
170
|
+
let head = toolCall(b.name, b.args);
|
|
171
|
+
if (b.name === "sh") {
|
|
172
|
+
try {
|
|
173
|
+
head = `$ ${JSON.parse(b.args).cmd}`;
|
|
174
|
+
}
|
|
175
|
+
catch { /* keep the generic header */ }
|
|
176
|
+
}
|
|
177
|
+
a.openPager([head, "", ...body], "output");
|
|
178
|
+
} },
|
|
109
179
|
{ name: "plan", help: "enter plan mode (read-only)", run: (a) => a.setMode("plan") },
|
|
110
180
|
{ name: "clear", help: "start a fresh conversation", alias: "new", run: (a) => {
|
|
111
181
|
a.newConversation();
|
|
@@ -249,10 +319,12 @@ export function buildCommands() {
|
|
|
249
319
|
return a.say(dim("no loop activity yet: send a task first"));
|
|
250
320
|
const tot = T.reduce((n, s) => n + s.completion + s.prompt, 0), ms = T.reduce((n, s) => n + s.ms, 0);
|
|
251
321
|
const waste = cacheWaste(a.tracer?.turns ?? []), wasteCost = waste ? estimateCost(a.cfg.model, waste, 0) : null, wasteNote = waste > 500 ? `${fmtTokens(waste)} tokens re-read at full price after cache breaks${wasteCost !== null ? ` (≈ ${fmtCost(wasteCost - estimateCost(a.cfg.model, waste, 0, waste))} wasted)` : ""} · ` : "";
|
|
252
|
-
const
|
|
322
|
+
const rounds = roundOf(T.map((s) => ({ ...s })));
|
|
323
|
+
const out = ["", bold(`Agent loop · ${T.length} turn${T.length === 1 ? "" : "s"} · ${rounds} round${rounds === 1 ? "" : "s"} · ${fmtDuration(ms)} · ${fmtTokens(tot)} tokens`), dim("◉ gather: read, search · ✎ act: edit, run · ✓ verify: test, build · guessed from the tool calls"), dim(`${wasteNote}${a.trimmed ? `trimmed ~${fmtTokens(Math.round(a.trimmed / 4))} tokens of stale or noisy output · ` : ""}${a.cacheBreaks ? `${a.cacheBreaks} cache break${a.cacheBreaks === 1 ? "" : "s"} · ` : ""}each turn: the model answers, its tool calls run, results go back; guards are shown where they fired`), ""];
|
|
253
324
|
for (const s of T) {
|
|
254
325
|
const pad = String(s.n).padStart(2);
|
|
255
|
-
|
|
326
|
+
const st = stagesOf(s.tools);
|
|
327
|
+
out.push(`${cyan(`${pad} ●`)} ${bold("model")}${st.length ? " " + cyan(st.map((x) => `${STAGE_GLYPH[x]} ${x}`).join(" + ")) : ""} ${dim(`${fmtDuration(s.ms)} · ${fmtTokens(s.prompt)} in · ${fmtTokens(s.completion)} out${s.cached ? ` · ${fmtTokens(s.cached)} cached` : ""}`)}`);
|
|
256
328
|
s.tools.forEach((x, i) => out.push(` ${dim(i === s.tools.length - 1 && !s.notes.length ? "└─" : "├─")} ${x.ok ? green("✓") : red("✗")} ${toolCall(x.name, x.args)} ${dim(fmtDuration(x.ms))}${s.parallel && s.parallel > 1 && i === 0 ? dim(` (${s.parallel} calls this turn)`) : ""}`));
|
|
257
329
|
s.notes.forEach((n, i) => out.push(` ${dim(i === s.notes.length - 1 ? "└─" : "├─")} ${yellow("⚑")} ${dim(n)}`));
|
|
258
330
|
if (!s.tools.length && !s.notes.length)
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
export type Suggestion = {
|
|
2
2
|
label: string;
|
|
3
3
|
insert: string;
|
|
4
|
-
detail?: string;
|
|
4
|
+
detail?: string; /** arg suggestions: more words are expected, so do not submit on Enter */
|
|
5
|
+
more?: boolean; /** arg suggestions: any value is allowed, so Enter runs what was typed unless the user moved into the list */
|
|
6
|
+
open?: boolean; /** category header shown above the first item of each group (only when the list is not filtered) */
|
|
7
|
+
group?: string;
|
|
5
8
|
};
|
|
6
9
|
export type Token = {
|
|
7
|
-
kind: "slash" | "file";
|
|
10
|
+
kind: "slash" | "file" | "arg";
|
|
8
11
|
query: string;
|
|
9
12
|
start: number;
|
|
10
|
-
end: number;
|
|
13
|
+
end: number; /** arg tokens: the command, and the words already typed after it */
|
|
14
|
+
cmd?: string;
|
|
15
|
+
words?: string[];
|
|
11
16
|
};
|
|
12
17
|
/** What is the user completing right now? A slash command (first word of the input) or an @file mention. */
|
|
13
18
|
export declare function tokenAtCursor(text: string, cursor: number): Token | null;
|
|
19
|
+
/** Categories of the built-in commands, in the order the menu shows them. A command that is not listed (yours, or a plugin's) goes under "Custom". */
|
|
20
|
+
export declare const GROUP_ORDER: readonly ["Conversation", "Model & keys", "Safety", "Quality", "Project", "Extend", "Interface", "Custom"];
|
|
21
|
+
export declare const groupOf: (name: string) => string;
|
|
22
|
+
/** All matching commands. With no query they come grouped by category (in GROUP_ORDER, A to Z inside a group); with a query, best match first. */
|
|
14
23
|
export declare function rankCommands(query: string, cmds: {
|
|
15
24
|
name: string;
|
|
16
25
|
help: string;
|
|
@@ -37,3 +46,14 @@ export declare class FileIndex {
|
|
|
37
46
|
private load;
|
|
38
47
|
private walk;
|
|
39
48
|
}
|
|
49
|
+
export type MenuRow = {
|
|
50
|
+
head: string;
|
|
51
|
+
} | {
|
|
52
|
+
item: number;
|
|
53
|
+
};
|
|
54
|
+
/** The rows to draw for a suggestion list: a window of `win` rows that keeps the selected item in view, with a header row above each new group (headers cannot be selected). */
|
|
55
|
+
export declare function menuRows(items: Suggestion[], sel: number, win: number): {
|
|
56
|
+
rows: MenuRow[];
|
|
57
|
+
above: number;
|
|
58
|
+
below: number;
|
|
59
|
+
};
|
package/dist/cli/tui/complete.js
CHANGED
|
@@ -8,17 +8,37 @@ export function tokenAtCursor(text, cursor) {
|
|
|
8
8
|
const slash = before.match(/^\/(\S*)$/);
|
|
9
9
|
if (slash)
|
|
10
10
|
return { kind: "slash", query: slash[1], start: 0, end: cursor };
|
|
11
|
+
const arg = before.match(/^\/(\S+)((?:\s+\S+)*)\s+(\S*)$/); // "/model gp": the command, the words before the last one, the word being typed
|
|
12
|
+
if (arg)
|
|
13
|
+
return { kind: "arg", cmd: arg[1], words: arg[2].trim() ? arg[2].trim().split(/\s+/) : [], query: arg[3], start: cursor - arg[3].length, end: cursor };
|
|
11
14
|
const file = before.match(/(?:^|\s)@([^\s@]*)$/);
|
|
12
15
|
if (file)
|
|
13
16
|
return { kind: "file", query: file[1], start: cursor - file[1].length - 1, end: cursor };
|
|
14
17
|
return null;
|
|
15
18
|
}
|
|
16
|
-
|
|
19
|
+
/** Categories of the built-in commands, in the order the menu shows them. A command that is not listed (yours, or a plugin's) goes under "Custom". */
|
|
20
|
+
export const GROUP_ORDER = ["Conversation", "Model & keys", "Safety", "Quality", "Project", "Extend", "Interface", "Custom"];
|
|
21
|
+
const GROUP_MEMBERS = {};
|
|
22
|
+
for (const [g, names] of Object.entries({
|
|
23
|
+
Conversation: "output clear compact resume branch branches export import undo rewind copy todos exit",
|
|
24
|
+
"Model & keys": "model provider connect effort api models router keepwarm budget tokens",
|
|
25
|
+
Safety: "mode plan permissions policy sandbox approver hooks",
|
|
26
|
+
Quality: "verify check review reviewer lsp report loop diff",
|
|
27
|
+
Project: "init pr tools procs shell status live",
|
|
28
|
+
Extend: "memory mcp skills agents plugins reload-plugins prompt strategy profile",
|
|
29
|
+
Interface: "help theme sidebar details diffmode mouse ui",
|
|
30
|
+
}))
|
|
31
|
+
for (const n of names.split(" "))
|
|
32
|
+
GROUP_MEMBERS[n] = g;
|
|
33
|
+
export const groupOf = (name) => GROUP_MEMBERS[name] ?? "Custom";
|
|
34
|
+
/** All matching commands. With no query they come grouped by category (in GROUP_ORDER, A to Z inside a group); with a query, best match first. */
|
|
35
|
+
export function rankCommands(query, cmds, limit = 500) {
|
|
17
36
|
const q = query.toLowerCase();
|
|
18
37
|
const score = (c) => (c.name.startsWith(q) ? 3 : c.alias?.startsWith(q) ? 2 : c.name.includes(q) || c.alias?.includes(q) ? 1 : 0);
|
|
38
|
+
const gi = (n) => GROUP_ORDER.indexOf(groupOf(n));
|
|
19
39
|
return cmds.map((c) => ({ c, score: score(c) }))
|
|
20
|
-
.filter((x) => x.score > 0 || !q).sort((a, b) => b.score - a.score || a.c.name.localeCompare(b.c.name)).slice(0, limit)
|
|
21
|
-
.map(({ c }) => ({ label: "/" + c.name, insert: "/" + c.name + " ", detail: c.help + (c.alias && !c.name.includes(query.toLowerCase()) && c.alias.includes(query.toLowerCase()) ? ` (also /${c.alias})` : "") }));
|
|
40
|
+
.filter((x) => x.score > 0 || !q).sort((a, b) => (q ? b.score - a.score : gi(a.c.name) - gi(b.c.name)) || a.c.name.localeCompare(b.c.name)).slice(0, limit)
|
|
41
|
+
.map(({ c }) => ({ group: q ? undefined : groupOf(c.name), label: "/" + c.name, insert: "/" + c.name + " ", detail: c.help + (c.alias && !c.name.includes(query.toLowerCase()) && c.alias.includes(query.toLowerCase()) ? ` (also /${c.alias})` : "") }));
|
|
22
42
|
}
|
|
23
43
|
/** Subsequence fuzzy match. Tries the greedy alignment and the exact-substring alignments and keeps the best. null = no match. */
|
|
24
44
|
export function fuzzyScore(query, path) {
|
|
@@ -111,3 +131,18 @@ export class FileIndex {
|
|
|
111
131
|
return out;
|
|
112
132
|
}
|
|
113
133
|
}
|
|
134
|
+
/** The rows to draw for a suggestion list: a window of `win` rows that keeps the selected item in view, with a header row above each new group (headers cannot be selected). */
|
|
135
|
+
export function menuRows(items, sel, win) {
|
|
136
|
+
const all = [];
|
|
137
|
+
let selRow = 0, last;
|
|
138
|
+
items.forEach((it, i) => { if (it.group && it.group !== last)
|
|
139
|
+
all.push({ head: it.group }); last = it.group; if (i === sel)
|
|
140
|
+
selRow = all.length; all.push({ item: i }); });
|
|
141
|
+
win = Math.max(1, win);
|
|
142
|
+
let top = Math.max(0, Math.min(selRow - Math.floor(win / 2), all.length - win));
|
|
143
|
+
if (selRow - 1 >= 0 && "head" in all[selRow - 1] && top > selRow - 1)
|
|
144
|
+
top = selRow - 1; // keep the group title with its first command
|
|
145
|
+
const rows = all.slice(top, top + win);
|
|
146
|
+
const count = (r) => r.filter((x) => "item" in x).length;
|
|
147
|
+
return { rows, above: count(all.slice(0, top)), below: count(all.slice(top + win)) };
|
|
148
|
+
}
|
package/dist/cli/tui/full.d.ts
CHANGED
|
@@ -59,6 +59,10 @@ export declare class FullView {
|
|
|
59
59
|
/** Keep a toggled block's header where it is on screen. */
|
|
60
60
|
private pin;
|
|
61
61
|
private drawEmpty;
|
|
62
|
+
/** Steps the user opened in the LOOP section by clicking. */
|
|
63
|
+
private openSteps;
|
|
64
|
+
private openFor;
|
|
65
|
+
private loopData;
|
|
62
66
|
private drawSidebar;
|
|
63
67
|
private drawStatus;
|
|
64
68
|
private editorRows;
|
package/dist/cli/tui/full.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// The full-screen UI: header, scrollable transcript, sidebar, input panel, modals, toasts and mouse handling, drawn on a cell canvas.
|
|
2
2
|
import { copyToClipboard } from "./clipboard.js";
|
|
3
|
+
import { menuRows } from "./complete.js";
|
|
4
|
+
import { capNote } from "./looppanel.js";
|
|
3
5
|
import { basename } from "node:path";
|
|
4
6
|
import { A, mix } from "./ansi.js";
|
|
5
7
|
import { brandOfModel } from "./brand.js";
|
|
@@ -162,6 +164,7 @@ export class FullView {
|
|
|
162
164
|
const live = new Set();
|
|
163
165
|
const blocks = app.blocks.blocks.filter((b) => !(b.kind === "tool" && (b.name === "todo_write" || (b.parent && b.agent && b.agent !== "lead")))); // todos live in the sidebar; a subagent's steps are drawn inside its card
|
|
164
166
|
let prevTool = false;
|
|
167
|
+
const lastTool = [...blocks].reverse().find((b) => b.kind === "tool" && (!b.agent || b.agent === "lead"))?.id; // its output stays open
|
|
165
168
|
for (let i = 0; i < blocks.length; i++) {
|
|
166
169
|
const b = blocks[i];
|
|
167
170
|
live.add(b.id);
|
|
@@ -169,7 +172,7 @@ export class FullView {
|
|
|
169
172
|
if (isQuiet(b))
|
|
170
173
|
while (i + run < blocks.length && isQuiet(blocks[i + run]))
|
|
171
174
|
run++;
|
|
172
|
-
const tctx = { ...ctx, tight: prevTool };
|
|
175
|
+
const tctx = { ...ctx, tight: prevTool, last: b.id === lastTool };
|
|
173
176
|
if (run >= 2) {
|
|
174
177
|
const members = blocks.slice(i, i + run), open = this.expandAll || this.groupOpen.has(b.id);
|
|
175
178
|
const key = `g|${tctx.tight}|${open}|${ctx.theme.name}|${ctx.width}|` + members.map((m) => `${m.id}:${m.status}:${m.ms ?? 0}`).join(",") + (members.some((m) => m.status === "running") ? `|${ctx.frame}` : ""), hit = this.cache.get(b.id);
|
|
@@ -182,7 +185,7 @@ export class FullView {
|
|
|
182
185
|
prevTool = r.rows.length <= 2;
|
|
183
186
|
continue;
|
|
184
187
|
}
|
|
185
|
-
const key = this.sig(b, tctx) + (tctx.tight ? "|t" : ""), hit = this.cache.get(b.id);
|
|
188
|
+
const key = this.sig(b, tctx) + (tctx.tight ? "|t" : "") + (tctx.last ? "|last" : ""), hit = this.cache.get(b.id);
|
|
186
189
|
const r = hit && hit.key === key ? hit.r : (() => { const rr = renderBlock(b, tctx); this.cache.set(b.id, { key, r: rr }); return rr; })();
|
|
187
190
|
this.items.push({ b, r, start: total });
|
|
188
191
|
total += r.rows.length;
|
|
@@ -330,6 +333,21 @@ export class FullView {
|
|
|
330
333
|
c.ansi(x0 + Math.max(0, Math.floor((w - cellWidth(TIPS[this.tipIdx]) - 5) / 2)), Math.min(y0 + h - 2, ry + 2), tip);
|
|
331
334
|
}
|
|
332
335
|
// ------------------------------------------------------------------ sidebar
|
|
336
|
+
/** Steps the user opened in the LOOP section by clicking. */
|
|
337
|
+
openSteps = new Set();
|
|
338
|
+
openFor = 0;
|
|
339
|
+
loopData(now) {
|
|
340
|
+
const app = this.app, steps = app.trace.filter((s) => s.t0 >= app.runStart - 50); // this run's steps (the trace spans the whole session)
|
|
341
|
+
if (!steps.length)
|
|
342
|
+
return undefined;
|
|
343
|
+
if (this.openFor !== app.runStart) {
|
|
344
|
+
this.openFor = app.runStart;
|
|
345
|
+
this.openSteps.clear();
|
|
346
|
+
} // step numbers start again in a new run
|
|
347
|
+
return { live: app.running, phase: app.phase?.phase ?? "model", cap: app.phase?.max ?? 30, now, frame: app.frame, steps,
|
|
348
|
+
elapsedMs: app.running ? now - app.runStart : Math.max(...steps.map((s) => s.t0 + s.ms)) - app.runStart,
|
|
349
|
+
inflight: [...app.inflight.values()].map((x) => ({ label: toolVerb(x.name, x.args), ms: now - x.t0, agent: x.agent, name: x.name, args: x.args })), open: [...this.openSteps] };
|
|
350
|
+
}
|
|
333
351
|
drawSidebar(c, t, x0, y0, w, h) {
|
|
334
352
|
const app = this.app, u = app.session.usage, win = contextWindow(app.cfg.model), now = Date.now();
|
|
335
353
|
const mkey = `${app.session.messages.length}|${app.lastPrompt}`;
|
|
@@ -353,15 +371,19 @@ export class FullView {
|
|
|
353
371
|
sandbox: app.cfg.sandbox,
|
|
354
372
|
procs: app.agent.procs?.list().map((p) => ({ id: p.id, name: p.name, status: p.status, secs: Math.round((now - p.startedAt) / 1000) })),
|
|
355
373
|
running: app.running, elapsedMs: now - this.startedAt, tip: TIPS[this.tipIdx],
|
|
356
|
-
|
|
357
|
-
live: [...app.inflight.values()].map((x) => ({ label: toolVerb(x.name, x.args), ms: now - x.t0, agent: x.agent })), done: app.activity.map((x) => ({ label: toolVerb(x.name, x.args, true), ok: x.ok, ms: x.ms })) } : undefined,
|
|
374
|
+
loop: this.loopData(now),
|
|
358
375
|
}, t, w, h);
|
|
359
376
|
rows.forEach((r, i) => c.put(x0 + 1, y0 + i, r, w - 1));
|
|
360
377
|
for (let i = 0; i < h; i++)
|
|
361
378
|
c.text(x0, y0 + i, "│", { fg: t.border, bg: t.surface });
|
|
362
379
|
for (const hh of hits)
|
|
363
380
|
if (hh.row < h)
|
|
364
|
-
this.regions.push({ x: x0, y: y0 + hh.row, w, h: 1, kind: "change", click: () => hh.
|
|
381
|
+
this.regions.push({ x: x0, y: y0 + hh.row, w, h: 1, kind: "change", click: () => { if (hh.kind === "step" && hh.step !== undefined) {
|
|
382
|
+
this.openSteps.has(hh.step) ? this.openSteps.delete(hh.step) : this.openSteps.add(hh.step);
|
|
383
|
+
this.invalidate();
|
|
384
|
+
}
|
|
385
|
+
else if (hh.blockId !== undefined)
|
|
386
|
+
this.scrollToBlock(hh.blockId); } });
|
|
365
387
|
}
|
|
366
388
|
// ------------------------------------------------------------------ status line, input, footer
|
|
367
389
|
drawStatus(c, t, x, y, w, now) {
|
|
@@ -372,7 +394,7 @@ export class FullView {
|
|
|
372
394
|
: app.mode === "plan" ? "Planning" : app.thinkBuf.trim() ? "Thinking" : app.streamBuf ? "Writing" : "Working", chars = [...clipWidth(verb, Math.max(8, w - 44)) + "…"];
|
|
373
395
|
const shim = chars.map((ch, i) => F(mix(t.accent, 0xffffff, Math.max(0, 1 - Math.abs(i - ((f * 0.9) % (chars.length + 8) - 4)) / 3)), ch)).join("");
|
|
374
396
|
const spark = app.runSeries.length > 1 ? " " + F(t.accent2, app.runSeries.slice(-10).map((v) => BARS[Math.min(7, Math.round((v / Math.max(1, ...app.runSeries)) * 7))]).join("")) : "";
|
|
375
|
-
c.ansi(x + 1, y, F(mix(t.accent, t.accent2, (f % 20) / 20), glyph) + " " + BOLD(shim) + F(t.dim, ` ${fmtDuration(now - app.runStart)} · ↓ ${fmtTokens(app.runTokens)}`) + (app.phase ? F(t.faint, ` · step ${app.phase.
|
|
397
|
+
c.ansi(x + 1, y, F(mix(t.accent, t.accent2, (f % 20) / 20), glyph) + " " + BOLD(shim) + F(t.dim, ` ${fmtDuration(now - app.runStart)} · ↓ ${fmtTokens(app.runTokens)}`) + (app.phase ? (() => { const n = app.phase.step + 1, cap = capNote(n, app.phase.max); return F(cap.level === "warn" ? t.warn : t.faint, ` · step ${n}${cap.level === "none" ? "" : cap.level === "dim" ? ` of ${app.phase.max}` : ` of ${app.phase.max}, near the limit`}`); })() : "") + spark + F(t.faint, " esc to interrupt"), {}, w - 2);
|
|
376
398
|
if (app.thinkBuf.trim() && app.queued.length === 0) { /* reasoning summary lives in the transcript's thinking block */ }
|
|
377
399
|
}
|
|
378
400
|
app.queued.slice(0, 2).forEach((q, i) => c.ansi(x + 1, y + (app.running ? 1 : 0) + i, F(t.warn, "▸ queued ") + F(t.dim, clipWidth(q.text.replace(/\s+/g, " "), w - 14)), {}, w - 2));
|
|
@@ -406,13 +428,19 @@ export class FullView {
|
|
|
406
428
|
const s = this.app.sugg;
|
|
407
429
|
if (!s || !s.items.length || this.app.overlay)
|
|
408
430
|
return;
|
|
409
|
-
const
|
|
410
|
-
if (
|
|
431
|
+
const w = Math.min(86, mainW), room = inputY - 4; // rows between the top of the screen and the box
|
|
432
|
+
if (room < 3)
|
|
411
433
|
return;
|
|
412
|
-
|
|
413
|
-
s.items.
|
|
414
|
-
|
|
415
|
-
|
|
434
|
+
const { rows, above, below } = menuRows(s.items, s.sel, Math.min(14, room)), y = inputY - rows.length - 2;
|
|
435
|
+
const pos = s.items.length > rows.filter((r) => "item" in r).length ? ` ${s.sel + 1}/${s.items.length}${above ? " ↑" : ""}${below ? " ↓" : ""}` : "";
|
|
436
|
+
c.box(x + 1, y, w, rows.length + 2, { border: t.border, bg: t.surface, style: "round", title: (s.kind === "slash" ? "commands" : s.kind === "arg" ? `/${s.token.cmd} options` : "files") + pos, titleFg: t.dim });
|
|
437
|
+
rows.forEach((r, i) => {
|
|
438
|
+
if ("head" in r) {
|
|
439
|
+
c.ansi(x + 2, y + 1 + i, fit(" " + F(t.faint, "── ") + BOLD(F(t.dim, r.head)) + " " + F(t.faint, "─".repeat(Math.max(2, 24 - r.head.length))), w - 2, t.surface), {}, w - 2);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const idx = r.item, it = s.items[idx], on = idx === s.sel, bg = on ? t.selection : t.surface;
|
|
443
|
+
const label = s.kind === "file" ? (() => { const n = it.label.split("/").pop(), d = it.label.slice(0, it.label.length - n.length); return F(t.faint, d) + BOLD(F(t.text, n)); })() : BOLD(F(t.accent2, it.label.padEnd(16)));
|
|
416
444
|
c.ansi(x + 2, y + 1 + i, fit((on ? F(t.accent, "▌") : " ") + " " + label + (it.detail ? " " + F(t.faint, it.detail) : ""), w - 2, bg), {}, w - 2);
|
|
417
445
|
this.regions.push({ x: x + 2, y: y + 1 + i, w: w - 2, h: 1, kind: "sugg", click: () => { s.sel = idx; this.app.acceptSuggPublic(true); } });
|
|
418
446
|
});
|
|
@@ -588,6 +616,8 @@ export class FullView {
|
|
|
588
616
|
} return false; }
|
|
589
617
|
/** Scroll keys; returns whether the key was used. */
|
|
590
618
|
key(name, ctrl) {
|
|
619
|
+
if (this.app.sugg && (name === "pageup" || name === "pagedown"))
|
|
620
|
+
return false; // the open menu pages through its list instead
|
|
591
621
|
const page = Math.max(3, Math.floor(this.viewH / 2));
|
|
592
622
|
if (name === "pageup") {
|
|
593
623
|
this.scrollBy(page);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Theme } from "./themes.ts";
|
|
2
|
+
export type LoopStep = {
|
|
3
|
+
n: number;
|
|
4
|
+
t0: number;
|
|
5
|
+
ms: number;
|
|
6
|
+
tools: {
|
|
7
|
+
name: string;
|
|
8
|
+
args: string;
|
|
9
|
+
ms: number;
|
|
10
|
+
ok: boolean;
|
|
11
|
+
}[];
|
|
12
|
+
notes: string[];
|
|
13
|
+
parallel?: number;
|
|
14
|
+
};
|
|
15
|
+
export type LoopData = {
|
|
16
|
+
/** the run is still going */ live: boolean;
|
|
17
|
+
phase: string;
|
|
18
|
+
cap: number;
|
|
19
|
+
now: number;
|
|
20
|
+
frame: number;
|
|
21
|
+
elapsedMs: number;
|
|
22
|
+
/** this run's steps, oldest first; while live, the last one is the current step */ steps: LoopStep[];
|
|
23
|
+
/** tool calls in flight right now (name and args let the panel tell which stage they belong to) */ inflight: {
|
|
24
|
+
label: string;
|
|
25
|
+
ms: number;
|
|
26
|
+
agent?: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
args?: string;
|
|
29
|
+
}[];
|
|
30
|
+
/** steps the user opened by clicking */ open: number[];
|
|
31
|
+
};
|
|
32
|
+
export type LoopHit = {
|
|
33
|
+
line: number;
|
|
34
|
+
step: number;
|
|
35
|
+
};
|
|
36
|
+
/** Notes that mean "the harness stepped in" (shown in the warning colour, and kept visible); everything else (cache, routing, trimming) is background. */
|
|
37
|
+
export declare const isIntervention: (note: string) => boolean;
|
|
38
|
+
/** "Read ×2 → Edit → Run": neighbouring calls of one kind merge, so a burst of reads is one short phrase. */
|
|
39
|
+
export declare function stepSummary(tools: {
|
|
40
|
+
name: string;
|
|
41
|
+
}[]): string;
|
|
42
|
+
/** Wall time of a step: the model call, then its tools (they overlap when run in parallel). */
|
|
43
|
+
export declare const stepMs: (s: LoopStep) => number;
|
|
44
|
+
/** The cap is a safety stop, not a goal, so say nothing about it until it could matter. */
|
|
45
|
+
export declare function capNote(step: number, cap: number): {
|
|
46
|
+
text: string;
|
|
47
|
+
level: "none" | "dim" | "warn";
|
|
48
|
+
};
|
|
49
|
+
export type Stage = "gather" | "act" | "verify";
|
|
50
|
+
export declare const STAGES: Stage[];
|
|
51
|
+
export declare const STAGE_GLYPH: Record<Stage, string>;
|
|
52
|
+
/** Which part of the cycle a tool call belongs to. A guess from the tool and, for shell commands, the command line; undefined for planning and bookkeeping tools. */
|
|
53
|
+
export declare function stageOfTool(name: string, args?: string): Stage | undefined;
|
|
54
|
+
/** The stages a step touched, in cycle order. */
|
|
55
|
+
export declare const stagesOf: (tools: {
|
|
56
|
+
name: string;
|
|
57
|
+
args: string;
|
|
58
|
+
}[]) => Stage[];
|
|
59
|
+
/** How many times the agent has come back round: a new round starts when it gathers or acts again after verifying. */
|
|
60
|
+
export declare function roundOf(steps: LoopStep[]): number;
|
|
61
|
+
/** Which stages to light: those running now, and (while the model is thinking and nothing runs) the last one used, dimmed. */
|
|
62
|
+
export declare function litStages(d: LoopData): {
|
|
63
|
+
now: Set<Stage>;
|
|
64
|
+
recent: Set<Stage>;
|
|
65
|
+
};
|
|
66
|
+
export declare function renderLoop(d: LoopData, t: Theme, inner: number, maxRows: number): {
|
|
67
|
+
lines: string[];
|
|
68
|
+
hits: LoopHit[];
|
|
69
|
+
title: string;
|
|
70
|
+
right: string;
|
|
71
|
+
};
|