@aitherium/shell-cli 1.1.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +13 -4
- package/dist/auth.js +18 -3
- package/dist/client.d.ts +21 -1
- package/dist/client.js +213 -9
- package/dist/command-registry.d.ts +1 -1
- package/dist/command-registry.js +28 -2
- package/dist/commands.d.ts +8 -0
- package/dist/commands.js +2068 -48
- package/dist/completions.js +236 -2
- package/dist/config.d.ts +30 -0
- package/dist/config.js +37 -4
- package/dist/crash-reporter.d.ts +22 -0
- package/dist/crash-reporter.js +275 -0
- package/dist/formatters.d.ts +16 -0
- package/dist/formatters.js +280 -0
- package/dist/interactive.d.ts +45 -0
- package/dist/interactive.js +232 -0
- package/dist/main.js +439 -41
- package/dist/mcp-client.d.ts +58 -0
- package/dist/mcp-client.js +202 -0
- package/dist/relay.d.ts +96 -0
- package/dist/relay.js +234 -0
- package/dist/renderer.d.ts +47 -17
- package/dist/renderer.js +506 -164
- package/dist/repl.js +205 -23
- package/dist/session-store.d.ts +30 -5
- package/dist/session-store.js +56 -0
- package/dist/status-banner.d.ts +59 -0
- package/dist/status-banner.js +239 -0
- package/dist/tui/controller.d.ts +3 -0
- package/dist/tui/controller.js +310 -0
- package/dist/tui/repl-tui.d.ts +3 -0
- package/dist/tui/repl-tui.js +898 -0
- package/dist/tui/screen.d.ts +68 -0
- package/dist/tui/screen.js +736 -0
- package/package.json +9 -2
package/dist/renderer.js
CHANGED
|
@@ -6,13 +6,34 @@ import ora from 'ora';
|
|
|
6
6
|
import { marked } from 'marked';
|
|
7
7
|
import { markedTerminal } from 'marked-terminal';
|
|
8
8
|
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
9
|
-
import { join } from 'path';
|
|
9
|
+
import { join, resolve } from 'path';
|
|
10
10
|
import { homedir } from 'os';
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import { getActiveConfig } from './config.js';
|
|
11
13
|
// Configure marked for terminal rendering
|
|
12
14
|
marked.use(markedTerminal({ reflowText: true, width: 80 }));
|
|
15
|
+
/* ── Trace verbosity ─────────────────────────────────────────────
|
|
16
|
+
* FULL trace is ON by default: every pipeline stage (planning/MCTS,
|
|
17
|
+
* neurons, middleware, per-turn timings) prints a permanent line so the
|
|
18
|
+
* user can SEE what the system is doing on every turn without asking.
|
|
19
|
+
* Set AITHER_TRACE=quiet (or off/0/false) to collapse stages back into a
|
|
20
|
+
* single transient status line.
|
|
21
|
+
*/
|
|
22
|
+
export const TRACE_FULL = (() => {
|
|
23
|
+
const v = (process.env.AITHER_TRACE || '').toLowerCase().trim();
|
|
24
|
+
if (v === 'quiet' || v === 'off' || v === '0' || v === 'false' || v === 'none')
|
|
25
|
+
return false;
|
|
26
|
+
return true; // default: full trace
|
|
27
|
+
})();
|
|
28
|
+
/* ── OSC 8 terminal hyperlink ────────────────────────────────── */
|
|
29
|
+
/** Wrap text in an OSC 8 clickable hyperlink for terminals that support it. */
|
|
30
|
+
export function osc8Link(url, label) {
|
|
31
|
+
const text = label || url;
|
|
32
|
+
return `\x1b]8;;${url}\x1b\\${chalk.cyan.underline(text)}\x1b]8;;\x1b\\`;
|
|
33
|
+
}
|
|
13
34
|
/* ── Banner ─────────────────────────────────────────────────── */
|
|
14
35
|
export function renderBanner(info) {
|
|
15
|
-
const version = 'v1.
|
|
36
|
+
const version = 'v1.11.0'; // keep in sync with package.json
|
|
16
37
|
const title = `AitherShell ${version}`;
|
|
17
38
|
let connected;
|
|
18
39
|
if (info.genesisOnline === true) {
|
|
@@ -121,42 +142,136 @@ function stripWebCruft(text) {
|
|
|
121
142
|
* and render as clickable OSC 8 terminal hyperlinks.
|
|
122
143
|
*/
|
|
123
144
|
export function resolveImagePath(relativePath) {
|
|
124
|
-
|
|
125
|
-
|
|
145
|
+
// ESM module — use the top-level imports, NOT require() (which is undefined
|
|
146
|
+
// here and made renderMarkdown throw → raw  markdown leaked through).
|
|
126
147
|
const home = homedir();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
148
|
+
const rel = relativePath.replace(/^\/api\/files\?path=/, '');
|
|
149
|
+
// If it's already an existing absolute path, keep it.
|
|
150
|
+
try {
|
|
151
|
+
if (/^([A-Za-z]:[/\\]|\/)/.test(rel) && existsSync(rel))
|
|
152
|
+
return rel;
|
|
153
|
+
}
|
|
154
|
+
catch { /* */ }
|
|
155
|
+
// Try known AitherOS roots (the Library is bind-mounted under <root>/AitherOS).
|
|
156
|
+
const roots = [
|
|
157
|
+
process.env.AITHER_ROOT,
|
|
158
|
+
'D:\\AitherOS-Fresh', // dev box repo root
|
|
159
|
+
resolve(home, 'AitherOS-Fresh'),
|
|
160
|
+
resolve(home, 'AitherOS'),
|
|
161
|
+
].filter(Boolean);
|
|
162
|
+
for (const root of roots) {
|
|
163
|
+
for (const sub of ['AitherOS', '']) {
|
|
164
|
+
try {
|
|
165
|
+
const p = resolve(root, sub, rel);
|
|
166
|
+
if (existsSync(p))
|
|
167
|
+
return p;
|
|
168
|
+
}
|
|
169
|
+
catch { /* */ }
|
|
138
170
|
}
|
|
139
|
-
catch { }
|
|
140
171
|
}
|
|
141
|
-
return resolve(
|
|
172
|
+
return resolve(rel); // best-effort (may not exist) — caller can check
|
|
173
|
+
}
|
|
174
|
+
/** Strip OSC-8 hyperlink escapes (blessed/TUI can't render them → raw `]8;;`). */
|
|
175
|
+
export function stripOsc8(text) {
|
|
176
|
+
// ESC ]8;;<url> ESC \ …label… ESC ]8;; ESC \ → keep just the label.
|
|
177
|
+
return text.replace(/\x1b\]8;;[^\x07\x1b]*(?:\x1b\\|\x07)/g, '');
|
|
178
|
+
}
|
|
179
|
+
/** The backend the shell is ACTUALLY connected to (local genesis, a remote
|
|
180
|
+
* gateway/mcp, etc.) — image URLs must resolve against IT, not a hardcoded host. */
|
|
181
|
+
function backendBase() {
|
|
182
|
+
const cfg = getActiveConfig();
|
|
183
|
+
const base = cfg?.genesisUrl
|
|
184
|
+
|| process.env.AITHER_API_URL || process.env.AITHER_GENESIS_URL
|
|
185
|
+
|| 'http://127.0.0.1:8001';
|
|
186
|
+
return String(base).replace(/\/+$/, '');
|
|
142
187
|
}
|
|
143
188
|
function resolveImagePaths(text) {
|
|
144
189
|
// Match  or 
|
|
145
190
|
return text.replace(/!\[([^\]]*)\]\((?:\/api\/files\?path=)?([^)]+)\)/g, (_match, alt, rawPath) => {
|
|
146
|
-
// Resolve to absolute path — Library is under AitherOS/
|
|
147
|
-
let absPath = rawPath;
|
|
148
|
-
if (!rawPath.match(/^[A-Za-z]:[/\\]/) && !rawPath.startsWith('/')) {
|
|
149
|
-
// Relative path — resolve from repo root
|
|
150
|
-
const repoRoot = process.env.AITHER_ROOT || process.cwd();
|
|
151
|
-
absPath = `${repoRoot}/AitherOS/${rawPath}`.replace(/\//g, '\\');
|
|
152
|
-
}
|
|
153
191
|
const label = alt || rawPath.split(/[/\\]/).pop() || 'image';
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
192
|
+
const base = backendBase();
|
|
193
|
+
const isLocalBackend = /(^|\/\/)(127\.0\.0\.1|localhost)\b/.test(base);
|
|
194
|
+
// If the raw path is already a full URL, keep it.
|
|
195
|
+
const isAbsUrl = /^https?:\/\//i.test(rawPath);
|
|
196
|
+
let url;
|
|
197
|
+
let shown;
|
|
198
|
+
// Only a LOCAL backend can have the file on THIS disk (bind-mounted
|
|
199
|
+
// Library). For a remote backend (gateway/mcp/…) the file lives there, so
|
|
200
|
+
// always link the backend's HTTP URL — never guess a local path.
|
|
201
|
+
const local = (!isAbsUrl && isLocalBackend) ? (() => {
|
|
202
|
+
try {
|
|
203
|
+
const c = resolveImagePath(rawPath);
|
|
204
|
+
return existsSync(c) ? c : null;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
})() : null;
|
|
210
|
+
if (local) {
|
|
211
|
+
url = `file:///${local.replace(/\\/g, '/')}`;
|
|
212
|
+
shown = local;
|
|
213
|
+
}
|
|
214
|
+
else if (isAbsUrl) {
|
|
215
|
+
url = rawPath;
|
|
216
|
+
shown = rawPath;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
const p = rawPath.replace(/^\/api\/files\?path=/, '');
|
|
220
|
+
url = `${base}/api/files?path=${encodeURIComponent(p)}`;
|
|
221
|
+
shown = url;
|
|
222
|
+
}
|
|
223
|
+
const link = `\x1b]8;;${url}\x1b\\${chalk.cyan.underline(label)}\x1b]8;;\x1b\\`;
|
|
224
|
+
return `🖼 ${link} ${chalk.dim(shown)}`;
|
|
158
225
|
});
|
|
159
226
|
}
|
|
227
|
+
// Images already auto-opened this process, so we don't reopen on re-render.
|
|
228
|
+
const _openedImages = new Set();
|
|
229
|
+
/** Open a local image in the OS default viewer (detached). Best-effort. */
|
|
230
|
+
export function openLocalImage(p) {
|
|
231
|
+
if (!p || _openedImages.has(p))
|
|
232
|
+
return;
|
|
233
|
+
try {
|
|
234
|
+
if (!existsSync(p))
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
_openedImages.add(p);
|
|
241
|
+
try {
|
|
242
|
+
const plat = process.platform;
|
|
243
|
+
if (plat === 'win32')
|
|
244
|
+
spawn('cmd', ['/c', 'start', '', p], { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
|
245
|
+
else if (plat === 'darwin')
|
|
246
|
+
spawn('open', [p], { detached: true, stdio: 'ignore' }).unref();
|
|
247
|
+
else
|
|
248
|
+
spawn('xdg-open', [p], { detached: true, stdio: 'ignore' }).unref();
|
|
249
|
+
}
|
|
250
|
+
catch { /* viewer unavailable — the path is still shown */ }
|
|
251
|
+
}
|
|
252
|
+
/** Scan answer text for generated-image markdown and open any LOCAL ones in the
|
|
253
|
+
* OS viewer (so the user actually SEES the image — the TUI can't render it
|
|
254
|
+
* inline). Opt out with AITHER_AUTO_OPEN_IMAGES=0. */
|
|
255
|
+
export function autoOpenImagesFromText(text) {
|
|
256
|
+
if (!text)
|
|
257
|
+
return;
|
|
258
|
+
const off = (process.env.AITHER_AUTO_OPEN_IMAGES || '').toLowerCase();
|
|
259
|
+
if (off === '0' || off === 'false' || off === 'off' || off === 'no')
|
|
260
|
+
return;
|
|
261
|
+
const re = /!\[[^\]]*\]\((?:\/api\/files\?path=)?([^)]+)\)/g;
|
|
262
|
+
let m;
|
|
263
|
+
while ((m = re.exec(text)) !== null) {
|
|
264
|
+
const raw = m[1];
|
|
265
|
+
if (/^https?:\/\//i.test(raw))
|
|
266
|
+
continue; // remote — nothing local to open
|
|
267
|
+
try {
|
|
268
|
+
const c = resolveImagePath(raw);
|
|
269
|
+
if (existsSync(c))
|
|
270
|
+
openLocalImage(c);
|
|
271
|
+
}
|
|
272
|
+
catch { /* */ }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
160
275
|
export function renderMarkdown(text) {
|
|
161
276
|
try {
|
|
162
277
|
let clean = text;
|
|
@@ -235,6 +350,20 @@ export function getSessionArtifacts() {
|
|
|
235
350
|
export function clearSessionArtifacts() {
|
|
236
351
|
_sessionArtifacts = [];
|
|
237
352
|
}
|
|
353
|
+
/** Register a locally-saved file as a session artifact (so /get + /artifacts find it). */
|
|
354
|
+
export function addSessionArtifact(a) {
|
|
355
|
+
const filename = a.path.split(/[\\/]/).pop() || a.path;
|
|
356
|
+
_sessionArtifacts.push({
|
|
357
|
+
filename,
|
|
358
|
+
path: a.path,
|
|
359
|
+
size: a.size ?? 0,
|
|
360
|
+
language: a.language || 'image',
|
|
361
|
+
retrieve_cmd: `/get ${_sessionArtifacts.length + 1}`,
|
|
362
|
+
download_url: '',
|
|
363
|
+
timestamp: new Date().toISOString(),
|
|
364
|
+
});
|
|
365
|
+
return _sessionArtifacts.length;
|
|
366
|
+
}
|
|
238
367
|
export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
239
368
|
let spinner = null;
|
|
240
369
|
let content = '';
|
|
@@ -244,8 +373,12 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
244
373
|
let completePrinted = false; // Prevent duplicate timing lines
|
|
245
374
|
const seenToolResults = new Set(); // Dedup tool_result entries
|
|
246
375
|
let tokenStreamed = false; // Track if answer was delivered via token events
|
|
376
|
+
let contentDisplayed = false; // True when actual response text was written to stdout
|
|
247
377
|
let tokensStarted = false; // Once true, spinner is permanently disabled for this session
|
|
378
|
+
let llmFired = false; // Set on llm_start — suppresses post-gen middleware even without token events
|
|
248
379
|
let lastStage = ''; // Current pipeline stage — shown in heartbeat spinner
|
|
380
|
+
let eagerActive = false; // Eager streaming: multiple answer_segments this turn
|
|
381
|
+
let eagerSegments = 0; // Count of segments rendered
|
|
249
382
|
// ── Session trace collection ──
|
|
250
383
|
const traceEvents = [];
|
|
251
384
|
const traceToolCalls = [];
|
|
@@ -323,8 +456,11 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
323
456
|
}
|
|
324
457
|
if (event.data.agent)
|
|
325
458
|
traceAgent = event.data.agent;
|
|
326
|
-
|
|
459
|
+
// Skip the "auto"/"unknown" placeholder — the real model arrives in
|
|
460
|
+
// llm_start / complete. Capturing it here is why the footer said "auto".
|
|
461
|
+
if (event.data.model && event.data.model !== 'auto' && event.data.model !== 'unknown') {
|
|
327
462
|
traceModel = event.data.model;
|
|
463
|
+
}
|
|
328
464
|
startSpinner(chalk.dim(`${event.data.agent || 'thinking'}...`));
|
|
329
465
|
break;
|
|
330
466
|
case 'thinking': {
|
|
@@ -392,8 +528,11 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
392
528
|
break;
|
|
393
529
|
}
|
|
394
530
|
case 'thinking_end': {
|
|
395
|
-
//
|
|
396
|
-
|
|
531
|
+
// Only clear the streaming thinking line if we haven't started
|
|
532
|
+
// writing answer tokens — otherwise we'd erase the answer.
|
|
533
|
+
if (!tokensStarted && !contentDisplayed) {
|
|
534
|
+
process.stdout.write('\r\x1b[2K');
|
|
535
|
+
}
|
|
397
536
|
break;
|
|
398
537
|
}
|
|
399
538
|
case 'progress':
|
|
@@ -407,6 +546,8 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
407
546
|
'affect_start', 'prefire_start', 'facet_plan_start',
|
|
408
547
|
'plan_start', 'planner_start', 'planner_done',
|
|
409
548
|
'research_start',
|
|
549
|
+
// Classification start
|
|
550
|
+
'classify_start',
|
|
410
551
|
// IntentPlanner sub-phases
|
|
411
552
|
'decompose_start', 'tool_match', 'critique_start',
|
|
412
553
|
// Context assembly sub-phases
|
|
@@ -430,20 +571,67 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
430
571
|
}
|
|
431
572
|
break;
|
|
432
573
|
}
|
|
574
|
+
case 'answer_segment': {
|
|
575
|
+
// Eager streaming: a new answer block (initial fast pass, or a
|
|
576
|
+
// refinement once background enrichment lands). Print a header and
|
|
577
|
+
// reset per-segment streaming state so tokens render cleanly.
|
|
578
|
+
eagerActive = true;
|
|
579
|
+
eagerSegments += 1;
|
|
580
|
+
stopSpinner();
|
|
581
|
+
const segKind = event.data.kind || 'initial';
|
|
582
|
+
const segReason = event.data.reason ? chalk.dim(` (${event.data.reason})`) : '';
|
|
583
|
+
if (hasOutput)
|
|
584
|
+
process.stdout.write('\n');
|
|
585
|
+
if (segKind === 'continuation') {
|
|
586
|
+
log(chalk.cyan(' ↳ Continuing') + segReason);
|
|
587
|
+
}
|
|
588
|
+
else if (segKind === 'refinement') {
|
|
589
|
+
log(chalk.cyan(' ↻ Refining') + segReason);
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
log(chalk.cyan(' 💬 Initial') + segReason);
|
|
593
|
+
}
|
|
594
|
+
tokensStarted = false; // re-enter first-token path for this segment
|
|
595
|
+
content = ''; // per-segment accumulator (final captured at complete)
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
case 'segment_end': {
|
|
599
|
+
if (!content.endsWith('\n'))
|
|
600
|
+
process.stdout.write('\n');
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
433
603
|
case 'token': {
|
|
434
604
|
stopSpinner();
|
|
435
|
-
tokensStarted
|
|
605
|
+
if (!tokensStarted) {
|
|
606
|
+
tokensStarted = true; // Permanently disable spinner for this response
|
|
607
|
+
// Clear any residual progress/thinking line before first token
|
|
608
|
+
process.stdout.write('\r\x1b[2K');
|
|
609
|
+
if (steeringBar?.active)
|
|
610
|
+
steeringBar.enterTokenMode();
|
|
611
|
+
}
|
|
436
612
|
const t = decodeHtmlEntities(event.data.t || '');
|
|
437
613
|
content += t;
|
|
438
614
|
process.stdout.write(t);
|
|
439
615
|
hasOutput = true;
|
|
440
616
|
tokenStreamed = true;
|
|
617
|
+
contentDisplayed = true;
|
|
441
618
|
break;
|
|
442
619
|
}
|
|
443
620
|
case 'message':
|
|
444
621
|
case 'answer':
|
|
445
622
|
case 'final_answer': {
|
|
446
623
|
stopSpinner();
|
|
624
|
+
// Eager streaming already rendered the answer as live segments —
|
|
625
|
+
// the terminal `answer` event is just the canonical copy for
|
|
626
|
+
// trace/getContent. Capture it, don't re-print.
|
|
627
|
+
if (eagerActive) {
|
|
628
|
+
content = event.data.answer || event.data.content || content;
|
|
629
|
+
contentDisplayed = true;
|
|
630
|
+
hasOutput = true;
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
// Clear any residual spinner/thinking line
|
|
634
|
+
process.stdout.write('\r\x1b[2K');
|
|
447
635
|
const answer = event.data.response || event.data.answer || event.data.content || '';
|
|
448
636
|
if (answer) {
|
|
449
637
|
// Dedup: skip if already printed (AgentRuntime emits final_answer,
|
|
@@ -466,6 +654,7 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
466
654
|
if (!answer.endsWith('\n'))
|
|
467
655
|
process.stdout.write('\n');
|
|
468
656
|
hasOutput = true;
|
|
657
|
+
contentDisplayed = true;
|
|
469
658
|
}
|
|
470
659
|
// Render interactive/display blocks in terminal
|
|
471
660
|
const blocks = event.data.render_blocks;
|
|
@@ -592,19 +781,26 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
592
781
|
case 'done':
|
|
593
782
|
case 'complete': {
|
|
594
783
|
stopSpinner();
|
|
784
|
+
if (steeringBar?.active)
|
|
785
|
+
steeringBar.exitTokenMode();
|
|
595
786
|
// Only render content + timing once (engine + router can both emit 'complete')
|
|
596
787
|
if (completePrinted)
|
|
597
788
|
break;
|
|
598
789
|
completePrinted = true;
|
|
599
|
-
if (!
|
|
790
|
+
if (!contentDisplayed && event.data.content) {
|
|
600
791
|
content = event.data.content;
|
|
792
|
+
process.stdout.write('\r\x1b[2K');
|
|
601
793
|
process.stdout.write(renderMarkdown(wrapBareCode(content)));
|
|
602
794
|
hasOutput = true;
|
|
795
|
+
contentDisplayed = true;
|
|
603
796
|
}
|
|
604
|
-
else if (tokenStreamed) {
|
|
797
|
+
else if (tokenStreamed && !eagerActive) {
|
|
605
798
|
// Content was streamed raw via token events — re-render with
|
|
606
799
|
// markdown so code blocks display properly. wrapBareCode catches
|
|
607
800
|
// bare code even when no fences are present.
|
|
801
|
+
// Skipped in eager mode: segments already streamed as plain text
|
|
802
|
+
// and `content` only holds the last segment, so a markdown
|
|
803
|
+
// re-render would duplicate / mangle the multi-segment output.
|
|
608
804
|
const wrapped = wrapBareCode(content);
|
|
609
805
|
if (wrapped !== content || content.includes('```')) {
|
|
610
806
|
process.stdout.write('\n');
|
|
@@ -613,19 +809,18 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
613
809
|
}
|
|
614
810
|
if (hasOutput)
|
|
615
811
|
process.stdout.write('\n');
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
if (
|
|
623
|
-
|
|
812
|
+
autoOpenImagesFromText(content); // pop generated images in the OS viewer
|
|
813
|
+
// Update trace vars from the authoritative complete event, then let
|
|
814
|
+
// finish() print the SINGLE canonical footer. Printing a footer here
|
|
815
|
+
// too caused the duplicate "[model \u00b7 ms \u00b7 agent]" + "[agent | model
|
|
816
|
+
// | time]" lines the user saw.
|
|
817
|
+
const _cm = event.data.model || event.data.model_used;
|
|
818
|
+
if (_cm && _cm !== 'auto' && _cm !== 'unknown')
|
|
819
|
+
traceModel = _cm;
|
|
624
820
|
if (event.data.agent)
|
|
625
|
-
|
|
626
|
-
if (
|
|
627
|
-
|
|
628
|
-
}
|
|
821
|
+
traceAgent = event.data.agent;
|
|
822
|
+
if (event.data.turns_completed)
|
|
823
|
+
traceTurns = event.data.turns_completed;
|
|
629
824
|
break;
|
|
630
825
|
}
|
|
631
826
|
case 'error':
|
|
@@ -639,6 +834,15 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
639
834
|
stopSpinner();
|
|
640
835
|
hasOutput = true; // Prevent "(no response)" — we already showed trace output
|
|
641
836
|
break;
|
|
837
|
+
case 'stream_interrupted':
|
|
838
|
+
// Transport died mid-turn (backend/LB restart) — unlike a soft
|
|
839
|
+
// timeout the connection is GONE, so say so instead of going silent.
|
|
840
|
+
stopSpinner();
|
|
841
|
+
log(chalk.yellow(`\n ⚠ Stream interrupted — ${event.data.error || 'connection lost'}`));
|
|
842
|
+
log(chalk.dim(' The backend connection dropped mid-turn; any answer above is partial. Resend to retry.'));
|
|
843
|
+
traceErrors.push(`stream interrupted: ${event.data.error || 'connection lost'}`);
|
|
844
|
+
hasOutput = true;
|
|
845
|
+
break;
|
|
642
846
|
case 'heartbeat':
|
|
643
847
|
case 'keepalive': {
|
|
644
848
|
const elapsedMs = event.data.elapsed_ms || event.data.elapsed;
|
|
@@ -1066,8 +1270,11 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
1066
1270
|
case 'llm_start': {
|
|
1067
1271
|
// From UCB: has model, prompt_tokens_est, has_tools
|
|
1068
1272
|
// From AgentRuntime: has turn, timestamp (minimal)
|
|
1273
|
+
llmFired = true;
|
|
1069
1274
|
const llmModel = event.data.model || 'auto';
|
|
1070
|
-
|
|
1275
|
+
if (event.data.model && event.data.model !== 'auto' && event.data.model !== 'unknown') {
|
|
1276
|
+
traceModel = event.data.model;
|
|
1277
|
+
}
|
|
1071
1278
|
const promptTokens = event.data.prompt_tokens_est || 0;
|
|
1072
1279
|
const hasTools2 = event.data.has_tools ? ' + tools' : '';
|
|
1073
1280
|
const turnInfo = event.data.turn ? `Turn ${event.data.turn} · ` : '';
|
|
@@ -1083,6 +1290,8 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
1083
1290
|
case 'llm_done':
|
|
1084
1291
|
case 'llm_end': {
|
|
1085
1292
|
stopSpinner();
|
|
1293
|
+
if (tokensStarted)
|
|
1294
|
+
break; // complete handler prints timing metadata
|
|
1086
1295
|
const llmMs = event.data.llm_time_ms || event.data.duration_ms || 0;
|
|
1087
1296
|
const tokensUsed = event.data.tokens_used || event.data.tokens || 0;
|
|
1088
1297
|
const llmModelUsed = event.data.model_used || event.data.model || 'default';
|
|
@@ -1093,21 +1302,32 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
1093
1302
|
}
|
|
1094
1303
|
case 'middleware_progress': {
|
|
1095
1304
|
const mwName = event.data.middleware || event.data.stage || 'middleware';
|
|
1096
|
-
const mwStatus = event.data.status || 'running';
|
|
1097
1305
|
const mwDetail = event.data.detail || '';
|
|
1306
|
+
const mwElapsed = event.data.elapsed_ms ? ` ${Math.round(event.data.elapsed_ms)}ms` : '';
|
|
1098
1307
|
lastStage = mwName;
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1308
|
+
const mwLabel = `${mwName}${mwDetail ? ` (${mwDetail})` : ''}`;
|
|
1309
|
+
// FULL TRACE (default): print a permanent line for every pipeline
|
|
1310
|
+
// stage — even after the LLM fires — so the user sees exactly what
|
|
1311
|
+
// the system is doing and where the time goes, every turn.
|
|
1312
|
+
if (TRACE_FULL) {
|
|
1313
|
+
log(chalk.dim(` ⚙ ${mwLabel}${mwElapsed}`));
|
|
1314
|
+
if (steeringBar?.active)
|
|
1315
|
+
steeringBar.setStatus(`${mwName}...`);
|
|
1316
|
+
break;
|
|
1317
|
+
}
|
|
1318
|
+
// QUIET mode: after LLM fires, suppress visible lines — bar status only.
|
|
1319
|
+
if (tokensStarted || llmFired) {
|
|
1320
|
+
if (steeringBar?.active)
|
|
1321
|
+
steeringBar.setStatus(`${mwName}...`);
|
|
1322
|
+
break;
|
|
1323
|
+
}
|
|
1324
|
+
// QUIET mode pre-generation: ONE transient status line instead of a
|
|
1325
|
+
// permanent line per middleware (avoids "→ planexecutor: running" spam).
|
|
1101
1326
|
if (steeringBar?.active) {
|
|
1102
|
-
|
|
1103
|
-
// Status updates without extra redraws.
|
|
1104
|
-
console.log(chalk.dim(` ${mwIcon} ${mwName}: ${mwStatus}${detailStr}`));
|
|
1105
|
-
steeringBar.setStatus(`${mwName}...`);
|
|
1327
|
+
steeringBar.setStatus(`${mwLabel}...`);
|
|
1106
1328
|
}
|
|
1107
1329
|
else {
|
|
1108
|
-
|
|
1109
|
-
log(chalk.dim(` ${mwIcon} ${mwName}: ${mwStatus}${detailStr}`));
|
|
1110
|
-
startSpinner(chalk.dim(`${mwName}...`));
|
|
1330
|
+
startSpinner(chalk.dim(`${mwLabel}...`));
|
|
1111
1331
|
}
|
|
1112
1332
|
break;
|
|
1113
1333
|
}
|
|
@@ -1181,6 +1401,7 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
1181
1401
|
const artCmd = event.data.retrieve_cmd || '';
|
|
1182
1402
|
const artLang = event.data.language || '';
|
|
1183
1403
|
const artPath = event.data.path || '';
|
|
1404
|
+
const artDownloadUrl = event.data.download_url || '';
|
|
1184
1405
|
// Track artifact in session store
|
|
1185
1406
|
_sessionArtifacts.push({
|
|
1186
1407
|
filename: artFile,
|
|
@@ -1188,14 +1409,15 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
1188
1409
|
size: artSize,
|
|
1189
1410
|
language: artLang,
|
|
1190
1411
|
retrieve_cmd: artCmd,
|
|
1412
|
+
download_url: artDownloadUrl,
|
|
1191
1413
|
timestamp: new Date().toISOString(),
|
|
1192
1414
|
});
|
|
1193
1415
|
const artIdx = _sessionArtifacts.length;
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
}
|
|
1416
|
+
const artSizeStr = artSize > 1024
|
|
1417
|
+
? `${(artSize / 1024).toFixed(0)} KB`
|
|
1418
|
+
: `${artSize} bytes`;
|
|
1419
|
+
log(chalk.green.bold(`\n 📦 Artifact #${artIdx}: ${artFile} (${artSizeStr})`));
|
|
1420
|
+
log(chalk.cyan(` /get ${artIdx}`) + chalk.dim(` to save to current directory`));
|
|
1199
1421
|
break;
|
|
1200
1422
|
}
|
|
1201
1423
|
// Pipeline stages that update spinner
|
|
@@ -1415,11 +1637,57 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
1415
1637
|
}
|
|
1416
1638
|
break;
|
|
1417
1639
|
}
|
|
1640
|
+
case 'mcts_iteration': {
|
|
1641
|
+
// Live MCTS planning progress — shows the planner is actually
|
|
1642
|
+
// working instead of a silent "planexecutor" blackout.
|
|
1643
|
+
const mi = event.data.iteration ?? '?';
|
|
1644
|
+
const mt = event.data.total ?? '?';
|
|
1645
|
+
const mbv = typeof event.data.best_value === 'number' ? event.data.best_value.toFixed(3) : '?';
|
|
1646
|
+
const mms = event.data.elapsed_ms ? ` ${Math.round(event.data.elapsed_ms)}ms` : '';
|
|
1647
|
+
lastStage = `mcts ${mi}/${mt}`;
|
|
1648
|
+
if (TRACE_FULL) {
|
|
1649
|
+
log(chalk.dim(` 🌲 MCTS: iter ${mi}/${mt} · best=${mbv} · depth=${event.data.depth ?? '?'}${mms}`));
|
|
1650
|
+
}
|
|
1651
|
+
else if (steeringBar?.active) {
|
|
1652
|
+
steeringBar.setStatus(`mcts ${mi}/${mt}...`);
|
|
1653
|
+
}
|
|
1654
|
+
break;
|
|
1655
|
+
}
|
|
1656
|
+
case 'plan_phase': {
|
|
1657
|
+
const ppPhase = event.data.name || event.data.phase || 'phase';
|
|
1658
|
+
const ppMs = event.data.elapsed_ms ? ` ${Math.round(event.data.elapsed_ms)}ms` : '';
|
|
1659
|
+
const ppCand = event.data.candidates != null ? ` · ${event.data.candidates} candidate(s)` : '';
|
|
1660
|
+
const ppIter = event.data.iterations ? ` · ${event.data.iterations} iters` : '';
|
|
1661
|
+
lastStage = `plan: ${ppPhase}`;
|
|
1662
|
+
if (TRACE_FULL) {
|
|
1663
|
+
log(chalk.dim(` 📐 Plan phase: ${ppPhase}${ppMs}${ppCand}${ppIter}`));
|
|
1664
|
+
}
|
|
1665
|
+
else if (steeringBar?.active) {
|
|
1666
|
+
steeringBar.setStatus(`plan: ${ppPhase}...`);
|
|
1667
|
+
}
|
|
1668
|
+
break;
|
|
1669
|
+
}
|
|
1670
|
+
case 'plan_start': {
|
|
1671
|
+
const psTier = event.data.tier || '';
|
|
1672
|
+
const psTimeout = event.data.timeout_ms ? ` (budget ${Math.round(event.data.timeout_ms)}ms)` : '';
|
|
1673
|
+
lastStage = 'planning';
|
|
1674
|
+
if (TRACE_FULL) {
|
|
1675
|
+
log(chalk.dim(` 📐 Planning started${psTier ? `: ${psTier}` : ''}${psTimeout}`));
|
|
1676
|
+
}
|
|
1677
|
+
else if (steeringBar?.active) {
|
|
1678
|
+
steeringBar.setStatus('planning...');
|
|
1679
|
+
}
|
|
1680
|
+
break;
|
|
1681
|
+
}
|
|
1418
1682
|
case 'plan_complete':
|
|
1419
1683
|
case 'plan_status': {
|
|
1420
1684
|
const pcStatus = event.data.status || event.data.outcome || 'done';
|
|
1421
1685
|
lastStage = `plan: ${pcStatus}`;
|
|
1422
|
-
|
|
1686
|
+
// "timeout"/"no_plan" are normal — planning is optional enrichment,
|
|
1687
|
+
// the turn proceeds without a plan. Don't alarm the user.
|
|
1688
|
+
const pcBenign = pcStatus === 'timeout' || pcStatus === 'no_plan' || pcStatus === 'no_result';
|
|
1689
|
+
const pcIcon = pcBenign ? '○' : '✓';
|
|
1690
|
+
log(chalk.dim(` ${pcIcon} Planning ${pcBenign ? `skipped (${pcStatus}) — proceeding without a plan` : pcStatus}`));
|
|
1423
1691
|
break;
|
|
1424
1692
|
}
|
|
1425
1693
|
case 'turn_progress': {
|
|
@@ -1433,6 +1701,48 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
1433
1701
|
}
|
|
1434
1702
|
break;
|
|
1435
1703
|
}
|
|
1704
|
+
// ── Pipeline transparency events ──
|
|
1705
|
+
case 'middleware_chain_complete': {
|
|
1706
|
+
const mwCount = event.data.count || 0;
|
|
1707
|
+
const mwMs = Math.round(event.data.total_ms || 0);
|
|
1708
|
+
log(chalk.dim(` 🔗 Pipeline: ${mwCount} stages, ${mwMs}ms`));
|
|
1709
|
+
break;
|
|
1710
|
+
}
|
|
1711
|
+
case 'context_flame_graph': {
|
|
1712
|
+
const neurons = event.data.neurons_fired || 0;
|
|
1713
|
+
const ctxTokens = event.data.total_tokens || 0;
|
|
1714
|
+
const quality = (event.data.quality_score || 0).toFixed(2);
|
|
1715
|
+
log(chalk.dim(` 🧠 Context: ${neurons} neurons, ${ctxTokens} tokens, quality=${quality}`));
|
|
1716
|
+
break;
|
|
1717
|
+
}
|
|
1718
|
+
case 'prefire_result': {
|
|
1719
|
+
if (event.data.tool) {
|
|
1720
|
+
log(chalk.dim(` ⚡ Pre-fired: ${event.data.tool} (${event.data.chars || 0} chars)`));
|
|
1721
|
+
}
|
|
1722
|
+
else if (event.data.tools) {
|
|
1723
|
+
log(chalk.dim(` ⚡ Pre-firing: ${event.data.tools.join(', ')}`));
|
|
1724
|
+
}
|
|
1725
|
+
break;
|
|
1726
|
+
}
|
|
1727
|
+
case 'context_eviction': {
|
|
1728
|
+
const evCount = event.data.evicted || 0;
|
|
1729
|
+
const evTokens = event.data.tokens_freed || 0;
|
|
1730
|
+
log(chalk.dim(` 🗑 Evicted: ${evCount} chunks, ${evTokens} tokens freed`));
|
|
1731
|
+
break;
|
|
1732
|
+
}
|
|
1733
|
+
case 'neuron_fire': {
|
|
1734
|
+
const nChunks = event.data.chunks_count || 0;
|
|
1735
|
+
const nTokens = event.data.total_tokens || 0;
|
|
1736
|
+
log(chalk.dim(` 🧬 Neurons: ${nChunks} chunks, ${nTokens} tokens`));
|
|
1737
|
+
break;
|
|
1738
|
+
}
|
|
1739
|
+
case 'notebook_export': {
|
|
1740
|
+
log(chalk.dim(` 📓 Notebook: ${event.data.notebook_id || ''}`));
|
|
1741
|
+
break;
|
|
1742
|
+
}
|
|
1743
|
+
case 'speculative_prefetch':
|
|
1744
|
+
case 'outcome_recorded':
|
|
1745
|
+
break; // silent — internal bookkeeping
|
|
1436
1746
|
default: {
|
|
1437
1747
|
// Show content from unexpected events as fallback
|
|
1438
1748
|
if (event.data.content && !hasOutput) {
|
|
@@ -1441,10 +1751,24 @@ export function createStreamRenderer(sessionId, prompt, steeringBar) {
|
|
|
1441
1751
|
content += event.data.content;
|
|
1442
1752
|
hasOutput = true;
|
|
1443
1753
|
}
|
|
1444
|
-
// Show meaningful unknown events so nothing is silently swallowed
|
|
1445
|
-
|
|
1754
|
+
// Show meaningful unknown events so nothing is silently swallowed.
|
|
1755
|
+
// Pipeline substages are discriminated by `stage` (the wire type is
|
|
1756
|
+
// often 'pipeline'); label by stage and prefer a human field, else —
|
|
1757
|
+
// under full trace — show a compact scalar dump so telemetry like
|
|
1758
|
+
// llm_route / thinking_budget / reasoning_summary is never invisible.
|
|
1759
|
+
const d = event.data || {};
|
|
1760
|
+
const label = d.stage || event.type;
|
|
1761
|
+
let msg = d.message || d.reason || d.status || d.summary || d.detail || '';
|
|
1762
|
+
if (!msg && TRACE_FULL && (d.stage || event.type === 'pipeline')) {
|
|
1763
|
+
msg = Object.entries(d)
|
|
1764
|
+
.filter(([k, v]) => k !== 'type' && k !== 'stage' &&
|
|
1765
|
+
(typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'))
|
|
1766
|
+
.slice(0, 4)
|
|
1767
|
+
.map(([k, v]) => `${k}=${String(v).slice(0, 30)}`)
|
|
1768
|
+
.join(' ');
|
|
1769
|
+
}
|
|
1446
1770
|
if (msg && event.type !== 'debug') {
|
|
1447
|
-
log(chalk.dim(` [${
|
|
1771
|
+
log(chalk.dim(` [${label}] ${String(msg).slice(0, 120)}`));
|
|
1448
1772
|
}
|
|
1449
1773
|
break;
|
|
1450
1774
|
}
|
|
@@ -1640,31 +1964,32 @@ export function formatTable(headers, rows) {
|
|
|
1640
1964
|
const body = rows.map(row => row.map((cell, i) => pad(cell || '', widths[i])).join(' ')).join('\n');
|
|
1641
1965
|
return `${chalk.bold(header)}\n${chalk.dim(sep)}\n${body}`;
|
|
1642
1966
|
}
|
|
1643
|
-
/* ── Steering Bar —
|
|
1967
|
+
/* ── Steering Bar — fixed bottom bar via ANSI scroll regions ──── */
|
|
1644
1968
|
/**
|
|
1645
|
-
* Keeps a
|
|
1646
|
-
* stream output
|
|
1647
|
-
*
|
|
1969
|
+
* Keeps a fixed input bar at the bottom of the terminal while
|
|
1970
|
+
* stream output scrolls above. Uses DECSTBM (Set Scrolling Region)
|
|
1971
|
+
* to physically separate the output area from the bar — no stdout
|
|
1972
|
+
* monkey-patching, no erase/redraw races.
|
|
1648
1973
|
*
|
|
1649
|
-
*
|
|
1650
|
-
* 1
|
|
1651
|
-
* 2
|
|
1652
|
-
*
|
|
1653
|
-
*
|
|
1654
|
-
* 5. On Enter, the bar clears and the repl routes to /chat/steer
|
|
1974
|
+
* Layout:
|
|
1975
|
+
* Rows 1 to (rows-3): SCROLL REGION — all output, tokens, spinners
|
|
1976
|
+
* Row (rows-2): Status spinner (e.g., "generation...")
|
|
1977
|
+
* Row (rows-1): Separator line ────────
|
|
1978
|
+
* Row (rows): [steer] > input
|
|
1655
1979
|
*
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1980
|
+
* Output cannot collide with the bar because they occupy different
|
|
1981
|
+
* terminal regions.
|
|
1658
1982
|
*/
|
|
1659
1983
|
export class SteeringBar {
|
|
1660
1984
|
_active = false;
|
|
1661
1985
|
_inputText = '';
|
|
1662
1986
|
_statusText = '';
|
|
1663
|
-
_origWrite = null;
|
|
1664
|
-
_barDrawn = false;
|
|
1665
|
-
_debounceTimer = null;
|
|
1666
1987
|
_spinFrame = 0;
|
|
1667
1988
|
_spinTimer = null;
|
|
1989
|
+
_tokenMode = false;
|
|
1990
|
+
_resizeHandler = null;
|
|
1991
|
+
_resizeTimer = null;
|
|
1992
|
+
_lastRows = 0; // Last geometry we drew the bar at (for resize cleanup)
|
|
1668
1993
|
static _SPIN_CHARS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
1669
1994
|
get active() { return this._active; }
|
|
1670
1995
|
get inputText() { return this._inputText; }
|
|
@@ -1674,90 +1999,90 @@ export class SteeringBar {
|
|
|
1674
1999
|
this._active = true;
|
|
1675
2000
|
this._inputText = '';
|
|
1676
2001
|
this._statusText = '';
|
|
1677
|
-
this._barDrawn = false;
|
|
1678
2002
|
if (!process.stdout.isTTY)
|
|
1679
2003
|
return;
|
|
1680
|
-
|
|
1681
|
-
this.
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
2004
|
+
// Set up scroll region and draw initial bar
|
|
2005
|
+
this._setupRegion();
|
|
2006
|
+
// Handle terminal resize. Dragging a terminal edge fires many 'resize'
|
|
2007
|
+
// events in a burst; redrawing the scroll-region bar on each one (against
|
|
2008
|
+
// partially-reflowed geometry) is what left a stale duplicate green
|
|
2009
|
+
// "aither >" prompt behind. Debounce, then do a CLEAN repaint that resets
|
|
2010
|
+
// the scroll region and erases the OLD bar lines before redrawing.
|
|
2011
|
+
this._resizeHandler = () => this._handleResize();
|
|
2012
|
+
process.stdout.on('resize', this._resizeHandler);
|
|
2013
|
+
}
|
|
2014
|
+
/** Debounced, artifact-free resize repaint. */
|
|
2015
|
+
_handleResize() {
|
|
2016
|
+
if (!this._active || !process.stdout.isTTY)
|
|
2017
|
+
return;
|
|
2018
|
+
if (this._resizeTimer)
|
|
2019
|
+
clearTimeout(this._resizeTimer);
|
|
2020
|
+
this._resizeTimer = setTimeout(() => {
|
|
2021
|
+
this._resizeTimer = null;
|
|
2022
|
+
if (!this._active || !process.stdout.isTTY)
|
|
2023
|
+
return;
|
|
2024
|
+
// Clear the bar lines at their PREVIOUS positions (the source of the
|
|
2025
|
+
// stale duplicate prompt) with the scroll region reset to full screen
|
|
2026
|
+
// so we can address any row, then re-establish the region + redraw.
|
|
2027
|
+
const oldRows = this._lastRows || (process.stdout.rows || 24);
|
|
2028
|
+
let buf = '\x1b7\x1b[r'; // save cursor, reset scroll region to full
|
|
2029
|
+
for (let r = Math.max(1, oldRows - 2); r <= oldRows; r++) {
|
|
2030
|
+
buf += `\x1b[${r};1H\x1b[2K`; // clear each old bar line
|
|
1691
2031
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
//
|
|
1695
|
-
|
|
1696
|
-
clearTimeout(self._debounceTimer);
|
|
1697
|
-
self._debounceTimer = setTimeout(() => {
|
|
1698
|
-
self._debounceTimer = null;
|
|
1699
|
-
if (self._active)
|
|
1700
|
-
self._drawBar(origWrite);
|
|
1701
|
-
}, 30);
|
|
1702
|
-
return result;
|
|
1703
|
-
};
|
|
1704
|
-
// Draw initial bar
|
|
1705
|
-
this._drawBar(origWrite);
|
|
2032
|
+
buf += '\x1b8'; // restore cursor
|
|
2033
|
+
process.stdout.write(buf);
|
|
2034
|
+
this._setupRegion(); // recompute region for new geometry + redraw
|
|
2035
|
+
}, 60);
|
|
1706
2036
|
}
|
|
1707
2037
|
deactivate() {
|
|
1708
2038
|
if (!this._active)
|
|
1709
2039
|
return;
|
|
1710
2040
|
this._active = false;
|
|
1711
2041
|
this._statusText = '';
|
|
1712
|
-
if (this._debounceTimer) {
|
|
1713
|
-
clearTimeout(this._debounceTimer);
|
|
1714
|
-
this._debounceTimer = null;
|
|
1715
|
-
}
|
|
1716
2042
|
if (this._spinTimer) {
|
|
1717
2043
|
clearInterval(this._spinTimer);
|
|
1718
2044
|
this._spinTimer = null;
|
|
1719
2045
|
}
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
this.
|
|
1723
|
-
this._barDrawn = false;
|
|
2046
|
+
if (this._resizeTimer) {
|
|
2047
|
+
clearTimeout(this._resizeTimer);
|
|
2048
|
+
this._resizeTimer = null;
|
|
1724
2049
|
}
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
this._origWrite = null;
|
|
2050
|
+
if (this._resizeHandler) {
|
|
2051
|
+
process.stdout.removeListener('resize', this._resizeHandler);
|
|
2052
|
+
this._resizeHandler = null;
|
|
1729
2053
|
}
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
clearTimeout(this._debounceTimer);
|
|
1737
|
-
const w = this._origWrite;
|
|
1738
|
-
this._debounceTimer = setTimeout(() => {
|
|
1739
|
-
this._debounceTimer = null;
|
|
1740
|
-
if (!this._active)
|
|
1741
|
-
return;
|
|
1742
|
-
if (this._barDrawn) {
|
|
1743
|
-
w('\x1b8\x1b[J');
|
|
1744
|
-
this._barDrawn = false;
|
|
2054
|
+
if (process.stdout.isTTY) {
|
|
2055
|
+
const rows = process.stdout.rows || 24;
|
|
2056
|
+
// Save cursor (in scroll region), clear the bottom 3 bar lines
|
|
2057
|
+
let buf = '\x1b7';
|
|
2058
|
+
for (let r = rows - 2; r <= rows; r++) {
|
|
2059
|
+
buf += `\x1b[${r};1H\x1b[2K`;
|
|
1745
2060
|
}
|
|
1746
|
-
|
|
1747
|
-
|
|
2061
|
+
// Reset scroll region to full screen, restore cursor, move to new line
|
|
2062
|
+
buf += '\x1b[r\x1b8\n';
|
|
2063
|
+
process.stdout.write(buf);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
/** Set scroll region and draw bar, preserving cursor position. */
|
|
2067
|
+
_setupRegion() {
|
|
2068
|
+
const rows = process.stdout.rows || 24;
|
|
2069
|
+
const scrollEnd = Math.max(1, rows - 3);
|
|
2070
|
+
// Save cursor, set scroll region, restore cursor — content stays put
|
|
2071
|
+
process.stdout.write(`\x1b7\x1b[1;${scrollEnd}r\x1b8`);
|
|
2072
|
+
this._lastRows = rows; // remember geometry for the next resize cleanup
|
|
2073
|
+
this._drawBar();
|
|
1748
2074
|
}
|
|
1749
2075
|
/** Show spinner + status text in the bar (replaces ora when bar is active). */
|
|
1750
2076
|
setStatus(text) {
|
|
1751
2077
|
this._statusText = text;
|
|
1752
|
-
if (!this._spinTimer && this._active
|
|
1753
|
-
const origWrite = this._origWrite;
|
|
2078
|
+
if (!this._spinTimer && this._active) {
|
|
1754
2079
|
this._spinTimer = setInterval(() => {
|
|
1755
2080
|
this._spinFrame = (this._spinFrame + 1) % SteeringBar._SPIN_CHARS.length;
|
|
1756
2081
|
if (this._active)
|
|
1757
|
-
this.
|
|
2082
|
+
this._drawBar();
|
|
1758
2083
|
}, 80);
|
|
1759
2084
|
}
|
|
1760
|
-
|
|
2085
|
+
this._drawBar();
|
|
1761
2086
|
}
|
|
1762
2087
|
/** Clear status text. */
|
|
1763
2088
|
clearStatus() {
|
|
@@ -1766,47 +2091,64 @@ export class SteeringBar {
|
|
|
1766
2091
|
clearInterval(this._spinTimer);
|
|
1767
2092
|
this._spinTimer = null;
|
|
1768
2093
|
}
|
|
1769
|
-
|
|
2094
|
+
this._drawBar();
|
|
1770
2095
|
}
|
|
1771
2096
|
/** Update displayed input text (called from readline _ttyWrite hook). */
|
|
1772
2097
|
setInput(text) {
|
|
1773
2098
|
this._inputText = text;
|
|
1774
|
-
|
|
1775
|
-
// Keystroke — redraw immediately (no debounce for input responsiveness)
|
|
1776
|
-
if (this._debounceTimer)
|
|
1777
|
-
clearTimeout(this._debounceTimer);
|
|
1778
|
-
this._debounceTimer = null;
|
|
1779
|
-
if (this._barDrawn) {
|
|
1780
|
-
this._origWrite('\x1b8\x1b[J');
|
|
1781
|
-
this._barDrawn = false;
|
|
1782
|
-
}
|
|
1783
|
-
this._drawBar(this._origWrite);
|
|
1784
|
-
}
|
|
2099
|
+
this._drawBar();
|
|
1785
2100
|
}
|
|
1786
2101
|
/** Clear input after steer send. */
|
|
1787
2102
|
clearInput() {
|
|
1788
2103
|
this._inputText = '';
|
|
1789
|
-
this.
|
|
2104
|
+
this._drawBar();
|
|
2105
|
+
}
|
|
2106
|
+
enterTokenMode() {
|
|
2107
|
+
this._tokenMode = true;
|
|
2108
|
+
if (this._spinTimer) {
|
|
2109
|
+
clearInterval(this._spinTimer);
|
|
2110
|
+
this._spinTimer = null;
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
exitTokenMode() {
|
|
2114
|
+
this._tokenMode = false;
|
|
1790
2115
|
}
|
|
1791
|
-
|
|
2116
|
+
/**
|
|
2117
|
+
* Draw the fixed bottom bar — single atomic write.
|
|
2118
|
+
* Uses absolute positioning outside the scroll region, then restores
|
|
2119
|
+
* cursor back into the scroll region.
|
|
2120
|
+
*/
|
|
2121
|
+
_drawBar() {
|
|
2122
|
+
if (!this._active || !process.stdout.isTTY)
|
|
2123
|
+
return;
|
|
2124
|
+
const rows = process.stdout.rows || 24;
|
|
1792
2125
|
const cols = process.stdout.columns || 80;
|
|
1793
2126
|
const barWidth = Math.min(cols, 60);
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
let statusLine = '';
|
|
2127
|
+
// Status line content
|
|
2128
|
+
let statusContent = '';
|
|
1797
2129
|
if (this._statusText) {
|
|
1798
2130
|
const frame = SteeringBar._SPIN_CHARS[this._spinFrame];
|
|
1799
|
-
|
|
2131
|
+
statusContent = `${chalk.cyan(frame)} ${chalk.dim(this._statusText)}`;
|
|
1800
2132
|
}
|
|
1801
|
-
// Input
|
|
1802
|
-
const prefix = chalk.dim('
|
|
1803
|
-
const prefixLen =
|
|
2133
|
+
// Input line content
|
|
2134
|
+
const prefix = chalk.dim(' aither') + chalk.yellow(' > ');
|
|
2135
|
+
const prefixLen = 10;
|
|
1804
2136
|
const maxInput = cols - prefixLen - 1;
|
|
1805
2137
|
const display = this._inputText.length > maxInput
|
|
1806
2138
|
? this._inputText.slice(-maxInput)
|
|
1807
2139
|
: this._inputText;
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
2140
|
+
const sep = chalk.dim('─'.repeat(barWidth));
|
|
2141
|
+
// Build entire bar as one atomic buffer:
|
|
2142
|
+
// Save cursor (in scroll region)
|
|
2143
|
+
let buf = '\x1b7';
|
|
2144
|
+
// Row rows-2: status line
|
|
2145
|
+
buf += `\x1b[${rows - 2};1H\x1b[2K${statusContent}`;
|
|
2146
|
+
// Row rows-1: separator
|
|
2147
|
+
buf += `\x1b[${rows - 1};1H\x1b[2K${sep}`;
|
|
2148
|
+
// Row rows: input prompt
|
|
2149
|
+
buf += `\x1b[${rows};1H\x1b[2K${prefix}${display}`;
|
|
2150
|
+
// Restore cursor (back to scroll region)
|
|
2151
|
+
buf += '\x1b8';
|
|
2152
|
+
process.stdout.write(buf);
|
|
1811
2153
|
}
|
|
1812
2154
|
}
|