oakode 0.0.0-stage → 0.5.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/CHANGELOG.md +86 -0
- package/LICENSE +9 -0
- package/README.md +243 -2
- package/SECURITY.md +13 -0
- package/dist/account.js +34 -0
- package/dist/agent.js +570 -0
- package/dist/agents/approval.js +116 -0
- package/dist/agents/budget.js +293 -0
- package/dist/agents/child.js +102 -0
- package/dist/agents/host.js +289 -0
- package/dist/agents/orchestrator.js +286 -0
- package/dist/agents/plan.js +143 -0
- package/dist/agents/types.js +20 -0
- package/dist/agents/verify.js +94 -0
- package/dist/app.js +619 -0
- package/dist/auth.js +456 -0
- package/dist/brief.js +97 -0
- package/dist/commands.js +587 -0
- package/dist/compact.js +231 -0
- package/dist/config.js +82 -0
- package/dist/diff.js +98 -0
- package/dist/doctor.js +106 -0
- package/dist/errors.js +15 -0
- package/dist/fileio.js +688 -0
- package/dist/input.js +244 -0
- package/dist/jobs.js +262 -0
- package/dist/main.js +560 -0
- package/dist/mask.js +63 -0
- package/dist/mcp.js +721 -0
- package/dist/models.js +106 -0
- package/dist/modes.js +114 -0
- package/dist/oicki.js +475 -0
- package/dist/output.js +101 -0
- package/dist/permissions.js +389 -0
- package/dist/permissions_ui.js +134 -0
- package/dist/print.js +76 -0
- package/dist/proc.js +63 -0
- package/dist/runners.js +264 -0
- package/dist/serial.js +9 -0
- package/dist/session.js +452 -0
- package/dist/shell.js +40 -0
- package/dist/skills.js +322 -0
- package/dist/syntax.js +80 -0
- package/dist/tools.js +480 -0
- package/dist/tui/editor.js +279 -0
- package/dist/tui/highlight.js +78 -0
- package/dist/tui/markdown.js +254 -0
- package/dist/tui/screen.js +146 -0
- package/dist/tui/tui.js +505 -0
- package/dist/tui/view.js +337 -0
- package/dist/ui.js +105 -0
- package/dist/update.js +64 -0
- package/package.json +57 -4
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONLY writer to the interactive terminal.
|
|
3
|
+
*
|
|
4
|
+
* Output has two parts: finished lines ("committed") that go to the terminal's scrollback and are never touched again,
|
|
5
|
+
* and a small "live frame" at the bottom (streaming block, spinner, editor, menus) that is erased and redrawn in place.
|
|
6
|
+
* Every update is one write wrapped in synchronized-output markers, so nothing flickers and components cannot interleave.
|
|
7
|
+
*/
|
|
8
|
+
import { truncate, width } from "../ui.js";
|
|
9
|
+
const BSU = "\x1b[?2026h", ESU = "\x1b[?2026l", HIDE = "\x1b[?25l", SHOW = "\x1b[?25h";
|
|
10
|
+
export class Screen {
|
|
11
|
+
out;
|
|
12
|
+
pending = [];
|
|
13
|
+
frame = null;
|
|
14
|
+
drawn = null; // what is on screen right now
|
|
15
|
+
liveRows = 0;
|
|
16
|
+
cursorRow = 0;
|
|
17
|
+
timer;
|
|
18
|
+
disposed = false;
|
|
19
|
+
onResize;
|
|
20
|
+
resizeTimer;
|
|
21
|
+
resizeHandler = () => {
|
|
22
|
+
// The terminal has already re-wrapped the old frame: recount its rows now, or a redraw before the debounce fires
|
|
23
|
+
// (a spinner tick) erases too few rows and leaves its top lines behind, once per resize step.
|
|
24
|
+
this.reflow();
|
|
25
|
+
clearTimeout(this.resizeTimer);
|
|
26
|
+
this.resizeTimer = setTimeout(() => {
|
|
27
|
+
this.onResize?.();
|
|
28
|
+
this.flush();
|
|
29
|
+
}, 25);
|
|
30
|
+
};
|
|
31
|
+
constructor(out) {
|
|
32
|
+
this.out = out;
|
|
33
|
+
out.on?.("resize", this.resizeHandler);
|
|
34
|
+
}
|
|
35
|
+
get cols() {
|
|
36
|
+
return Math.max(20, this.out.columns || 80);
|
|
37
|
+
}
|
|
38
|
+
/** Rows the live frame may use (always leaves one row of scrollback visible). */
|
|
39
|
+
get maxLive() {
|
|
40
|
+
return Math.max(4, (this.out.rows || 24) - 1);
|
|
41
|
+
}
|
|
42
|
+
commit(lines) {
|
|
43
|
+
if (lines.length)
|
|
44
|
+
this.pending.push(...lines);
|
|
45
|
+
this.schedule();
|
|
46
|
+
}
|
|
47
|
+
setFrame(f) {
|
|
48
|
+
this.frame = f;
|
|
49
|
+
this.schedule();
|
|
50
|
+
}
|
|
51
|
+
schedule() {
|
|
52
|
+
if (this.disposed || this.timer)
|
|
53
|
+
return;
|
|
54
|
+
this.timer = setTimeout(() => this.flush(), 12);
|
|
55
|
+
}
|
|
56
|
+
/** Draw now (used before blocking, on exit, and by tests). */
|
|
57
|
+
flush() {
|
|
58
|
+
clearTimeout(this.timer);
|
|
59
|
+
this.timer = undefined;
|
|
60
|
+
if (this.disposed)
|
|
61
|
+
return;
|
|
62
|
+
const cols = this.cols;
|
|
63
|
+
let buf = BSU + HIDE;
|
|
64
|
+
// 1. erase the live frame (cursor is on `cursorRow` of it)
|
|
65
|
+
if (this.liveRows > 0) {
|
|
66
|
+
if (this.cursorRow > 0)
|
|
67
|
+
buf += `\x1b[${this.cursorRow}A`;
|
|
68
|
+
buf += "\r\x1b[J";
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
buf += "\r";
|
|
72
|
+
// 2. finished lines go above the live frame
|
|
73
|
+
// finished lines are not cut at the width: they scroll away, and the terminal soft-wraps them, so a long URL or code
|
|
74
|
+
// line can still be clicked or copied whole (only the live frame below must fit, to be redrawn in place)
|
|
75
|
+
for (const l of this.pending)
|
|
76
|
+
buf += l + "\r\n";
|
|
77
|
+
this.pending = [];
|
|
78
|
+
// 3. redraw the live frame
|
|
79
|
+
const f = this.frame;
|
|
80
|
+
if (f && f.lines.length) {
|
|
81
|
+
let lines = f.lines;
|
|
82
|
+
let cursor = f.cursor;
|
|
83
|
+
const max = this.maxLive;
|
|
84
|
+
if (lines.length > max) {
|
|
85
|
+
const cut = lines.length - max;
|
|
86
|
+
lines = lines.slice(cut);
|
|
87
|
+
if (cursor)
|
|
88
|
+
cursor = { row: Math.max(0, cursor.row - cut), col: cursor.col };
|
|
89
|
+
}
|
|
90
|
+
buf += lines.map((l) => this.fit(l, cols)).join("\r\n");
|
|
91
|
+
this.liveRows = lines.length;
|
|
92
|
+
if (cursor) {
|
|
93
|
+
const up = lines.length - 1 - Math.min(cursor.row, lines.length - 1);
|
|
94
|
+
if (up > 0)
|
|
95
|
+
buf += `\x1b[${up}A`;
|
|
96
|
+
buf += `\x1b[${Math.min(cursor.col, cols - 1) + 1}G`;
|
|
97
|
+
this.cursorRow = Math.min(cursor.row, lines.length - 1);
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
this.cursorRow = lines.length - 1;
|
|
101
|
+
this.drawn = { lines, cursor };
|
|
102
|
+
buf += cursor ? SHOW : "";
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
this.liveRows = 0;
|
|
106
|
+
this.cursorRow = 0;
|
|
107
|
+
this.drawn = null;
|
|
108
|
+
buf += SHOW;
|
|
109
|
+
}
|
|
110
|
+
buf += ESU;
|
|
111
|
+
this.out.write(buf);
|
|
112
|
+
}
|
|
113
|
+
fit(line, cols) {
|
|
114
|
+
return width(line) > cols ? truncate(line, cols) : line;
|
|
115
|
+
}
|
|
116
|
+
/** After a resize, the terminal may have re-wrapped the old frame: work out how many rows it occupies now. */
|
|
117
|
+
reflow() {
|
|
118
|
+
const d = this.drawn;
|
|
119
|
+
if (!d)
|
|
120
|
+
return;
|
|
121
|
+
const cols = this.cols;
|
|
122
|
+
const rows = (l) => Math.max(1, Math.ceil(width(l) / cols));
|
|
123
|
+
const cr = Math.min(d.cursor?.row ?? d.lines.length - 1, d.lines.length - 1);
|
|
124
|
+
this.liveRows = d.lines.reduce((n, l) => n + rows(l), 0);
|
|
125
|
+
this.cursorRow = d.lines.slice(0, cr).reduce((n, l) => n + rows(l), 0) + (d.cursor ? Math.floor(d.cursor.col / cols) : 0);
|
|
126
|
+
}
|
|
127
|
+
/** Clear the visible screen (not the scrollback) and forget the old frame. */
|
|
128
|
+
clear() {
|
|
129
|
+
this.pending = [];
|
|
130
|
+
this.liveRows = 0;
|
|
131
|
+
this.cursorRow = 0;
|
|
132
|
+
this.drawn = null;
|
|
133
|
+
this.out.write("\x1b[2J\x1b[H");
|
|
134
|
+
}
|
|
135
|
+
/** Erase the live frame and give the terminal back. */
|
|
136
|
+
dispose() {
|
|
137
|
+
if (this.disposed)
|
|
138
|
+
return;
|
|
139
|
+
this.frame = null;
|
|
140
|
+
this.flush();
|
|
141
|
+
this.disposed = true;
|
|
142
|
+
clearTimeout(this.resizeTimer);
|
|
143
|
+
this.out.off?.("resize", this.resizeHandler);
|
|
144
|
+
this.out.write(SHOW);
|
|
145
|
+
}
|
|
146
|
+
}
|
package/dist/tui/tui.js
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive terminal controller: raw keyboard input in, one `Screen` out.
|
|
3
|
+
*
|
|
4
|
+
* The live frame (bottom of the terminal) is composed here from, top to bottom:
|
|
5
|
+
* live provider (streaming answer, running tools, spinner) · rule · editor or menu · completions · footer.
|
|
6
|
+
* Everything finished is committed to scrollback via `print`. Typing while the agent works queues messages.
|
|
7
|
+
*/
|
|
8
|
+
import { KeyParser, armWatchdog } from "../input.js";
|
|
9
|
+
import { bold, cyan, dim, gray, truncate, width, yellow } from "../ui.js";
|
|
10
|
+
import { Editor } from "./editor.js";
|
|
11
|
+
import { Screen } from "./screen.js";
|
|
12
|
+
const PROMPT = "› ";
|
|
13
|
+
const MENU_GUARD_MS = 400;
|
|
14
|
+
/** Terminals that show OSC 9 as a desktop notification (inside tmux the outer terminal is unknown: BEL). */
|
|
15
|
+
const osc9 = (env = process.env) => !env.TMUX && ["iTerm.app", "WezTerm", "ghostty"].includes(env.TERM_PROGRAM ?? "");
|
|
16
|
+
export class Tui {
|
|
17
|
+
screen;
|
|
18
|
+
/** Called when the user asks to interrupt (Esc / Ctrl+C while busy). */
|
|
19
|
+
onAbort;
|
|
20
|
+
/** Lines for the area above the input (stream, tools, spinner). Receives the usable width and row budget. */
|
|
21
|
+
live = () => [];
|
|
22
|
+
footer = () => "";
|
|
23
|
+
completer;
|
|
24
|
+
/** Shift+Tab at the prompt (also while the agent works). */
|
|
25
|
+
onShiftTab;
|
|
26
|
+
/** A /command typed while busy: return true if it was handled now (e.g. /agents); otherwise it is queued. */
|
|
27
|
+
onBusyCommand;
|
|
28
|
+
/** The search text of the menu that was shown last (valid after chooseFrom resolves). */
|
|
29
|
+
menuQuery = "";
|
|
30
|
+
watchdog = false;
|
|
31
|
+
/** Messages typed (and sent with Enter) while the agent was busy. */
|
|
32
|
+
queue = [];
|
|
33
|
+
inp;
|
|
34
|
+
out;
|
|
35
|
+
parser = new KeyParser();
|
|
36
|
+
editor = new Editor();
|
|
37
|
+
started = false;
|
|
38
|
+
firstChunk = true;
|
|
39
|
+
escTimer;
|
|
40
|
+
busy = false;
|
|
41
|
+
reading;
|
|
42
|
+
lastCtrlC = 0;
|
|
43
|
+
notice = "";
|
|
44
|
+
noticeTimer;
|
|
45
|
+
popup = null;
|
|
46
|
+
popupSel = 0;
|
|
47
|
+
// menu state
|
|
48
|
+
menu;
|
|
49
|
+
lastKeyAt = 0;
|
|
50
|
+
paintTimer;
|
|
51
|
+
constructor(inp, out) {
|
|
52
|
+
this.inp = inp;
|
|
53
|
+
this.out = out;
|
|
54
|
+
this.screen = new Screen(out);
|
|
55
|
+
this.screen.onResize = () => this.invalidate();
|
|
56
|
+
}
|
|
57
|
+
// ------------------------------------------------------------------ lifecycle
|
|
58
|
+
start() {
|
|
59
|
+
if (this.started)
|
|
60
|
+
return;
|
|
61
|
+
this.started = true;
|
|
62
|
+
this.firstChunk = true;
|
|
63
|
+
this.inp.setEncoding("utf8");
|
|
64
|
+
this.inp.setRawMode(true);
|
|
65
|
+
this.inp.resume();
|
|
66
|
+
this.out.write("\x1b[?2004h\x1b[?1004h"); // bracketed paste on; focus reporting on (for notify)
|
|
67
|
+
this.inp.on("data", this.onData);
|
|
68
|
+
this.inp.on("end", this.onEnd);
|
|
69
|
+
this.invalidate();
|
|
70
|
+
}
|
|
71
|
+
/** Always safe to call (signal handlers, exit). Callers settle commands and session data BEFORE this point. */
|
|
72
|
+
stop() {
|
|
73
|
+
if (!this.started)
|
|
74
|
+
return;
|
|
75
|
+
this.started = false;
|
|
76
|
+
clearTimeout(this.escTimer);
|
|
77
|
+
clearTimeout(this.noticeTimer);
|
|
78
|
+
clearTimeout(this.paintTimer);
|
|
79
|
+
this.paintTimer = undefined;
|
|
80
|
+
this.inp.off("data", this.onData);
|
|
81
|
+
this.inp.off("end", this.onEnd);
|
|
82
|
+
try {
|
|
83
|
+
this.screen.dispose();
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
86
|
+
// setRawMode(false) blocks if the terminal stopped reading; the watchdog (real terminal only) guarantees we can exit.
|
|
87
|
+
if (this.inp === process.stdin)
|
|
88
|
+
this.watchdog = armWatchdog(process.pid);
|
|
89
|
+
try {
|
|
90
|
+
this.out.write("\x1b[?2004l\x1b[?1004l");
|
|
91
|
+
this.inp.setRawMode(false);
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
this.inp.pause();
|
|
95
|
+
}
|
|
96
|
+
onEnd = () => {
|
|
97
|
+
this.reading?.(null);
|
|
98
|
+
this.menu?.resolve(-1);
|
|
99
|
+
};
|
|
100
|
+
// ------------------------------------------------------------------ output
|
|
101
|
+
print(lines) {
|
|
102
|
+
this.screen.commit(lines);
|
|
103
|
+
}
|
|
104
|
+
/** Repaint at most once per frame: a fast stream sends many events per frame, and each paint re-renders the answer. */
|
|
105
|
+
invalidate() {
|
|
106
|
+
if (!this.started || this.paintTimer)
|
|
107
|
+
return;
|
|
108
|
+
this.paintTimer = setTimeout(() => this.flush(), 12);
|
|
109
|
+
}
|
|
110
|
+
flush() {
|
|
111
|
+
clearTimeout(this.paintTimer);
|
|
112
|
+
this.paintTimer = undefined;
|
|
113
|
+
this.paint();
|
|
114
|
+
this.screen.flush();
|
|
115
|
+
}
|
|
116
|
+
setBusy(b) {
|
|
117
|
+
this.busy = b;
|
|
118
|
+
this.invalidate();
|
|
119
|
+
}
|
|
120
|
+
flash(msg, ms = 2000) {
|
|
121
|
+
this.notice = msg;
|
|
122
|
+
clearTimeout(this.noticeTimer);
|
|
123
|
+
this.noticeTimer = setTimeout(() => {
|
|
124
|
+
this.notice = "";
|
|
125
|
+
this.invalidate();
|
|
126
|
+
}, ms);
|
|
127
|
+
this.invalidate();
|
|
128
|
+
}
|
|
129
|
+
paint() {
|
|
130
|
+
if (!this.started)
|
|
131
|
+
return;
|
|
132
|
+
const cols = this.screen.cols;
|
|
133
|
+
const max = this.screen.maxLive;
|
|
134
|
+
const lower = [];
|
|
135
|
+
let cursor;
|
|
136
|
+
const rule = gray("─".repeat(Math.min(cols, 200)));
|
|
137
|
+
if (this.menu) {
|
|
138
|
+
lower.push(rule, ...this.menuLines(cols));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const view = this.editor.render(cols, this.busy ? dim(PROMPT) : bold(cyan(PROMPT)), width(PROMPT));
|
|
142
|
+
lower.push(...this.queueLines(cols));
|
|
143
|
+
lower.push(rule);
|
|
144
|
+
cursor = { row: lower.length + view.cursor.row, col: view.cursor.col };
|
|
145
|
+
lower.push(...view.lines);
|
|
146
|
+
if (this.popup && this.popup.items.length)
|
|
147
|
+
lower.push(...this.popupLines(cols));
|
|
148
|
+
}
|
|
149
|
+
const foot = this.notice ? yellow(this.notice) : this.footer();
|
|
150
|
+
if (foot)
|
|
151
|
+
lower.push(truncate(foot, cols));
|
|
152
|
+
const upperBudget = Math.max(0, max - lower.length);
|
|
153
|
+
const upper = this.live(cols, upperBudget).slice(-upperBudget || undefined);
|
|
154
|
+
const lines = upperBudget === 0 ? lower : [...upper, ...lower];
|
|
155
|
+
if (cursor)
|
|
156
|
+
cursor = { row: cursor.row + (upperBudget === 0 ? 0 : upper.length), col: cursor.col };
|
|
157
|
+
this.screen.setFrame({ lines, cursor });
|
|
158
|
+
}
|
|
159
|
+
/** Queued messages, waiting for the agent's next step: the last ones, and how to take one back. */
|
|
160
|
+
queueLines(cols) {
|
|
161
|
+
if (!this.queue.length)
|
|
162
|
+
return [];
|
|
163
|
+
const shown = this.queue.slice(-3);
|
|
164
|
+
const out = shown.map((m, i) => {
|
|
165
|
+
const first = m.trim().split("\n")[0];
|
|
166
|
+
const when = m.startsWith("/") ? " (runs after this turn)" : "";
|
|
167
|
+
return truncate(dim(`⏎ queued ${this.queue.length - shown.length + i + 1}: `) + first + dim(when), cols);
|
|
168
|
+
});
|
|
169
|
+
if (this.queue.length > shown.length)
|
|
170
|
+
out.unshift(dim(`⏎ ${this.queue.length - shown.length} more queued`));
|
|
171
|
+
out.push(truncate(dim(" Alt+↑ edits or withdraws the last · sent after this step"), cols));
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
/** Alt+Up: the last queued message back into the editor (before any draft), to edit and queue again, or delete. */
|
|
175
|
+
unqueue() {
|
|
176
|
+
let at = -1;
|
|
177
|
+
for (let i = this.queue.length - 1; i >= 0; i--)
|
|
178
|
+
if (!this.queue[i].startsWith("/")) {
|
|
179
|
+
at = i;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
const last = at >= 0 ? this.queue.splice(at, 1)[0] : undefined;
|
|
183
|
+
if (last === undefined)
|
|
184
|
+
return;
|
|
185
|
+
const draft = this.editor.text();
|
|
186
|
+
this.editor.reset(draft.trim() ? `${last}\n${draft}` : last);
|
|
187
|
+
this.invalidate();
|
|
188
|
+
}
|
|
189
|
+
popupLines(cols) {
|
|
190
|
+
const p = this.popup;
|
|
191
|
+
const rows = 8;
|
|
192
|
+
const start = Math.min(Math.max(0, this.popupSel - rows + 1), Math.max(0, p.items.length - rows));
|
|
193
|
+
const vis = p.items.slice(start, start + rows);
|
|
194
|
+
const w = Math.max(...vis.map((i) => width(i.value)));
|
|
195
|
+
const out = vis.map((it, i) => {
|
|
196
|
+
const on = start + i === this.popupSel;
|
|
197
|
+
const line = ` ${on ? "❯" : " "} ${it.value}${" ".repeat(w - width(it.value))}${it.desc ? " " + it.desc : ""}`;
|
|
198
|
+
return truncate(on ? bold(cyan(line)) : dim(line), cols);
|
|
199
|
+
});
|
|
200
|
+
if (p.items.length > rows)
|
|
201
|
+
out.push(dim(` (${this.popupSel + 1}/${p.items.length})`));
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
menuLines(cols) {
|
|
205
|
+
const m = this.menu;
|
|
206
|
+
const opts = m.spec.options(m.query);
|
|
207
|
+
const out = [];
|
|
208
|
+
if (m.spec.title)
|
|
209
|
+
out.push(bold(m.spec.title));
|
|
210
|
+
for (const l of typeof m.spec.lines === "function" ? m.spec.lines() : (m.spec.lines ?? []))
|
|
211
|
+
out.push(truncate(l, cols));
|
|
212
|
+
if (m.spec.search)
|
|
213
|
+
out.push(truncate(`${dim("Search:")} ${m.query}${cyan("▏")}${opts.length === 0 ? dim(" (no matches)") : ""}`, cols));
|
|
214
|
+
const rows = 12;
|
|
215
|
+
const start = Math.min(Math.max(0, m.sel - 5), Math.max(0, opts.length - rows));
|
|
216
|
+
if (start > 0)
|
|
217
|
+
out.push(dim(` ↑ ${start} more`));
|
|
218
|
+
opts.slice(start, start + rows).forEach((o, k) => {
|
|
219
|
+
const i = start + k;
|
|
220
|
+
const line = ` ${i === m.sel ? "❯" : " "} ${i + 1}. ${o.label}${o.hint ? " " + dim(o.hint) : ""}`;
|
|
221
|
+
out.push(truncate(i === m.sel ? bold(cyan(line)) : line, cols));
|
|
222
|
+
});
|
|
223
|
+
if (start + rows < opts.length)
|
|
224
|
+
out.push(dim(` ↓ ${opts.length - start - rows} more`));
|
|
225
|
+
out.push(dim(m.spec.hint ?? "↑/↓ choose · Enter confirm · Esc cancel"));
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
// ------------------------------------------------------------------ input
|
|
229
|
+
/** From focus reports; undefined until the terminal sends one (then OAKODE never notifies). */
|
|
230
|
+
focused;
|
|
231
|
+
/** Desktop notifications (config `"notifications": false` turns them off). */
|
|
232
|
+
notifications = true;
|
|
233
|
+
notifiedAt = 0;
|
|
234
|
+
/**
|
|
235
|
+
* Tells the person something is waiting for them, but only when the terminal said it lost focus: OSC 9 (a desktop
|
|
236
|
+
* notification) where the terminal supports it, else BEL (which most terminals turn into an alert when unfocused).
|
|
237
|
+
* At most one every 5 s.
|
|
238
|
+
*/
|
|
239
|
+
notify(message) {
|
|
240
|
+
if (this.focused !== false || !this.notifications || Date.now() - this.notifiedAt < 5000)
|
|
241
|
+
return;
|
|
242
|
+
this.notifiedAt = Date.now();
|
|
243
|
+
const text = [...message.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ").replace(/\s+/g, " ").trim()].slice(0, 80).join("");
|
|
244
|
+
this.out.write(osc9() ? `\x1b]9;${text}\x07` : "\x07");
|
|
245
|
+
}
|
|
246
|
+
/** Whether the last submitted message began with a typed (not pasted) character. */
|
|
247
|
+
submittedTyped = false;
|
|
248
|
+
/** Edit a message. While busy, Enter queues it instead; resolves with the text on Enter when idle (null: Ctrl+D / EOF). */
|
|
249
|
+
readMessage(opts = {}) {
|
|
250
|
+
this.editor.multiline = opts.multiline ?? true;
|
|
251
|
+
if (opts.prefill !== undefined)
|
|
252
|
+
this.editor.reset(opts.prefill);
|
|
253
|
+
this.busy = false;
|
|
254
|
+
this.refreshPopup();
|
|
255
|
+
this.invalidate();
|
|
256
|
+
return new Promise((resolve) => (this.reading = resolve));
|
|
257
|
+
}
|
|
258
|
+
/** Queued messages, removed from the queue. `messagesOnly`: leave /commands queued (they are for OAKODE, not the model; they come back to the editor after the turn). */
|
|
259
|
+
/** The unsent text in the editor (kept aside while the editor is used for something else, then put back). */
|
|
260
|
+
get draft() {
|
|
261
|
+
return this.editor.text();
|
|
262
|
+
}
|
|
263
|
+
set draft(text) {
|
|
264
|
+
this.editor.reset(text);
|
|
265
|
+
this.invalidate();
|
|
266
|
+
}
|
|
267
|
+
takeQueued(messagesOnly = false) {
|
|
268
|
+
const keep = (m) => messagesOnly && m.startsWith("/");
|
|
269
|
+
const q = this.queue.filter((m) => !keep(m));
|
|
270
|
+
this.queue = this.queue.filter(keep);
|
|
271
|
+
if (q.length)
|
|
272
|
+
this.invalidate(); // no longer shown as queued
|
|
273
|
+
return q;
|
|
274
|
+
}
|
|
275
|
+
/** Text typed but not sent (queued messages + the unfinished line), to put back in the editor. */
|
|
276
|
+
leftover() {
|
|
277
|
+
const parts = [...this.queue, this.editor.text()].filter((x) => x.trim());
|
|
278
|
+
this.queue = [];
|
|
279
|
+
this.editor.reset("");
|
|
280
|
+
return parts.join("\n\n");
|
|
281
|
+
}
|
|
282
|
+
/** Show a selection menu in place of the editor. Resolves with the chosen index, or -1 for Esc / end of input. */
|
|
283
|
+
chooseFrom(spec) {
|
|
284
|
+
return new Promise((resolve) => {
|
|
285
|
+
this.popup = null;
|
|
286
|
+
this.menu = { spec, sel: spec.initial ?? 0, shownAt: Date.now(), armed: !!spec.search, query: "", resolve: (i) => { if (this.menu)
|
|
287
|
+
this.menuQuery = this.menu.query; this.menu = undefined; this.invalidate(); resolve(i); } };
|
|
288
|
+
this.invalidate();
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/** Dismiss an open menu as if Esc was pressed (e.g. the turn was aborted from outside). */
|
|
292
|
+
cancelMenu() {
|
|
293
|
+
this.menu?.resolve(-1);
|
|
294
|
+
}
|
|
295
|
+
/** Clear the visible screen (scrollback is kept). */
|
|
296
|
+
clearScreen() {
|
|
297
|
+
this.screen.clear();
|
|
298
|
+
this.invalidate();
|
|
299
|
+
}
|
|
300
|
+
onData = (d) => {
|
|
301
|
+
clearTimeout(this.escTimer);
|
|
302
|
+
// Anything typed before raw mode was line-buffered by the kernel, which turned Enter (CR) into LF (our Ctrl+J).
|
|
303
|
+
if (this.firstChunk) {
|
|
304
|
+
this.firstChunk = false;
|
|
305
|
+
d = d.replace(/\n/g, "\r");
|
|
306
|
+
}
|
|
307
|
+
for (const key of this.parser.feed(d))
|
|
308
|
+
this.onKey(key);
|
|
309
|
+
this.escTimer = setTimeout(() => {
|
|
310
|
+
for (const key of this.parser.flushPending())
|
|
311
|
+
this.onKey(key);
|
|
312
|
+
}, 40);
|
|
313
|
+
};
|
|
314
|
+
onKey(key) {
|
|
315
|
+
if (key.k === "focusin" || key.k === "focusout")
|
|
316
|
+
return void (this.focused = key.k === "focusin");
|
|
317
|
+
const now = Date.now();
|
|
318
|
+
const sinceLast = now - this.lastKeyAt;
|
|
319
|
+
this.lastKeyAt = now;
|
|
320
|
+
if (this.menu)
|
|
321
|
+
return this.menuKey(key, sinceLast);
|
|
322
|
+
this.editKey(key);
|
|
323
|
+
}
|
|
324
|
+
menuKey(key, sinceLast) {
|
|
325
|
+
const m = this.menu;
|
|
326
|
+
// Keys that were already in flight when the menu appeared, or part of a typing burst, can never choose anything.
|
|
327
|
+
// The menu "arms" on the first key that follows a pause; from then on it responds at normal speed.
|
|
328
|
+
if (key.k === "text")
|
|
329
|
+
m.spec.onChar?.(key.s);
|
|
330
|
+
if (m.spec.search) {
|
|
331
|
+
if (key.k === "text" || key.k === "paste") {
|
|
332
|
+
m.query += (key.k === "text" ? key.s : key.s).replace(/[\r\n]+/g, " ").slice(0, 200);
|
|
333
|
+
m.sel = 0;
|
|
334
|
+
return this.invalidate();
|
|
335
|
+
}
|
|
336
|
+
if (key.k === "backspace") {
|
|
337
|
+
m.query = [...m.query].slice(0, -1).join("");
|
|
338
|
+
m.sel = 0;
|
|
339
|
+
return this.invalidate();
|
|
340
|
+
}
|
|
341
|
+
if (key.k === "ctrl" && key.c === "u") {
|
|
342
|
+
m.query = "";
|
|
343
|
+
m.sel = 0;
|
|
344
|
+
return this.invalidate();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (!m.armed) {
|
|
348
|
+
if (key.k === "text" || key.k === "paste")
|
|
349
|
+
return; // typing never arms a menu
|
|
350
|
+
if (Date.now() - m.shownAt < MENU_GUARD_MS || sinceLast < MENU_GUARD_MS)
|
|
351
|
+
return;
|
|
352
|
+
m.armed = true;
|
|
353
|
+
}
|
|
354
|
+
const n = Math.max(1, m.spec.options(m.query).length);
|
|
355
|
+
if (m.spec.onKey?.(key, m.sel))
|
|
356
|
+
return this.invalidate();
|
|
357
|
+
switch (key.k) {
|
|
358
|
+
case "up":
|
|
359
|
+
m.sel = (m.sel + n - 1) % n;
|
|
360
|
+
break;
|
|
361
|
+
case "down":
|
|
362
|
+
m.sel = (m.sel + 1) % n;
|
|
363
|
+
break;
|
|
364
|
+
case "tab":
|
|
365
|
+
m.spec.onTab?.();
|
|
366
|
+
break;
|
|
367
|
+
case "enter": return m.spec.options(m.query).length ? m.resolve(m.sel) : undefined;
|
|
368
|
+
case "escape": return m.resolve(-1);
|
|
369
|
+
case "ctrl":
|
|
370
|
+
if (key.c === "c") {
|
|
371
|
+
m.resolve(-1);
|
|
372
|
+
this.onAbort?.();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
break;
|
|
376
|
+
default: return; // text and paste are ignored
|
|
377
|
+
}
|
|
378
|
+
this.invalidate();
|
|
379
|
+
}
|
|
380
|
+
refreshPopup() {
|
|
381
|
+
const r = this.completer?.(this.editor.beforeCursor()) ?? null;
|
|
382
|
+
this.popup = r && r.items.length ? r : null;
|
|
383
|
+
this.popupSel = Math.min(this.popupSel, Math.max(0, (this.popup?.items.length ?? 1) - 1));
|
|
384
|
+
}
|
|
385
|
+
acceptCompletion() {
|
|
386
|
+
const p = this.popup;
|
|
387
|
+
if (!p)
|
|
388
|
+
return false;
|
|
389
|
+
const it = p.items[this.popupSel];
|
|
390
|
+
if (!it)
|
|
391
|
+
return false;
|
|
392
|
+
this.editor.replaceBeforeCursor(p.from, it.value + (it.space ? " " : ""));
|
|
393
|
+
this.popupSel = 0;
|
|
394
|
+
this.refreshPopup();
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
editKey(key) {
|
|
398
|
+
switch (key.k) {
|
|
399
|
+
case "escape":
|
|
400
|
+
if (this.popup)
|
|
401
|
+
this.popup = null;
|
|
402
|
+
else if (this.busy)
|
|
403
|
+
this.onAbort?.();
|
|
404
|
+
this.invalidate();
|
|
405
|
+
return;
|
|
406
|
+
case "tab":
|
|
407
|
+
if (!this.popup)
|
|
408
|
+
this.refreshPopup();
|
|
409
|
+
this.acceptCompletion();
|
|
410
|
+
this.invalidate();
|
|
411
|
+
return;
|
|
412
|
+
case "shifttab":
|
|
413
|
+
this.onShiftTab?.();
|
|
414
|
+
this.invalidate();
|
|
415
|
+
return;
|
|
416
|
+
case "altup":
|
|
417
|
+
// takes back a queued message while the agent works; otherwise it is just Up
|
|
418
|
+
if (this.busy && this.queue.some((m) => !m.startsWith("/")))
|
|
419
|
+
return this.unqueue();
|
|
420
|
+
key = { k: "up" };
|
|
421
|
+
break;
|
|
422
|
+
case "ctrl":
|
|
423
|
+
if (key.c === "c") {
|
|
424
|
+
if (this.busy)
|
|
425
|
+
return void this.onAbort?.();
|
|
426
|
+
if (this.popup)
|
|
427
|
+
this.popup = null;
|
|
428
|
+
else if (!this.editor.empty) {
|
|
429
|
+
// a cleared draft stays reachable with Up, as if it had been sent
|
|
430
|
+
this.editor.remember(this.editor.text());
|
|
431
|
+
this.editor.reset("");
|
|
432
|
+
this.flash("Cleared (Up brings it back)");
|
|
433
|
+
}
|
|
434
|
+
else if (Date.now() - this.lastCtrlC < 2000)
|
|
435
|
+
return void this.reading?.(null);
|
|
436
|
+
else {
|
|
437
|
+
this.lastCtrlC = Date.now();
|
|
438
|
+
this.flash("Press Ctrl+C again (or Ctrl+D) to exit");
|
|
439
|
+
}
|
|
440
|
+
this.invalidate();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (key.c === "d") {
|
|
444
|
+
if (this.editor.empty && !this.busy)
|
|
445
|
+
return void this.reading?.(null);
|
|
446
|
+
if (!this.editor.empty)
|
|
447
|
+
this.editor.handle({ k: "delete" });
|
|
448
|
+
this.refreshPopup();
|
|
449
|
+
this.invalidate();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (key.c === "l")
|
|
453
|
+
return void this.invalidate();
|
|
454
|
+
break;
|
|
455
|
+
case "up":
|
|
456
|
+
case "down":
|
|
457
|
+
if (this.popup && !this.editor.multiLine) {
|
|
458
|
+
const n = this.popup.items.length;
|
|
459
|
+
this.popupSel = (this.popupSel + (key.k === "up" ? n - 1 : 1)) % n;
|
|
460
|
+
this.invalidate();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
break;
|
|
464
|
+
case "enter": {
|
|
465
|
+
// A partially typed command NAME completes instead of sending (arguments complete with Tab; Enter sends them as typed).
|
|
466
|
+
if (this.popup && this.popup.from === 0 && this.popup.items[this.popupSel] && this.editor.beforeCursor().slice(this.popup.from) !== this.popup.items[this.popupSel].value) {
|
|
467
|
+
this.acceptCompletion();
|
|
468
|
+
this.invalidate();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
default: break;
|
|
474
|
+
}
|
|
475
|
+
const r = this.editor.handle(key);
|
|
476
|
+
if (r === "submit") {
|
|
477
|
+
const text = this.editor.text();
|
|
478
|
+
const typed = this.editor.startTyped;
|
|
479
|
+
this.editor.remember(text);
|
|
480
|
+
this.editor.reset("");
|
|
481
|
+
this.popup = null;
|
|
482
|
+
if (this.busy) {
|
|
483
|
+
const m = text.trim();
|
|
484
|
+
if (m && text.startsWith("/") && this.onBusyCommand?.(m)) {
|
|
485
|
+
this.invalidate();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
// shown above the editor until it is delivered (then it is printed as sent) or taken back with Alt+Up
|
|
489
|
+
if (m)
|
|
490
|
+
this.queue.push(text.trimEnd()); // a leading space keeps "/path" a message, not a command (as in App.run)
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const resolve = this.reading;
|
|
494
|
+
this.reading = undefined;
|
|
495
|
+
this.submittedTyped = typed;
|
|
496
|
+
resolve?.(text);
|
|
497
|
+
}
|
|
498
|
+
this.invalidate();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (r === "edited")
|
|
502
|
+
this.refreshPopup();
|
|
503
|
+
this.invalidate();
|
|
504
|
+
}
|
|
505
|
+
}
|