agent-from-zero 0.30.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
@@ -19,7 +19,7 @@ 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 } from "./ui.js"; // welcome box, prompt, model labels, status line
22
+ import { banner, framedPrompt, formatModelChoices, statusLine, sentMessage } from "./ui.js"; // welcome box, prompt, model labels, status line, sent-message echo
23
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)
@@ -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({
@@ -249,7 +261,7 @@ async function main() {
249
261
  lineWaiters.shift()("exit"); // wake every waiter with a polite "exit"
250
262
  });
251
263
  // Get the next line of user input, showing a prompt if we have to wait.
252
- const readUserLine = async (prompt, footer) => {
264
+ const readUserLine = async (prompt, footer, echo) => {
253
265
  if (pendingLines.length)
254
266
  return pendingLines.shift(); // typed-ahead (or piped) line — use it
255
267
  if (stdinDone)
@@ -259,7 +271,7 @@ async function main() {
259
271
  // so the status bar stays pinned below however the line wraps.
260
272
  editing = true;
261
273
  try {
262
- const res = await editLine(rl, { prompt, footer, history, onTab: () => toggleLastCollapsed(), onReveal: () => revealReasoning(), onTools: () => revealToolCalls(), transformPaste: normalizeDroppedPaths });
274
+ const res = await editLine(rl, { prompt, footer, history, echo, onTab: () => toggleLastCollapsed(), onReveal: () => revealReasoning(), onTools: () => revealToolCalls(), transformPaste: normalizeDroppedPaths });
263
275
  if (res.type === "line") {
264
276
  if (res.value.trim())
265
277
  history.push(res.value); // remember non-empty entries for ↑/↓
@@ -648,7 +660,7 @@ async function main() {
648
660
  console.log(""); // one blank line separates the previous answer from the prompt
649
661
  footer = statusLine(CONFIG.model, path.basename(process.cwd()), gitBranch(), ctxPct, costMeter.cost(), Date.now() - sessionStartedAt); // status pinned below the prompt
650
662
  }
651
- const line = (await readUserLine(framedPrompt(isPlanMode()), footer)).trim(); // next input, or "exit" on EOF
663
+ const line = (await readUserLine(framedPrompt(isPlanMode()), footer, sentMessage)).trim(); // next input (echoed as a sent message above), or "exit" on EOF
652
664
  if (!line)
653
665
  continue; // empty line — just re-prompt
654
666
  if (line === "exit" || line === "quit") {
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
  }
package/dist/editor.js CHANGED
@@ -239,13 +239,24 @@ export function editLine(rl, opts) {
239
239
  input.off("keypress", onKey);
240
240
  for (const l of otherKeypress)
241
241
  input.on("keypress", l); // give readline its keypress handler back
242
- // Leave the typed command on screen, drop the footer, land on a fresh line.
243
- const { rows } = layout(state.buffer, state.cursor, promptW, cols());
242
+ // Climb to the top of the input block and wipe it (prompt + wrapped input + footer).
244
243
  if (prevCursorRow > 0)
245
244
  out.write(`\x1b[${prevCursorRow}A`);
246
245
  out.write("\r\x1b[0J");
247
- const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
248
- out.write(inputRows.join("\r\n") + "\r\n");
246
+ // What stays on screen: with an echo styler (the main REPL prompt), a
247
+ // submitted message is re-rendered as a distinct "sent" line and the input
248
+ // box vanishes — your message scrolls up, the input clears, like a chat app.
249
+ // Without it (sub-prompts), leave the typed command under its prompt.
250
+ if (opts.echo) {
251
+ if (result.type === "line" && state.buffer.trim())
252
+ out.write(opts.echo(state.buffer) + "\r\n");
253
+ // empty submit / cancel / eof: leave nothing — the wipe above already cleared it
254
+ }
255
+ else {
256
+ const { rows } = layout(state.buffer, state.cursor, promptW, cols());
257
+ const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
258
+ out.write(inputRows.join("\r\n") + "\r\n");
259
+ }
249
260
  try {
250
261
  input.setRawMode(wasRaw); // hand stdin back exactly as we found it
251
262
  }
@@ -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
+ }