aegiscode 6.1.0 → 6.2.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 +121 -78
- package/bin/aegiscode.js +9 -1
- package/package.json +3 -3
- package/scripts/predist.mjs +11 -1
- package/src/agents.js +136 -0
- package/src/app.js +522 -164
- package/src/chatflow.js +1475 -0
- package/src/checkpoint.js +85 -0
- package/src/clipboard.js +62 -0
- package/src/commands.js +1234 -150
- package/src/config.js +163 -0
- package/src/deps.js +14 -1
- package/src/devrun.js +110 -0
- package/src/engine.js +62 -0
- package/src/events.js +278 -0
- package/src/export.js +64 -0
- package/src/history.js +201 -0
- package/src/init.js +162 -0
- package/src/input.js +136 -0
- package/src/keys.js +141 -0
- package/src/panels.js +1171 -0
- package/src/permissions.js +102 -0
- package/src/render.js +33 -1
- package/src/summarize.js +90 -0
- package/src/system.js +37 -0
- package/src/tokens.js +166 -0
- package/vendor/desktop/lib/local/agents.js +102 -0
- package/vendor/desktop/lib/local/engine.js +972 -0
- package/vendor/desktop/lib/local/prompt.js +91 -0
- package/vendor/desktop/lib/local/shell.js +208 -0
- package/vendor/desktop/lib/local/tools.js +882 -0
package/src/chatflow.js
ADDED
|
@@ -0,0 +1,1475 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The chatflow — the full-screen session loop, and the row/render helpers it
|
|
5
|
+
* runs on.
|
|
6
|
+
*
|
|
7
|
+
* This is the aegiscodex-dev session loop: an alternate-screen frame of
|
|
8
|
+
*
|
|
9
|
+
* ╭──── AEGIS Code v6.2.0 ────╮ header rule
|
|
10
|
+
* …transcript viewport… user rows, assistant markdown, tool rows
|
|
11
|
+
* ✻ Tempering… (3s · ↓412 tokens) spinner, or the effort line when idle
|
|
12
|
+
* ─────────────────────────────
|
|
13
|
+
* ❯ what should I fix? input line, with history + completion
|
|
14
|
+
* ─────────────────────────────
|
|
15
|
+
* ⏸ manual mode on · ? for shortcuts status line
|
|
16
|
+
*
|
|
17
|
+
* driven by a raw key stream, with the turn lifecycle of the reference: a user
|
|
18
|
+
* row, a live assistant row that grows as the model streams, tool rows that
|
|
19
|
+
* resolve from "Running 1 shell command…" to "Ran 1 shell command", a
|
|
20
|
+
* `✻ Churned/Worked for Ns` completion row, `(stopped)` on Esc and
|
|
21
|
+
* `(backend error: …)` on failure.
|
|
22
|
+
*
|
|
23
|
+
* The split from `app.js` is the reference's split: this module owns the frame,
|
|
24
|
+
* the rows, the keys and the turn; `app.js` owns the transport, the tool
|
|
25
|
+
* registry, the command table and the session tallies. Everything this module
|
|
26
|
+
* needs from the app arrives through the small `host` interface documented on
|
|
27
|
+
* `runSession` — which is what lets the whole loop be driven from a test with a
|
|
28
|
+
* stub host and no TTY.
|
|
29
|
+
*
|
|
30
|
+
* Pure helpers are exported at module scope (`resolveToolDone`,
|
|
31
|
+
* `finalizeTurnText`, `transcriptLines`, …) so a regression test can drive the
|
|
32
|
+
* exact pairing, marker and viewport logic without a terminal.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const {
|
|
36
|
+
getSize, span, padLine, paint, w, wrapBlock,
|
|
37
|
+
hideCursor, showCursor, moveTo, clearScreen,
|
|
38
|
+
enterAltScreen, leaveAltScreen,
|
|
39
|
+
enableBracketedPaste, disableBracketedPaste,
|
|
40
|
+
enableMouseTracking, disableMouseTracking,
|
|
41
|
+
} = require('./screen.js');
|
|
42
|
+
const {
|
|
43
|
+
KEY, attachKeyStream, nextKey, nextKeyTimeout, requeueKeys, resetKeyStream,
|
|
44
|
+
isKeyStreamSuspended,
|
|
45
|
+
} = require('./events.js');
|
|
46
|
+
const { GLYPH, VERBS, DONE_VERBS, BOLD, BOLD_OFF, themeOf } = require('./theme.js');
|
|
47
|
+
const { LineEditor } = require('./input.js');
|
|
48
|
+
const { renderMarkdown } = require('./markdown.js');
|
|
49
|
+
const overlays = require('./overlays.js');
|
|
50
|
+
const fuzzy = require('./fuzzy.js');
|
|
51
|
+
const { fmtTokens, fmtEur, fmtElapsed } = require('./format.js');
|
|
52
|
+
|
|
53
|
+
/** The rotating placeholder shown on an empty input line. */
|
|
54
|
+
const SUGGESTIONS = [
|
|
55
|
+
'edit <filepath> to...',
|
|
56
|
+
'refactor <filepath>',
|
|
57
|
+
'how do I log an error?',
|
|
58
|
+
'write a test for <filepath>',
|
|
59
|
+
'create a util logging.py that...',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Terminal title spinner while a turn runs (the reference's frames). */
|
|
63
|
+
const TITLE_SPIN = ['⠐', '⠂', '⠄', '⠆', '⠈', '⠠', '⠰', '⠁'];
|
|
64
|
+
|
|
65
|
+
const FRAME_MS = 33; // ~30fps cap for streaming repaints
|
|
66
|
+
|
|
67
|
+
// ── pure turn helpers ───────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** The noun a tool row uses: "shell command", "subagent", "read command"… */
|
|
70
|
+
function toolLabel(name, n) {
|
|
71
|
+
const base =
|
|
72
|
+
name === 'Bash' ? 'shell command'
|
|
73
|
+
: name === 'Task' ? 'subagent'
|
|
74
|
+
: `${String(name).toLowerCase()} command`;
|
|
75
|
+
return n > 1 ? base + 's' : base;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a tool "done" event to its running transcript row.
|
|
80
|
+
*
|
|
81
|
+
* Ids pair events exactly (parallel same-name calls, nested subagent rows);
|
|
82
|
+
* id-less streams fall back to name + agent matching. Mutates and returns the
|
|
83
|
+
* row, or null when nothing matches.
|
|
84
|
+
*/
|
|
85
|
+
function resolveToolDone(transcript, t) {
|
|
86
|
+
let idx = -1;
|
|
87
|
+
if (t.id !== undefined) {
|
|
88
|
+
for (let i = transcript.length - 1; i >= 0; i--) {
|
|
89
|
+
const m = transcript[i];
|
|
90
|
+
if (m.role === 'tool' && m.phase === 'run' && m.id === t.id) {
|
|
91
|
+
idx = i;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (idx === -1) {
|
|
97
|
+
for (let i = transcript.length - 1; i >= 0; i--) {
|
|
98
|
+
const m = transcript[i];
|
|
99
|
+
if (m.role === 'tool' && m.phase === 'run' && m.name === t.name && m.agent === t.agent) {
|
|
100
|
+
idx = i;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (idx === -1) return null;
|
|
106
|
+
const m = transcript[idx];
|
|
107
|
+
const n = transcript.filter(
|
|
108
|
+
(x, i) => x.role === 'tool' && x.name === t.name && x.agent === t.agent && i <= idx
|
|
109
|
+
).length;
|
|
110
|
+
m.phase = 'done';
|
|
111
|
+
m.elapsed = t.elapsed;
|
|
112
|
+
m.ok = t.ok;
|
|
113
|
+
m.label = `Ran ${n} ${m.agent ? `${m.agent} ▸ ` : ''}${toolLabel(t.name, n)}`;
|
|
114
|
+
return m;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Finalize an assistant message's text: append a "(backend error: …)" marker
|
|
119
|
+
* when the turn failed mid-stream, a single "(stopped)" marker on abort, and
|
|
120
|
+
* fall back to "(no response)" for empty text. An abort wins over an error so
|
|
121
|
+
* Esc-cancel never produces the doubled marker.
|
|
122
|
+
*/
|
|
123
|
+
function finalizeTurnText(text, { aborted, error } = {}) {
|
|
124
|
+
let t = String(text == null ? '' : text);
|
|
125
|
+
if (error && !aborted && error !== 'stopped') {
|
|
126
|
+
const err = `(backend error: ${error})`;
|
|
127
|
+
t = t.trim() ? `${t.trimEnd()}\n\n${err}` : err;
|
|
128
|
+
}
|
|
129
|
+
if (aborted) t = t.trim() ? `${t.trimEnd()} (stopped)` : '(stopped)';
|
|
130
|
+
if (!t.trim()) t = '(no response)';
|
|
131
|
+
return t;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Every prior user/assistant pair, for the engine's conversation history. */
|
|
135
|
+
function historyPairs(rows) {
|
|
136
|
+
return rows
|
|
137
|
+
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
138
|
+
.map((m) => ({ role: m.role, content: m.text }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Palette ranking query for a typed command line: "/memory tiers" must keep
|
|
143
|
+
* ranking on "memory" so the command stays listed while the user finishes the
|
|
144
|
+
* line; the ENTER branch passes the remainder as args. Single words unchanged.
|
|
145
|
+
*/
|
|
146
|
+
function paletteQuery(q) {
|
|
147
|
+
const t = String(q || '').trim();
|
|
148
|
+
return t.includes(' ') ? t.split(/\s+/)[0] : t;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── the frame ───────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/** The rounded header rule: `╭──── AEGIS Code v6.2.0 ────╮`. */
|
|
154
|
+
function headerLine(version, cols, ctx) {
|
|
155
|
+
const t = themeOf(ctx);
|
|
156
|
+
const label = ` AEGIS Code v${version} `;
|
|
157
|
+
const fill = Math.max(0, cols - [...label].length - 2);
|
|
158
|
+
const left = Math.floor(fill / 2);
|
|
159
|
+
return [span(t.coral, `╭${'─'.repeat(left)}${label}${'─'.repeat(fill - left)}╮`)];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const separatorLine = (cols, ctx) => [span(themeOf(ctx).dim, '─'.repeat(cols))];
|
|
163
|
+
|
|
164
|
+
/** The idle bottom-left line: which effort level the next turn runs at. */
|
|
165
|
+
function effortLine(ctx, cols) {
|
|
166
|
+
const t = themeOf(ctx);
|
|
167
|
+
const txt = `● ${ctx.effort || 'high'} · /effort`;
|
|
168
|
+
const pad = ' '.repeat(Math.max(0, cols - [...txt].length - 4));
|
|
169
|
+
return [span(t.gray, pad + txt)];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The working line: the `✻`-family spinner with a shimmering verb, the elapsed
|
|
174
|
+
* time and a token estimate — the reference's
|
|
175
|
+
* `✻ Tempering… (3s · ↓412 tokens)`.
|
|
176
|
+
*/
|
|
177
|
+
function spinnerLine(state, ctx) {
|
|
178
|
+
const t = themeOf(ctx);
|
|
179
|
+
const secs = Math.max(1, Math.round((state.elapsedMs || 0) / 1000));
|
|
180
|
+
const shimmer = [...(state.verb || VERBS[0])];
|
|
181
|
+
const frame = Math.floor((state.frame || 0) / 2) % 3;
|
|
182
|
+
const tinted = shimmer.map((ch, i) =>
|
|
183
|
+
span((i + frame) % 3 === 0 ? t.coral : t.gray, ch)
|
|
184
|
+
);
|
|
185
|
+
const tail = state.streamed
|
|
186
|
+
? span(t.gray, ` (${secs}s · ↓${fmtTokens(state.streamed)} tokens)`)
|
|
187
|
+
: span(t.gray, ` (${secs}s · thinking)`);
|
|
188
|
+
const glyph = GLYPH.spin[(state.frame || 0) % GLYPH.spin.length];
|
|
189
|
+
return [span(t.coral, glyph), ...tinted, span(t.white, '… '), tail];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The idle/modifier status line at the bottom of the frame. */
|
|
193
|
+
function statusLine(state, cols, ctx) {
|
|
194
|
+
const t = themeOf(ctx);
|
|
195
|
+
let left;
|
|
196
|
+
if (state.inputPrompt) {
|
|
197
|
+
left = [span(t.gray, 'enter to confirm · esc to cancel')];
|
|
198
|
+
} else if (state.working) {
|
|
199
|
+
left = [span(t.gray, ` ${GLYPH.bullet} esc to interrupt ${GLYPH.bullet} ${GLYPH.leftarrow} for agents`)];
|
|
200
|
+
} else if (state.streamJob) {
|
|
201
|
+
left = [span(t.gray, `${GLYPH.bullet} esc to stop ${GLYPH.bullet} ${GLYPH.leftarrow} for agents`)];
|
|
202
|
+
} else if (state.yolo) {
|
|
203
|
+
left = [span(t.gray, `${GLYPH.bullet} YOLO mode on ${GLYPH.bullet} ? for shortcuts ${GLYPH.bullet} ${GLYPH.leftarrow} for agents`)];
|
|
204
|
+
} else {
|
|
205
|
+
left = [span(t.gray, `${GLYPH.pause} manual mode on ${GLYPH.bullet} ? for shortcuts ${GLYPH.bullet} ${GLYPH.leftarrow} for agents`)];
|
|
206
|
+
}
|
|
207
|
+
return padLine(left, cols);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The `?` shortcuts grid. */
|
|
211
|
+
function shortcutsGrid(cols, ctx) {
|
|
212
|
+
const t = themeOf(ctx);
|
|
213
|
+
const cell = (a, b, c, d) => [
|
|
214
|
+
span('', ' '), span(t.white, a), span(t.gray, ' ' + b),
|
|
215
|
+
span('', ' '), span(t.white, c), span(t.gray, ' ' + d),
|
|
216
|
+
];
|
|
217
|
+
return [
|
|
218
|
+
cell('/', 'for commands', 'shift + tab', 'to auto-accept'),
|
|
219
|
+
cell('?', 'for shortcuts', 'ctrl + c', 'to quit'),
|
|
220
|
+
cell('\\ + return', 'for newline', 'ctrl + o', 'for permissions'),
|
|
221
|
+
cell('@', 'for file paths', 'alt + p', 'to switch model'),
|
|
222
|
+
cell('esc', 'to interrupt a turn', 'ctrl + l', 'to clear the screen'),
|
|
223
|
+
[span('', ' '), span(t.white, 'ctrl + t'), span(t.gray, ' to show tokens'), span('', ' '), span(t.white, 'ctrl + r'), span(t.gray, ' to resume a session')],
|
|
224
|
+
[span('', ' '), span(t.white, '↑ / ↓'), span(t.gray, ' for history', span(' ', 1)), span('', ' '), span(t.white, 'tab'), span(t.gray, ' to complete a command')],
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The tool-approval dialog body. */
|
|
229
|
+
function confirmLines(overlay, cols, ctx) {
|
|
230
|
+
const t = themeOf(ctx);
|
|
231
|
+
const lines = [];
|
|
232
|
+
lines.push([span(t.gray, '─'.repeat(Math.min(Math.max(10, cols - 4), 80)))]);
|
|
233
|
+
lines.push([span(t.white, `${overlay.name} command`)]);
|
|
234
|
+
lines.push([span('', '')]);
|
|
235
|
+
const subject =
|
|
236
|
+
overlay.name === 'Bash'
|
|
237
|
+
? String((overlay.args && overlay.args.command) || '')
|
|
238
|
+
: String((overlay.args && (overlay.args.file_path || overlay.args.pattern)) || '');
|
|
239
|
+
for (const l of wrapBlock(subject, Math.max(8, cols - 4))) lines.push([span(t.gray, l)]);
|
|
240
|
+
lines.push([span('', '')]);
|
|
241
|
+
lines.push([span(t.white, 'Do you want to proceed?')]);
|
|
242
|
+
const opt = (i, label) => {
|
|
243
|
+
const active = overlay.sel === i;
|
|
244
|
+
const left = active ? span(t.lavender, GLYPH.cursor) : span('', ' ');
|
|
245
|
+
return [left, span(t.gray, ` ${i + 1}. `), span(active ? t.lavender : t.white, label)];
|
|
246
|
+
};
|
|
247
|
+
lines.push(opt(0, 'Yes'));
|
|
248
|
+
lines.push(opt(1, 'No'));
|
|
249
|
+
lines.push([span('', '')]);
|
|
250
|
+
lines.push([span(t.gray, 'Esc to cancel')]);
|
|
251
|
+
return lines;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Render one transcript row to span lines. */
|
|
255
|
+
function rowLines(msg, cols, ctx, now = Date.now()) {
|
|
256
|
+
const t = themeOf(ctx);
|
|
257
|
+
const out = [];
|
|
258
|
+
if (msg.role === 'tool') {
|
|
259
|
+
if (msg.phase === 'run') {
|
|
260
|
+
const secs = msg.start ? Math.max(1, Math.round((now - msg.start) / 1000)) : 0;
|
|
261
|
+
out.push([span(t.white, GLYPH.block), span(t.gray, ` ${msg.label} · ${secs}s…`)]);
|
|
262
|
+
} else {
|
|
263
|
+
out.push([span(t.gray, msg.label)]);
|
|
264
|
+
}
|
|
265
|
+
const argsStr =
|
|
266
|
+
typeof msg.args === 'string'
|
|
267
|
+
? msg.args
|
|
268
|
+
: msg.args && typeof msg.args === 'object'
|
|
269
|
+
? msg.args.command ?? msg.args.file_path ?? msg.args.description ??
|
|
270
|
+
Object.values(msg.args).find((v) => typeof v === 'string') ?? ''
|
|
271
|
+
: '';
|
|
272
|
+
if (argsStr) out.push([span(t.gray, ` ${GLYPH.hook} $ ${String(argsStr)}`)]);
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
if (msg.role === 'note') {
|
|
276
|
+
out.push([span(t.gray, `· ${msg.text}`)]);
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
if (msg.role === 'tip') {
|
|
280
|
+
for (const l of wrapBlock(String(msg.text), cols)) out.push([span(t.gray, l)]);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
if (msg.role === 'done') {
|
|
284
|
+
// Real 2.1.211: "✻ Churned for 6s" — bloom glyph + gray text.
|
|
285
|
+
out.push([span(t.gray, GLYPH.bloom), span(t.gray, ` ${msg.text}`)]);
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
if (msg.role === 'meta') {
|
|
289
|
+
// Deliberate divergence from the reference: this client's reason to exist
|
|
290
|
+
// is showing what a turn consumed, so the accounting line is a transcript
|
|
291
|
+
// row rather than something only /cost can reveal.
|
|
292
|
+
const m = msg.meta || {};
|
|
293
|
+
const bits = [];
|
|
294
|
+
if (m.model) bits.push(span(t.blue, m.model));
|
|
295
|
+
if (m.tokens != null) bits.push(span(t.white, `${fmtTokens(m.tokens)} tok`));
|
|
296
|
+
if (m.input != null || m.output != null) {
|
|
297
|
+
bits.push(span(t.gray, `${fmtTokens(m.input || 0)}/${fmtTokens(m.output || 0)}`));
|
|
298
|
+
}
|
|
299
|
+
if (m.eur != null) bits.push(span(m.eur > 0 ? t.coral : t.green, fmtEur(m.eur)));
|
|
300
|
+
if (m.ms != null) bits.push(span(t.gray, fmtElapsed(m.ms)));
|
|
301
|
+
if (m.calls > 1) bits.push(span(t.gray, `${m.calls} calls`));
|
|
302
|
+
if (bits.length) {
|
|
303
|
+
const line = [span(t.dim, `${GLYPH.hook} `)];
|
|
304
|
+
bits.forEach((b, i) => {
|
|
305
|
+
if (i) line.push(span(t.dim, ` ${GLYPH.bullet} `));
|
|
306
|
+
line.push(b);
|
|
307
|
+
});
|
|
308
|
+
out.push(line);
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
if (msg.role === 'panel') {
|
|
313
|
+
for (const l of msg.lines || []) out.push(padLine(l, cols));
|
|
314
|
+
out.push([span('', '')]);
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
if (msg.role === 'error') {
|
|
318
|
+
out.push([span(t.red, `✗ ${msg.text}`)]);
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
if (msg.role === 'user') {
|
|
322
|
+
const body = wrapBlock(String(msg.text), Math.max(8, cols - 2));
|
|
323
|
+
body.forEach((l, i) => {
|
|
324
|
+
if (i === 0) out.push([span(t.gray, GLYPH.cursor), span('', ' '), span(t.white, l)]);
|
|
325
|
+
else out.push([span('', ' '), span(t.white, l)]);
|
|
326
|
+
});
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
// assistant (and anything else textual)
|
|
330
|
+
const rendered = msg.role === 'assistant'
|
|
331
|
+
? renderMarkdown(String(msg.text == null ? '' : msg.text), cols, ctx)
|
|
332
|
+
: wrapBlock(String(msg.text == null ? '' : msg.text), cols).map((l) => [span(t.white, l)]);
|
|
333
|
+
const segment = rendered.length ? rendered : [[span('', '')]];
|
|
334
|
+
for (let i = 0; i < segment.length; i++) {
|
|
335
|
+
const line = segment[i].slice();
|
|
336
|
+
if (i === 0) line.unshift(span(t.white, GLYPH.block), span('', ' '));
|
|
337
|
+
out.push(line);
|
|
338
|
+
}
|
|
339
|
+
if (msg.streaming) {
|
|
340
|
+
const last = out[out.length - 1];
|
|
341
|
+
if (last) last.push(span(t.white, GLYPH.block));
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Build the visible transcript window.
|
|
348
|
+
*
|
|
349
|
+
* Two things here are load-bearing:
|
|
350
|
+
*
|
|
351
|
+
* · finished rows are cached by identity + text + cols, because re-running
|
|
352
|
+
* markdown over the whole transcript on every frame during a streaming burst
|
|
353
|
+
* is an O(transcript) reparse up to 30x/sec and freezes input;
|
|
354
|
+
* · while scrolled up the window anchors to an ABSOLUTE line index
|
|
355
|
+
* (`anchorEnd`) rather than a bottom-relative offset, so rows appended below
|
|
356
|
+
* the window (streaming deltas, tool rows — `total` grows every frame) leave
|
|
357
|
+
* the reader's position alone instead of sliding it toward the bottom.
|
|
358
|
+
*
|
|
359
|
+
* @returns {{lines:Array, scroll:number, anchorEnd:number|null}}
|
|
360
|
+
*/
|
|
361
|
+
function transcriptLines(rows, view, ctx, now = Date.now(), cache = null) {
|
|
362
|
+
const { cols, rows: termRows } = view;
|
|
363
|
+
const avail = Math.max(1, termRows - 6);
|
|
364
|
+
const out = [];
|
|
365
|
+
for (const msg of rows) {
|
|
366
|
+
const key = cache || null;
|
|
367
|
+
const isGrower = msg.role === 'assistant' && msg.streaming;
|
|
368
|
+
if (key && !isGrower) {
|
|
369
|
+
const hit = key.get(msg);
|
|
370
|
+
if (hit && hit.text === msg.text && hit.cols === cols) {
|
|
371
|
+
for (const l of hit.segment) out.push(l);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const segment = rowLines(msg, cols, ctx, now);
|
|
375
|
+
key.set(msg, { text: msg.text, cols, segment });
|
|
376
|
+
for (const l of segment) out.push(l);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
for (const l of rowLines(msg, cols, ctx, now)) out.push(l);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let scroll = view.scroll || 0;
|
|
383
|
+
let anchorEnd = view.anchorEnd == null ? null : view.anchorEnd;
|
|
384
|
+
const total = out.length;
|
|
385
|
+
const maxScroll = Math.max(0, total - avail);
|
|
386
|
+
if (scroll > maxScroll) scroll = maxScroll;
|
|
387
|
+
let end;
|
|
388
|
+
if (scroll === 0) {
|
|
389
|
+
anchorEnd = null;
|
|
390
|
+
end = total;
|
|
391
|
+
} else {
|
|
392
|
+
if (anchorEnd === null) anchorEnd = Math.max(0, total - scroll);
|
|
393
|
+
end = Math.min(anchorEnd, total);
|
|
394
|
+
}
|
|
395
|
+
if (total <= avail) {
|
|
396
|
+
scroll = 0;
|
|
397
|
+
anchorEnd = null;
|
|
398
|
+
end = total;
|
|
399
|
+
}
|
|
400
|
+
const start = Math.max(0, end - avail);
|
|
401
|
+
return { lines: out.slice(start, end), scroll, anchorEnd };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── the input line ──────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The visible text of the input row. A pasted multi-line buffer collapses to a
|
|
408
|
+
* one-line preview ("head … (+N lines)") while `editor.buf` keeps the full
|
|
409
|
+
* text, so submit still sends everything — a raw '\n' painted into a one-row
|
|
410
|
+
* input would move the terminal to a new line mid-paint.
|
|
411
|
+
*/
|
|
412
|
+
function inputPreviewText(buf) {
|
|
413
|
+
const nl = buf.indexOf('\n');
|
|
414
|
+
if (nl === -1) return buf;
|
|
415
|
+
const n = buf.split('\n').length - 1;
|
|
416
|
+
return `${buf.slice(0, nl)} … (+${n} line${n === 1 ? '' : 's'})`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Cells available to the input buffer (2 for "❯ ", 1 spare). */
|
|
420
|
+
const inputRowCells = (cols) => Math.max(1, cols - 3);
|
|
421
|
+
|
|
422
|
+
const bufCells = (buf) => [...buf].reduce((a, ch) => a + w(ch), 0);
|
|
423
|
+
|
|
424
|
+
function cursorCells(buf, cursor) {
|
|
425
|
+
const arr = [...buf];
|
|
426
|
+
let n = 0;
|
|
427
|
+
for (let i = 0; i < Math.min(cursor, arr.length); i++) n += w(arr[i]);
|
|
428
|
+
return n;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Horizontal viewport for the input row, so the buffer scrolls once it is wider
|
|
433
|
+
* than the row instead of running off the right edge while the user types.
|
|
434
|
+
*/
|
|
435
|
+
function inputScroll(buf, cursor, cols) {
|
|
436
|
+
const avail = inputRowCells(cols);
|
|
437
|
+
const bw = bufCells(buf);
|
|
438
|
+
if (bw <= avail) return 0;
|
|
439
|
+
// The '…' indicator occupies a cell, so the visible window is one narrower
|
|
440
|
+
// once scrolling starts; without this the cursor parks past the last glyph.
|
|
441
|
+
const vis = avail - 1;
|
|
442
|
+
return Math.max(0, Math.min(cursorCells(buf, cursor) - vis, bw - vis));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** First codepoint index of the visible window for a scroll offset. */
|
|
446
|
+
function inputStart(buf, scroll) {
|
|
447
|
+
const arr = [...buf];
|
|
448
|
+
let cells = 0;
|
|
449
|
+
for (let i = 0; i < arr.length; i++) {
|
|
450
|
+
if (cells >= scroll) return i;
|
|
451
|
+
cells += w(arr[i]);
|
|
452
|
+
}
|
|
453
|
+
return arr.length;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The input row as span lines.
|
|
458
|
+
* @returns {{line:Array, cursorCol:number}}
|
|
459
|
+
*/
|
|
460
|
+
function inputLine(state, cols, ctx) {
|
|
461
|
+
const t = themeOf(ctx);
|
|
462
|
+
const line = [span(t.gray, GLYPH.cursor), span('', ' '), span('', ' ')];
|
|
463
|
+
if (state.inputPrompt) {
|
|
464
|
+
line.push(span(t.white, `${state.inputPrompt.title}: `));
|
|
465
|
+
for (const ch of [...state.inputPrompt.buf]) line.push(span(t.white, ch));
|
|
466
|
+
const col = Math.min(3 + w(state.inputPrompt.title) + 2 + w(state.inputPrompt.buf), cols);
|
|
467
|
+
return { line: padLine(line, cols), cursorCol: col };
|
|
468
|
+
}
|
|
469
|
+
if (state.working) {
|
|
470
|
+
// During generation the input line stays empty (the spinner lives above).
|
|
471
|
+
return { line: padLine(line, cols), cursorCol: 3 };
|
|
472
|
+
}
|
|
473
|
+
const buf = state.buf || '';
|
|
474
|
+
if (!buf.length) {
|
|
475
|
+
if (state.insertMode) return { line: padLine(line, cols), cursorCol: 3 };
|
|
476
|
+
const sug = SUGGESTIONS[(state.suggestionIdx || 0) % SUGGESTIONS.length];
|
|
477
|
+
line.push(span(t.white + BOLD, `Try "${sug}"`), span(BOLD_OFF, ''));
|
|
478
|
+
return { line: padLine(line, cols), cursorCol: 3 };
|
|
479
|
+
}
|
|
480
|
+
if (buf.includes('\n')) {
|
|
481
|
+
const preview = inputPreviewText(buf);
|
|
482
|
+
for (const ch of [...preview]) line.push(span(t.white, ch));
|
|
483
|
+
return { line: padLine(line, cols), cursorCol: Math.min(3 + w(preview), cols) };
|
|
484
|
+
}
|
|
485
|
+
const scroll = inputScroll(buf, state.cursor, cols);
|
|
486
|
+
const start = inputStart(buf, scroll);
|
|
487
|
+
if (scroll > 0) line.push(span(t.dim, '…'));
|
|
488
|
+
const chars = [...buf];
|
|
489
|
+
for (let i = start; i < chars.length; i++) line.push(span(t.white, chars[i]));
|
|
490
|
+
const visBefore = chars.slice(start, Math.min(state.cursor, chars.length)).join('');
|
|
491
|
+
const col = Math.min(3 + (scroll > 0 ? 1 : 0) + w(visBefore), cols);
|
|
492
|
+
return { line: padLine(line, cols), cursorCol: col };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ── the session ─────────────────────────────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Drive a full-screen chat session.
|
|
499
|
+
*
|
|
500
|
+
* `host` — the app's side of the contract:
|
|
501
|
+
* ctx live mutable session context ({model, effort, vim, light, …})
|
|
502
|
+
* version string
|
|
503
|
+
* transcript the shared row array (also the engine's history source)
|
|
504
|
+
* session live tallies ({turns, calls, tokens, inputTokens, outputTokens, cost, balance})
|
|
505
|
+
* client the thin transport
|
|
506
|
+
* ask(prompt, {history, presenter}) -> Promise<{text, usage, model, ms, interrupted}>
|
|
507
|
+
* makeCommandContext() -> the frozen `c` (this loop overrides the IO fields)
|
|
508
|
+
* buildState() -> a snapshot for panels.js
|
|
509
|
+
* dispatchLine(line, c) -> Promise<false|void>; false ends the session
|
|
510
|
+
* refreshSpend() -> Promise<{balance, lastCost}|null>
|
|
511
|
+
* updateConfig(patch)
|
|
512
|
+
* wantsExit() -> bool (set by a handler's c.exit())
|
|
513
|
+
*
|
|
514
|
+
* @returns {Promise<number>} exit code
|
|
515
|
+
*/
|
|
516
|
+
async function runSession(host) {
|
|
517
|
+
const ctx = host.ctx;
|
|
518
|
+
|
|
519
|
+
const editor = new LineEditor();
|
|
520
|
+
const transcript = host.transcript;
|
|
521
|
+
const lineCache = new WeakMap();
|
|
522
|
+
|
|
523
|
+
let overlay = null;
|
|
524
|
+
let working = false;
|
|
525
|
+
let abort = null;
|
|
526
|
+
let spinnerFrame = 0;
|
|
527
|
+
let spinnerTimer = null;
|
|
528
|
+
let startedAt = 0;
|
|
529
|
+
let verb = VERBS[0];
|
|
530
|
+
let turnCount = 0;
|
|
531
|
+
let toolSeq = 0;
|
|
532
|
+
let suggestionIdx = 0;
|
|
533
|
+
let scroll = 0;
|
|
534
|
+
let anchorEnd = null;
|
|
535
|
+
let insertMode = false;
|
|
536
|
+
let hintUntil = 0;
|
|
537
|
+
let hintText = '';
|
|
538
|
+
let inputPrompt = null;
|
|
539
|
+
let streamJob = null;
|
|
540
|
+
let streamStartedAt = 0;
|
|
541
|
+
let tabCycle = null;
|
|
542
|
+
|
|
543
|
+
// ── rendering ──
|
|
544
|
+
let renderPending = false;
|
|
545
|
+
let lastPaintAt = 0;
|
|
546
|
+
|
|
547
|
+
const setTitle = (text, spinning) => {
|
|
548
|
+
const frame = spinning ? TITLE_SPIN[Math.floor(spinnerFrame / 2) % TITLE_SPIN.length] : '';
|
|
549
|
+
try {
|
|
550
|
+
process.stdout.write(`\x1b]0;${frame ? `${frame} ` : ''}${text}\x07`);
|
|
551
|
+
} catch {
|
|
552
|
+
/* not a TTY */
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const buildFrame = () => {
|
|
557
|
+
const { cols, rows } = getSize();
|
|
558
|
+
const lines = [];
|
|
559
|
+
lines.push(headerLine(host.version, cols, ctx));
|
|
560
|
+
const avail = Math.max(1, rows - 6);
|
|
561
|
+
const view = transcriptLines(transcript, { cols, rows, scroll, anchorEnd }, ctx, Date.now(), lineCache);
|
|
562
|
+
scroll = view.scroll;
|
|
563
|
+
anchorEnd = view.anchorEnd;
|
|
564
|
+
for (const l of view.lines) lines.push(l);
|
|
565
|
+
// Exactly `avail` transcript rows, so the frame is exactly `rows` lines:
|
|
566
|
+
// header(1) + transcript(avail) + spinner/effort + rule + input + rule +
|
|
567
|
+
// status = rows. The input row's 1-based position is therefore rows - 2.
|
|
568
|
+
while (lines.length < avail + 1) lines.push([span('', '')]);
|
|
569
|
+
const t = themeOf(ctx);
|
|
570
|
+
if (working) {
|
|
571
|
+
lines.push(
|
|
572
|
+
spinnerLine(
|
|
573
|
+
{ verb, frame: spinnerFrame, elapsedMs: Date.now() - startedAt, streamed: streamedCells() },
|
|
574
|
+
ctx
|
|
575
|
+
)
|
|
576
|
+
);
|
|
577
|
+
} else if (Date.now() < hintUntil) {
|
|
578
|
+
lines.push([span(t.gray, ` ${hintText}`)]);
|
|
579
|
+
} else if (streamJob) {
|
|
580
|
+
const secs = Math.max(1, Math.round((Date.now() - streamStartedAt) / 1000));
|
|
581
|
+
lines.push([span(t.gray, ` ${streamJob.label || 'running'}… (${secs}s · esc to stop)`)]);
|
|
582
|
+
} else {
|
|
583
|
+
lines.push(effortLine(ctx, cols));
|
|
584
|
+
}
|
|
585
|
+
lines.push(separatorLine(cols, ctx));
|
|
586
|
+
const inp = inputLine(
|
|
587
|
+
{ buf: editor.buf, cursor: editor.cursor, working, insertMode, suggestionIdx, inputPrompt },
|
|
588
|
+
cols,
|
|
589
|
+
ctx
|
|
590
|
+
);
|
|
591
|
+
lines.push(inp.line);
|
|
592
|
+
lines.push(separatorLine(cols, ctx));
|
|
593
|
+
lines.push(statusLine({ working, inputPrompt, streamJob, yolo: host.isYolo && host.isYolo() }, cols, ctx));
|
|
594
|
+
return { lines, inputCol: inp.cursorCol, rows, inputRow: rows - 2 };
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const render = () => {
|
|
598
|
+
if (isKeyStreamSuspended()) return;
|
|
599
|
+
const cols = getSize().cols;
|
|
600
|
+
const { lines, inputCol, rows, inputRow } = buildFrame();
|
|
601
|
+
if (overlay) applyOverlay(lines, rows, cols);
|
|
602
|
+
paint(lines);
|
|
603
|
+
lastPaintAt = Date.now();
|
|
604
|
+
if (overlay) {
|
|
605
|
+
hideCursor();
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (inputPrompt || !working) {
|
|
609
|
+
moveTo(inputRow, inputCol);
|
|
610
|
+
showCursor();
|
|
611
|
+
} else {
|
|
612
|
+
hideCursor();
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const scheduleRender = () => {
|
|
617
|
+
if (renderPending) return;
|
|
618
|
+
renderPending = true;
|
|
619
|
+
const wait = Math.max(0, FRAME_MS - (Date.now() - lastPaintAt));
|
|
620
|
+
setTimeout(() => {
|
|
621
|
+
renderPending = false;
|
|
622
|
+
render();
|
|
623
|
+
}, wait);
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const applyOverlay = (lines, termRows, cols) => {
|
|
627
|
+
if (!overlay) return;
|
|
628
|
+
let ol = null;
|
|
629
|
+
if (overlay.type === 'palette') {
|
|
630
|
+
ol = overlays.renderPalette(host.visibleCommands(), { query: paletteQuery(overlay.query), sel: overlay.sel }, cols, termRows);
|
|
631
|
+
} else if (overlay.type === 'model') {
|
|
632
|
+
ol = overlays.renderModelPicker(overlay.items || [], overlay.sel || 0, cols, termRows, overlay.current);
|
|
633
|
+
} else if (overlay.type === 'effort') {
|
|
634
|
+
ol = overlays.renderEffortPicker(overlay.sel || 0, cols, ctx.effort);
|
|
635
|
+
} else if (overlay.type === 'resume') {
|
|
636
|
+
ol = overlays.renderResumeList(overlay.items || [], overlay.sel || 0, cols, termRows);
|
|
637
|
+
} else if (overlay.type === 'shortcuts') {
|
|
638
|
+
ol = shortcutsGrid(cols, ctx);
|
|
639
|
+
} else if (overlay.type === 'confirm') {
|
|
640
|
+
ol = confirmLines(overlay, cols, ctx);
|
|
641
|
+
} else if (overlay.type === 'panel') {
|
|
642
|
+
ol = overlay.lines || [];
|
|
643
|
+
}
|
|
644
|
+
if (!ol) return;
|
|
645
|
+
if (overlay.type === 'shortcuts') {
|
|
646
|
+
const start = Math.max(2, termRows - ol.length - 1);
|
|
647
|
+
for (let i = 0; i < ol.length; i++) lines[start + i] = padLine(ol[i], cols);
|
|
648
|
+
hideCursor();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const start = Math.max(2, Math.floor((termRows - ol.length) / 2));
|
|
652
|
+
for (let i = 0; i < ol.length; i++) lines[start + i] = padLine(ol[i], cols);
|
|
653
|
+
hideCursor();
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// ── transcript rows ──
|
|
657
|
+
|
|
658
|
+
const push = (msg, o) => {
|
|
659
|
+
transcript.push(msg);
|
|
660
|
+
if (!o || o.follow !== false) {
|
|
661
|
+
scroll = 0;
|
|
662
|
+
anchorEnd = null;
|
|
663
|
+
}
|
|
664
|
+
return msg;
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
const note = (text) => push({ role: 'note', text }, { follow: false });
|
|
668
|
+
|
|
669
|
+
let streamedChars = 0;
|
|
670
|
+
const streamedCells = () => streamedChars;
|
|
671
|
+
|
|
672
|
+
// ── the turn ──
|
|
673
|
+
|
|
674
|
+
const confirmTool = (info) =>
|
|
675
|
+
new Promise((resolve) => {
|
|
676
|
+
overlay = { type: 'confirm', name: info.tool || info.name || 'tool', args: info.args || {}, sel: 0, info };
|
|
677
|
+
render();
|
|
678
|
+
(async () => {
|
|
679
|
+
for (;;) {
|
|
680
|
+
const key = await nextKey();
|
|
681
|
+
if (key.name === KEY.UP || key.name === KEY.DOWN || key.name === KEY.TAB) {
|
|
682
|
+
overlay.sel = 1 - overlay.sel;
|
|
683
|
+
render();
|
|
684
|
+
} else if (key.name === KEY.ENTER) {
|
|
685
|
+
const yes = overlay.sel === 0;
|
|
686
|
+
overlay = null;
|
|
687
|
+
render();
|
|
688
|
+
resolve(yes ? 'allow' : 'deny');
|
|
689
|
+
return;
|
|
690
|
+
} else if (key.name === KEY.ESC || key.name === KEY.CTRL_C) {
|
|
691
|
+
overlay = null;
|
|
692
|
+
render();
|
|
693
|
+
resolve('deny');
|
|
694
|
+
return;
|
|
695
|
+
} else if (key.name === 'char') {
|
|
696
|
+
const ch = String(key.ch).trim();
|
|
697
|
+
if (ch === '1') {
|
|
698
|
+
overlay = null;
|
|
699
|
+
render();
|
|
700
|
+
resolve('allow');
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (ch === '2') {
|
|
704
|
+
overlay = null;
|
|
705
|
+
render();
|
|
706
|
+
resolve('deny');
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
})();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
const startResponse = async (prompt) => {
|
|
715
|
+
working = true;
|
|
716
|
+
spinnerFrame = 0;
|
|
717
|
+
startedAt = Date.now();
|
|
718
|
+
streamedChars = 0;
|
|
719
|
+
toolSeq = 0;
|
|
720
|
+
verb = VERBS[turnCount % VERBS.length];
|
|
721
|
+
abort = new AbortController();
|
|
722
|
+
spinnerTimer = setInterval(() => {
|
|
723
|
+
spinnerFrame++;
|
|
724
|
+
setTitle('AEGIS Code', true);
|
|
725
|
+
if (spinnerFrame % 3 === 0) render();
|
|
726
|
+
}, 100);
|
|
727
|
+
if (spinnerTimer.unref) spinnerTimer.unref();
|
|
728
|
+
|
|
729
|
+
push({ role: 'user', text: prompt });
|
|
730
|
+
const msg = push({ role: 'assistant', text: '', streaming: true });
|
|
731
|
+
|
|
732
|
+
const presenter = {
|
|
733
|
+
text: (delta) => {
|
|
734
|
+
msg.text += delta;
|
|
735
|
+
streamedChars += w(delta);
|
|
736
|
+
scheduleRender();
|
|
737
|
+
},
|
|
738
|
+
reasoning: () => {},
|
|
739
|
+
tool: (tool) => {
|
|
740
|
+
if (tool.phase === 'run') {
|
|
741
|
+
const n = ++toolSeq;
|
|
742
|
+
const prefix = tool.agent ? `${tool.agent} ▸ ` : '';
|
|
743
|
+
push(
|
|
744
|
+
{
|
|
745
|
+
role: 'tool',
|
|
746
|
+
phase: 'run',
|
|
747
|
+
name: tool.name,
|
|
748
|
+
args: tool.args,
|
|
749
|
+
agent: tool.agent,
|
|
750
|
+
id: tool.id,
|
|
751
|
+
label: `Running ${n} ${prefix}${toolLabel(tool.name, n)}…`,
|
|
752
|
+
start: Date.now(),
|
|
753
|
+
},
|
|
754
|
+
{ follow: false }
|
|
755
|
+
);
|
|
756
|
+
} else {
|
|
757
|
+
resolveToolDone(transcript, tool);
|
|
758
|
+
}
|
|
759
|
+
scheduleRender();
|
|
760
|
+
},
|
|
761
|
+
approval: (info) => confirmTool(info),
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
let result = null;
|
|
765
|
+
try {
|
|
766
|
+
result = await host.ask(prompt, {
|
|
767
|
+
history: historyPairs(transcript),
|
|
768
|
+
presenter,
|
|
769
|
+
signal: abort.signal,
|
|
770
|
+
});
|
|
771
|
+
} catch (err) {
|
|
772
|
+
result = { error: (err && err.message) || String(err) };
|
|
773
|
+
} finally {
|
|
774
|
+
msg.streaming = false;
|
|
775
|
+
working = false;
|
|
776
|
+
clearInterval(spinnerTimer);
|
|
777
|
+
spinnerTimer = null;
|
|
778
|
+
setTitle('AEGIS Code', false);
|
|
779
|
+
const secs = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
|
780
|
+
const abortedFlag = !!(abort && abort.signal.aborted);
|
|
781
|
+
msg.text = finalizeTurnText(msg.text, { aborted: abortedFlag, error: result && result.error });
|
|
782
|
+
push({ role: 'done', text: `${toolSeq > 0 ? DONE_VERBS[1] : DONE_VERBS[0]} for ${secs}s` }, { follow: false });
|
|
783
|
+
suggestionIdx = turnCount + 1;
|
|
784
|
+
turnCount++;
|
|
785
|
+
abort = null;
|
|
786
|
+
|
|
787
|
+
// Accounting: fold the turn's usage into the session tallies, ask the
|
|
788
|
+
// ledger what it settled at, and show both — tokens beside €.
|
|
789
|
+
host.recordTurn(result);
|
|
790
|
+
let lastCost = null;
|
|
791
|
+
try {
|
|
792
|
+
const spend = await host.refreshSpend();
|
|
793
|
+
lastCost = spend ? spend.lastCost : null;
|
|
794
|
+
} catch {
|
|
795
|
+
/* accounting must never break a turn */
|
|
796
|
+
}
|
|
797
|
+
const usage = result && result.usage;
|
|
798
|
+
const tokens = host.tokensFor ? host.tokensFor(usage) : null;
|
|
799
|
+
push(
|
|
800
|
+
{
|
|
801
|
+
role: 'meta',
|
|
802
|
+
meta: {
|
|
803
|
+
model: result && result.model,
|
|
804
|
+
tokens,
|
|
805
|
+
input: usage ? usage.input_tokens ?? usage.prompt_tokens : null,
|
|
806
|
+
output: usage ? usage.output_tokens ?? usage.completion_tokens : null,
|
|
807
|
+
eur: lastCost,
|
|
808
|
+
ms: result && result.ms,
|
|
809
|
+
calls: (result && result.calls) || 1,
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
{ follow: false }
|
|
813
|
+
);
|
|
814
|
+
render();
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// ── inline input (askInput) ──
|
|
819
|
+
|
|
820
|
+
const askInput = (title) =>
|
|
821
|
+
new Promise((resolve) => {
|
|
822
|
+
inputPrompt = { title, buf: '' };
|
|
823
|
+
render();
|
|
824
|
+
(async () => {
|
|
825
|
+
for (;;) {
|
|
826
|
+
const key = await nextKey();
|
|
827
|
+
if (!inputPrompt) {
|
|
828
|
+
resolve(null);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (key.name === KEY.ESC || key.name === KEY.CTRL_C || key.name === KEY.CTRL_D) {
|
|
832
|
+
inputPrompt = null;
|
|
833
|
+
render();
|
|
834
|
+
resolve(null);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (key.name === KEY.ENTER) {
|
|
838
|
+
const value = inputPrompt.buf;
|
|
839
|
+
inputPrompt = null;
|
|
840
|
+
render();
|
|
841
|
+
resolve(value);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (key.name === KEY.BACKSPACE) {
|
|
845
|
+
inputPrompt.buf = [...inputPrompt.buf].slice(0, -1).join('');
|
|
846
|
+
} else if (key.name === 'char') {
|
|
847
|
+
inputPrompt.buf += key.ch;
|
|
848
|
+
} else if (key.name === 'paste') {
|
|
849
|
+
inputPrompt.buf += String(key.text).replace(/\n/g, ' ');
|
|
850
|
+
}
|
|
851
|
+
render();
|
|
852
|
+
}
|
|
853
|
+
})();
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
const withWorking = async (fn) => {
|
|
857
|
+
const prev = abort;
|
|
858
|
+
working = true;
|
|
859
|
+
spinnerFrame = 0;
|
|
860
|
+
startedAt = Date.now();
|
|
861
|
+
verb = VERBS[turnCount % VERBS.length];
|
|
862
|
+
abort = new AbortController();
|
|
863
|
+
spinnerTimer = setInterval(() => {
|
|
864
|
+
spinnerFrame++;
|
|
865
|
+
if (spinnerFrame % 3 === 0) render();
|
|
866
|
+
}, 100);
|
|
867
|
+
if (spinnerTimer.unref) spinnerTimer.unref();
|
|
868
|
+
try {
|
|
869
|
+
return await fn(abort.signal);
|
|
870
|
+
} finally {
|
|
871
|
+
working = false;
|
|
872
|
+
clearInterval(spinnerTimer);
|
|
873
|
+
spinnerTimer = null;
|
|
874
|
+
abort = prev;
|
|
875
|
+
render();
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
// ── the command context this loop owns ──
|
|
880
|
+
|
|
881
|
+
const makeContext = () => {
|
|
882
|
+
const c = host.makeCommandContext();
|
|
883
|
+
Object.assign(c, {
|
|
884
|
+
push: (row, o) => {
|
|
885
|
+
push(row, o);
|
|
886
|
+
render();
|
|
887
|
+
},
|
|
888
|
+
note: (text) => {
|
|
889
|
+
push({ role: 'note', text });
|
|
890
|
+
render();
|
|
891
|
+
},
|
|
892
|
+
panel: (lines) => {
|
|
893
|
+
push({ role: 'panel', lines: lines || [] });
|
|
894
|
+
render();
|
|
895
|
+
},
|
|
896
|
+
render: () => render(),
|
|
897
|
+
openOverlay: (o) => {
|
|
898
|
+
overlay = o;
|
|
899
|
+
render();
|
|
900
|
+
},
|
|
901
|
+
closeOverlay: () => {
|
|
902
|
+
overlay = null;
|
|
903
|
+
showCursor();
|
|
904
|
+
render();
|
|
905
|
+
},
|
|
906
|
+
askInput: (title) => askInput(title),
|
|
907
|
+
withWorking: (fn) => withWorking(fn),
|
|
908
|
+
runPrompt: async (text) => {
|
|
909
|
+
await startResponse(text);
|
|
910
|
+
},
|
|
911
|
+
state: () => host.buildState(),
|
|
912
|
+
setInput: (text) => {
|
|
913
|
+
editor.buf = String(text == null ? '' : text);
|
|
914
|
+
editor.end();
|
|
915
|
+
render();
|
|
916
|
+
},
|
|
917
|
+
exit: () => host.requestExit(),
|
|
918
|
+
openStream: (job) => {
|
|
919
|
+
streamJob = job || null;
|
|
920
|
+
streamStartedAt = Date.now();
|
|
921
|
+
render();
|
|
922
|
+
},
|
|
923
|
+
closeStream: (job) => {
|
|
924
|
+
if (!job || streamJob === job) streamJob = null;
|
|
925
|
+
render();
|
|
926
|
+
},
|
|
927
|
+
showThemePicker: () => {
|
|
928
|
+
ctx.light = !ctx.light;
|
|
929
|
+
ctx.themeIndex = ctx.light ? 0 : 1;
|
|
930
|
+
host.updateConfig({ themeIndex: ctx.themeIndex });
|
|
931
|
+
note(`theme: ${ctx.light ? 'light' : 'dark'}`);
|
|
932
|
+
render();
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
return c;
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
// ── live scroll while a turn runs ──
|
|
939
|
+
|
|
940
|
+
const applyLiveScroll = (key) => {
|
|
941
|
+
let d = null;
|
|
942
|
+
if (key.name === KEY.PAGE_UP) d = +5;
|
|
943
|
+
else if (key.name === KEY.PAGE_DOWN) d = -5;
|
|
944
|
+
else if (key.name === 'wheel') d = key.dir === 'up' ? +3 : -3;
|
|
945
|
+
else if (key.name === 'char' && ctx.vim && !insertMode && !editor.buf) {
|
|
946
|
+
if (key.ch === 'j') d = +1;
|
|
947
|
+
else if (key.ch === 'k') d = -1;
|
|
948
|
+
}
|
|
949
|
+
if (d === null) return false;
|
|
950
|
+
scroll = Math.max(0, scroll + d);
|
|
951
|
+
anchorEnd = null;
|
|
952
|
+
scheduleRender();
|
|
953
|
+
return true;
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
// ── mid-turn key drain ──
|
|
957
|
+
// The loop awaits a turn inline, so it is NOT parked on nextKey() while the
|
|
958
|
+
// model works. drainWhileWorking keeps it alive by polling for keys: Esc /
|
|
959
|
+
// Ctrl-C abort the active controller, scroll keys act live, everything else
|
|
960
|
+
// is replayed into the queue afterwards so typed-ahead text still lands.
|
|
961
|
+
const drainWhileWorking = (promise, getAbort) => {
|
|
962
|
+
const replay = [];
|
|
963
|
+
let settled = false;
|
|
964
|
+
let result;
|
|
965
|
+
let error;
|
|
966
|
+
promise.then(
|
|
967
|
+
(v) => {
|
|
968
|
+
settled = true;
|
|
969
|
+
result = v;
|
|
970
|
+
},
|
|
971
|
+
(e) => {
|
|
972
|
+
settled = true;
|
|
973
|
+
error = e;
|
|
974
|
+
}
|
|
975
|
+
);
|
|
976
|
+
return (async () => {
|
|
977
|
+
while (!settled) {
|
|
978
|
+
if (!working || (overlay && overlay.type === 'confirm')) {
|
|
979
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
const key = await nextKeyTimeout(120);
|
|
983
|
+
if (key === null) continue;
|
|
984
|
+
if (key.name === KEY.ESC || key.name === KEY.CTRL_C) {
|
|
985
|
+
const controller = getAbort && getAbort();
|
|
986
|
+
if (controller) controller.abort();
|
|
987
|
+
} else if (!applyLiveScroll(key)) {
|
|
988
|
+
replay.push(key);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
requeueKeys(replay);
|
|
992
|
+
if (error) throw error;
|
|
993
|
+
return result;
|
|
994
|
+
})();
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
const runGuarded = async (fn, getAbort) => {
|
|
998
|
+
try {
|
|
999
|
+
return await drainWhileWorking(fn(), getAbort);
|
|
1000
|
+
} catch (err) {
|
|
1001
|
+
push({ role: 'note', text: `Error: ${(err && err.message) || String(err)}` }, { follow: false });
|
|
1002
|
+
render();
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
// ── key handling on the idle input line ──
|
|
1008
|
+
|
|
1009
|
+
const completeTab = () => {
|
|
1010
|
+
const buf = editor.buf;
|
|
1011
|
+
if (!buf.startsWith('/') || buf.includes(' ')) return false;
|
|
1012
|
+
const q = buf.slice(1);
|
|
1013
|
+
const cands = host
|
|
1014
|
+
.visibleCommands()
|
|
1015
|
+
.flatMap((c) => [c.name, ...(c.aliases || [])])
|
|
1016
|
+
.filter((n) => n.startsWith(q))
|
|
1017
|
+
.sort();
|
|
1018
|
+
if (!cands.length) return false;
|
|
1019
|
+
if (!tabCycle || tabCycle.q !== q) tabCycle = { q, i: -1 };
|
|
1020
|
+
tabCycle.i = (tabCycle.i + 1) % cands.length;
|
|
1021
|
+
editor.buf = '/' + cands[tabCycle.i];
|
|
1022
|
+
editor.end();
|
|
1023
|
+
return true;
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
const handleOverlayKey = async (key) => {
|
|
1027
|
+
const type = overlay.type;
|
|
1028
|
+
if (key.name === KEY.ESC || key.name === KEY.CTRL_C) {
|
|
1029
|
+
overlay = null;
|
|
1030
|
+
render();
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (type === 'shortcuts' || type === 'panel') {
|
|
1034
|
+
overlay = null;
|
|
1035
|
+
render();
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (key.name === KEY.ENTER) {
|
|
1039
|
+
if (type === 'palette') {
|
|
1040
|
+
// Rank with the same function the palette rendered with, so the row
|
|
1041
|
+
// highlighted is the row Enter runs.
|
|
1042
|
+
const list = fuzzy.fuzzyRankWithAliases(
|
|
1043
|
+
paletteQuery(overlay.query),
|
|
1044
|
+
host.visibleCommands(),
|
|
1045
|
+
(c) => c.name,
|
|
1046
|
+
(c) => c.aliases || []
|
|
1047
|
+
);
|
|
1048
|
+
const chosen = list[overlay.sel || 0];
|
|
1049
|
+
const arg = String(overlay.query || '').trim().split(/\s+/).slice(1).join(' ');
|
|
1050
|
+
overlay = null;
|
|
1051
|
+
render();
|
|
1052
|
+
if (chosen) {
|
|
1053
|
+
await dispatch(arg ? `/${chosen.name} ${arg}` : `/${chosen.name}`);
|
|
1054
|
+
}
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (type === 'model') {
|
|
1058
|
+
const chosen = (overlay.items || [])[overlay.sel || 0];
|
|
1059
|
+
overlay = null;
|
|
1060
|
+
if (chosen) {
|
|
1061
|
+
ctx.model = chosen.id;
|
|
1062
|
+
host.updateConfig({ model: chosen.id });
|
|
1063
|
+
note(`model: ${chosen.id}`);
|
|
1064
|
+
}
|
|
1065
|
+
render();
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (type === 'effort') {
|
|
1069
|
+
const levels = ['low', 'medium', 'high'];
|
|
1070
|
+
const chosen = levels[overlay.sel || 0];
|
|
1071
|
+
overlay = null;
|
|
1072
|
+
ctx.effort = chosen;
|
|
1073
|
+
host.updateConfig({ effort: chosen });
|
|
1074
|
+
note(`effort: ${chosen}`);
|
|
1075
|
+
render();
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (type === 'resume') {
|
|
1079
|
+
const chosen = (overlay.items || [])[overlay.sel || 0];
|
|
1080
|
+
overlay = null;
|
|
1081
|
+
render();
|
|
1082
|
+
if (chosen && host.resumeSession) await host.resumeSession(chosen);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
overlay = null;
|
|
1086
|
+
render();
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (key.name === KEY.UP) {
|
|
1090
|
+
overlay.sel = Math.max(0, (overlay.sel || 0) - 1);
|
|
1091
|
+
render();
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (key.name === KEY.DOWN) {
|
|
1095
|
+
overlay.sel = (overlay.sel || 0) + 1;
|
|
1096
|
+
render();
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
if (key.name === 'char') {
|
|
1100
|
+
if (type === 'palette') {
|
|
1101
|
+
overlay.query = (overlay.query || '') + key.ch;
|
|
1102
|
+
overlay.sel = 0;
|
|
1103
|
+
render();
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (type === 'model') {
|
|
1107
|
+
const idx = parseInt(key.ch, 10);
|
|
1108
|
+
const items = overlay.items || [];
|
|
1109
|
+
if (Number.isFinite(idx) && idx >= 1 && idx <= items.length) {
|
|
1110
|
+
overlay.sel = idx - 1;
|
|
1111
|
+
render();
|
|
1112
|
+
}
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (type === 'effort' || type === 'resume') {
|
|
1116
|
+
const idx = parseInt(key.ch, 10);
|
|
1117
|
+
if (Number.isFinite(idx) && idx >= 1) {
|
|
1118
|
+
overlay.sel = idx - 1;
|
|
1119
|
+
render();
|
|
1120
|
+
}
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
if (key.name === KEY.BACKSPACE && type === 'palette') {
|
|
1125
|
+
overlay.query = String(overlay.query || '').slice(0, -1);
|
|
1126
|
+
render();
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
const handleKey = async (key) => {
|
|
1131
|
+
if (overlay) {
|
|
1132
|
+
await handleOverlayKey(key);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
// vim normal-mode motions (only when the buffer is empty, so j/k do not
|
|
1136
|
+
// fight typed text).
|
|
1137
|
+
if (ctx.vim && !insertMode && !editor.buf && key.name === 'char') {
|
|
1138
|
+
const ch = key.ch;
|
|
1139
|
+
if (ch === 'i' || ch === 'a' || ch === 'o' || ch === 's' || ch === 'S') {
|
|
1140
|
+
if (ch === 's') editor.substChar();
|
|
1141
|
+
if (ch === 'S') editor.substLine();
|
|
1142
|
+
if (ch === 'a') editor.right();
|
|
1143
|
+
insertMode = true;
|
|
1144
|
+
render();
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
if (ch === 'h') {
|
|
1148
|
+
editor.left();
|
|
1149
|
+
render();
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
if (ch === 'l') {
|
|
1153
|
+
editor.right();
|
|
1154
|
+
render();
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
if (ch === '0') {
|
|
1158
|
+
editor.home();
|
|
1159
|
+
render();
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (ch === '$') {
|
|
1163
|
+
editor.end();
|
|
1164
|
+
render();
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (ch === 'x') {
|
|
1168
|
+
editor.delete();
|
|
1169
|
+
render();
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (ch === 'D') {
|
|
1173
|
+
editor.killToEnd();
|
|
1174
|
+
render();
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
if (ch === 'A') {
|
|
1178
|
+
editor.end();
|
|
1179
|
+
insertMode = true;
|
|
1180
|
+
render();
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (applyLiveScroll(key)) return;
|
|
1184
|
+
} else if (ctx.vim && insertMode && key.name === KEY.ESC) {
|
|
1185
|
+
insertMode = false;
|
|
1186
|
+
if (editor.cursor > 0) editor.cursor--;
|
|
1187
|
+
render();
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (key.name === KEY.ENTER) {
|
|
1192
|
+
const text = editor.submit();
|
|
1193
|
+
tabCycle = null;
|
|
1194
|
+
insertMode = false;
|
|
1195
|
+
if (!text) {
|
|
1196
|
+
render();
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
await dispatch(text);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
if (key.name === 'paste') {
|
|
1203
|
+
if (ctx.vim) insertMode = true;
|
|
1204
|
+
editor.insert(String(key.text));
|
|
1205
|
+
render();
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (key.name === 'char') {
|
|
1209
|
+
if (ctx.vim && !insertMode) {
|
|
1210
|
+
// A printable key in normal mode is not typed text; ignore it (the
|
|
1211
|
+
// user must press i/a first), matching the reference keymap.
|
|
1212
|
+
render();
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
editor.insert(key.ch);
|
|
1216
|
+
render();
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
if (key.name === KEY.BACKSPACE) {
|
|
1220
|
+
editor.backspace();
|
|
1221
|
+
render();
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
if (key.name === KEY.DELETE) {
|
|
1225
|
+
editor.delete();
|
|
1226
|
+
render();
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (key.name === KEY.LEFT || key.name === KEY.CTRL_LEFT) {
|
|
1230
|
+
if (key.name === KEY.CTRL_LEFT) editor.wordBack();
|
|
1231
|
+
else editor.left();
|
|
1232
|
+
render();
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
if (key.name === KEY.RIGHT || key.name === KEY.CTRL_RIGHT) {
|
|
1236
|
+
editor.right();
|
|
1237
|
+
render();
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
if (key.name === KEY.HOME || key.name === KEY.CTRL_A) {
|
|
1241
|
+
editor.home();
|
|
1242
|
+
render();
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (key.name === KEY.END || key.name === KEY.CTRL_E) {
|
|
1246
|
+
editor.end();
|
|
1247
|
+
render();
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
if (key.name === KEY.UP) {
|
|
1251
|
+
editor.historyUp();
|
|
1252
|
+
render();
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
if (key.name === KEY.DOWN) {
|
|
1256
|
+
editor.historyDown();
|
|
1257
|
+
render();
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (key.name === KEY.TAB) {
|
|
1261
|
+
completeTab();
|
|
1262
|
+
render();
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
if (key.name === KEY.CTRL_U) {
|
|
1266
|
+
editor.buf = '';
|
|
1267
|
+
editor.cursor = 0;
|
|
1268
|
+
render();
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
if (key.name === KEY.CTRL_K) {
|
|
1272
|
+
editor.killToEnd();
|
|
1273
|
+
render();
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
if (key.name === KEY.CTRL_W) {
|
|
1277
|
+
editor.wordDelete();
|
|
1278
|
+
render();
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
if (key.name === KEY.CTRL_L) {
|
|
1282
|
+
clearScreen();
|
|
1283
|
+
render();
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (key.name === KEY.CTRL_T) {
|
|
1287
|
+
note(host.tokenSummary ? host.tokenSummary() : 'no token usage yet this session');
|
|
1288
|
+
render();
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
if (key.name === KEY.CTRL_R) {
|
|
1292
|
+
await dispatch('/resume');
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (key.name === KEY.PAGE_UP || key.name === KEY.PAGE_DOWN || key.name === 'wheel') {
|
|
1296
|
+
applyLiveScroll(key);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
if (key.name === 'alt' && key.ch === 'p') {
|
|
1300
|
+
await dispatch('/model');
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
if (key.name === KEY.CTRL_C) {
|
|
1304
|
+
if (editor.buf) {
|
|
1305
|
+
editor.buf = '';
|
|
1306
|
+
editor.cursor = 0;
|
|
1307
|
+
render();
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
await endSession();
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
if (key.name === KEY.CTRL_D) {
|
|
1314
|
+
if (!editor.buf) {
|
|
1315
|
+
await endSession();
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
editor.delete();
|
|
1319
|
+
render();
|
|
1320
|
+
}
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
// ── dispatch ──
|
|
1324
|
+
|
|
1325
|
+
const dispatch = async (line) => {
|
|
1326
|
+
const trimmed = String(line || '').trim();
|
|
1327
|
+
if (trimmed === '?') {
|
|
1328
|
+
overlay = { type: 'shortcuts' };
|
|
1329
|
+
render();
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
const keep = await runGuarded(async () => {
|
|
1333
|
+
if (trimmed.startsWith('/')) {
|
|
1334
|
+
const c = makeContext();
|
|
1335
|
+
return host.dispatchLine(trimmed, c);
|
|
1336
|
+
}
|
|
1337
|
+
await startResponse(trimmed);
|
|
1338
|
+
return true;
|
|
1339
|
+
}, () => abort);
|
|
1340
|
+
if (keep === false || host.wantsExit()) await endSession();
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
// ── lifecycle ──
|
|
1344
|
+
|
|
1345
|
+
let ended = false;
|
|
1346
|
+
let resolveExit = null;
|
|
1347
|
+
const endSession = () => {
|
|
1348
|
+
if (ended) return;
|
|
1349
|
+
ended = true;
|
|
1350
|
+
if (resolveExit) resolveExit(0);
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1353
|
+
const cleanup = () => {
|
|
1354
|
+
hideCursor();
|
|
1355
|
+
// Blank the alt screen so the shell we hand back is not left holding a
|
|
1356
|
+
// half-painted frame if the terminal ignores the leave sequence.
|
|
1357
|
+
paint([]);
|
|
1358
|
+
try {
|
|
1359
|
+
process.stdout.write('\x1b[0m');
|
|
1360
|
+
} catch {
|
|
1361
|
+
/* not a TTY */
|
|
1362
|
+
}
|
|
1363
|
+
try {
|
|
1364
|
+
process.stdout.write('\x1b]0;\x07');
|
|
1365
|
+
} catch {
|
|
1366
|
+
/* not a TTY */
|
|
1367
|
+
}
|
|
1368
|
+
try {
|
|
1369
|
+
process.stdin.setRawMode(false);
|
|
1370
|
+
} catch {
|
|
1371
|
+
/* not a TTY */
|
|
1372
|
+
}
|
|
1373
|
+
disableBracketedPaste();
|
|
1374
|
+
disableMouseTracking();
|
|
1375
|
+
leaveAltScreen();
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
// Enter the alternate screen and take over the terminal. `host.stdin` exists
|
|
1379
|
+
// so the loop can be driven from a test with a synthetic key stream.
|
|
1380
|
+
const stdin = host.stdin || process.stdin;
|
|
1381
|
+
attachKeyStream(stdin);
|
|
1382
|
+
enableBracketedPaste();
|
|
1383
|
+
enableMouseTracking();
|
|
1384
|
+
enterAltScreen();
|
|
1385
|
+
clearScreen();
|
|
1386
|
+
hideCursor();
|
|
1387
|
+
setTitle('AEGIS Code', false);
|
|
1388
|
+
|
|
1389
|
+
const onResize = () => render();
|
|
1390
|
+
if (process.platform !== 'win32') process.on('SIGWINCH', onResize);
|
|
1391
|
+
else process.stdout.on('resize', onResize);
|
|
1392
|
+
|
|
1393
|
+
const onSigint = () => {
|
|
1394
|
+
if (working && abort) {
|
|
1395
|
+
abort.abort();
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
endSession();
|
|
1399
|
+
};
|
|
1400
|
+
process.on('SIGINT', onSigint);
|
|
1401
|
+
|
|
1402
|
+
const crash = (err) => {
|
|
1403
|
+
cleanup();
|
|
1404
|
+
try {
|
|
1405
|
+
process.stderr.write(
|
|
1406
|
+
`\nAEGIS Code hit an unexpected error. Your transcript is saved; rerun to continue.\n${
|
|
1407
|
+
(err && err.stack) || err
|
|
1408
|
+
}\n`
|
|
1409
|
+
);
|
|
1410
|
+
} catch {
|
|
1411
|
+
/* ignore */
|
|
1412
|
+
}
|
|
1413
|
+
process.exit(1);
|
|
1414
|
+
};
|
|
1415
|
+
process.on('uncaughtException', crash);
|
|
1416
|
+
process.on('unhandledRejection', crash);
|
|
1417
|
+
|
|
1418
|
+
render();
|
|
1419
|
+
|
|
1420
|
+
try {
|
|
1421
|
+
const code = await new Promise((resolve) => {
|
|
1422
|
+
resolveExit = resolve;
|
|
1423
|
+
(async () => {
|
|
1424
|
+
while (!ended) {
|
|
1425
|
+
const key = await nextKey();
|
|
1426
|
+
if (ended) break;
|
|
1427
|
+
try {
|
|
1428
|
+
await handleKey(key);
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
push({ role: 'note', text: `Error: ${(err && err.message) || String(err)}` }, { follow: false });
|
|
1431
|
+
render();
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
})();
|
|
1435
|
+
});
|
|
1436
|
+
return code;
|
|
1437
|
+
} finally {
|
|
1438
|
+
ended = true;
|
|
1439
|
+
process.off('SIGINT', onSigint);
|
|
1440
|
+
if (process.platform !== 'win32') process.off('SIGWINCH', onResize);
|
|
1441
|
+
else process.stdout.off('resize', onResize);
|
|
1442
|
+
process.off('uncaughtException', crash);
|
|
1443
|
+
process.off('unhandledRejection', crash);
|
|
1444
|
+
cleanup();
|
|
1445
|
+
resetKeyStream();
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
module.exports = {
|
|
1450
|
+
// pure helpers (unit-testable without a TTY)
|
|
1451
|
+
SUGGESTIONS,
|
|
1452
|
+
TITLE_SPIN,
|
|
1453
|
+
FRAME_MS,
|
|
1454
|
+
toolLabel,
|
|
1455
|
+
resolveToolDone,
|
|
1456
|
+
finalizeTurnText,
|
|
1457
|
+
historyPairs,
|
|
1458
|
+
paletteQuery,
|
|
1459
|
+
headerLine,
|
|
1460
|
+
separatorLine,
|
|
1461
|
+
effortLine,
|
|
1462
|
+
spinnerLine,
|
|
1463
|
+
statusLine,
|
|
1464
|
+
shortcutsGrid,
|
|
1465
|
+
confirmLines,
|
|
1466
|
+
rowLines,
|
|
1467
|
+
transcriptLines,
|
|
1468
|
+
inputPreviewText,
|
|
1469
|
+
inputRowCells,
|
|
1470
|
+
inputScroll,
|
|
1471
|
+
inputStart,
|
|
1472
|
+
inputLine,
|
|
1473
|
+
// the loop
|
|
1474
|
+
runSession,
|
|
1475
|
+
};
|