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 +21 -11
- package/dist/drop.js +104 -0
- package/dist/editor.js +18 -0
- package/dist/loop.js +56 -22
- package/dist/markdown.js +37 -2
- package/dist/tui.js +56 -7
- package/dist/ui.js +8 -0
- package/package.json +2 -2
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
|
|
23
|
-
import { gitBranch, toggleLastCollapsed, cleanup as tuiCleanup } from "./tui.js"; // git branch
|
|
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
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
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("
|
|
641
|
-
footer =
|
|
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 {
|
|
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.
|
|
380
|
-
//
|
|
381
|
-
//
|
|
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
|
-
|
|
386
|
-
|
|
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 =
|
|
472
|
+
const indent = agentIndent(opts);
|
|
446
473
|
if (!opts.quiet) {
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
79
|
-
//
|
|
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.
|
|
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"
|