agent-from-zero 0.27.0 → 0.29.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/board.js ADDED
@@ -0,0 +1,179 @@
1
+ // ============ The shared task board (Day 40) ============
2
+ // Day 38/39 gave us a team, but the LEAD still had to hand every teammate its
3
+ // work by message. That doesn't scale: with twenty tasks the lead becomes a
4
+ // dispatcher, and a teammate that finishes early sits idle while the lead is
5
+ // busy. s17's fix: a shared TASK BOARD that agents pull from THEMSELVES. The
6
+ // lead (or a teammate) drops tasks on the board; any idle teammate scans it,
7
+ // CLAIMS a ready one, does it, marks it complete, and scans again — autonomous
8
+ // work distribution with no dispatcher in the middle.
9
+ //
10
+ // Like the mailboxes, the board is ON DISK (落盘) for observability: each task is
11
+ // a .mini-agent/tasks/<id>.json file you can read while the team runs. And like
12
+ // the mailboxes, it needs NO lock despite "concurrent" claimers: claimTask is a
13
+ // synchronous read-check-write, and on one event loop a synchronous function
14
+ // runs to completion without yielding — so two teammates can never both win the
15
+ // same task. (The Python reference uses proper-lockfile because its teammates are
16
+ // OS threads; production CC adds fs.watch + atomic claim-with-busy-check.)
17
+ //
18
+ // Not to be confused with todo_write (Day 36): that is one agent's PRIVATE
19
+ // checklist; this is the team's SHARED, claimable work queue.
20
+ import fs from "node:fs"; // tasks are real files
21
+ import path from "node:path"; // path joining
22
+ let seq = 0; // increments per task — seeds the id
23
+ let wiped = false; // wipe stale board files once per process run (like team.ts)
24
+ // Resolved lazily so it tracks the cwd, exactly like the session + team dirs.
25
+ function boardRoot() {
26
+ const root = path.resolve(".mini-agent", "tasks");
27
+ fs.mkdirSync(root, { recursive: true });
28
+ if (!wiped) {
29
+ for (const f of fs.readdirSync(root))
30
+ if (f.endsWith(".json"))
31
+ fs.rmSync(path.join(root, f)); // fresh board for this run
32
+ wiped = true;
33
+ }
34
+ return root;
35
+ }
36
+ const taskPath = (id) => path.join(boardRoot(), `${id}.json`);
37
+ function saveTask(task) {
38
+ fs.writeFileSync(taskPath(task.id), JSON.stringify(task, null, 2)); // pretty, so `cat` is readable
39
+ }
40
+ export function getTask(id) {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(taskPath(id), "utf8"));
43
+ }
44
+ catch {
45
+ return undefined; // missing or corrupt
46
+ }
47
+ }
48
+ // Every task on the board, oldest first (creation order = the natural reading
49
+ // order for a dependency graph).
50
+ export function listTasks() {
51
+ const root = boardRoot();
52
+ return fs
53
+ .readdirSync(root)
54
+ .filter((f) => f.endsWith(".json"))
55
+ .map((f) => {
56
+ try {
57
+ return JSON.parse(fs.readFileSync(path.join(root, f), "utf8"));
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ })
63
+ .filter((t) => t !== null)
64
+ .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
65
+ }
66
+ // Create a task. `blockedBy` may reference ids that don't exist yet — canStart
67
+ // treats an unknown dependency as "not satisfied", so order of creation is free.
68
+ export function createTask(subject, description = "", blockedBy = []) {
69
+ const task = { id: `task_${++seq}`, subject, description, status: "pending", owner: null, blockedBy, createdAt: Date.now() };
70
+ saveTask(task);
71
+ return task;
72
+ }
73
+ // Resolve a blockedBy entry to a task. Models naturally reference a dependency
74
+ // by its SUBJECT ("alpha") rather than its id ("task_1"), so we accept either —
75
+ // id first, then a unique subject match. Returns undefined if it matches nothing
76
+ // (or an ambiguous subject), which canStart then treats as still-blocking.
77
+ export function resolveDep(dep) {
78
+ const byId = getTask(dep);
79
+ if (byId)
80
+ return byId;
81
+ const bySubject = listTasks().filter((t) => t.subject === dep);
82
+ return bySubject.length === 1 ? bySubject[0] : undefined; // ignore an ambiguous subject
83
+ }
84
+ // Can this task be started? Only if every blockedBy dependency resolves to a
85
+ // COMPLETED task. A missing/ambiguous dependency counts as blocking (fail closed).
86
+ export function canStart(id) {
87
+ const task = getTask(id);
88
+ if (!task)
89
+ return false;
90
+ return task.blockedBy.every((dep) => resolveDep(dep)?.status === "completed");
91
+ }
92
+ // Claim a task for `owner`. The critical section: read, check it's still
93
+ // pending+unowned+ready, then write owner+in_progress — all synchronous, so it
94
+ // is atomic against any other agent on this event loop. Returns ok=false (with a
95
+ // reason) if someone else got there first or a dependency isn't done.
96
+ export function claimTask(id, owner) {
97
+ const task = getTask(id);
98
+ if (!task)
99
+ return { ok: false, error: `No task "${id}".` };
100
+ if (task.status !== "pending")
101
+ return { ok: false, error: `Task ${id} is ${task.status}${task.owner ? ` (owner ${task.owner})` : ""}, not claimable.` };
102
+ if (task.owner)
103
+ return { ok: false, error: `Task ${id} is already owned by ${task.owner}.` };
104
+ if (!canStart(id))
105
+ return { ok: false, error: `Task ${id} is blocked by unfinished tasks: ${task.blockedBy.filter((d) => resolveDep(d)?.status !== "completed").join(", ")}.` };
106
+ task.owner = owner;
107
+ task.status = "in_progress";
108
+ saveTask(task);
109
+ return { ok: true, task };
110
+ }
111
+ // Complete a task and report which downstream tasks it just unblocked (so the
112
+ // claimer can mention them, and the board moves forward).
113
+ export function completeTask(id, by) {
114
+ const task = getTask(id);
115
+ if (!task)
116
+ return { ok: false, error: `No task "${id}".` };
117
+ if (task.status !== "in_progress")
118
+ return { ok: false, error: `Task ${id} is ${task.status}, not in progress.` };
119
+ if (by && task.owner && task.owner !== by)
120
+ return { ok: false, error: `Task ${id} is owned by ${task.owner}, not ${by}.` };
121
+ task.status = "completed";
122
+ saveTask(task);
123
+ // Tasks that listed this one as a dependency (by id or subject) and are now
124
+ // ready just opened up.
125
+ const unblocked = listTasks()
126
+ .filter((t) => t.status === "pending" && !t.owner && t.blockedBy.some((d) => resolveDep(d)?.id === id) && canStart(t.id))
127
+ .map((t) => t.id);
128
+ return { ok: true, unblocked };
129
+ }
130
+ // Scan for the next task this agent can take and CLAIM it, atomically. Returns
131
+ // the claimed task, or null if nothing is available right now. This is the heart
132
+ // of autonomy: an idle teammate calls this and either gets work or keeps waiting.
133
+ // Trying each candidate (rather than just the first) closes the race where two
134
+ // agents scanned the same "available" task — the loser simply tries the next.
135
+ export function claimNextAvailable(owner) {
136
+ for (const t of listTasks()) {
137
+ if (t.status !== "pending" || t.owner || !canStart(t.id))
138
+ continue;
139
+ const res = claimTask(t.id, owner);
140
+ if (res.ok)
141
+ return res.task;
142
+ }
143
+ return null;
144
+ }
145
+ // Is there pending-and-ready work nobody has claimed? Used to tell "the board is
146
+ // genuinely drained" from "a teammate is briefly between tasks".
147
+ export function hasClaimableWork() {
148
+ return listTasks().some((t) => t.status === "pending" && !t.owner && canStart(t.id));
149
+ }
150
+ // Any task not yet completed — the board still has open work (claimed or not).
151
+ export function hasOpenTasks() {
152
+ return listTasks().some((t) => t.status !== "completed");
153
+ }
154
+ // A compact, human-readable board snapshot — for the list_tasks tool result and
155
+ // the /tasks command. One line per task: status, id, owner, deps.
156
+ export function boardSummary() {
157
+ const tasks = listTasks();
158
+ if (!tasks.length)
159
+ return "(the task board is empty)";
160
+ const icon = { pending: "○", in_progress: "◐", completed: "✓" };
161
+ return tasks
162
+ .map((t) => {
163
+ const owner = t.owner ? ` @${t.owner}` : "";
164
+ const deps = t.blockedBy.length ? ` (blockedBy: ${t.blockedBy.join(", ")})` : "";
165
+ return `${icon[t.status]} ${t.id} [${t.status}${owner}] ${t.subject}${deps}`;
166
+ })
167
+ .join("\n");
168
+ }
169
+ // Wipe the board: delete every task file and reset the counter. Called on
170
+ // /clear, /resume, and when the lead disbands the team — each task starts fresh.
171
+ export function resetBoard() {
172
+ const root = path.resolve(".mini-agent", "tasks");
173
+ if (fs.existsSync(root))
174
+ for (const f of fs.readdirSync(root))
175
+ if (f.endsWith(".json"))
176
+ fs.rmSync(path.join(root, f));
177
+ seq = 0;
178
+ wiped = true; // we just cleared it; don't re-wipe on the next touch this run
179
+ }
package/dist/drop.js ADDED
@@ -0,0 +1,104 @@
1
+ // Drag-and-drop a file onto the terminal and it pastes the file's path into the
2
+ // input — but as a SHELL token: backslash-escaped spaces (/Users/me/my\ file.txt)
3
+ // or wrapped in quotes, and usually with a trailing space. That's noisy to read
4
+ // and clumsy to hand to the agent. This turns a dropped path (or several) into
5
+ // clean ABSOLUTE paths, so the user never types a long path by hand.
6
+ //
7
+ // It is deliberately conservative: it only rewrites text that is, after
8
+ // unescaping, one or more EXISTING ABSOLUTE paths (that's exactly what a terminal
9
+ // drag produces). Anything else — ordinary pasted prose, a relative snippet — is
10
+ // returned untouched, so this can run on every paste without surprising anyone.
11
+ import fs from "node:fs"; // confirm the tokens are real paths (the drag signal)
12
+ import os from "node:os"; // expand a leading ~
13
+ import path from "node:path"; // resolve + normalize to absolute
14
+ // Terminals with "bracketed paste" wrap the text in these markers; strip them so
15
+ // a drop still parses if the mode happens to be on.
16
+ const BRACKET_PASTE = /\x1b\[20[01]~/g;
17
+ // Split a dropped string into path tokens, honoring the escaping a terminal adds:
18
+ // backslash escapes (\<space>, \( …) and single/double quotes. Whitespace OUTSIDE
19
+ // a quote/escape separates tokens (so several dropped files become several paths).
20
+ function splitTokens(raw) {
21
+ const s = raw.replace(BRACKET_PASTE, "").trim();
22
+ const tokens = [];
23
+ let cur = "";
24
+ let started = false; // did the current token get any character? (distinguishes "" from absent)
25
+ let i = 0;
26
+ while (i < s.length) {
27
+ const c = s[i];
28
+ if (c === "\\" && i + 1 < s.length) {
29
+ cur += s[i + 1];
30
+ i += 2;
31
+ started = true;
32
+ continue;
33
+ } // \x → literal x
34
+ if (c === "'") { // single quotes: everything literal until the next '
35
+ i++;
36
+ while (i < s.length && s[i] !== "'")
37
+ cur += s[i++];
38
+ i++;
39
+ started = true;
40
+ continue;
41
+ }
42
+ if (c === '"') { // double quotes: backslash still escapes inside
43
+ i++;
44
+ while (i < s.length && s[i] !== '"') {
45
+ if (s[i] === "\\" && i + 1 < s.length) {
46
+ cur += s[i + 1];
47
+ i += 2;
48
+ }
49
+ else
50
+ cur += s[i++];
51
+ }
52
+ i++;
53
+ started = true;
54
+ continue;
55
+ }
56
+ if (c === " " || c === "\t" || c === "\n" || c === "\r") { // a separator between tokens
57
+ if (started) {
58
+ tokens.push(cur);
59
+ cur = "";
60
+ started = false;
61
+ }
62
+ i++;
63
+ continue;
64
+ }
65
+ cur += c;
66
+ i++;
67
+ started = true;
68
+ }
69
+ if (started)
70
+ tokens.push(cur);
71
+ return tokens;
72
+ }
73
+ // A drop produces ABSOLUTE paths (macOS/Linux terminals). Treat a leading ~ as
74
+ // home. Anything not absolute-ish is not a drop — we leave it alone.
75
+ const looksAbsolute = (t) => t.startsWith("/") || t === "~" || t.startsWith("~/");
76
+ const expandTilde = (t) => (t === "~" || t.startsWith("~/") ? os.homedir() + t.slice(1) : t);
77
+ // Quote a path that contains whitespace so several dropped paths stay separable
78
+ // (and the whole thing survives being read as shell-ish tokens later). Prefer
79
+ // single quotes; fall back to double if the path itself contains a single quote.
80
+ function quoteIfNeeded(p) {
81
+ if (!/\s/.test(p))
82
+ return p;
83
+ return p.includes("'") ? `"${p}"` : `'${p}'`;
84
+ }
85
+ // The entry point: given a pasted string, return clean absolute path(s) if it is
86
+ // a drop, otherwise the original text unchanged. A successful normalization ends
87
+ // with a single trailing space, so the cursor lands ready to type the question.
88
+ export function normalizeDroppedPaths(raw) {
89
+ if (!raw)
90
+ return raw;
91
+ const tokens = splitTokens(raw);
92
+ if (!tokens.length)
93
+ return raw;
94
+ const abs = [];
95
+ for (const t of tokens) {
96
+ if (!looksAbsolute(t))
97
+ return raw; // not a dragged absolute path → leave the paste as-is
98
+ const resolved = path.resolve(expandTilde(t)); // normalize (//, .., trailing /) → canonical absolute
99
+ if (!fs.existsSync(resolved))
100
+ return raw; // a path that doesn't exist isn't a real drop
101
+ abs.push(resolved);
102
+ }
103
+ return abs.map(quoteIfNeeded).join(" ") + " ";
104
+ }
package/dist/editor.js ADDED
@@ -0,0 +1,319 @@
1
+ import readline from "node:readline"; // raw-mode keypress decoding + the interface we pause
2
+ // ---- pure editing logic -----------------------------------------------------
3
+ // Map a keypress onto a new state + an action. Emacs-style bindings, the ones a
4
+ // terminal user expects. Everything printable (letters, digits, punctuation,
5
+ // pasted text, CJK) falls through to insertion at the cursor.
6
+ export function reduceEditor(s, str, key) {
7
+ const { buffer, cursor } = s;
8
+ const name = key?.name;
9
+ if (key?.ctrl) {
10
+ switch (name) {
11
+ case "c":
12
+ return { state: s, action: "cancel" }; // Ctrl+C → caller decides (at the prompt: quit)
13
+ case "d":
14
+ return buffer.length === 0 ? { state: s, action: "eof" } : forwardDelete(s); // Ctrl+D empty → EOF, else delete
15
+ case "a":
16
+ return { state: { buffer, cursor: 0 }, action: "edit" }; // start of line
17
+ case "e":
18
+ return { state: { buffer, cursor: buffer.length }, action: "edit" }; // end of line
19
+ case "u":
20
+ return { state: { buffer: buffer.slice(cursor), cursor: 0 }, action: "edit" }; // kill to start
21
+ case "k":
22
+ return { state: { buffer: buffer.slice(0, cursor), cursor }, action: "edit" }; // kill to end
23
+ case "w":
24
+ return killWordBack(s); // kill the word before the cursor
25
+ case "b":
26
+ return { state: { buffer, cursor: Math.max(0, cursor - 1) }, action: "edit" }; // back one char
27
+ case "f":
28
+ return { state: { buffer, cursor: Math.min(buffer.length, cursor + 1) }, action: "edit" }; // forward one char
29
+ case "r":
30
+ return { state: s, action: "reveal" }; // Ctrl+R → reveal the model's collapsed thinking
31
+ default:
32
+ return { state: s, action: "none" };
33
+ }
34
+ }
35
+ switch (name) {
36
+ case "return":
37
+ case "enter":
38
+ return { state: s, action: "submit" };
39
+ case "backspace":
40
+ return cursor > 0
41
+ ? { state: { buffer: buffer.slice(0, cursor - 1) + buffer.slice(cursor), cursor: cursor - 1 }, action: "edit" }
42
+ : { state: s, action: "none" };
43
+ case "delete":
44
+ return forwardDelete(s);
45
+ case "left":
46
+ return { state: { buffer, cursor: Math.max(0, cursor - 1) }, action: "edit" };
47
+ case "right":
48
+ return { state: { buffer, cursor: Math.min(buffer.length, cursor + 1) }, action: "edit" };
49
+ case "home":
50
+ return { state: { buffer, cursor: 0 }, action: "edit" };
51
+ case "end":
52
+ return { state: { buffer, cursor: buffer.length }, action: "edit" };
53
+ case "up":
54
+ return { state: s, action: "histPrev" };
55
+ case "down":
56
+ return { state: s, action: "histNext" };
57
+ case "escape":
58
+ return { state: { buffer: "", cursor: 0 }, action: "edit" }; // Esc clears the line
59
+ case "tab":
60
+ return { state: s, action: "tab" };
61
+ default:
62
+ // Printable insertion. A paste arrives as a multi-char string; insert it whole.
63
+ if (str && !key?.meta && isPrintable(str)) {
64
+ return { state: { buffer: buffer.slice(0, cursor) + str + buffer.slice(cursor), cursor: cursor + str.length }, action: "edit" };
65
+ }
66
+ return { state: s, action: "none" };
67
+ }
68
+ }
69
+ function forwardDelete(s) {
70
+ const { buffer, cursor } = s;
71
+ return cursor < buffer.length ? { state: { buffer: buffer.slice(0, cursor) + buffer.slice(cursor + 1), cursor }, action: "edit" } : { state: s, action: "none" };
72
+ }
73
+ function killWordBack(s) {
74
+ const { buffer, cursor } = s;
75
+ let i = cursor;
76
+ while (i > 0 && /\s/.test(buffer[i - 1]))
77
+ i--; // skip trailing spaces
78
+ while (i > 0 && !/\s/.test(buffer[i - 1]))
79
+ i--; // then the word
80
+ return { state: { buffer: buffer.slice(0, i) + buffer.slice(cursor), cursor: i }, action: "edit" };
81
+ }
82
+ function isPrintable(str) {
83
+ return [...str].every((c) => c >= " " && c !== "\x7f"); // no control chars (incl. lone newlines/DEL)
84
+ }
85
+ // ---- width + wrapping math --------------------------------------------------
86
+ const ANSI = /\x1b\[[0-9;]*m/g; // SGR color codes — they take zero display columns
87
+ // Display width of a string, ANSI-aware and CJK-aware (full-width glyphs are 2
88
+ // columns; String.length would miscount them and break wrapping/cursor math).
89
+ export function displayWidth(s) {
90
+ let w = 0;
91
+ for (const ch of s.replace(ANSI, ""))
92
+ w += charWidth(ch.codePointAt(0));
93
+ return w;
94
+ }
95
+ function charWidth(cp) {
96
+ if (cp >= 0x1100 &&
97
+ (cp <= 0x115f || // Hangul Jamo
98
+ cp === 0x2329 ||
99
+ cp === 0x232a ||
100
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK … Yi
101
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
102
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
103
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
104
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
105
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
106
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji
107
+ (cp >= 0x20000 && cp <= 0x3fffd)) // CJK extension B+
108
+ )
109
+ return 2;
110
+ return 1;
111
+ }
112
+ // Clip a (possibly ANSI-styled) string to a display width: keep escape codes
113
+ // (zero width) intact, never split a wide glyph. This stops a long status footer
114
+ // from wrapping — a wrapped footer row occupies TWO physical rows, which would
115
+ // desync the editor's row math and drop the caret a line too low (and then every
116
+ // repaint clears from the wrong row, stacking a fresh prompt each keystroke).
117
+ export function truncateToWidth(s, width) {
118
+ let w = 0;
119
+ let out = "";
120
+ let i = 0;
121
+ while (i < s.length) {
122
+ if (s[i] === "\x1b") {
123
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i)); // an SGR colour code — copy it, it has no width
124
+ if (m) {
125
+ out += m[0];
126
+ i += m[0].length;
127
+ continue;
128
+ }
129
+ }
130
+ const cp = s.codePointAt(i);
131
+ const ch = String.fromCodePoint(cp);
132
+ const cw = charWidth(cp);
133
+ if (w + cw > width)
134
+ return out + "\x1b[0m"; // would overflow → stop, closing any open colour
135
+ out += ch;
136
+ w += cw;
137
+ i += ch.length;
138
+ }
139
+ return out;
140
+ }
141
+ // Wrap the input across the terminal width and find where the cursor lands.
142
+ // Row 0 begins after the prompt (promptW columns); continuation rows start at 0.
143
+ // Returns the buffer text per row (the driver prepends the prompt to row 0) plus
144
+ // the cursor's (row, col) — so a wrapped line keeps the caret exactly right.
145
+ export function layout(buffer, cursor, promptW, cols) {
146
+ const width = Math.max(2, cols);
147
+ const rows = [""];
148
+ let row = 0;
149
+ let col = promptW;
150
+ let cursorRow = 0;
151
+ let cursorCol = promptW;
152
+ let set = false;
153
+ let cu = 0; // code-unit offset, to match `cursor`
154
+ for (const ch of buffer) {
155
+ if (!set && cu === cursor) {
156
+ cursorRow = row;
157
+ cursorCol = col;
158
+ set = true;
159
+ }
160
+ const w = charWidth(ch.codePointAt(0));
161
+ if (col + w > width) {
162
+ row++;
163
+ col = 0;
164
+ rows[row] = "";
165
+ }
166
+ rows[row] += ch;
167
+ col += w;
168
+ cu += ch.length;
169
+ }
170
+ if (!set) {
171
+ cursorRow = row;
172
+ cursorCol = col; // cursor at the very end
173
+ }
174
+ if (cursorCol >= width) {
175
+ // caret sat exactly at the right edge — it belongs at the start of the next row
176
+ cursorRow++;
177
+ cursorCol = 0;
178
+ if (rows.length <= cursorRow)
179
+ rows.push("");
180
+ }
181
+ return { rows, cursorRow, cursorCol };
182
+ }
183
+ // Read one line with the full editor + pinned footer. Resolves with the line, or
184
+ // cancel (Ctrl+C) / eof (Ctrl+D on an empty line) for the caller to interpret.
185
+ export function editLine(rl, opts) {
186
+ return new Promise((resolve) => {
187
+ const input = opts.input ?? process.stdin;
188
+ const out = process.stdout;
189
+ if (!input.isTTY)
190
+ return resolve({ type: "eof" }); // caller guards, but be safe
191
+ const promptW = displayWidth(opts.prompt);
192
+ const footerRows = opts.footer ? opts.footer.split("\n") : [];
193
+ const history = opts.history ?? [];
194
+ let hi = history.length; // history cursor; == length means "the live draft"
195
+ let draft = "";
196
+ let state = { buffer: opts.initial ?? "", cursor: (opts.initial ?? "").length };
197
+ let done = false;
198
+ let first = true;
199
+ let prevCursorRow = 0; // where the caret sits within the block, to climb back next repaint
200
+ const wasRaw = input.isRaw === true; // restore THIS on exit (forcing false would close the live readline)
201
+ const cols = () => out.columns || 80;
202
+ // The session's readline keeps its OWN 'keypress' handler on stdin. Once
203
+ // readline has been activated (after the first resume), that handler fires on
204
+ // every key alongside ours and fights us for the cursor — the line stacks
205
+ // instead of redrawing in place. Detach the other keypress listeners while we
206
+ // own the input, and restore them exactly on exit (menu/confirm still need them).
207
+ let otherKeypress = [];
208
+ // Repaint the whole block in place: climb to its top, clear downward, reprint
209
+ // input rows + footer, then drop the caret onto its computed (row, col).
210
+ const draw = () => {
211
+ const { rows, cursorRow, cursorCol } = layout(state.buffer, state.cursor, promptW, cols());
212
+ if (!first) {
213
+ if (prevCursorRow > 0)
214
+ out.write(`\x1b[${prevCursorRow}A`); // up to the block's top row
215
+ out.write("\r");
216
+ }
217
+ out.write("\x1b[0J"); // clear from the top of the block to end of screen
218
+ const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
219
+ // Truncate each footer row so it can't wrap — otherwise its extra physical
220
+ // row throws off the cursor math below (caret lands low → prompts stack).
221
+ const fr = footerRows.map((f) => truncateToWidth(f, Math.max(1, cols() - 1)));
222
+ const all = [...inputRows, ...fr];
223
+ out.write(all.join("\r\n"));
224
+ const lastRow = all.length - 1; // every row is now exactly one physical line
225
+ if (lastRow > cursorRow)
226
+ out.write(`\x1b[${lastRow - cursorRow}A`); // up from the last footer row
227
+ out.write("\r");
228
+ if (cursorCol > 0)
229
+ out.write(`\x1b[${cursorCol}C`); // right to the caret column
230
+ prevCursorRow = cursorRow;
231
+ first = false;
232
+ };
233
+ const finish = (result) => {
234
+ if (done)
235
+ return;
236
+ done = true;
237
+ input.off("keypress", onKey);
238
+ for (const l of otherKeypress)
239
+ input.on("keypress", l); // give readline its keypress handler back
240
+ // Leave the typed command on screen, drop the footer, land on a fresh line.
241
+ const { rows } = layout(state.buffer, state.cursor, promptW, cols());
242
+ if (prevCursorRow > 0)
243
+ out.write(`\x1b[${prevCursorRow}A`);
244
+ out.write("\r\x1b[0J");
245
+ const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
246
+ out.write(inputRows.join("\r\n") + "\r\n");
247
+ try {
248
+ input.setRawMode(wasRaw); // hand stdin back exactly as we found it
249
+ }
250
+ catch {
251
+ /* not all streams support it */
252
+ }
253
+ rl.resume();
254
+ resolve(result);
255
+ };
256
+ const onKey = (str, key) => {
257
+ if (done)
258
+ return;
259
+ // A drag-and-drop (or any paste) arrives as one multi-char string. Give the
260
+ // caller a chance to rewrite it — e.g. turn dropped file paths into clean
261
+ // absolute paths — before it lands in the buffer. No-op for normal text.
262
+ if (str && str.length > 1 && !key?.ctrl && !key?.meta && opts.transformPaste) {
263
+ str = opts.transformPaste(str);
264
+ }
265
+ const next = reduceEditor(state, str, key || {});
266
+ state = next.state;
267
+ switch (next.action) {
268
+ case "submit":
269
+ return finish({ type: "line", value: state.buffer });
270
+ case "cancel":
271
+ return finish({ type: "cancel" });
272
+ case "eof":
273
+ return finish({ type: "eof" });
274
+ case "histPrev":
275
+ if (hi > 0) {
276
+ if (hi === history.length)
277
+ draft = state.buffer; // stash the live draft on the way up
278
+ hi--;
279
+ state = { buffer: history[hi], cursor: history[hi].length };
280
+ }
281
+ return draw();
282
+ case "histNext":
283
+ if (hi < history.length) {
284
+ hi++;
285
+ const b = hi === history.length ? draft : history[hi];
286
+ state = { buffer: b, cursor: b.length };
287
+ }
288
+ return draw();
289
+ case "tab":
290
+ opts.onTab?.(); // may print above us (e.g. expand a collapsed block) and scroll
291
+ first = true; // so the next paint lands fresh at the cursor, not a stale climb-up
292
+ return draw();
293
+ case "reveal":
294
+ opts.onReveal?.(); // print the model's collapsed thinking above us, then repaint
295
+ first = true; // the next paint lands fresh, not a stale climb-up
296
+ return draw();
297
+ case "edit":
298
+ return draw();
299
+ case "none":
300
+ return;
301
+ }
302
+ };
303
+ try {
304
+ rl.pause(); // stop the session reader from turning our keystrokes into lines
305
+ if (input === process.stdin)
306
+ readline.emitKeypressEvents(input); // decode raw bytes into named keys
307
+ otherKeypress = input.listeners("keypress"); // readline's handler(s)…
308
+ for (const l of otherKeypress)
309
+ input.off("keypress", l); // …detached so only we drive the cursor
310
+ input.setRawMode(true);
311
+ input.resume(); // rl.pause() also paused the stream — resume it or no keys arrive
312
+ input.on("keypress", onKey);
313
+ draw();
314
+ }
315
+ catch {
316
+ finish({ type: "eof" }); // setup failed (non-TTY etc.) → behave like EOF
317
+ }
318
+ });
319
+ }