agent-from-zero 0.29.0 → 0.31.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, sentMessage } from "./ui.js"; // welcome box, prompt, model labels, status line, sent-message echo
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).
@@ -248,7 +249,7 @@ async function main() {
248
249
  lineWaiters.shift()("exit"); // wake every waiter with a polite "exit"
249
250
  });
250
251
  // Get the next line of user input, showing a prompt if we have to wait.
251
- const readUserLine = async (prompt, footer) => {
252
+ const readUserLine = async (prompt, footer, echo) => {
252
253
  if (pendingLines.length)
253
254
  return pendingLines.shift(); // typed-ahead (or piped) line — use it
254
255
  if (stdinDone)
@@ -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, echo, 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,18 +637,18 @@ 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
- const line = (await readUserLine(framedPrompt(isPlanMode()), footer)).trim(); // next input, or "exit" on EOF
651
+ const line = (await readUserLine(framedPrompt(isPlanMode()), footer, sentMessage)).trim(); // next input (echoed as a sent message above), or "exit" on EOF
650
652
  if (!line)
651
653
  continue; // empty line — just re-prompt
652
654
  if (line === "exit" || line === "quit") {
@@ -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
  }
@@ -237,13 +239,24 @@ export function editLine(rl, opts) {
237
239
  input.off("keypress", onKey);
238
240
  for (const l of otherKeypress)
239
241
  input.on("keypress", l); // give readline its keypress handler back
240
- // Leave the typed command on screen, drop the footer, land on a fresh line.
241
- const { rows } = layout(state.buffer, state.cursor, promptW, cols());
242
+ // Climb to the top of the input block and wipe it (prompt + wrapped input + footer).
242
243
  if (prevCursorRow > 0)
243
244
  out.write(`\x1b[${prevCursorRow}A`);
244
245
  out.write("\r\x1b[0J");
245
- const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
246
- out.write(inputRows.join("\r\n") + "\r\n");
246
+ // What stays on screen: with an echo styler (the main REPL prompt), a
247
+ // submitted message is re-rendered as a distinct "sent" line and the input
248
+ // box vanishes — your message scrolls up, the input clears, like a chat app.
249
+ // Without it (sub-prompts), leave the typed command under its prompt.
250
+ if (opts.echo) {
251
+ if (result.type === "line" && state.buffer.trim())
252
+ out.write(opts.echo(state.buffer) + "\r\n");
253
+ // empty submit / cancel / eof: leave nothing — the wipe above already cleared it
254
+ }
255
+ else {
256
+ const { rows } = layout(state.buffer, state.cursor, promptW, cols());
257
+ const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
258
+ out.write(inputRows.join("\r\n") + "\r\n");
259
+ }
247
260
  try {
248
261
  input.setRawMode(wasRaw); // hand stdin back exactly as we found it
249
262
  }
@@ -294,6 +307,10 @@ export function editLine(rl, opts) {
294
307
  opts.onReveal?.(); // print the model's collapsed thinking above us, then repaint
295
308
  first = true; // the next paint lands fresh, not a stale climb-up
296
309
  return draw();
310
+ case "tools":
311
+ opts.onTools?.(); // print the folded tool-call trace above us, then repaint
312
+ first = true;
313
+ return draw();
297
314
  case "edit":
298
315
  return draw();
299
316
  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
  };
@@ -116,6 +124,12 @@ export function inputRule() {
116
124
  export function framedPrompt(planMode) {
117
125
  return planMode ? chalk.yellow.bold("⏸ plan ❯ ") : chalk.cyan.bold("❯ ");
118
126
  }
127
+ // How a SUBMITTED message is echoed into the scrollback once you hit Enter: a
128
+ // distinct, muted "> …" line (not the bright ❯ prompt) so each turn reads like a
129
+ // sent chat message scrolling up, leaving the input box empty below.
130
+ export function sentMessage(text) {
131
+ return chalk.dim("> " + text);
132
+ }
119
133
  // ---- the selection menu -----------------------------------------------------
120
134
  // Render a numbered list of options with one highlighted. The interactive part
121
135
  // (raw-mode arrow keys) lives in menu.ts; this is just the pure drawing, so it's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.29.0",
3
+ "version": "0.31.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": {
@@ -16,7 +16,7 @@
16
16
  "eval": "tsx --env-file=.env eval/run.ts",
17
17
  "build": "tsc -p tsconfig.build.json",
18
18
  "prepare": "npm run build",
19
- "test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/ui.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts"
19
+ "test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/ui.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts"
20
20
  },
21
21
  "engines": {
22
22
  "node": ">=20.6"