agent-from-zero 0.28.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,10 +19,11 @@ 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, cleanup as tuiCleanup } from "./tui.js"; // git branch for the status line + collapsible output
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
+ import { normalizeDroppedPaths } from "./drop.js"; // drag-and-drop: a dropped file's path → a clean absolute path in the input
26
27
  import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
27
28
  import { loadSkills, buildSkillTool, findSkill, skillInstructions } from "./skills.js"; // Markdown-as-plugin skills
28
29
  import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
@@ -78,7 +79,12 @@ const SESSION_HELP = `commands:
78
79
  /resume list recent sessions in this project and continue one of them
79
80
  /skills list the reusable skills available in this project
80
81
  /skill <name> run a skill yourself (works even for user-only skills)
81
- exit leave (Ctrl+C at the prompt does the same)`;
82
+ exit leave (Ctrl+C at the prompt does the same)
83
+
84
+ keys (at the prompt):
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)
87
+ Tab expand/collapse the most recent tool output`;
82
88
  // Injected when the user turns plan mode on, so the model knows the rules of the
83
89
  // mode it now lives in (the permission gate enforces them regardless).
84
90
  const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
@@ -253,7 +259,7 @@ async function main() {
253
259
  // so the status bar stays pinned below however the line wraps.
254
260
  editing = true;
255
261
  try {
256
- const res = await editLine(rl, { prompt, footer, history, onTab: () => toggleLastCollapsed() });
262
+ const res = await editLine(rl, { prompt, footer, history, onTab: () => toggleLastCollapsed(), onReveal: () => revealReasoning(), onTools: () => revealToolCalls(), transformPaste: normalizeDroppedPaths });
257
263
  if (res.type === "line") {
258
264
  if (res.value.trim())
259
265
  history.push(res.value); // remember non-empty entries for ↑/↓
@@ -437,6 +443,8 @@ async function main() {
437
443
  clearTodos(); // the plan belonged to the old task — drop it
438
444
  resetTeam(); // the team belonged to the old task — forget it (mailboxes re-wipe on next use)
439
445
  resetBoard(); // and the shared task board (Day 40)
446
+ clearReasoning(); // drop the collapsed thinking too
447
+ clearToolCalls(); // ...and the folded tool-call trace
440
448
  sessionId = newSessionId(); // a fresh conversation is a fresh session file
441
449
  console.log(chalk.dim("(history cleared)")); // confirm the reset
442
450
  return true;
@@ -629,16 +637,16 @@ async function main() {
629
637
  });
630
638
  }
631
639
  while (true) {
632
- // The input area, Claude-Code style: a top rule, the "❯" you type on, then a
633
- // bottom rule + status line (model · 📁 dir · 🌿 branch · ctx% · $ · time)
634
- // pinned BELOW the input. readUserLine draws the footer under the prompt and
635
- // keeps the cursor on the ❯ line, so the status reads like a real bottom bar.
636
- // 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.
637
645
  let footer;
638
646
  if (process.stdin.isTTY) {
639
647
  const ctxPct = Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100));
640
- console.log("\n" + inputRule()); // top rule above the prompt
641
- 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
642
650
  }
643
651
  const line = (await readUserLine(framedPrompt(isPlanMode()), footer)).trim(); // next input, or "exit" on EOF
644
652
  if (!line)
@@ -670,6 +678,8 @@ async function main() {
670
678
  if (refused.length)
671
679
  console.log(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
672
680
  messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
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
673
683
  running = true; // Ctrl+C now means "interrupt the task"
674
684
  interrupted = false; // fresh interrupt state for this task
675
685
  controller = new AbortController(); // fresh abort signal for this task
package/dist/drop.js ADDED
@@ -0,0 +1,104 @@
1
+ // Drag-and-drop a file onto the terminal and it pastes the file's path into the
2
+ // input — but as a SHELL token: backslash-escaped spaces (/Users/me/my\ file.txt)
3
+ // or wrapped in quotes, and usually with a trailing space. That's noisy to read
4
+ // and clumsy to hand to the agent. This turns a dropped path (or several) into
5
+ // clean ABSOLUTE paths, so the user never types a long path by hand.
6
+ //
7
+ // It is deliberately conservative: it only rewrites text that is, after
8
+ // unescaping, one or more EXISTING ABSOLUTE paths (that's exactly what a terminal
9
+ // drag produces). Anything else — ordinary pasted prose, a relative snippet — is
10
+ // returned untouched, so this can run on every paste without surprising anyone.
11
+ import fs from "node:fs"; // confirm the tokens are real paths (the drag signal)
12
+ import os from "node:os"; // expand a leading ~
13
+ import path from "node:path"; // resolve + normalize to absolute
14
+ // Terminals with "bracketed paste" wrap the text in these markers; strip them so
15
+ // a drop still parses if the mode happens to be on.
16
+ const BRACKET_PASTE = /\x1b\[20[01]~/g;
17
+ // Split a dropped string into path tokens, honoring the escaping a terminal adds:
18
+ // backslash escapes (\<space>, \( …) and single/double quotes. Whitespace OUTSIDE
19
+ // a quote/escape separates tokens (so several dropped files become several paths).
20
+ function splitTokens(raw) {
21
+ const s = raw.replace(BRACKET_PASTE, "").trim();
22
+ const tokens = [];
23
+ let cur = "";
24
+ let started = false; // did the current token get any character? (distinguishes "" from absent)
25
+ let i = 0;
26
+ while (i < s.length) {
27
+ const c = s[i];
28
+ if (c === "\\" && i + 1 < s.length) {
29
+ cur += s[i + 1];
30
+ i += 2;
31
+ started = true;
32
+ continue;
33
+ } // \x → literal x
34
+ if (c === "'") { // single quotes: everything literal until the next '
35
+ i++;
36
+ while (i < s.length && s[i] !== "'")
37
+ cur += s[i++];
38
+ i++;
39
+ started = true;
40
+ continue;
41
+ }
42
+ if (c === '"') { // double quotes: backslash still escapes inside
43
+ i++;
44
+ while (i < s.length && s[i] !== '"') {
45
+ if (s[i] === "\\" && i + 1 < s.length) {
46
+ cur += s[i + 1];
47
+ i += 2;
48
+ }
49
+ else
50
+ cur += s[i++];
51
+ }
52
+ i++;
53
+ started = true;
54
+ continue;
55
+ }
56
+ if (c === " " || c === "\t" || c === "\n" || c === "\r") { // a separator between tokens
57
+ if (started) {
58
+ tokens.push(cur);
59
+ cur = "";
60
+ started = false;
61
+ }
62
+ i++;
63
+ continue;
64
+ }
65
+ cur += c;
66
+ i++;
67
+ started = true;
68
+ }
69
+ if (started)
70
+ tokens.push(cur);
71
+ return tokens;
72
+ }
73
+ // A drop produces ABSOLUTE paths (macOS/Linux terminals). Treat a leading ~ as
74
+ // home. Anything not absolute-ish is not a drop — we leave it alone.
75
+ const looksAbsolute = (t) => t.startsWith("/") || t === "~" || t.startsWith("~/");
76
+ const expandTilde = (t) => (t === "~" || t.startsWith("~/") ? os.homedir() + t.slice(1) : t);
77
+ // Quote a path that contains whitespace so several dropped paths stay separable
78
+ // (and the whole thing survives being read as shell-ish tokens later). Prefer
79
+ // single quotes; fall back to double if the path itself contains a single quote.
80
+ function quoteIfNeeded(p) {
81
+ if (!/\s/.test(p))
82
+ return p;
83
+ return p.includes("'") ? `"${p}"` : `'${p}'`;
84
+ }
85
+ // The entry point: given a pasted string, return clean absolute path(s) if it is
86
+ // a drop, otherwise the original text unchanged. A successful normalization ends
87
+ // with a single trailing space, so the cursor lands ready to type the question.
88
+ export function normalizeDroppedPaths(raw) {
89
+ if (!raw)
90
+ return raw;
91
+ const tokens = splitTokens(raw);
92
+ if (!tokens.length)
93
+ return raw;
94
+ const abs = [];
95
+ for (const t of tokens) {
96
+ if (!looksAbsolute(t))
97
+ return raw; // not a dragged absolute path → leave the paste as-is
98
+ const resolved = path.resolve(expandTilde(t)); // normalize (//, .., trailing /) → canonical absolute
99
+ if (!fs.existsSync(resolved))
100
+ return raw; // a path that doesn't exist isn't a real drop
101
+ abs.push(resolved);
102
+ }
103
+ return abs.map(quoteIfNeeded).join(" ") + " ";
104
+ }
package/dist/editor.js CHANGED
@@ -26,6 +26,10 @@ export function reduceEditor(s, str, key) {
26
26
  return { state: { buffer, cursor: Math.max(0, cursor - 1) }, action: "edit" }; // back one char
27
27
  case "f":
28
28
  return { state: { buffer, cursor: Math.min(buffer.length, cursor + 1) }, action: "edit" }; // forward one char
29
+ case "r":
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
29
33
  default:
30
34
  return { state: s, action: "none" };
31
35
  }
@@ -254,6 +258,12 @@ export function editLine(rl, opts) {
254
258
  const onKey = (str, key) => {
255
259
  if (done)
256
260
  return;
261
+ // A drag-and-drop (or any paste) arrives as one multi-char string. Give the
262
+ // caller a chance to rewrite it — e.g. turn dropped file paths into clean
263
+ // absolute paths — before it lands in the buffer. No-op for normal text.
264
+ if (str && str.length > 1 && !key?.ctrl && !key?.meta && opts.transformPaste) {
265
+ str = opts.transformPaste(str);
266
+ }
257
267
  const next = reduceEditor(state, str, key || {});
258
268
  state = next.state;
259
269
  switch (next.action) {
@@ -282,6 +292,14 @@ export function editLine(rl, opts) {
282
292
  opts.onTab?.(); // may print above us (e.g. expand a collapsed block) and scroll
283
293
  first = true; // so the next paint lands fresh at the cursor, not a stale climb-up
284
294
  return draw();
295
+ case "reveal":
296
+ opts.onReveal?.(); // print the model's collapsed thinking above us, then repaint
297
+ first = true; // the next paint lands fresh, not a stale climb-up
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();
285
303
  case "edit":
286
304
  return draw();
287
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 } from "./tui.js"; // collapsible tool output
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
@@ -328,6 +328,11 @@ opts) {
328
328
  const word = thinkingWord(modelCallSeq++); // a rotating "thinking" word for this call
329
329
  const startedAt = Date.now(); // for the spinner's live elapsed counter
330
330
  let streamedChars = 0; // bytes of content+reasoning streamed this call → a live token estimate
331
+ // Reasoning is COLLAPSED by default: while the model thinks, the spinner keeps
332
+ // spinning (with a 💭 hint) instead of dumping the trace; we stash it for Ctrl+R.
333
+ let reasoningBuf = ""; // the accumulated thinking, hidden behind the spinner
334
+ let reasoningActive = false; // true while reasoning tokens are arriving → spinner shows 💭
335
+ let reasoningShown = false; // have we printed the "thought for Ns" indicator yet?
331
336
  const spinner = opts.quiet
332
337
  ? null // the eval harness wants silence
333
338
  : ora({ text: spinnerText(word, 0, !!opts.subAgent, opts.model), discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
@@ -338,8 +343,10 @@ opts) {
338
343
  // - stall (slow but alive for 30s) → log it and keep waiting
339
344
  // It also ticks the spinner: elapsed seconds + a live token estimate (~chars/4).
340
345
  const watchdog = setInterval(() => {
346
+ // While the model is reasoning, prefix the spinner with 💭 so "it's thinking"
347
+ // reads differently from "it's waiting on the first token".
341
348
  if (spinner?.isSpinning)
342
- spinner.text = spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4));
349
+ spinner.text = (reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4));
343
350
  const quietMs = Date.now() - lastEvent; // ms since the last event
344
351
  if (quietMs > IDLE_TIMEOUT_MS) {
345
352
  emit("agent_watchdog_idle"); // record the cut — these should be rare
@@ -364,9 +371,27 @@ opts) {
364
371
  { model: opts.model, messages, tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
365
372
  let content = ""; // accumulated answer text
366
373
  let printedPrefix = false; // have we printed the answer prefix yet?
367
- let printedThinking = false; // have we started printing the reasoning trace yet?
368
374
  let md = null; // renders the streamed answer as markdown, block by block
369
375
  const calls = []; // tool calls under assembly, indexed by delta.index
376
+ // The model has finished thinking and is about to say/do something. Close out
377
+ // the spinner, stash the trace, and print a single one-line indicator with the
378
+ // shortcut to reveal it. Idempotent + collapses to nothing for sub-agents and
379
+ // the eval harness (they never show thinking).
380
+ const flushReasoning = () => {
381
+ if (!reasoningBuf || reasoningShown)
382
+ return;
383
+ reasoningShown = true;
384
+ reasoningActive = false;
385
+ if (spinner?.isSpinning)
386
+ spinner.stop();
387
+ if (opts.quiet || opts.subAgent)
388
+ return; // no UI for silent / nested runs
389
+ recordReasoning(reasoningBuf); // keep it for Ctrl+R (never goes into history)
390
+ const secs = Math.round((Date.now() - startedAt) / 1000);
391
+ const toks = Math.round(reasoningBuf.length / 4); // same ~chars/4 estimate as the spinner
392
+ const tokStr = toks >= 1000 ? `${(toks / 1000).toFixed(1)}k` : `${toks}`;
393
+ process.stdout.write(chalk.dim(`💭 thought for ${secs}s (~${tokStr} tokens) · Ctrl+R to view`) + "\n");
394
+ };
370
395
  for await (const chunk of stream) {
371
396
  lastEvent = Date.now(); // feed the watchdog
372
397
  stallWarned = false; // the stream spoke — reset the stall warning
@@ -376,31 +401,24 @@ opts) {
376
401
  if (!delta)
377
402
  continue; // keep-alive or usage chunk — nothing to do
378
403
  // Reasoning models (e.g. deepseek-reasoner / R1) stream their thinking in a
379
- // separate `reasoning_content` field BEFORE the answer. Show it dimly so a
380
- // switch to a reasoning model is visibly different — but do NOT keep it:
381
- // the reasoning is not the answer and must not be replayed in later turns.
404
+ // separate `reasoning_content` field BEFORE the answer. We do NOT print it
405
+ // (it buries the actual reply) and do NOT keep it (it must not be replayed
406
+ // in later turns): we let the spinner keep spinning, stash the trace, and
407
+ // surface it only behind Ctrl+R.
382
408
  const reasoning = delta.reasoning_content;
383
409
  if (reasoning) {
384
410
  streamedChars += reasoning.length; // count reasoning toward the live token estimate
385
- if (spinner?.isSpinning)
386
- spinner.stop();
387
- if (!opts.quiet && !opts.subAgent) {
388
- if (!printedThinking) {
389
- process.stdout.write("\n" + chalk.dim("💭 thinking: ")); // label the trace once
390
- printedThinking = true;
391
- }
392
- process.stdout.write(chalk.dim(reasoning)); // stream the thinking, dimmed
393
- }
411
+ reasoningBuf += reasoning; // stash it for Ctrl+R instead of streaming it
412
+ reasoningActive = true; // the spinner now shows 💭 (set by the watchdog)
394
413
  }
395
414
  if (delta.content) {
415
+ flushReasoning(); // thinking is over — show the one-line indicator first
396
416
  streamedChars += delta.content.length; // count the answer toward the live token estimate
397
417
  if (spinner?.isSpinning)
398
418
  spinner.stop(); // first token: replace the spinner with real output
399
419
  if (!opts.quiet && !opts.subAgent) {
400
420
  // Only the top-level agent streams to the screen — a sub-agent's
401
421
  // inner monologue would be mistaken for the answer.
402
- if (printedThinking && !printedPrefix)
403
- process.stdout.write("\n"); // end the thinking block before the answer
404
422
  if (!printedPrefix) {
405
423
  // Stream the answer through the markdown renderer: it buffers a block
406
424
  // (paragraph / heading / list / table) and prints it formatted the
@@ -413,6 +431,8 @@ opts) {
413
431
  }
414
432
  content += delta.content; // always keep it for the history (the answer only, never the reasoning)
415
433
  }
434
+ if (delta.tool_calls?.length)
435
+ flushReasoning(); // thinking is over — indicator before the tool logs (which print after we return)
416
436
  for (const tc of delta.tool_calls ?? []) {
417
437
  if (spinner?.isSpinning)
418
438
  spinner.stop(); // tool call starting — spinner served its purpose
@@ -425,6 +445,7 @@ opts) {
425
445
  slot.args += tc.function.arguments; // arguments stream in fragments — concatenate
426
446
  }
427
447
  }
448
+ flushReasoning(); // a reasoning-only turn (no content, no tools) still records + indicates
428
449
  if (md)
429
450
  md.end(); // flush the final (un-terminated) block through the renderer
430
451
  if (printedPrefix)
@@ -437,17 +458,25 @@ opts) {
437
458
  spinner.stop(); // and never leave a zombie spinner
438
459
  }
439
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
+ }
440
467
  // Run one tool call end to end: log it, run it through the permission gate,
441
468
  // ask the human if needed, execute, and return the paired result. Read-only
442
469
  // calls (allow/deny only, never "ask") are safe to run via this from inside a
443
470
  // Promise.all batch; calls that can prompt must be awaited one at a time.
444
471
  async function runOneCall(call, opts) {
445
- 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);
446
473
  if (!opts.quiet) {
447
- // Compact one-line summary — long args are hidden behind Tab.
448
- const summary = indent + mark.tool(call.name, call.args.length > 60 ? call.args.slice(0, 57) + "..." : call.args);
449
- const full = `tool: ${call.name}\nargs: ${call.args}`;
450
- 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}`);
451
480
  }
452
481
  // The permission gate sits between the model's intent and execution.
453
482
  const v = checkPermission(call.name, call.args);
@@ -1203,6 +1232,11 @@ opts) {
1203
1232
  const r = await runOneCall(out.toolCalls[i++], opts);
1204
1233
  messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
1205
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
+ }
1206
1240
  // Background tasks (Day 37): if a job the model started has finished, slip
1207
1241
  // its <task_notification> in here, right after this round's tool results —
1208
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() {
@@ -58,6 +51,62 @@ export function toggleLastCollapsed() {
58
51
  }
59
52
  return true;
60
53
  }
54
+ // ---- collapsed reasoning (the model's thinking) ------------------------------
55
+ // Reasoning models (deepseek-reasoner / R1) stream a long thinking trace before
56
+ // the answer. Dumping it all to the screen buries the actual reply, so we hide
57
+ // it behind a spinner and a one-line indicator, keeping the trace here for the
58
+ // user to reveal with Ctrl+R (handled by the line editor, like Tab for tools).
59
+ // Stored as one entry per model round; cleared when a new user turn begins, so
60
+ // Ctrl+R always shows the thinking behind the LAST answer.
61
+ const reasoningTrace = [];
62
+ export function recordReasoning(text) {
63
+ const t = text.trim();
64
+ if (t)
65
+ reasoningTrace.push(t);
66
+ }
67
+ // Drop the current turn's thinking. Called when a new prompt is submitted (the
68
+ // old thinking belonged to the old answer) and on /clear.
69
+ export function clearReasoning() {
70
+ reasoningTrace.length = 0;
71
+ }
72
+ // Ctrl+R handler: print the stored thinking for the last answer, dimmed and
73
+ // nested. Returns false (nothing to show) so the editor can ignore the key.
74
+ export function revealReasoning() {
75
+ if (!reasoningTrace.length)
76
+ return false;
77
+ process.stdout.write(chalk.dim("💭 model's thinking:\n"));
78
+ process.stdout.write(chalk.dim(reasoningTrace.join("\n\n— — —\n\n").replace(/^/gm, " ")) + "\n");
79
+ return true;
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
+ }
61
110
  // ---- cleanup -----------------------------------------------------------------
62
111
  // Called on exit. Nothing fancy to restore now (we no longer hijack the bottom
63
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.28.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": {
@@ -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"
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"
20
20
  },
21
21
  "engines": {
22
22
  "node": ">=20.6"