agent-from-zero 0.29.0 → 0.30.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/dist/agent.js CHANGED
@@ -19,8 +19,8 @@ import { clearTodos, getTodos, renderTodos } from "./todos.js"; // the agent's p
19
19
  import { renderDiff } from "./diff.js"; // show what /undo put back / what /diff changed, reusing the Day 21 diff renderer
20
20
  import path from "node:path"; // shorten paths for the /diff summary
21
21
  import { expandMentions } from "./mentions.js"; // @file mentions: pull referenced files into context (secret files refused)
22
- import { banner, framedPrompt, formatModelChoices, statusLine, inputRule } from "./ui.js"; // welcome box, prompt, model labels, status line
23
- import { gitBranch, toggleLastCollapsed, revealReasoning, clearReasoning, cleanup as tuiCleanup } from "./tui.js"; // git branch for the status line + collapsible output + collapsed reasoning (Ctrl+R)
22
+ import { banner, framedPrompt, formatModelChoices, statusLine } from "./ui.js"; // welcome box, prompt, model labels, status line
23
+ import { gitBranch, toggleLastCollapsed, revealReasoning, clearReasoning, revealToolCalls, clearToolCalls, cleanup as tuiCleanup } from "./tui.js"; // git branch + collapsible output + collapsed reasoning (Ctrl+R) + folded tool-call trace (Ctrl+T)
24
24
  import { promptSelect, promptForm } from "./menu.js"; // arrow-key approval menu + multi-question form
25
25
  import { editLine } from "./editor.js"; // our own line editor (keeps the status footer pinned even when input wraps)
26
26
  import { normalizeDroppedPaths } from "./drop.js"; // drag-and-drop: a dropped file's path → a clean absolute path in the input
@@ -83,6 +83,7 @@ const SESSION_HELP = `commands:
83
83
 
84
84
  keys (at the prompt):
85
85
  Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
86
+ Ctrl+T reveal the folded tool-call trace for the last answer (read_file/search/… shown as a tally)
86
87
  Tab expand/collapse the most recent tool output`;
87
88
  // Injected when the user turns plan mode on, so the model knows the rules of the
88
89
  // mode it now lives in (the permission gate enforces them regardless).
@@ -258,7 +259,7 @@ async function main() {
258
259
  // so the status bar stays pinned below however the line wraps.
259
260
  editing = true;
260
261
  try {
261
- const res = await editLine(rl, { prompt, footer, history, onTab: () => toggleLastCollapsed(), onReveal: () => revealReasoning(), transformPaste: normalizeDroppedPaths });
262
+ const res = await editLine(rl, { prompt, footer, history, onTab: () => toggleLastCollapsed(), onReveal: () => revealReasoning(), onTools: () => revealToolCalls(), transformPaste: normalizeDroppedPaths });
262
263
  if (res.type === "line") {
263
264
  if (res.value.trim())
264
265
  history.push(res.value); // remember non-empty entries for ↑/↓
@@ -443,6 +444,7 @@ async function main() {
443
444
  resetTeam(); // the team belonged to the old task — forget it (mailboxes re-wipe on next use)
444
445
  resetBoard(); // and the shared task board (Day 40)
445
446
  clearReasoning(); // drop the collapsed thinking too
447
+ clearToolCalls(); // ...and the folded tool-call trace
446
448
  sessionId = newSessionId(); // a fresh conversation is a fresh session file
447
449
  console.log(chalk.dim("(history cleared)")); // confirm the reset
448
450
  return true;
@@ -635,16 +637,16 @@ async function main() {
635
637
  });
636
638
  }
637
639
  while (true) {
638
- // The input area, Claude-Code style: a top rule, the "❯" you type on, then a
639
- // bottom rule + status line (model · 📁 dir · 🌿 branch · ctx% · $ · time)
640
- // pinned BELOW the input. readUserLine draws the footer under the prompt and
641
- // keeps the cursor on the ❯ line, so the status reads like a real bottom bar.
642
- // Only in a TTY; piped input stays clean.
640
+ // The input area: a blank line for breathing room, the "❯" you type on, then
641
+ // a single status line (model · 📁 dir · 🌿 branch · ctx% · $ · time) pinned
642
+ // BELOW the input. We dropped the old top/bottom horizontal rules — two bare
643
+ // lines around an empty prompt just looked like noise. Only in a TTY; piped
644
+ // input stays clean.
643
645
  let footer;
644
646
  if (process.stdin.isTTY) {
645
647
  const ctxPct = Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100));
646
- console.log("\n" + inputRule()); // top rule above the prompt
647
- footer = inputRule() + "\n" + statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt); // bottom rule + status, pinned below
648
+ console.log(""); // one blank line separates the previous answer from the prompt
649
+ footer = statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt); // status pinned below the prompt
648
650
  }
649
651
  const line = (await readUserLine(framedPrompt(isPlanMode()), footer)).trim(); // next input, or "exit" on EOF
650
652
  if (!line)
@@ -677,6 +679,7 @@ async function main() {
677
679
  console.log(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
678
680
  messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
679
681
  clearReasoning(); // Ctrl+R should reveal THIS turn's thinking, not the previous answer's
682
+ clearToolCalls(); // ...and Ctrl+T should reveal THIS turn's tool calls
680
683
  running = true; // Ctrl+C now means "interrupt the task"
681
684
  interrupted = false; // fresh interrupt state for this task
682
685
  controller = new AbortController(); // fresh abort signal for this task
package/dist/editor.js CHANGED
@@ -28,6 +28,8 @@ export function reduceEditor(s, str, key) {
28
28
  return { state: { buffer, cursor: Math.min(buffer.length, cursor + 1) }, action: "edit" }; // forward one char
29
29
  case "r":
30
30
  return { state: s, action: "reveal" }; // Ctrl+R → reveal the model's collapsed thinking
31
+ case "t":
32
+ return { state: s, action: "tools" }; // Ctrl+T → reveal the folded tool-call trace
31
33
  default:
32
34
  return { state: s, action: "none" };
33
35
  }
@@ -294,6 +296,10 @@ export function editLine(rl, opts) {
294
296
  opts.onReveal?.(); // print the model's collapsed thinking above us, then repaint
295
297
  first = true; // the next paint lands fresh, not a stale climb-up
296
298
  return draw();
299
+ case "tools":
300
+ opts.onTools?.(); // print the folded tool-call trace above us, then repaint
301
+ first = true;
302
+ return draw();
297
303
  case "edit":
298
304
  return draw();
299
305
  case "none":
package/dist/loop.js CHANGED
@@ -12,7 +12,7 @@ import { emit } from "./telemetry.js"; // local-only event log (no-op unless the
12
12
  import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
13
13
  import { recordUsage } from "./cost.js"; // meter token usage from the stream
14
14
  import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
15
- import { printToolSummary, recordReasoning } from "./tui.js"; // collapsible tool output + the model's hidden thinking (Ctrl+R)
15
+ import { recordToolCall, recordReasoning } from "./tui.js"; // folded tool-call trace (Ctrl+T) + the model's hidden thinking (Ctrl+R)
16
16
  import { MarkdownStream } from "./markdown.js"; // render the streamed answer as terminal markdown
17
17
  import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
18
18
  import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
@@ -458,17 +458,25 @@ opts) {
458
458
  spinner.stop(); // and never leave a zombie spinner
459
459
  }
460
460
  }
461
+ // How this agent's activity is prefixed on screen: teammate tagged by name,
462
+ // plain sub-agent nested in blue, top-level flush-left. Shared by the per-call
463
+ // recording and the per-round tally so they line up.
464
+ function agentIndent(opts) {
465
+ return opts.teammate ? chalk.magenta(` ⎿ [${opts.teammate.name}] `) : opts.subAgent ? chalk.blue(" ⎿ ") : "";
466
+ }
461
467
  // Run one tool call end to end: log it, run it through the permission gate,
462
468
  // ask the human if needed, execute, and return the paired result. Read-only
463
469
  // calls (allow/deny only, never "ask") are safe to run via this from inside a
464
470
  // Promise.all batch; calls that can prompt must be awaited one at a time.
465
471
  async function runOneCall(call, opts) {
466
- const indent = opts.teammate ? chalk.magenta(` ⎿ [${opts.teammate.name}] `) : opts.subAgent ? chalk.blue(" ⎿ ") : ""; // teammate activity is tagged by name; plain sub-agent is nested in blue
472
+ const indent = agentIndent(opts);
467
473
  if (!opts.quiet) {
468
- // Compact one-line summary — long args are hidden behind Tab.
469
- const summary = indent + mark.tool(call.name, call.args.length > 60 ? call.args.slice(0, 57) + "..." : call.args);
470
- const full = `tool: ${call.name}\nargs: ${call.args}`;
471
- printToolSummary(summary, full);
474
+ // The announcement is FOLDED (Day-after-40 UI fix): instead of printing a
475
+ // line per call (which floods the screen on a big exploration), we record it
476
+ // for the per-round tally + Ctrl+T. Diffs / prompts / results below still
477
+ // print live. A generous slice keeps Ctrl+T readable without dumping megabytes.
478
+ const line = indent + mark.tool(call.name, call.args.length > 300 ? call.args.slice(0, 297) + "..." : call.args);
479
+ recordToolCall(line, `tool: ${call.name}\nargs: ${call.args}`);
472
480
  }
473
481
  // The permission gate sits between the model's intent and execution.
474
482
  const v = checkPermission(call.name, call.args);
@@ -1224,6 +1232,11 @@ opts) {
1224
1232
  const r = await runOneCall(out.toolCalls[i++], opts);
1225
1233
  messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
1226
1234
  }
1235
+ // Fold this round's tool calls into ONE tally line instead of one line per
1236
+ // call (the per-call announcements were recorded for Ctrl+T in runOneCall).
1237
+ if (!opts.quiet && out.toolCalls.length) {
1238
+ console.log(agentIndent(opts) + mark.toolTally(out.toolCalls.map((c) => c.name)) + chalk.dim(" · Ctrl+T"));
1239
+ }
1227
1240
  // Background tasks (Day 37): if a job the model started has finished, slip
1228
1241
  // its <task_notification> in here, right after this round's tool results —
1229
1242
  // the model sees the outcome on the next round without ever having blocked
package/dist/markdown.js CHANGED
@@ -75,8 +75,12 @@ export function renderMarkdown(md, width = termWidth()) {
75
75
  const w = Math.max(20, width);
76
76
  const m = new Marked();
77
77
  m.use(renderer(w));
78
- // Override the table renderer AFTER marked-terminal so ours wins: render each
79
- // cell's inline markdown to ANSI (parseInline), then lay the table out to fit w.
78
+ // Override the table AND list renderers AFTER marked-terminal so ours win.
79
+ // marked-terminal v7 predates marked v15's token-object renderer API, so its
80
+ // built-in table/list renderers receive tokens they don't understand and spit
81
+ // out the RAW markdown — literal `**bold**`, `` `code` ``, and `*` bullets
82
+ // inside list items (and unaligned tables). We render both ourselves, calling
83
+ // parseInline so the inline markup inside cells / list items actually renders.
80
84
  m.use({
81
85
  renderer: {
82
86
  table(token) {
@@ -84,6 +88,37 @@ export function renderMarkdown(md, width = termWidth()) {
84
88
  const rows = token.rows.map((r) => r.map((c) => this.parser.parseInline(c.tokens)));
85
89
  return renderTable(header, rows, w) + "\n";
86
90
  },
91
+ list(token) {
92
+ // Render items at a given nesting depth: a bullet (• / N.) + the item's
93
+ // inline content (bold/code/links parsed), wrapped to width with a hanging
94
+ // indent so continuation lines sit under the text. Nested lists recurse.
95
+ const parser = this.parser;
96
+ const render = (list, depth) => {
97
+ const pad = " ".repeat(depth);
98
+ const lines = [];
99
+ list.items.forEach((item, idx) => {
100
+ const bullet = list.ordered ? `${(typeof list.start === "number" ? list.start : 1) + idx}.` : "•";
101
+ const box = item.task ? (item.checked ? "[x] " : "[ ] ") : "";
102
+ let inline = "";
103
+ const nested = [];
104
+ for (const t of item.tokens) {
105
+ if (t.type === "list")
106
+ nested.push(...render(t, depth + 1));
107
+ else if (t.tokens)
108
+ inline += parser.parseInline(t.tokens);
109
+ else
110
+ inline += t.text ?? "";
111
+ }
112
+ const head = `${pad}${bullet} ${box}`;
113
+ const wrapped = wrapAnsi(head + inline.trim(), Math.max(10, w), { hard: true, trim: false }).split("\n");
114
+ const hang = " ".repeat(displayWidth(head)); // align wrapped lines under the text
115
+ lines.push(...wrapped.map((ln, i) => (i === 0 ? ln : hang + ln.replace(/^\s+/, ""))));
116
+ lines.push(...nested);
117
+ });
118
+ return lines;
119
+ };
120
+ return render(token, 0).join("\n") + "\n\n";
121
+ },
87
122
  },
88
123
  });
89
124
  const out = m.parse(md, { async: false });
package/dist/tui.js CHANGED
@@ -35,13 +35,6 @@ export function gitBranch() {
35
35
  }
36
36
  const collapsed = [];
37
37
  const MAX_COLLAPSED = 50; // bound the memory — old blocks fall off
38
- // Print the one-line summary now; stash the full text for Tab to reveal later.
39
- export function printToolSummary(summary, full) {
40
- process.stdout.write(summary + "\n");
41
- collapsed.push({ full, expanded: false });
42
- if (collapsed.length > MAX_COLLAPSED)
43
- collapsed.shift();
44
- }
45
38
  // Tab handler: reveal (or re-hide) the full text of the most recent tool block.
46
39
  // Returns false if there's nothing to toggle.
47
40
  export function toggleLastCollapsed() {
@@ -85,6 +78,35 @@ export function revealReasoning() {
85
78
  process.stdout.write(chalk.dim(reasoningTrace.join("\n\n— — —\n\n").replace(/^/gm, " ")) + "\n");
86
79
  return true;
87
80
  }
81
+ // ---- collapsed tool calls (the activity trace) -------------------------------
82
+ // A long task fires dozens of read_file/search calls; printing one line each
83
+ // floods the screen. Instead the loop prints a compact per-round tally and we
84
+ // keep the full call lines here for Ctrl+T to reveal. Cleared per turn, so
85
+ // Ctrl+T shows the calls behind the LAST answer. (Diffs, prompts, and results
86
+ // still print live — only the one-line announcements are folded.)
87
+ const toolCallLog = [];
88
+ const MAX_TOOL_LOG = 500; // bound the memory
89
+ // Record a tool call's full line for Ctrl+T, and stash its args for Tab.
90
+ export function recordToolCall(line, full) {
91
+ toolCallLog.push(line);
92
+ if (toolCallLog.length > MAX_TOOL_LOG)
93
+ toolCallLog.shift();
94
+ collapsed.push({ full, expanded: false }); // Tab still reveals the most recent call's full args
95
+ if (collapsed.length > MAX_COLLAPSED)
96
+ collapsed.shift();
97
+ }
98
+ export function clearToolCalls() {
99
+ toolCallLog.length = 0;
100
+ }
101
+ // Ctrl+T handler: print every tool call behind the last answer. Returns false
102
+ // (nothing to show) so the editor can ignore the key.
103
+ export function revealToolCalls() {
104
+ if (!toolCallLog.length)
105
+ return false;
106
+ process.stdout.write(chalk.dim(`tool calls this turn (${toolCallLog.length}):\n`));
107
+ process.stdout.write(toolCallLog.join("\n") + "\n");
108
+ return true;
109
+ }
88
110
  // ---- cleanup -----------------------------------------------------------------
89
111
  // Called on exit. Nothing fancy to restore now (we no longer hijack the bottom
90
112
  // of the screen), but keep the hook so callers don't have to change if that
package/dist/ui.js CHANGED
@@ -52,6 +52,14 @@ export const mark = {
52
52
  teammateStart: (name, role) => chalk.magenta(" ⎿ teammate ") + chalk.bold(name) + chalk.magenta(` spawned`) + chalk.dim(` — ${role}`), // Day 38
53
53
  teammateDone: (name, ok) => (ok ? chalk.magenta(" ⎿ teammate ") + chalk.bold(name) + chalk.magenta(" finished") : chalk.yellow(" ⎿ teammate ") + chalk.bold(name) + chalk.yellow(" ended (not done)")),
54
54
  inbox: (n) => chalk.magenta(" ⎿ ") + chalk.magenta(`${n} team message${n === 1 ? "" : "s"} received`),
55
+ // A round's tool calls folded into one line: "⏺ 6 tools · read_file ×4 · search ×2" (Ctrl+T expands them).
56
+ toolTally: (names) => {
57
+ const counts = new Map();
58
+ for (const n of names)
59
+ counts.set(n, (counts.get(n) ?? 0) + 1);
60
+ const parts = [...counts.entries()].map(([n, c]) => `${n} ×${c}`).join(" · ");
61
+ return chalk.cyan("⏺ ") + chalk.dim(`${names.length} tool${names.length === 1 ? "" : "s"} · ${parts}`);
62
+ },
55
63
  note: (s) => chalk.dim(s), // dim asides (resumed, attached, cleared…)
56
64
  warn: (s) => chalk.yellow(s),
57
65
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Build a Claude Code-style CLI agent from scratch, with AI as your pair programmer — companion code for a 10-day tutorial (DeepSeek by default, any OpenAI-compatible model)",
5
5
  "type": "module",
6
6
  "bin": {