agent-from-zero 0.27.0 → 0.28.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/README.md +43 -38
- package/dist/agent.js +121 -14
- package/dist/background.js +160 -0
- package/dist/board.js +179 -0
- package/dist/editor.js +307 -0
- package/dist/loop.js +697 -5
- package/dist/markdown.js +152 -0
- package/dist/permissions.js +51 -3
- package/dist/prompt.js +16 -0
- package/dist/team.js +214 -0
- package/dist/todos.js +87 -0
- package/dist/tools.js +86 -1
- package/dist/ui.js +5 -1
- package/package.json +7 -3
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/editor.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
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
|
+
default:
|
|
30
|
+
return { state: s, action: "none" };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
switch (name) {
|
|
34
|
+
case "return":
|
|
35
|
+
case "enter":
|
|
36
|
+
return { state: s, action: "submit" };
|
|
37
|
+
case "backspace":
|
|
38
|
+
return cursor > 0
|
|
39
|
+
? { state: { buffer: buffer.slice(0, cursor - 1) + buffer.slice(cursor), cursor: cursor - 1 }, action: "edit" }
|
|
40
|
+
: { state: s, action: "none" };
|
|
41
|
+
case "delete":
|
|
42
|
+
return forwardDelete(s);
|
|
43
|
+
case "left":
|
|
44
|
+
return { state: { buffer, cursor: Math.max(0, cursor - 1) }, action: "edit" };
|
|
45
|
+
case "right":
|
|
46
|
+
return { state: { buffer, cursor: Math.min(buffer.length, cursor + 1) }, action: "edit" };
|
|
47
|
+
case "home":
|
|
48
|
+
return { state: { buffer, cursor: 0 }, action: "edit" };
|
|
49
|
+
case "end":
|
|
50
|
+
return { state: { buffer, cursor: buffer.length }, action: "edit" };
|
|
51
|
+
case "up":
|
|
52
|
+
return { state: s, action: "histPrev" };
|
|
53
|
+
case "down":
|
|
54
|
+
return { state: s, action: "histNext" };
|
|
55
|
+
case "escape":
|
|
56
|
+
return { state: { buffer: "", cursor: 0 }, action: "edit" }; // Esc clears the line
|
|
57
|
+
case "tab":
|
|
58
|
+
return { state: s, action: "tab" };
|
|
59
|
+
default:
|
|
60
|
+
// Printable insertion. A paste arrives as a multi-char string; insert it whole.
|
|
61
|
+
if (str && !key?.meta && isPrintable(str)) {
|
|
62
|
+
return { state: { buffer: buffer.slice(0, cursor) + str + buffer.slice(cursor), cursor: cursor + str.length }, action: "edit" };
|
|
63
|
+
}
|
|
64
|
+
return { state: s, action: "none" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function forwardDelete(s) {
|
|
68
|
+
const { buffer, cursor } = s;
|
|
69
|
+
return cursor < buffer.length ? { state: { buffer: buffer.slice(0, cursor) + buffer.slice(cursor + 1), cursor }, action: "edit" } : { state: s, action: "none" };
|
|
70
|
+
}
|
|
71
|
+
function killWordBack(s) {
|
|
72
|
+
const { buffer, cursor } = s;
|
|
73
|
+
let i = cursor;
|
|
74
|
+
while (i > 0 && /\s/.test(buffer[i - 1]))
|
|
75
|
+
i--; // skip trailing spaces
|
|
76
|
+
while (i > 0 && !/\s/.test(buffer[i - 1]))
|
|
77
|
+
i--; // then the word
|
|
78
|
+
return { state: { buffer: buffer.slice(0, i) + buffer.slice(cursor), cursor: i }, action: "edit" };
|
|
79
|
+
}
|
|
80
|
+
function isPrintable(str) {
|
|
81
|
+
return [...str].every((c) => c >= " " && c !== "\x7f"); // no control chars (incl. lone newlines/DEL)
|
|
82
|
+
}
|
|
83
|
+
// ---- width + wrapping math --------------------------------------------------
|
|
84
|
+
const ANSI = /\x1b\[[0-9;]*m/g; // SGR color codes — they take zero display columns
|
|
85
|
+
// Display width of a string, ANSI-aware and CJK-aware (full-width glyphs are 2
|
|
86
|
+
// columns; String.length would miscount them and break wrapping/cursor math).
|
|
87
|
+
export function displayWidth(s) {
|
|
88
|
+
let w = 0;
|
|
89
|
+
for (const ch of s.replace(ANSI, ""))
|
|
90
|
+
w += charWidth(ch.codePointAt(0));
|
|
91
|
+
return w;
|
|
92
|
+
}
|
|
93
|
+
function charWidth(cp) {
|
|
94
|
+
if (cp >= 0x1100 &&
|
|
95
|
+
(cp <= 0x115f || // Hangul Jamo
|
|
96
|
+
cp === 0x2329 ||
|
|
97
|
+
cp === 0x232a ||
|
|
98
|
+
(cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || // CJK … Yi
|
|
99
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
|
|
100
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
|
|
101
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
|
|
102
|
+
(cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
|
|
103
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
104
|
+
(cp >= 0x1f300 && cp <= 0x1faff) || // emoji
|
|
105
|
+
(cp >= 0x20000 && cp <= 0x3fffd)) // CJK extension B+
|
|
106
|
+
)
|
|
107
|
+
return 2;
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
// Clip a (possibly ANSI-styled) string to a display width: keep escape codes
|
|
111
|
+
// (zero width) intact, never split a wide glyph. This stops a long status footer
|
|
112
|
+
// from wrapping — a wrapped footer row occupies TWO physical rows, which would
|
|
113
|
+
// desync the editor's row math and drop the caret a line too low (and then every
|
|
114
|
+
// repaint clears from the wrong row, stacking a fresh prompt each keystroke).
|
|
115
|
+
export function truncateToWidth(s, width) {
|
|
116
|
+
let w = 0;
|
|
117
|
+
let out = "";
|
|
118
|
+
let i = 0;
|
|
119
|
+
while (i < s.length) {
|
|
120
|
+
if (s[i] === "\x1b") {
|
|
121
|
+
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i)); // an SGR colour code — copy it, it has no width
|
|
122
|
+
if (m) {
|
|
123
|
+
out += m[0];
|
|
124
|
+
i += m[0].length;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const cp = s.codePointAt(i);
|
|
129
|
+
const ch = String.fromCodePoint(cp);
|
|
130
|
+
const cw = charWidth(cp);
|
|
131
|
+
if (w + cw > width)
|
|
132
|
+
return out + "\x1b[0m"; // would overflow → stop, closing any open colour
|
|
133
|
+
out += ch;
|
|
134
|
+
w += cw;
|
|
135
|
+
i += ch.length;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
// Wrap the input across the terminal width and find where the cursor lands.
|
|
140
|
+
// Row 0 begins after the prompt (promptW columns); continuation rows start at 0.
|
|
141
|
+
// Returns the buffer text per row (the driver prepends the prompt to row 0) plus
|
|
142
|
+
// the cursor's (row, col) — so a wrapped line keeps the caret exactly right.
|
|
143
|
+
export function layout(buffer, cursor, promptW, cols) {
|
|
144
|
+
const width = Math.max(2, cols);
|
|
145
|
+
const rows = [""];
|
|
146
|
+
let row = 0;
|
|
147
|
+
let col = promptW;
|
|
148
|
+
let cursorRow = 0;
|
|
149
|
+
let cursorCol = promptW;
|
|
150
|
+
let set = false;
|
|
151
|
+
let cu = 0; // code-unit offset, to match `cursor`
|
|
152
|
+
for (const ch of buffer) {
|
|
153
|
+
if (!set && cu === cursor) {
|
|
154
|
+
cursorRow = row;
|
|
155
|
+
cursorCol = col;
|
|
156
|
+
set = true;
|
|
157
|
+
}
|
|
158
|
+
const w = charWidth(ch.codePointAt(0));
|
|
159
|
+
if (col + w > width) {
|
|
160
|
+
row++;
|
|
161
|
+
col = 0;
|
|
162
|
+
rows[row] = "";
|
|
163
|
+
}
|
|
164
|
+
rows[row] += ch;
|
|
165
|
+
col += w;
|
|
166
|
+
cu += ch.length;
|
|
167
|
+
}
|
|
168
|
+
if (!set) {
|
|
169
|
+
cursorRow = row;
|
|
170
|
+
cursorCol = col; // cursor at the very end
|
|
171
|
+
}
|
|
172
|
+
if (cursorCol >= width) {
|
|
173
|
+
// caret sat exactly at the right edge — it belongs at the start of the next row
|
|
174
|
+
cursorRow++;
|
|
175
|
+
cursorCol = 0;
|
|
176
|
+
if (rows.length <= cursorRow)
|
|
177
|
+
rows.push("");
|
|
178
|
+
}
|
|
179
|
+
return { rows, cursorRow, cursorCol };
|
|
180
|
+
}
|
|
181
|
+
// Read one line with the full editor + pinned footer. Resolves with the line, or
|
|
182
|
+
// cancel (Ctrl+C) / eof (Ctrl+D on an empty line) for the caller to interpret.
|
|
183
|
+
export function editLine(rl, opts) {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
const input = opts.input ?? process.stdin;
|
|
186
|
+
const out = process.stdout;
|
|
187
|
+
if (!input.isTTY)
|
|
188
|
+
return resolve({ type: "eof" }); // caller guards, but be safe
|
|
189
|
+
const promptW = displayWidth(opts.prompt);
|
|
190
|
+
const footerRows = opts.footer ? opts.footer.split("\n") : [];
|
|
191
|
+
const history = opts.history ?? [];
|
|
192
|
+
let hi = history.length; // history cursor; == length means "the live draft"
|
|
193
|
+
let draft = "";
|
|
194
|
+
let state = { buffer: opts.initial ?? "", cursor: (opts.initial ?? "").length };
|
|
195
|
+
let done = false;
|
|
196
|
+
let first = true;
|
|
197
|
+
let prevCursorRow = 0; // where the caret sits within the block, to climb back next repaint
|
|
198
|
+
const wasRaw = input.isRaw === true; // restore THIS on exit (forcing false would close the live readline)
|
|
199
|
+
const cols = () => out.columns || 80;
|
|
200
|
+
// The session's readline keeps its OWN 'keypress' handler on stdin. Once
|
|
201
|
+
// readline has been activated (after the first resume), that handler fires on
|
|
202
|
+
// every key alongside ours and fights us for the cursor — the line stacks
|
|
203
|
+
// instead of redrawing in place. Detach the other keypress listeners while we
|
|
204
|
+
// own the input, and restore them exactly on exit (menu/confirm still need them).
|
|
205
|
+
let otherKeypress = [];
|
|
206
|
+
// Repaint the whole block in place: climb to its top, clear downward, reprint
|
|
207
|
+
// input rows + footer, then drop the caret onto its computed (row, col).
|
|
208
|
+
const draw = () => {
|
|
209
|
+
const { rows, cursorRow, cursorCol } = layout(state.buffer, state.cursor, promptW, cols());
|
|
210
|
+
if (!first) {
|
|
211
|
+
if (prevCursorRow > 0)
|
|
212
|
+
out.write(`\x1b[${prevCursorRow}A`); // up to the block's top row
|
|
213
|
+
out.write("\r");
|
|
214
|
+
}
|
|
215
|
+
out.write("\x1b[0J"); // clear from the top of the block to end of screen
|
|
216
|
+
const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
|
|
217
|
+
// Truncate each footer row so it can't wrap — otherwise its extra physical
|
|
218
|
+
// row throws off the cursor math below (caret lands low → prompts stack).
|
|
219
|
+
const fr = footerRows.map((f) => truncateToWidth(f, Math.max(1, cols() - 1)));
|
|
220
|
+
const all = [...inputRows, ...fr];
|
|
221
|
+
out.write(all.join("\r\n"));
|
|
222
|
+
const lastRow = all.length - 1; // every row is now exactly one physical line
|
|
223
|
+
if (lastRow > cursorRow)
|
|
224
|
+
out.write(`\x1b[${lastRow - cursorRow}A`); // up from the last footer row
|
|
225
|
+
out.write("\r");
|
|
226
|
+
if (cursorCol > 0)
|
|
227
|
+
out.write(`\x1b[${cursorCol}C`); // right to the caret column
|
|
228
|
+
prevCursorRow = cursorRow;
|
|
229
|
+
first = false;
|
|
230
|
+
};
|
|
231
|
+
const finish = (result) => {
|
|
232
|
+
if (done)
|
|
233
|
+
return;
|
|
234
|
+
done = true;
|
|
235
|
+
input.off("keypress", onKey);
|
|
236
|
+
for (const l of otherKeypress)
|
|
237
|
+
input.on("keypress", l); // give readline its keypress handler back
|
|
238
|
+
// Leave the typed command on screen, drop the footer, land on a fresh line.
|
|
239
|
+
const { rows } = layout(state.buffer, state.cursor, promptW, cols());
|
|
240
|
+
if (prevCursorRow > 0)
|
|
241
|
+
out.write(`\x1b[${prevCursorRow}A`);
|
|
242
|
+
out.write("\r\x1b[0J");
|
|
243
|
+
const inputRows = rows.map((r, i) => (i === 0 ? opts.prompt + r : r));
|
|
244
|
+
out.write(inputRows.join("\r\n") + "\r\n");
|
|
245
|
+
try {
|
|
246
|
+
input.setRawMode(wasRaw); // hand stdin back exactly as we found it
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
/* not all streams support it */
|
|
250
|
+
}
|
|
251
|
+
rl.resume();
|
|
252
|
+
resolve(result);
|
|
253
|
+
};
|
|
254
|
+
const onKey = (str, key) => {
|
|
255
|
+
if (done)
|
|
256
|
+
return;
|
|
257
|
+
const next = reduceEditor(state, str, key || {});
|
|
258
|
+
state = next.state;
|
|
259
|
+
switch (next.action) {
|
|
260
|
+
case "submit":
|
|
261
|
+
return finish({ type: "line", value: state.buffer });
|
|
262
|
+
case "cancel":
|
|
263
|
+
return finish({ type: "cancel" });
|
|
264
|
+
case "eof":
|
|
265
|
+
return finish({ type: "eof" });
|
|
266
|
+
case "histPrev":
|
|
267
|
+
if (hi > 0) {
|
|
268
|
+
if (hi === history.length)
|
|
269
|
+
draft = state.buffer; // stash the live draft on the way up
|
|
270
|
+
hi--;
|
|
271
|
+
state = { buffer: history[hi], cursor: history[hi].length };
|
|
272
|
+
}
|
|
273
|
+
return draw();
|
|
274
|
+
case "histNext":
|
|
275
|
+
if (hi < history.length) {
|
|
276
|
+
hi++;
|
|
277
|
+
const b = hi === history.length ? draft : history[hi];
|
|
278
|
+
state = { buffer: b, cursor: b.length };
|
|
279
|
+
}
|
|
280
|
+
return draw();
|
|
281
|
+
case "tab":
|
|
282
|
+
opts.onTab?.(); // may print above us (e.g. expand a collapsed block) and scroll
|
|
283
|
+
first = true; // so the next paint lands fresh at the cursor, not a stale climb-up
|
|
284
|
+
return draw();
|
|
285
|
+
case "edit":
|
|
286
|
+
return draw();
|
|
287
|
+
case "none":
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
try {
|
|
292
|
+
rl.pause(); // stop the session reader from turning our keystrokes into lines
|
|
293
|
+
if (input === process.stdin)
|
|
294
|
+
readline.emitKeypressEvents(input); // decode raw bytes into named keys
|
|
295
|
+
otherKeypress = input.listeners("keypress"); // readline's handler(s)…
|
|
296
|
+
for (const l of otherKeypress)
|
|
297
|
+
input.off("keypress", l); // …detached so only we drive the cursor
|
|
298
|
+
input.setRawMode(true);
|
|
299
|
+
input.resume(); // rl.pause() also paused the stream — resume it or no keys arrive
|
|
300
|
+
input.on("keypress", onKey);
|
|
301
|
+
draw();
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
finish({ type: "eof" }); // setup failed (non-TTY etc.) → behave like EOF
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|