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.
@@ -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 (ctrl+o expands everything)`));
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();
@@ -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 known = list.map((m) => ({ label: m.id, value: m.id, hint: m.hint }));
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 id = await a.pick("Provider", Object.keys(PROVIDERS).map((k) => ({ label: PROVIDERS[k].label, value: k, hint: PROVIDERS[k].baseUrl })));
47
- if (!id)
48
- return;
49
- const key = id === "anthropic" ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
50
- let apiKey = key;
51
- if (PROVIDERS[id].needsKey && !apiKey) {
52
- apiKey = await a.askText(`${PROVIDERS[id].label} API key (hidden; ${where()})`, { secret: true });
53
- if (!apiKey)
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 out = ["", bold(`Agent loop · ${T.length} turn${T.length === 1 ? "" : "s"} · ${fmtDuration(ms)} · ${fmtTokens(tot)} tokens`), 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`), ""];
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
- out.push(`${cyan(`${pad} ●`)} ${bold("model")} ${dim(`${fmtDuration(s.ms)} · ${fmtTokens(s.prompt)} in · ${fmtTokens(s.completion)} out${s.cached ? ` · ${fmtTokens(s.cached)} cached` : ""}`)}`);
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
+ };
@@ -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
- export function rankCommands(query, cmds, limit = 8) {
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
+ }
@@ -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;
@@ -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
- run: app.running ? { phase: app.phase?.phase ?? "model", step: app.phase?.step ?? 0, max: app.phase?.max ?? 30, elapsedMs: now - app.runStart, frame: app.frame,
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.blockId !== undefined && this.scrollToBlock(hh.blockId) });
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.step + 1}/${app.phase.max}`) : "") + spark + F(t.faint, " esc to interrupt"), {}, w - 2);
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 rows = Math.min(8, s.items.length), w = Math.min(70, mainW), top = Math.max(0, Math.min(s.sel - 3, s.items.length - rows)), y = inputY - rows - 2;
410
- if (y < 1)
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
- c.box(x + 1, y, w, rows + 2, { border: t.border, bg: t.surface, style: "round", title: s.kind === "slash" ? "commands" : "files", titleFg: t.dim });
413
- s.items.slice(top, top + rows).forEach((it, i) => {
414
- const idx = top + i, on = idx === s.sel, bg = on ? t.selection : t.surface;
415
- 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));
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
+ };