agent-from-zero 0.31.0 → 0.32.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
@@ -27,6 +27,7 @@ import { normalizeDroppedPaths } from "./drop.js"; // drag-and-drop: a dropped f
27
27
  import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
28
28
  import { loadSkills, buildSkillTool, findSkill, skillInstructions } from "./skills.js"; // Markdown-as-plugin skills
29
29
  import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
30
+ import { launchInk } from "./ink/launch.js"; // the Ink REPL — the default front-end for interactive sessions
30
31
  import { listBackground, hasRunningBackground, killAllBackground } from "./background.js"; // background tasks: /bg view + kill-on-exit (Day 37)
31
32
  import { listTeam, resetTeam } from "./team.js"; // agent teams: /team view + reset on clear/resume (Day 38)
32
33
  import { boardSummary, resetBoard } from "./board.js"; // task board: /tasks view + reset on clear/resume (Day 40)
@@ -107,6 +108,17 @@ async function main() {
107
108
  process.exitCode = 2;
108
109
  return;
109
110
  }
111
+ // Interactive sessions default to the Ink REPL (a pinned input box, the
112
+ // conversation scrolling above it — like Claude Code). It does its OWN full
113
+ // setup (MCP, skills, judge, cost, hooks, session), so we hand off here before
114
+ // the readline path below ever runs. Print mode (-p) is a non-interactive
115
+ // one-shot that streams to stdout, so it stays on the readline path. Set
116
+ // MINI_AGENT_NO_INK=1 to use the readline REPL below instead (a simpler
117
+ // reference front-end, and an escape hatch for terminals Ink misbehaves in).
118
+ if (printTask === null && process.env.MINI_AGENT_NO_INK !== "1") {
119
+ await launchInk({ resume: argv.includes("-r") || argv.includes("--resume") });
120
+ return;
121
+ }
110
122
  requireApiKey(); // fail fast with instructions, not a stack trace from inside the SDK
111
123
  // Build the API client once, for the whole session.
112
124
  const client = new OpenAI({
package/dist/context.js CHANGED
@@ -80,9 +80,10 @@ export function recoverFileState() {
80
80
  export async function compactHistory(messages, // the history, mutated in place
81
81
  client, // the same client the loop uses
82
82
  model, // the same model summarizes its own conversation
83
- signal) {
83
+ signal, // Ctrl+C must abort compaction too
84
+ log = (s) => console.log(s)) {
84
85
  const before = estimateHistoryTokens(messages); // for the log line
85
- console.log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
86
+ log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
86
87
  const res = await client.chat.completions.create({
87
88
  model, // same model — no need for a fancier one to summarize
88
89
  messages: [...messages, { role: "user", content: SUMMARY_PROMPT }], // full history + the summary instruction
@@ -103,5 +104,5 @@ signal) {
103
104
  const recoveredNote = recoverFileState(); // restore working-file contents from disk
104
105
  if (recoveredNote)
105
106
  messages.push({ role: "user", content: recoveredNote }); // attach as a second message
106
- console.log(chalk.magenta(`📦 compacted: ~${before} → ~${estimateHistoryTokens(messages)} tokens`)); // report the result
107
+ log(chalk.magenta(`📦 compacted: ~${before} → ~${estimateHistoryTokens(messages)} tokens`)); // report the result
107
108
  }
@@ -0,0 +1,435 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useState, useEffect, useRef } from "react";
3
+ import { Box, Text, Static, useInput, useApp } from "ink";
4
+ import chalk from "chalk";
5
+ import { formatElapsed, renderMenu, MENU_HINT, formatModelChoices } from "../ui.js"; // status format + the SAME pure menu renderer the readline REPL uses
6
+ import { renderMarkdown } from "../markdown.js"; // model speaks markdown → ANSI, same as the non-Ink REPL
7
+ import { initFormState, reduceForm, renderForm, collectAnswers } from "../form.js"; // the ask_user form: pure state machine + renderer
8
+ import { CONFIG, saveGlobalSetting } from "../config.js"; // session allowlist + /model save
9
+ import { TerminateReason } from "../loop.js"; // how a turn can end
10
+ import { compactHistory, COMPACT_AT } from "../context.js"; // /compact
11
+ import { forgetFilesExcept } from "../tools.js"; // /clear resets the file read-state
12
+ import { clearUndo } from "../undo.js";
13
+ import { clearTodos } from "../todos.js";
14
+ import { resetTeam } from "../team.js";
15
+ import { resetBoard } from "../board.js";
16
+ import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, cleanup as tuiCleanup } from "../tui.js";
17
+ import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
18
+ import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
19
+ import { newSessionId, saveSession, listSessions, loadSession } from "../session.js";
20
+ import { isPlanMode, setPlanMode } from "../permissions.js";
21
+ import { findSkill, skillInstructions } from "../skills.js";
22
+ import { extractMemories } from "../memory.js";
23
+ import { runHooks } from "../hooks.js";
24
+ import { emit } from "../telemetry.js";
25
+ import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
26
+ import { runInfoCommand } from "./commands.js"; // the non-interactive slash commands
27
+ // Injected when plan mode turns on, so the model knows the rules it now lives in.
28
+ 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.`;
29
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; // the braille spinner ora uses, hand-rolled for Ink
30
+ function Spinner() {
31
+ const [f, setF] = useState(0);
32
+ useEffect(() => {
33
+ const id = setInterval(() => setF((x) => (x + 1) % SPINNER_FRAMES.length), 80);
34
+ return () => clearInterval(id);
35
+ }, []);
36
+ return _jsx(Text, { color: "cyan", children: SPINNER_FRAMES[f] });
37
+ }
38
+ // One committed line of conversation. `user` is a highlight bar; `answer` is a
39
+ // finished reply rendered as markdown; `note` is already-formatted narration.
40
+ function ItemView({ item }) {
41
+ if (item.kind === "user") {
42
+ return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { backgroundColor: "gray", color: "whiteBright", children: `> ${item.text}` }) }));
43
+ }
44
+ if (item.kind === "answer") {
45
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: renderMarkdown(item.text) }) })] }));
46
+ }
47
+ return (_jsx(Box, { children: _jsx(Text, { children: item.text }) }));
48
+ }
49
+ // The bottom status bar — same content + colours as the non-Ink statusLine():
50
+ // [model] · 📁 dir · 🌿 branch · ctx N% · $cost · ⏱ time.
51
+ function StatusBar({ model, dir, branch, status }) {
52
+ const parts = [
53
+ _jsxs(Text, { color: "cyan", children: ["[", model, "]"] }, "m"),
54
+ _jsxs(Text, { dimColor: true, children: ["\uD83D\uDCC1 ", dir] }, "d"),
55
+ ...(branch ? [_jsxs(Text, { color: "green", children: ["\uD83C\uDF3F ", branch] }, "b")] : []),
56
+ _jsxs(Text, { dimColor: true, children: ["ctx ", status.ctxPct, "%"] }, "c"),
57
+ _jsxs(Text, { color: "yellow", children: ["$", status.cost.toFixed(status.cost < 1 ? 4 : 2)] }, "$"),
58
+ _jsxs(Text, { dimColor: true, children: ["\u23F1 ", formatElapsed(status.elapsedMs)] }, "t"),
59
+ ];
60
+ return (_jsx(Box, { children: parts.map((p, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: " · " }), p] }, i))) }));
61
+ }
62
+ // One human note per non-Done ending, so the user always learns why a turn stopped.
63
+ const EXIT_NOTES = {
64
+ [TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here.",
65
+ [TerminateReason.RetryBudgetExhausted]: "Too many failed API calls — giving up. Check your network.",
66
+ [TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then retry.",
67
+ [TerminateReason.ContextTooLong]: "The conversation no longer fits the context window. Use /clear.",
68
+ [TerminateReason.CompactionFailed]: "Automatic compaction kept failing. Use /clear to start fresh.",
69
+ [TerminateReason.FatalApiError]: "Unrecoverable API error — check your API key and request.",
70
+ [TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
71
+ };
72
+ export function App({ session, runTurn }) {
73
+ const { client, messages, systemMessage, costMeter, skills, judge, model, dir, branch, getStatus } = session;
74
+ // Seed the scrollback with the welcome banner + the dim startup notices.
75
+ const [items, setItems] = useState(() => [{ kind: "note", text: session.bannerText }, ...session.notices.map((n) => ({ kind: "note", text: chalk.dim(n) }))]);
76
+ const [status, setStatus] = useState(null); // the live spinner line
77
+ const [live, setLive] = useState(null); // the streaming answer
78
+ const [input, setInput] = useState(""); // the current input buffer
79
+ const [busy, setBusy] = useState(false); // a turn is in flight
80
+ const [pending, setPending] = useState(null); // a prompt/menu blocking input
81
+ const [menuSel, setMenuSel] = useState(0); // the select menu's cursor
82
+ const [formState, setFormState] = useState(null); // the ask_user form's state
83
+ const [sessionId, setSessionId] = useState(session.initialSessionId); // changes on /clear, /resume
84
+ const [planMode, setPlan] = useState(isPlanMode()); // mirrored into the prompt frame
85
+ const [histIdx, setHistIdx] = useState(null); // ↑/↓ recall position (null = editing a fresh line)
86
+ const [, setTick] = useState(0); // forces a re-render once a second so the clock / cost tick
87
+ const { exit } = useApp();
88
+ const pushItem = useRef((it) => setItems((xs) => [...xs, it])).current;
89
+ const note = (text) => pushItem({ kind: "note", text });
90
+ const sink = useRef(makeInkSink({ setStatus, setLive, pushItem })).current;
91
+ const history = useRef([]).current; // past prompts, for ↑/↓ recall
92
+ const turn = useRef(null); // the in-flight turn, for Esc-interrupt
93
+ useEffect(() => {
94
+ const id = setInterval(() => setTick((t) => t + 1), 1000);
95
+ return () => clearInterval(id);
96
+ }, []);
97
+ // Open a one-of-N menu and run `onChoose` with the picked index (-1 = cancel).
98
+ const openSelect = (header, options, onChoose) => {
99
+ setMenuSel(0);
100
+ setPending({ kind: "select", header, options, onChoose });
101
+ };
102
+ // Run one conversation turn through the real loop. `display` is what scrolls up
103
+ // as the prompt (a user highlight bar, or a note for /skill); the loop appends
104
+ // `content` to `messages` itself. The permission menu + ask_user form raise a
105
+ // `pending` prompt that returns a promise the loop awaits.
106
+ const runConversationTurn = (content, display) => {
107
+ pushItem(display);
108
+ clearReasoning(); // Ctrl+R should reveal THIS turn's thinking
109
+ clearToolCalls(); // ...and Ctrl+T THIS turn's tool calls
110
+ setBusy(true);
111
+ const controller = new AbortController();
112
+ const tstate = { controller, interrupted: false };
113
+ turn.current = tstate; // expose it so Esc can interrupt this turn
114
+ const hooks = {
115
+ output: sink,
116
+ confirm: (question, toolName) => new Promise((resolve) => {
117
+ const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
118
+ openSelect(`⚠ approval needed — ${question}`, ["Yes", allowLabel, "No — let me tell the agent what to do instead"], (i) => {
119
+ const approved = i === 0 || i === 1;
120
+ if (approved && i === 1 && toolName)
121
+ CONFIG.permissions.allow.push(`tool:${toolName}`); // "don't ask again" → session allowlist
122
+ note(`${chalk.yellow("⚠ approval")} — ${question.split("\n")[0]} → ${approved ? chalk.green("✓ approved") : chalk.yellow("✗ declined")}`);
123
+ resolve(approved);
124
+ });
125
+ }),
126
+ askUser: (questions) => new Promise((resolve) => {
127
+ if (!questions.length)
128
+ return resolve(null);
129
+ setFormState(initFormState(questions));
130
+ setPending({ kind: "form", questions, resolve });
131
+ }),
132
+ signal: controller.signal,
133
+ isInterrupted: () => tstate.interrupted, // polled between steps for a clean stop
134
+ judge,
135
+ };
136
+ runTurn(content, hooks)
137
+ .then(async (result) => {
138
+ if (result.reason !== TerminateReason.Done)
139
+ note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
140
+ saveSession(sessionId, model, messages); // snapshot after every turn — crash-safe by construction
141
+ if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
142
+ try {
143
+ const got = await extractMemories(client, CONFIG.subAgentModel || model, messages);
144
+ if (got.length)
145
+ note(chalk.dim(`(remembered ${got.length}: ${got.map((g) => g.fact.slice(0, 50)).join("; ")})`));
146
+ }
147
+ catch {
148
+ /* memory extraction must never break the session */
149
+ }
150
+ }
151
+ })
152
+ .catch((e) => note(chalk.red(`[error] ${e.message}`)))
153
+ .finally(() => {
154
+ turn.current = null;
155
+ setBusy(false);
156
+ setStatus(null);
157
+ setLive(null);
158
+ });
159
+ };
160
+ // A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
161
+ // mentions, then start the turn. Mirrors agent.ts's submit path.
162
+ const submitLine = async (text) => {
163
+ if (text === "exit" || text === "quit")
164
+ return doExit();
165
+ if (await handleCommand(text))
166
+ return; // slash commands never reach the model
167
+ const hook = await runHooks("UserPromptSubmit", { prompt: text });
168
+ if (hook.block)
169
+ return note(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${hook.feedback.slice(0, 150)})`));
170
+ const injected = hook.stdout ? `\n\n[context added by a UserPromptSubmit hook]\n${hook.stdout}` : "";
171
+ const { augmented, mentions } = expandMentions(text);
172
+ const attached = mentions.filter((m) => m.status === "ok").map((m) => m.raw);
173
+ const refused = mentions.filter((m) => m.status === "denied").map((m) => m.raw);
174
+ if (attached.length)
175
+ note(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
176
+ if (refused.length)
177
+ note(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
178
+ runConversationTurn(augmented + injected, { kind: "user", text }); // show the original line; send the augmented content
179
+ };
180
+ // Handle a /slash command. Returns true if the line was a command (handled).
181
+ const handleCommand = async (line) => {
182
+ const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
183
+ if (info !== null) {
184
+ note(info);
185
+ return true;
186
+ }
187
+ // /model [name|save <name>]
188
+ if (line === "/model" || line.startsWith("/model ")) {
189
+ const arg = line.slice("/model".length).trim();
190
+ const save = arg === "save" || arg.startsWith("save ");
191
+ const name = save ? arg.slice(4).trim() : arg;
192
+ const apply = (next) => {
193
+ const prev = CONFIG.model;
194
+ CONFIG.model = next;
195
+ if (save) {
196
+ saveGlobalSetting("model", next);
197
+ note(chalk.dim(`(model: ${prev} → ${next} · saved as your default)`));
198
+ }
199
+ else {
200
+ note(chalk.dim(`(model: ${prev} → ${next} · this session only — use "/model save ${next}" to keep it)`));
201
+ }
202
+ };
203
+ if (name) {
204
+ apply(name);
205
+ return true;
206
+ }
207
+ note(chalk.dim(`model: ${CONFIG.model}\nendpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`));
208
+ let models = [];
209
+ try {
210
+ const page = await client.models.list();
211
+ models = [...new Set((page.data ?? []).map((m) => m.id).filter(Boolean))].sort();
212
+ }
213
+ catch {
214
+ /* not every endpoint lists models */
215
+ }
216
+ if (!models.length) {
217
+ note(chalk.dim("(couldn't list models from this endpoint — switch with: /model <name>)"));
218
+ return true;
219
+ }
220
+ openSelect("switch model (this session):", formatModelChoices(models, CONFIG.model), (i) => {
221
+ if (i < 0 || models[i] === CONFIG.model)
222
+ return note(chalk.dim("(model unchanged)"));
223
+ apply(models[i]);
224
+ });
225
+ return true;
226
+ }
227
+ // /skill <name> — run a skill on the user's behalf (works for user-only skills)
228
+ if (line === "/skill" || line.startsWith("/skill ")) {
229
+ const name = line.slice("/skill".length).trim();
230
+ if (!name) {
231
+ note(chalk.dim(skills.length ? `usage: /skill <name> — available: ${skills.map((s) => s.name).join(", ")}` : "(no skills in this project — add one at .mini-agent/skills/<name>/SKILL.md)"));
232
+ return true;
233
+ }
234
+ const s = findSkill(skills, name);
235
+ if (!s) {
236
+ note(chalk.yellow(`(no skill named "${name}")`));
237
+ return true;
238
+ }
239
+ runConversationTurn(`Run the "${s.name}" skill.\n\n${skillInstructions(s)}`, { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
240
+ return true;
241
+ }
242
+ switch (line) {
243
+ case "/clear":
244
+ messages.length = 0; // mutate in place — same array ref the loop holds
245
+ messages.push({ role: "system", content: systemMessage });
246
+ forgetFilesExcept([]);
247
+ clearUndo();
248
+ clearTodos();
249
+ resetTeam();
250
+ resetBoard();
251
+ clearReasoning();
252
+ clearToolCalls();
253
+ setSessionId(newSessionId());
254
+ note(chalk.dim("(history cleared)"));
255
+ return true;
256
+ case "/plan":
257
+ if (isPlanMode()) {
258
+ setPlanMode(false);
259
+ setPlan(false);
260
+ note(chalk.dim("(plan mode OFF — writing and executing tools enabled again)"));
261
+ }
262
+ else {
263
+ setPlanMode(true);
264
+ setPlan(true);
265
+ messages.push({ role: "user", content: PLAN_MODE_NOTICE });
266
+ note(chalk.dim("(plan mode ON — read-only research; the agent will present a plan for you to approve)"));
267
+ }
268
+ return true;
269
+ case "/compact": {
270
+ if (messages.length <= 1) {
271
+ note(chalk.dim("(nothing to compact yet)"));
272
+ return true;
273
+ }
274
+ setBusy(true);
275
+ try {
276
+ await compactHistory(messages, client, CONFIG.model, new AbortController().signal, note);
277
+ }
278
+ catch (err) {
279
+ note(chalk.yellow(`compaction failed: ${err.message}`));
280
+ }
281
+ finally {
282
+ setBusy(false);
283
+ }
284
+ return true;
285
+ }
286
+ case "/resume": {
287
+ const sessions = listSessions(10);
288
+ if (!sessions.length) {
289
+ note(chalk.dim("(no saved sessions in this project yet)"));
290
+ return true;
291
+ }
292
+ const labels = sessions.map((s) => `${s.savedAt.slice(0, 16).replace("T", " ")} · ${s.messageCount} msg · ${s.title.slice(0, 60)}`);
293
+ openSelect("recent sessions in this project:", labels, (i) => {
294
+ if (i < 0)
295
+ return note(chalk.dim("(cancelled)"));
296
+ const chosen = loadSession(sessions[i].id);
297
+ if (!chosen)
298
+ return note(chalk.yellow("(could not load that session — it may be corrupt)"));
299
+ messages.length = 0;
300
+ messages.push({ role: "system", content: systemMessage }, ...chosen.messages);
301
+ setSessionId(chosen.id);
302
+ forgetFilesExcept([]);
303
+ clearUndo();
304
+ clearTodos();
305
+ resetTeam();
306
+ resetBoard();
307
+ note(chalk.dim(`(resumed ${chosen.id} — ${chosen.messages.length} messages; files must be re-read before editing)`));
308
+ });
309
+ return true;
310
+ }
311
+ default:
312
+ if (line.startsWith("/")) {
313
+ note(chalk.dim(`unknown command: ${line} — try /help`));
314
+ return true; // consumed — don't send typos to the model
315
+ }
316
+ return false; // a normal task line
317
+ }
318
+ };
319
+ // Graceful exit: SessionEnd hooks (tiny budget — the user may be leaving), then unmount.
320
+ const doExit = async () => {
321
+ try {
322
+ await runHooks("SessionEnd", {});
323
+ }
324
+ catch {
325
+ /* never block exit on a hook */
326
+ }
327
+ emit("agent_session_end");
328
+ tuiCleanup();
329
+ exit();
330
+ };
331
+ useInput((char, key) => {
332
+ if (key.ctrl && char === "c") {
333
+ tuiCleanup();
334
+ return exit(); // Ctrl+C quits immediately (process.on exit cleans up MCP/background)
335
+ }
336
+ // Ctrl+R / Ctrl+T: reveal the LAST answer's collapsed thinking / tool calls.
337
+ if (key.ctrl && char === "r")
338
+ return note(getReasoning() ?? chalk.dim("(no thinking recorded for the last answer)"));
339
+ if (key.ctrl && char === "t")
340
+ return note(getToolCalls() ?? chalk.dim("(no tool calls recorded for the last answer)"));
341
+ // Esc while a turn runs (and no menu is up): interrupt it — like the first
342
+ // Ctrl+C of the readline REPL, without the force-quit escalation.
343
+ if (key.escape && busy && !pending) {
344
+ const t = turn.current;
345
+ if (t && !t.interrupted) {
346
+ t.interrupted = true; // the loop polls this between steps
347
+ t.controller.abort(); // cancel the in-flight API request
348
+ note(chalk.yellow("(esc — interrupting; back at the prompt)"));
349
+ }
350
+ return;
351
+ }
352
+ // A select menu is up (approval / model / resume): ↑↓ move, Enter choose, Esc cancel.
353
+ if (pending?.kind === "select") {
354
+ const n = pending.options.length;
355
+ if (key.upArrow)
356
+ setMenuSel((i) => (i - 1 + n) % n);
357
+ else if (key.downArrow)
358
+ setMenuSel((i) => (i + 1) % n);
359
+ else if (key.return || key.escape) {
360
+ const choice = key.escape ? -1 : menuSel;
361
+ const onChoose = pending.onChoose;
362
+ setPending(null);
363
+ onChoose(choice);
364
+ }
365
+ return;
366
+ }
367
+ // The ask_user form is up: reuse form.ts's pure state machine verbatim.
368
+ if (pending?.kind === "form" && formState) {
369
+ if (key.upArrow)
370
+ setFormState(reduceForm(pending.questions, formState, "up").state);
371
+ else if (key.downArrow || key.tab)
372
+ setFormState(reduceForm(pending.questions, formState, "down").state);
373
+ else if (key.return || char === " ") {
374
+ const next = reduceForm(pending.questions, formState, "select");
375
+ setFormState(next.state);
376
+ if (next.done) {
377
+ pending.resolve(collectAnswers(pending.questions, next.state));
378
+ setPending(null);
379
+ setFormState(null);
380
+ }
381
+ }
382
+ else if (key.escape) {
383
+ pending.resolve(null);
384
+ setPending(null);
385
+ setFormState(null);
386
+ }
387
+ return;
388
+ }
389
+ if (busy)
390
+ return; // a turn in flight with no prompt — ignore typing for now (type-ahead is later)
391
+ if (key.return) {
392
+ const text = input.trim();
393
+ setInput("");
394
+ setHistIdx(null);
395
+ if (!text)
396
+ return;
397
+ if (text.trim())
398
+ history.push(text); // remember non-empty entries for ↑/↓
399
+ void submitLine(text);
400
+ }
401
+ else if (key.upArrow) {
402
+ // ↑ recall an earlier prompt (newest-first), like a shell.
403
+ if (!history.length)
404
+ return;
405
+ const idx = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
406
+ setHistIdx(idx);
407
+ setInput(history[idx]);
408
+ }
409
+ else if (key.downArrow) {
410
+ // ↓ walk back toward the fresh line.
411
+ if (histIdx === null)
412
+ return;
413
+ const idx = histIdx + 1;
414
+ if (idx >= history.length) {
415
+ setHistIdx(null);
416
+ setInput("");
417
+ }
418
+ else {
419
+ setHistIdx(idx);
420
+ setInput(history[idx]);
421
+ }
422
+ }
423
+ else if (key.backspace || key.delete) {
424
+ setInput((s) => s.slice(0, -1));
425
+ }
426
+ else if (char && !key.ctrl && !key.meta) {
427
+ // A bulk chunk (a paste / a dropped file path) is normalized: a dropped
428
+ // file becomes a clean absolute path; ordinary pasted prose is unchanged.
429
+ const add = char.length > 1 ? normalizeDroppedPaths(char) : char;
430
+ setHistIdx(null);
431
+ setInput((s) => s + add);
432
+ }
433
+ });
434
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), live !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : live }) })] })), status !== null && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status] })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), _jsxs(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : "cyan", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: planMode ? "magenta" : "cyan", children: planMode ? "plan ❯ " : "❯ " }), _jsx(Text, { children: input }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(StatusBar, { model: model, dir: dir, branch: branch, status: getStatus() })] }));
435
+ }
@@ -0,0 +1,22 @@
1
+ import { runLoop } from "../loop.js"; // the REAL agent loop — tools, permissions, retries, the lot
2
+ import { CONFIG } from "../config.js"; // model + sub-agent tier
3
+ // Drive one real turn through the loop. The loop mutates `messages` in place
4
+ // (appends the assistant reply + any tool messages), exactly as the readline
5
+ // REPL relies on — so we only push the user turn here and let the loop do the
6
+ // rest. All screen output flows through hooks.output (the Ink sink).
7
+ export function makeRunTurn(client, messages) {
8
+ return async (input, hooks) => {
9
+ messages.push({ role: "user", content: input });
10
+ return runLoop(messages, {
11
+ client,
12
+ model: CONFIG.model,
13
+ signal: hooks.signal,
14
+ isInterrupted: hooks.isInterrupted,
15
+ confirm: hooks.confirm,
16
+ askUser: hooks.askUser,
17
+ judge: hooks.judge,
18
+ subAgentModel: CONFIG.subAgentModel,
19
+ output: hooks.output,
20
+ });
21
+ };
22
+ }
@@ -0,0 +1,103 @@
1
+ import chalk from "chalk";
2
+ import path from "node:path";
3
+ import { CONFIG } from "../config.js";
4
+ import { statsReport } from "../telemetry.js";
5
+ import { readMemoryTyped, MEMORY_PATH } from "../memory.js";
6
+ import { getTodos, renderTodos } from "../todos.js";
7
+ import { listBackground } from "../background.js";
8
+ import { listTeam } from "../team.js";
9
+ import { boardSummary } from "../board.js";
10
+ import { undoLast, sessionChanges } from "../undo.js";
11
+ import { renderDiff } from "../diff.js";
12
+ // The non-interactive slash commands: each produces a block of text to commit as
13
+ // a note. Lifted from agent.ts's handleCommand, with console.log → a returned
14
+ // string so the Ink REPL can render them inside its tree. The stateful commands
15
+ // (/clear, /compact, /plan, /model, /resume, /skill) stay in the App — they
16
+ // mutate the conversation or pop an interactive menu.
17
+ // The in-session command reference shown by /help.
18
+ export const SESSION_HELP = `commands:
19
+ /help this text
20
+ /clear wipe the conversation (and the file read-state) — fresh start
21
+ /compact compact the history into a summary right now (happens automatically near the limit)
22
+ /model switch model for THIS session: "/model" to pick from a list, "/model <name>" to set; "/model save <name>" to make it your default
23
+ /stats event counts for this session (local telemetry — nothing leaves this machine)
24
+ /memory show the durable facts the agent remembers about this project
25
+ /cost tokens, cache hit rate and estimated spend this session (local)
26
+ /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
27
+ /todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
28
+ /bg list background tasks this session (run_bash_background) and their status
29
+ /team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
30
+ /tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
31
+ /undo revert the most recent file write (write_file / edit_file) this session
32
+ /diff show every file changed this session, as a diff from where it started
33
+ /resume list recent sessions in this project and continue one of them
34
+ /skills list the reusable skills available in this project
35
+ /skill <name> run a skill yourself (works even for user-only skills)
36
+ exit leave (Ctrl+C does the same)
37
+
38
+ keys (at the prompt):
39
+ Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
40
+ Ctrl+T reveal the folded tool-call trace for the last answer`;
41
+ // Run a non-interactive command. Returns the note text to display, or null if
42
+ // `line` isn't one of these (the App handles the rest).
43
+ export function runInfoCommand(line, ctx) {
44
+ switch (line) {
45
+ case "/help":
46
+ return chalk.dim(SESSION_HELP);
47
+ case "/cost":
48
+ return chalk.dim(ctx.costMeter.report()); // tokens, cache hit rate, estimated spend (local)
49
+ case "/stats":
50
+ return chalk.dim(statsReport()); // local counters, busiest first
51
+ case "/memory": {
52
+ const entries = readMemoryTyped();
53
+ return chalk.dim(entries.length
54
+ ? `long-term memory (${MEMORY_PATH}):\n${entries.map((e) => ` [${e.type}] ${e.fact}`).join("\n")}`
55
+ : `(no long-term memory yet — the model saves facts with the remember tool${CONFIG.memory.autoExtract ? "" : "; turn on settings.memory.autoExtract to capture them automatically"})`);
56
+ }
57
+ case "/todos": {
58
+ const todos = getTodos();
59
+ return todos.length ? renderTodos(todos) : chalk.dim("(no plan yet — the agent writes one with todo_write on multi-step tasks)");
60
+ }
61
+ case "/bg": {
62
+ const bg = listBackground();
63
+ if (!bg.length)
64
+ return chalk.dim("(no background tasks — the agent starts them with run_bash_background for slow commands)");
65
+ const icon = { running: chalk.yellow("●"), completed: chalk.green("✓"), failed: chalk.red("✗"), killed: chalk.dim("∅") };
66
+ return chalk.dim("background tasks this session:\n") + bg.map((t) => chalk.dim(` ${icon[t.status]} ${t.id} [${t.status}, ${t.elapsed}s] — ${t.command.slice(0, 70)}`)).join("\n");
67
+ }
68
+ case "/team": {
69
+ const team = listTeam();
70
+ if (!team.length)
71
+ return chalk.dim("(no team — the agent forms one with spawn_teammate for large parallel tasks)");
72
+ const icon = { active: chalk.yellow("●"), idle: chalk.cyan("◐"), done: chalk.green("✓"), failed: chalk.red("✗") };
73
+ return (chalk.dim("agent team this session:\n") +
74
+ team.map((t) => chalk.dim(` ${icon[t.status] ?? "?"} ${t.name} [${t.status}, ${t.elapsed}s] — ${t.role.slice(0, 60)}${t.pending ? chalk.yellow(` · ${t.pending} unread`) : ""}`)).join("\n"));
75
+ }
76
+ case "/tasks": {
77
+ const summary = boardSummary();
78
+ return summary === "(the task board is empty)" ? chalk.dim("(no tasks — the lead adds them with create_task; teammates claim them autonomously)") : chalk.dim(`task board:\n${summary}`);
79
+ }
80
+ case "/skills": {
81
+ if (!ctx.skills.length)
82
+ return chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)");
83
+ return (chalk.dim("skills in this project:\n") +
84
+ ctx.skills.map((s) => chalk.dim(` ${s.name} [${s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user")}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`)).join("\n"));
85
+ }
86
+ case "/undo": {
87
+ const undone = undoLast();
88
+ if (!undone)
89
+ return chalk.dim("(nothing to undo — no file writes recorded this session)");
90
+ return chalk.dim(`↩ ${undone.summary}`) + "\n" + renderDiff(undone.after ?? "", undone.before ?? "");
91
+ }
92
+ case "/diff": {
93
+ const changes = sessionChanges();
94
+ if (!changes.length)
95
+ return chalk.dim("(no file changes this session)");
96
+ const verb = { created: chalk.green("created"), modified: chalk.yellow("modified"), deleted: chalk.red("deleted") };
97
+ const head = chalk.dim(`${changes.length} file${changes.length === 1 ? "" : "s"} changed this session:`);
98
+ return head + "\n" + changes.map((c) => `\n${verb[c.status]} ${path.relative(process.cwd(), c.path) || c.path}\n${renderDiff(c.baseline, c.current)}`).join("");
99
+ }
100
+ default:
101
+ return null; // not an info command — the App handles it (or it's a normal task line)
102
+ }
103
+ }
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from "ink";
3
+ import { buildInkSession } from "./setup.js"; // the front-end-agnostic session bootstrap
4
+ import { makeRunTurn } from "./chat.js"; // drives one turn through the real loop
5
+ import { App } from "./app.js"; // the Ink REPL
6
+ // Launch the Ink REPL and resolve when it exits. buildInkSession does the same
7
+ // wiring agent.ts does (MCP, skills, judge, memory, cost, hooks, telemetry,
8
+ // optional resume); App renders it and runs the loop. Exported so BOTH the
9
+ // `npm run ink` entry (main.tsx) and agent.ts (the default `mini-agent` entry,
10
+ // once it hands interactive sessions to Ink) can start it.
11
+ export async function launchInk(opts = {}) {
12
+ const session = await buildInkSession({ resume: opts.resume });
13
+ const { waitUntilExit } = render(_jsx(App, { session: session, runTurn: makeRunTurn(session.client, session.messages) }));
14
+ await waitUntilExit(); // keep the process alive until the user quits
15
+ }
@@ -0,0 +1,4 @@
1
+ import { launchInk } from "./launch.js"; // the reusable launcher (shared with agent.ts)
2
+ // Direct entry for the Ink REPL: npm run ink. agent.ts launches the same thing
3
+ // for a normal `mini-agent` interactive session.
4
+ await launchInk({ resume: process.argv.includes("-r") || process.argv.includes("--resume") });
@@ -0,0 +1,102 @@
1
+ import OpenAI from "openai";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { CONFIG, requireApiKey } from "../config.js";
5
+ import { buildSystemMessage } from "../prompt.js";
6
+ import { estimateHistoryTokens } from "../context.js";
7
+ import { initCostMeter, DEFAULT_PRICING } from "../cost.js";
8
+ import { gitBranch } from "../tui.js";
9
+ import { newSessionId, latestSession } from "../session.js";
10
+ import { initTelemetry, emit } from "../telemetry.js";
11
+ import { runHooks } from "../hooks.js";
12
+ import { connectMcpServers } from "../mcp.js";
13
+ import { Judge } from "../judge.js";
14
+ import { registerExternalTool } from "../tools.js";
15
+ import { rememberTool, readMemory } from "../memory.js";
16
+ import { loadSkills, buildSkillTool } from "../skills.js";
17
+ import { killAllBackground } from "../background.js";
18
+ import { banner } from "../ui.js";
19
+ // The Ink REPL's session bootstrap — the same wiring agent.ts (the readline REPL)
20
+ // does before the prompt loop, but front-end-agnostic: it touches none of
21
+ // readline/editLine, just the shared modules (MCP, skills, judge, memory, cost,
22
+ // hooks, telemetry, session). It returns everything the Ink App needs to run a
23
+ // real session, so main.tsx stays a thin render() call. (agent.ts is left
24
+ // untouched so `npm start` keeps working until 2d flips the default.)
25
+ const pkg = createRequire(import.meta.url)("../../package.json");
26
+ // Build the session. `resume` mirrors `-r/--resume`: fold the most recent
27
+ // conversation in this directory into the fresh constitution.
28
+ export async function buildInkSession(opts = {}) {
29
+ requireApiKey();
30
+ const client = new OpenAI({ baseURL: CONFIG.baseURL, apiKey: CONFIG.apiKey, maxRetries: 0 });
31
+ const systemMessage = buildSystemMessage(); // stable prefix = cache hits every request
32
+ const messages = [{ role: "system", content: systemMessage }];
33
+ const notices = [];
34
+ let sessionId = newSessionId();
35
+ const startedAt = Date.now();
36
+ if (opts.resume) {
37
+ const prev = latestSession();
38
+ if (prev) {
39
+ messages.push(...prev.messages);
40
+ sessionId = prev.id; // keep appending to the same file
41
+ notices.push(`(resumed session ${prev.id} — ${prev.messages.length} messages; files must be re-read before editing)`);
42
+ }
43
+ else {
44
+ notices.push("(no previous session here — starting fresh)");
45
+ }
46
+ }
47
+ initTelemetry(sessionId);
48
+ emit("agent_session_start", { mode: "repl" });
49
+ // MCP servers — must finish before the first model call so their tools appear
50
+ // in the manual. A server that fails to start is skipped, never fatal.
51
+ const disconnectMcp = await connectMcpServers();
52
+ process.on("exit", disconnectMcp);
53
+ process.on("exit", killAllBackground); // SIGKILL any background job so it never outlives the agent
54
+ const judge = CONFIG.judge.enabled ? new Judge(client, CONFIG.judge.model || CONFIG.model) : undefined;
55
+ if (judge)
56
+ notices.push(`(permission judge on — model ${CONFIG.judge.model || CONFIG.model})`);
57
+ // The remember tool flows through the same permission gate as any tool.
58
+ registerExternalTool(rememberTool);
59
+ const memCount = readMemory().length;
60
+ if (memCount)
61
+ notices.push(`(long-term memory: ${memCount} facts loaded)`);
62
+ // Skills: model-invocable ones get the single `skill` tool; user-only ones are
63
+ // reachable via /skill <name>.
64
+ const skills = loadSkills();
65
+ if (skills.some((s) => !s.disableModelInvocation))
66
+ registerExternalTool(buildSkillTool(skills));
67
+ if (skills.length)
68
+ notices.push(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`);
69
+ const costMeter = initCostMeter({
70
+ inputPerM: CONFIG.pricing.inputPerM ?? DEFAULT_PRICING.inputPerM,
71
+ cachedInputPerM: CONFIG.pricing.cachedInputPerM ?? DEFAULT_PRICING.cachedInputPerM,
72
+ outputPerM: CONFIG.pricing.outputPerM ?? DEFAULT_PRICING.outputPerM,
73
+ });
74
+ // SessionStart hooks run once; their stdout becomes context on the first turn.
75
+ const sessionStart = await runHooks("SessionStart", {});
76
+ if (sessionStart.stdout)
77
+ messages.push({ role: "user", content: `[SessionStart hook]\n${sessionStart.stdout}` });
78
+ const dir = path.basename(process.cwd());
79
+ const branch = gitBranch();
80
+ const getStatus = () => ({
81
+ ctxPct: Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100)),
82
+ cost: costMeter.cost(),
83
+ elapsedMs: Date.now() - startedAt,
84
+ });
85
+ return {
86
+ client,
87
+ messages,
88
+ systemMessage,
89
+ initialSessionId: sessionId,
90
+ startedAt,
91
+ costMeter,
92
+ skills,
93
+ judge,
94
+ model: CONFIG.model,
95
+ dir,
96
+ branch,
97
+ bannerText: banner(pkg.version, CONFIG.model, new URL(CONFIG.baseURL).host),
98
+ notices,
99
+ getStatus,
100
+ disconnectMcp,
101
+ };
102
+ }
@@ -0,0 +1,50 @@
1
+ // Build a LoopOutput backed by Ink state. Pure glue: every method maps a loop
2
+ // event to a state change.
3
+ export function makeInkSink(api) {
4
+ return {
5
+ // A spinner becomes the single "status" line below the conversation. A fresh
6
+ // handle per model call (the loop makes one per round); `active` mirrors
7
+ // ora's isSpinning so the loop's `spinner?.spinning` checks behave the same.
8
+ spinner(text) {
9
+ api.setStatus(text);
10
+ let active = true;
11
+ return {
12
+ set: (t) => {
13
+ if (active)
14
+ api.setStatus(t);
15
+ },
16
+ stop: () => {
17
+ if (active) {
18
+ active = false;
19
+ api.setStatus(null);
20
+ }
21
+ },
22
+ get spinning() {
23
+ return active;
24
+ },
25
+ };
26
+ },
27
+ // The "💭 thought for Ns" indicator is just a committed line.
28
+ reasoning: (line) => api.pushItem({ kind: "note", text: line }),
29
+ // The streamed answer accumulates in the dynamic region as raw text (live,
30
+ // "it's typing"), then commits as ONE markdown-rendered item when it ends —
31
+ // partial markdown mid-stream renders badly, so we format only once it's whole.
32
+ answer() {
33
+ let buf = "";
34
+ api.setLive("");
35
+ return {
36
+ push: (tok) => {
37
+ buf += tok;
38
+ api.setLive(buf);
39
+ },
40
+ end: () => {
41
+ api.setLive(null); // clear the live preview first, then commit (no flicker/duplicate)
42
+ if (buf.trim())
43
+ api.pushItem({ kind: "answer", text: buf });
44
+ },
45
+ };
46
+ },
47
+ // Generic narration — already chalk-formatted by the loop; just commit it.
48
+ note: (line) => api.pushItem({ kind: "note", text: line }),
49
+ };
50
+ }
package/dist/loop.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import chalk from "chalk"; // terminal colors for status lines
2
- import ora from "ora"; // the "thinking..." spinner while waiting for the first token
3
2
  import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadOnlyTool } from "./tools.js"; // the tool manuals + the executor + file-state isolation
4
3
  import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
5
4
  import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
@@ -13,7 +12,7 @@ import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / Pos
13
12
  import { recordUsage } from "./cost.js"; // meter token usage from the stream
14
13
  import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
15
14
  import { recordToolCall, recordReasoning } from "./tui.js"; // folded tool-call trace (Ctrl+T) + the model's hidden thinking (Ctrl+R)
16
- import { MarkdownStream } from "./markdown.js"; // render the streamed answer as terminal markdown
15
+ import { STDOUT_OUTPUT } from "./output.js"; // where the loop's screen output goes (stdout by default, Ink REPL passes its own)
17
16
  import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
18
17
  import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
19
18
  export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
@@ -36,6 +35,11 @@ export var TerminateReason;
36
35
  TerminateReason["FatalApiError"] = "fatal_api_error";
37
36
  TerminateReason["UserInterrupt"] = "user_interrupt";
38
37
  })(TerminateReason || (TerminateReason = {}));
38
+ // Resolve the output sink for a loop: the caller's sink, or stdout (the old
39
+ // behaviour) when none was passed. Centralised so every site reads the same way.
40
+ function sink(opts) {
41
+ return opts.output ?? STDOUT_OUTPUT;
42
+ }
39
43
  // Which model should a delegated sub-agent run on? The configured sub-agent
40
44
  // model if set and non-blank, otherwise the same model as the orchestrator.
41
45
  // Pulled out as a pure function so the tiering rule is testable on its own.
@@ -281,7 +285,7 @@ async function runSubAgent(description, opts) {
281
285
  // Show the delegation, and the model when it differs from the orchestrator's —
282
286
  // the tiering should be visible, not a silent surprise on the bill.
283
287
  if (!opts.quiet)
284
- console.log(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : "")); // annotate the tier only on a real switch
288
+ sink(opts).note(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : "")); // annotate the tier only on a real switch
285
289
  await runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // lifecycle hook (observational)
286
290
  const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
287
291
  try {
@@ -301,7 +305,7 @@ async function runSubAgent(description, opts) {
301
305
  restoreFileState(snapshot); // the parent's read-before-edit state, exactly as it was
302
306
  await runHooks("SubagentStop", { description: description.slice(0, 200) }); // lifecycle hook (observational)
303
307
  if (!opts.quiet)
304
- console.log(mark.subAgentDone); // close the bracket
308
+ sink(opts).note(mark.subAgentDone); // close the bracket
305
309
  }
306
310
  }
307
311
  // Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
@@ -335,7 +339,7 @@ opts) {
335
339
  let reasoningShown = false; // have we printed the "thought for Ns" indicator yet?
336
340
  const spinner = opts.quiet
337
341
  ? null // the eval harness wants silence
338
- : ora({ text: spinnerText(word, 0, !!opts.subAgent, opts.model), discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
342
+ : sink(opts).spinner(spinnerText(word, 0, !!opts.subAgent, opts.model)); // a fresh spinner from the sink (stdout → ora; Ink → React state)
339
343
  let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
340
344
  let stallWarned = false; // only warn once per quiet stretch
341
345
  // The two-level watchdog, checked once per second:
@@ -345,8 +349,8 @@ opts) {
345
349
  const watchdog = setInterval(() => {
346
350
  // While the model is reasoning, prefix the spinner with 💭 so "it's thinking"
347
351
  // reads differently from "it's waiting on the first token".
348
- if (spinner?.isSpinning)
349
- spinner.text = (reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4));
352
+ if (spinner?.spinning)
353
+ spinner.set((reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4)));
350
354
  const quietMs = Date.now() - lastEvent; // ms since the last event
351
355
  if (quietMs > IDLE_TIMEOUT_MS) {
352
356
  emit("agent_watchdog_idle"); // record the cut — these should be rare
@@ -355,7 +359,7 @@ opts) {
355
359
  else if (quietMs > STALL_WARN_MS && !stallWarned) {
356
360
  stallWarned = true; // don't repeat the warning every second
357
361
  emit("agent_watchdog_stall"); // record the slowness — pattern-spotting data
358
- console.log(chalk.dim(` [watchdog] stream quiet for ${Math.round(quietMs / 1000)}s — still waiting (slow ≠ dead)`));
362
+ sink(opts).note(chalk.dim(` [watchdog] stream quiet for ${Math.round(quietMs / 1000)}s — still waiting (slow ≠ dead)`));
359
363
  }
360
364
  }, 1000);
361
365
  try {
@@ -370,8 +374,7 @@ opts) {
370
374
  // teammate's manual simply omits spawn_teammate and task.
371
375
  { model: opts.model, messages, tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
372
376
  let content = ""; // accumulated answer text
373
- let printedPrefix = false; // have we printed the answer prefix yet?
374
- let md = null; // renders the streamed answer as markdown, block by block
377
+ let ans = null; // renders the streamed answer (stdout: markdown block by block; Ink: React state) — non-null once the answer starts
375
378
  const calls = []; // tool calls under assembly, indexed by delta.index
376
379
  // The model has finished thinking and is about to say/do something. Close out
377
380
  // the spinner, stash the trace, and print a single one-line indicator with the
@@ -382,7 +385,7 @@ opts) {
382
385
  return;
383
386
  reasoningShown = true;
384
387
  reasoningActive = false;
385
- if (spinner?.isSpinning)
388
+ if (spinner?.spinning)
386
389
  spinner.stop();
387
390
  if (opts.quiet || opts.subAgent)
388
391
  return; // no UI for silent / nested runs
@@ -390,7 +393,7 @@ opts) {
390
393
  const secs = Math.round((Date.now() - startedAt) / 1000);
391
394
  const toks = Math.round(reasoningBuf.length / 4); // same ~chars/4 estimate as the spinner
392
395
  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");
396
+ sink(opts).reasoning(chalk.dim(`💭 thought for ${secs}s (~${tokStr} tokens) · Ctrl+R to view`));
394
397
  };
395
398
  for await (const chunk of stream) {
396
399
  lastEvent = Date.now(); // feed the watchdog
@@ -414,27 +417,24 @@ opts) {
414
417
  if (delta.content) {
415
418
  flushReasoning(); // thinking is over — show the one-line indicator first
416
419
  streamedChars += delta.content.length; // count the answer toward the live token estimate
417
- if (spinner?.isSpinning)
420
+ if (spinner?.spinning)
418
421
  spinner.stop(); // first token: replace the spinner with real output
419
422
  if (!opts.quiet && !opts.subAgent) {
420
423
  // Only the top-level agent streams to the screen — a sub-agent's
421
- // inner monologue would be mistaken for the answer.
422
- if (!printedPrefix) {
423
- // Stream the answer through the markdown renderer: it buffers a block
424
- // (paragraph / heading / list / table) and prints it formatted the
425
- // moment that block completes — still live, but no raw `##`/`|---|`.
426
- // The ⏺ marker leads the first line; wrapped lines align under it.
427
- md = new MarkdownStream((s) => process.stdout.write(s), { firstPrefix: "\n" + mark.answer, indent: " " });
428
- printedPrefix = true;
429
- }
430
- md.push(delta.content); // hand the token to the renderer (it decides when to paint)
424
+ // inner monologue would be mistaken for the answer. The sink decides
425
+ // how to paint: the stdout sink buffers a markdown block (paragraph /
426
+ // heading / list / table) and flushes it whole — live, but no raw
427
+ // `##`/`|---|`; the Ink sink accumulates into React state.
428
+ if (!ans)
429
+ ans = sink(opts).answer(); // first token: start the answer renderer
430
+ ans.push(delta.content); // hand the token to the renderer (it decides when to paint)
431
431
  }
432
432
  content += delta.content; // always keep it for the history (the answer only, never the reasoning)
433
433
  }
434
434
  if (delta.tool_calls?.length)
435
435
  flushReasoning(); // thinking is over — indicator before the tool logs (which print after we return)
436
436
  for (const tc of delta.tool_calls ?? []) {
437
- if (spinner?.isSpinning)
437
+ if (spinner?.spinning)
438
438
  spinner.stop(); // tool call starting — spinner served its purpose
439
439
  const slot = (calls[tc.index] ??= { id: "", name: "", args: "" }); // create the slot on first fragment
440
440
  if (tc.id)
@@ -446,15 +446,13 @@ opts) {
446
446
  }
447
447
  }
448
448
  flushReasoning(); // a reasoning-only turn (no content, no tools) still records + indicates
449
- if (md)
450
- md.end(); // flush the final (un-terminated) block through the renderer
451
- if (printedPrefix)
452
- process.stdout.write("\n"); // end the streamed line cleanly
449
+ if (ans)
450
+ ans.end(); // flush the final (un-terminated) block + end the line cleanly (the sink owns the trailing newline)
453
451
  return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
454
452
  }
455
453
  finally {
456
454
  clearInterval(watchdog); // always stop the timer
457
- if (spinner?.isSpinning)
455
+ if (spinner?.spinning)
458
456
  spinner.stop(); // and never leave a zombie spinner
459
457
  }
460
458
  }
@@ -487,13 +485,13 @@ async function runOneCall(call, opts) {
487
485
  if (!opts.quiet && v.decision !== "deny" && (call.name === "write_file" || call.name === "edit_file")) {
488
486
  const preview = previewChange(call.name, call.args);
489
487
  if (preview)
490
- console.log(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
488
+ sink(opts).note(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
491
489
  }
492
490
  let content; // what goes back to the model as the tool result
493
491
  if (v.decision === "deny") {
494
492
  emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
495
493
  if (!opts.quiet)
496
- console.log(mark.denied(v.reason)); // tell the user we blocked it
494
+ sink(opts).note(mark.denied(v.reason)); // tell the user we blocked it
497
495
  content = `[permission] Denied: ${v.reason}. This is a hard rule — do not try to work around it; pick a different approach or ask the user.`; // teach the model the boundary
498
496
  }
499
497
  else if (v.decision === "ask") {
@@ -510,14 +508,14 @@ async function runOneCall(call, opts) {
510
508
  if (cmd && (await opts.judge.classify(cmd)) === "allow") {
511
509
  autoAllowed = true;
512
510
  if (!opts.quiet)
513
- console.log(mark.judge); // visible — the user can see the judge worked
511
+ sink(opts).note(mark.judge); // visible — the user can see the judge worked
514
512
  }
515
513
  }
516
514
  const ok = autoAllowed || (await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`, call.name)); // judge-allowed, or pause and ask the human
517
515
  if (!ok)
518
516
  emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
519
517
  if (!ok && !opts.quiet)
520
- console.log(mark.declined); // make the refusal visible
518
+ sink(opts).note(mark.declined); // make the refusal visible
521
519
  content = ok
522
520
  ? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
523
521
  : `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
@@ -528,7 +526,7 @@ async function runOneCall(call, opts) {
528
526
  // todo_write's whole point is the VISIBLE plan: the model got a one-line tally,
529
527
  // the human gets the rendered checklist (quiet runs — eval/sub-agents — stay silent).
530
528
  if (call.name === "todo_write" && !opts.quiet && !content.startsWith("[error]")) {
531
- console.log(renderTodos(getTodos()));
529
+ sink(opts).note(renderTodos(getTodos()));
532
530
  }
533
531
  return { id: call.id, content }; // paired by id for the API
534
532
  }
@@ -546,7 +544,7 @@ async function runWithHooks(call, opts) {
546
544
  if (pre.block) {
547
545
  emit("agent_hook_block", { event: "PreToolUse", tool: call.name }); // a hook said no
548
546
  if (!opts.quiet)
549
- console.log(mark.hookBlock("PreToolUse")); // make it visible
547
+ sink(opts).note(mark.hookBlock("PreToolUse")); // make it visible
550
548
  return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
551
549
  }
552
550
  // A PreToolUse hook may REWRITE the arguments (e.g. add a commit trailer). The
@@ -556,7 +554,7 @@ async function runWithHooks(call, opts) {
556
554
  try {
557
555
  JSON.parse(pre.rewrite); // must be valid JSON args
558
556
  if (!opts.quiet)
559
- console.log(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
557
+ sink(opts).note(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
560
558
  call = { ...call, args: pre.rewrite };
561
559
  }
562
560
  catch {
@@ -744,7 +742,7 @@ async function runCreateTask(call, opts) {
744
742
  return "[error] create_task needs a 'subject'.";
745
743
  const task = createTask(subject, description, blockedBy);
746
744
  if (!opts.quiet)
747
- console.log(chalk.magenta(` ⎿ ${boardActor(opts)} created ${task.id}: ${subject.slice(0, 50)}`));
745
+ sink(opts).note(chalk.magenta(` ⎿ ${boardActor(opts)} created ${task.id}: ${subject.slice(0, 50)}`));
748
746
  return `Created ${task.id}: "${subject}"${blockedBy.length ? ` (blockedBy: ${blockedBy.join(", ")})` : ""}. It's on the board for a teammate to claim.`;
749
747
  }
750
748
  async function runListTasks(_call, opts) {
@@ -781,7 +779,7 @@ async function runCompleteTask(call, opts) {
781
779
  if (!res.ok)
782
780
  return `[error] ${res.error}`;
783
781
  if (!opts.quiet)
784
- console.log(chalk.magenta(` ⎿ ${boardActor(opts)} completed ${taskId}`));
782
+ sink(opts).note(chalk.magenta(` ⎿ ${boardActor(opts)} completed ${taskId}`));
785
783
  const unblocked = res.unblocked?.length ? ` Unblocked: ${res.unblocked.join(", ")}.` : "";
786
784
  return `Completed ${taskId}.${unblocked} Claim the next ready task, or idle until the lead shuts you down.`;
787
785
  }
@@ -817,7 +815,7 @@ async function runSpawnTeammate(call, opts) {
817
815
  const done = runTeammate(name, role, task, opts);
818
816
  registerTeammate(name, role, done);
819
817
  if (!opts.quiet)
820
- console.log(mark.teammateStart(name, role));
818
+ sink(opts).note(mark.teammateStart(name, role));
821
819
  return `Teammate "${name}" (${role}) spawned and is now working in parallel. It will message you (as "lead") with progress and its final result. Reply with send_message; keep coordinating the rest of the team. Do not wait idly — continue your own work.`;
822
820
  }
823
821
  // Run one teammate to completion: a fresh conversation seeded with its task, a
@@ -847,7 +845,7 @@ async function runTeammate(name, role, task, opts) {
847
845
  subAgent: true,
848
846
  teammate: { name },
849
847
  maxRounds: TEAMMATE_MAX_ROUNDS,
850
- confirm: teammateConfirm(name),
848
+ confirm: teammateConfirm(name, sink(opts)),
851
849
  askUser: undefined, // teammates have no line to the human
852
850
  });
853
851
  // How it ended decides what the lead hears. A shutdown handshake gets a
@@ -861,7 +859,7 @@ async function runTeammate(name, role, task, opts) {
861
859
  sendMessage(name, LEAD, summary, "result");
862
860
  finishTeammate(name, result.reason === TerminateReason.Done);
863
861
  if (!opts.quiet)
864
- console.log(mark.teammateDone(name, result.reason === TerminateReason.Done));
862
+ sink(opts).note(mark.teammateDone(name, result.reason === TerminateReason.Done));
865
863
  }
866
864
  catch (err) {
867
865
  // A teammate must never take the whole process down. Report the failure to
@@ -885,11 +883,11 @@ const SHUTDOWN_GRACE_MS = 30_000; // how long the lead waits for teammates to ex
885
883
  // DENY rules (the no-fly zone) are enforced by the gate before confirm is ever
886
884
  // called, so they always hold. Production Claude Code instead "bubbles" the
887
885
  // request up to the lead and on to the human — omitted here, like the reference.
888
- function teammateConfirm(name) {
886
+ function teammateConfirm(name, output) {
889
887
  return async (_question, toolName) => {
890
888
  const ok = toolName === "write_file" || toolName === "edit_file";
891
889
  if (!ok)
892
- console.log(chalk.yellow(` ⎿ [${name}] declined ${toolName ?? "an action"} (teammates can't prompt you; do it yourself or allowlist it)`));
890
+ output.note(chalk.yellow(` ⎿ [${name}] declined ${toolName ?? "an action"} (teammates can't prompt you; do it yourself or allowlist it)`));
893
891
  return ok;
894
892
  };
895
893
  }
@@ -925,7 +923,7 @@ compaction) {
925
923
  try {
926
924
  if (!opts.subAgent)
927
925
  await runHooks("PreCompact", {}); // lifecycle hook (top-level only; hooks are the human's project policy)
928
- await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
926
+ await compactHistory(messages, opts.client, opts.model, opts.signal, (s) => sink(opts).note(s)); // do the actual work — narration through the sink (so it doesn't corrupt an Ink live region)
929
927
  compaction.count++; // one more successful compaction this query
930
928
  compaction.failures = 0; // success resets the failure streak
931
929
  emit("agent_compaction_ok"); // worth counting — frequent compaction means tasks are too big
@@ -956,7 +954,7 @@ function injectBackgroundNotifications(messages, opts) {
956
954
  const count = (note.match(/<task_notification>/g) ?? []).length; // for the one-line on-screen mark
957
955
  messages.push({ role: "user", content: note }); // the model reacts to it on the next round
958
956
  if (!opts.quiet)
959
- console.log(mark.bgNote(count));
957
+ sink(opts).note(mark.bgNote(count));
960
958
  return true;
961
959
  }
962
960
  // Consume this agent's mailbox into its conversation, ROUTING protocol messages
@@ -1005,7 +1003,7 @@ function consumeInbox(messages, opts) {
1005
1003
  const header = opts.teammate ? "Messages from your team — read and act on them:" : "Team inbox — react to these (RESULT = that teammate finished a part):";
1006
1004
  messages.push({ role: "user", content: `${header}\n\n${lines.join("\n\n")}` });
1007
1005
  if (!opts.quiet)
1008
- console.log(opts.teammate ? chalk.dim(` ⎿ [${me}] received ${msgs.length} message(s)`) : mark.inbox(msgs.length));
1006
+ sink(opts).note(opts.teammate ? chalk.dim(` ⎿ [${me}] received ${msgs.length} message(s)`) : mark.inbox(msgs.length));
1009
1007
  return true;
1010
1008
  }
1011
1009
  // At a stop-point (no tool calls), decide whether the TEAM keeps this agent's
@@ -1041,7 +1039,7 @@ async function continueForTeam(messages, opts) {
1041
1039
  // reference's identity-reinjection, folded into the work hand-off).
1042
1040
  messages.push({ role: "user", content: `You are ${me}. You claimed task ${claimed.id} from the board: "${claimed.subject}".\n${claimed.description}\nWhen it is done and verified, call complete_task("${claimed.id}") — then you'll be offered the next ready task.` });
1043
1041
  if (!opts.quiet)
1044
- console.log(chalk.magenta(` ⎿ [${me}] claimed ${claimed.id}: ${claimed.subject.slice(0, 50)}`));
1042
+ sink(opts).note(chalk.magenta(` ⎿ [${me}] claimed ${claimed.id}: ${claimed.subject.slice(0, 50)}`));
1045
1043
  setTeammateState(me, "active");
1046
1044
  return true;
1047
1045
  }
@@ -1069,7 +1067,7 @@ async function shutdownTeam(opts) {
1069
1067
  if (!running.length)
1070
1068
  return;
1071
1069
  if (!opts.quiet)
1072
- console.log(chalk.magenta(` ⎿ disbanding team — shutting down ${running.length} teammate(s)`));
1070
+ sink(opts).note(chalk.magenta(` ⎿ disbanding team — shutting down ${running.length} teammate(s)`));
1073
1071
  for (const name of running) {
1074
1072
  const id = createRequest("shutdown", LEAD, name, "task complete");
1075
1073
  markShutdown(name, id);
@@ -1152,7 +1150,7 @@ opts) {
1152
1150
  if (attempts.total >= MAX_RETRIES)
1153
1151
  return { reason: TerminateReason.RetryBudgetExhausted, detail: `${e.kind}: ${e.message}` };
1154
1152
  const wait = backoffMs(attempts.consecutive); // how long to back off this time
1155
- console.log(chalk.dim(` [retry] ${e.kind} — attempt ${attempts.total}/${MAX_RETRIES}, waiting ${wait}ms`)); // retries must be visible, or the app just looks frozen
1153
+ sink(opts).note(chalk.dim(` [retry] ${e.kind} — attempt ${attempts.total}/${MAX_RETRIES}, waiting ${wait}ms`)); // retries must be visible, or the app just looks frozen
1156
1154
  await interruptibleSleep(wait, opts.isInterrupted); // wait, but stay responsive to Ctrl+C
1157
1155
  continue; // retry the same round
1158
1156
  }
@@ -1187,7 +1185,7 @@ opts) {
1187
1185
  if (stop.block) {
1188
1186
  emit("agent_hook_block", { event: "Stop" }); // the agent was sent back to work
1189
1187
  if (!opts.quiet)
1190
- console.log(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
1188
+ sink(opts).note(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
1191
1189
  messages.push({ role: "user", content: `[Stop hook] You are not finished: ${stop.feedback}` }); // inject the instruction
1192
1190
  break; // exit the inner while → advance the round counter
1193
1191
  }
@@ -1235,7 +1233,7 @@ opts) {
1235
1233
  // Fold this round's tool calls into ONE tally line instead of one line per
1236
1234
  // call (the per-call announcements were recorded for Ctrl+T in runOneCall).
1237
1235
  if (!opts.quiet && out.toolCalls.length) {
1238
- console.log(agentIndent(opts) + mark.toolTally(out.toolCalls.map((c) => c.name)) + chalk.dim(" · Ctrl+T"));
1236
+ sink(opts).note(agentIndent(opts) + mark.toolTally(out.toolCalls.map((c) => c.name)) + chalk.dim(" · Ctrl+T"));
1239
1237
  }
1240
1238
  // Background tasks (Day 37): if a job the model started has finished, slip
1241
1239
  // its <task_notification> in here, right after this round's tool results —
@@ -1255,7 +1253,7 @@ opts) {
1255
1253
  if (nag) {
1256
1254
  messages.push({ role: "user", content: nag });
1257
1255
  if (!opts.quiet)
1258
- console.log(chalk.dim(" ⎿ plan reminder injected"));
1256
+ sink(opts).note(chalk.dim(" ⎿ plan reminder injected"));
1259
1257
  }
1260
1258
  }
1261
1259
  break; // round complete, move to the next one
package/dist/output.js ADDED
@@ -0,0 +1,50 @@
1
+ import ora from "ora"; // the "thinking…" spinner the default sink drives
2
+ import { MarkdownStream } from "./markdown.js"; // the default sink renders the streamed answer as terminal markdown
3
+ import { mark } from "./ui.js"; // the ⏺ answer marker, kept consistent with the rest of the UI
4
+ // ---- the default sink: stdout, identical to the pre-sink behaviour -----------
5
+ class StdoutSpinner {
6
+ s;
7
+ constructor(text) {
8
+ // discardStdin:false — ora would otherwise swallow Ctrl+C while spinning.
9
+ this.s = ora({ text, discardStdin: false }).start();
10
+ }
11
+ set(text) {
12
+ if (this.s.isSpinning)
13
+ this.s.text = text;
14
+ }
15
+ stop() {
16
+ if (this.s.isSpinning)
17
+ this.s.stop();
18
+ }
19
+ get spinning() {
20
+ return this.s.isSpinning;
21
+ }
22
+ }
23
+ // The sink the readline REPL (and every non-Ink caller) gets when it passes no
24
+ // `output`. Each method is the exact line the loop used to run inline.
25
+ export const STDOUT_OUTPUT = {
26
+ spinner: (text) => new StdoutSpinner(text),
27
+ reasoning: (line) => process.stdout.write(line + "\n"),
28
+ answer: () => {
29
+ // Lazily build the MarkdownStream on the first token (so a tool-only or
30
+ // empty turn prints nothing), then end with a single trailing newline —
31
+ // exactly the old `if (printedPrefix) process.stdout.write("\n")`.
32
+ let md = null;
33
+ let printed = false;
34
+ return {
35
+ push(token) {
36
+ if (!md)
37
+ md = new MarkdownStream((s) => process.stdout.write(s), { firstPrefix: "\n" + mark.answer, indent: " " });
38
+ printed = true;
39
+ md.push(token);
40
+ },
41
+ end() {
42
+ if (md)
43
+ md.end();
44
+ if (printed)
45
+ process.stdout.write("\n");
46
+ },
47
+ };
48
+ },
49
+ note: (line) => console.log(line),
50
+ };
package/dist/tui.js CHANGED
@@ -107,6 +107,18 @@ export function revealToolCalls() {
107
107
  process.stdout.write(toolCallLog.join("\n") + "\n");
108
108
  return true;
109
109
  }
110
+ // Ink variants of the two reveals: RETURN the formatted block (or null) instead
111
+ // of writing to stdout, so the Ink REPL can commit it as a note in its tree.
112
+ export function getReasoning() {
113
+ if (!reasoningTrace.length)
114
+ return null;
115
+ return chalk.dim("💭 model's thinking:\n") + chalk.dim(reasoningTrace.join("\n\n— — —\n\n").replace(/^/gm, " "));
116
+ }
117
+ export function getToolCalls() {
118
+ if (!toolCallLog.length)
119
+ return null;
120
+ return chalk.dim(`tool calls this turn (${toolCallLog.length}):\n`) + toolCallLog.join("\n");
121
+ }
110
122
  // ---- cleanup -----------------------------------------------------------------
111
123
  // Called on exit. Nothing fancy to restore now (we no longer hijack the bottom
112
124
  // of the screen), but keep the hook so callers don't have to change if that
package/dist/ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import chalk from "chalk"; // the one place color lives
2
+ import { displayWidth } from "./editor.js"; // CJK-/ANSI-aware width, to pad the sent-message bar to the full row
2
3
  // The app's visual theme, in one place. Before this, emoji and colors were
3
4
  // sprinkled across agent.ts and loop.ts — no coherent look, and impossible to
4
5
  // restyle without hunting. This module owns "what the tool looks like": the
@@ -124,11 +125,18 @@ export function inputRule() {
124
125
  export function framedPrompt(planMode) {
125
126
  return planMode ? chalk.yellow.bold("⏸ plan ❯ ") : chalk.cyan.bold("❯ ");
126
127
  }
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.
128
+ // How a SUBMITTED message is echoed into the scrollback once you hit Enter:
129
+ // a "> …" line on a full-width highlight BAR (like Claude Code), so each turn
130
+ // reads as a distinct sent message scrolling up — not just faint text. The line
131
+ // is padded to the terminal width so the background spans the whole row; if the
132
+ // message is longer than the row it just wraps (the tail line won't be padded,
133
+ // a minor cosmetic). Colors fall back gracefully when the terminal lacks 256.
130
134
  export function sentMessage(text) {
131
- return chalk.dim("> " + text);
135
+ const cols = Math.max(20, process.stdout.columns || 80);
136
+ const content = "> " + text;
137
+ const w = displayWidth(content);
138
+ const padded = w < cols ? content + " ".repeat(cols - w) : content; // fill the row when it fits
139
+ return chalk.bgAnsi256(237).ansi256(252)(padded); // light-grey text on a subtle dark-grey bar
132
140
  }
133
141
  // ---- the selection menu -----------------------------------------------------
134
142
  // Render a numbered list of options with one highlighted. The interactive part
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.31.0",
3
+ "version": "0.32.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": {
@@ -12,6 +12,7 @@
12
12
  ],
13
13
  "scripts": {
14
14
  "start": "tsx --env-file=.env src/agent.ts",
15
+ "ink": "tsx --env-file=.env src/ink/main.tsx",
15
16
  "typecheck": "tsc --noEmit",
16
17
  "eval": "tsx --env-file=.env eval/run.ts",
17
18
  "build": "tsc -p tsconfig.build.json",
@@ -25,14 +26,17 @@
25
26
  "dependencies": {
26
27
  "chalk": "^5.6.2",
27
28
  "cli-table3": "^0.6.5",
29
+ "ink": "^5.2.1",
28
30
  "marked": "^15.0.12",
29
31
  "marked-terminal": "^7.3.0",
30
32
  "openai": "^6.42.0",
31
33
  "ora": "^9.4.0",
34
+ "react": "^18.3.1",
32
35
  "wrap-ansi": "^10.0.0"
33
36
  },
34
37
  "devDependencies": {
35
38
  "@types/node": "^25.9.2",
39
+ "@types/react": "^18.3.31",
36
40
  "tsx": "^4.22.4",
37
41
  "typescript": "^6.0.3"
38
42
  }