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 +16 -4
- package/dist/context.js +4 -3
- package/dist/editor.js +15 -4
- package/dist/ink/app.js +435 -0
- package/dist/ink/chat.js +22 -0
- package/dist/ink/commands.js +103 -0
- package/dist/ink/launch.js +15 -0
- package/dist/ink/main.js +4 -0
- package/dist/ink/setup.js +102 -0
- package/dist/ink/sink.js +50 -0
- package/dist/loop.js +50 -52
- package/dist/output.js +50 -0
- package/dist/tui.js +12 -0
- package/dist/ui.js +14 -0
- package/package.json +6 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
248
|
-
|
|
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
|
}
|
package/dist/ink/app.js
ADDED
|
@@ -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
|
+
}
|
package/dist/ink/chat.js
ADDED
|
@@ -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
|
+
}
|