@aitherium/shell-cli 1.1.0 → 1.11.1

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.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * status-banner.ts — Live fleet/backend status for the connect banner AND the
3
+ * in-TUI welcome header.
4
+ *
5
+ * The boxed connect banner (main.ts `showBanner`) prints to the normal terminal
6
+ * BEFORE the blessed alt-screen takes over, so inside the TUI it scrolls away and
7
+ * the user is left with a bare "AitherShell · url" header. `gatherStatus()` +
8
+ * `formatStatusLines()` let the TUI render the same live picture — services up,
9
+ * which backend (local vs Genesis/cloud), and the orchestrator + reasoning models
10
+ * actually loaded — right inside the output pane.
11
+ *
12
+ * The low-level probe/model helpers live here (rather than main.ts) so both the
13
+ * connect banner and the TUI share one implementation.
14
+ */
15
+ import chalk from 'chalk';
16
+ /** Probe a health endpoint, tolerating http↔https and a single cold-start miss. */
17
+ export async function probeHealth(url, timeoutMs = 4000) {
18
+ const tryFetch = async (u) => {
19
+ try {
20
+ const r = await fetch(u, {
21
+ signal: AbortSignal.timeout(timeoutMs),
22
+ // @ts-ignore — Node 18+ supports this for self-signed certs
23
+ ...(u.startsWith('https') ? { dispatcher: undefined } : {}),
24
+ });
25
+ return r.ok;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ };
31
+ if (await tryFetch(url))
32
+ return true;
33
+ if (url.startsWith('http://')) {
34
+ if (await tryFetch(url.replace('http://', 'https://')))
35
+ return true;
36
+ }
37
+ else if (url.startsWith('https://')) {
38
+ if (await tryFetch(url.replace('https://', 'http://')))
39
+ return true;
40
+ }
41
+ // One lenient retry: heavy compounds can miss a single 4s window at cold connect.
42
+ await new Promise((r) => setTimeout(r, 500));
43
+ return tryFetch(url);
44
+ }
45
+ /** Build the health-probe set from the configured endpoints. Remote endpoints
46
+ * probe the public authenticated edges they actually use (gateway/identity/mcp)
47
+ * instead of 127.0.0.1, which is unreachable from a real remote node. */
48
+ export function buildProbes(config) {
49
+ const strip = (u) => u.replace(/\/+$/, '');
50
+ const isRemote = config.requireAuth || !/127\.0\.0\.1|localhost/.test(config.genesisUrl);
51
+ if (isRemote) {
52
+ const probes = [
53
+ { name: 'Gateway', url: `${strip(config.genesisUrl)}/health` },
54
+ { name: 'Identity', url: `${strip(config.identityUrl)}/health` },
55
+ ];
56
+ if (config.mcpUrl)
57
+ probes.push({ name: 'MCP', url: `${strip(config.mcpUrl)}/health` });
58
+ const msUrl = process.env.AITHER_LLM_URL;
59
+ if (msUrl)
60
+ probes.push({ name: 'MicroScheduler', url: `${strip(msUrl)}/health` });
61
+ return probes;
62
+ }
63
+ const probes = [
64
+ { name: 'Genesis', url: 'https://127.0.0.1:8001/health' },
65
+ { name: 'Node', url: 'https://127.0.0.1:8090/health' },
66
+ { name: 'Pulse', url: 'https://127.0.0.1:8081/health' },
67
+ { name: 'MicroScheduler', url: 'https://127.0.0.1:8150/health' },
68
+ { name: 'Identity', url: 'https://127.0.0.1:8115/health' },
69
+ { name: 'Secrets', url: 'https://127.0.0.1:8111/health' },
70
+ { name: 'Chronicle', url: 'https://127.0.0.1:8121/health' },
71
+ { name: 'Strata', url: 'https://127.0.0.1:8136/health' },
72
+ ];
73
+ const msUrl = process.env.AITHER_LLM_URL || 'https://127.0.0.1:8150';
74
+ probes.find(p => p.name === 'MicroScheduler').url = `${msUrl}/health`;
75
+ return probes;
76
+ }
77
+ /** Friendly location label for an LLM backend id (from the MS snapshot). */
78
+ const _BACKEND_WHERE = {
79
+ vllm: 'vLLM (local)',
80
+ vllm_orchestrator: 'vLLM (local)',
81
+ vllm_coding: 'vLLM (local)',
82
+ vllm_swap: 'vLLM swap (local)',
83
+ vllm_reasoning: 'vLLM reasoning (local)',
84
+ vllm_dgx: 'DGX Spark',
85
+ vllm_dgx_orch: 'DGX Spark',
86
+ vllm_dgx_swap: 'DGX Spark (swap)',
87
+ deepseek_api: 'DeepSeek (cloud)',
88
+ };
89
+ /** Pick the model the orchestrator path is actually serving + where, from the
90
+ * MicroScheduler /llm/backends/snapshot. Prefers the local orchestrator vLLM
91
+ * and the tuned LoRA (the live default), so the banner names the real model. */
92
+ export function pickServingModel(backends) {
93
+ const order = ['vllm', 'vllm_orchestrator', 'vllm_coding', 'vllm_dgx'];
94
+ for (const id of order) {
95
+ const b = backends[id];
96
+ if (b?.healthy && Array.isArray(b.models) && b.models.length) {
97
+ const models = b.models;
98
+ const model = models.find((m) => /tuned/i.test(m))
99
+ || models.find((m) => /orchestrator/i.test(m))
100
+ || models[0];
101
+ return { model, where: _BACKEND_WHERE[id] || id };
102
+ }
103
+ }
104
+ for (const [id, b] of Object.entries(backends)) {
105
+ if (b?.healthy && Array.isArray(b.models) && b.models.length) {
106
+ return { model: b.models[0], where: _BACKEND_WHERE[id] || id };
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+ /** Gather a full live status snapshot. Every sub-fetch degrades to null/empty —
112
+ * never throws — so a partial fleet still produces a useful banner. */
113
+ export async function gatherStatus(client, config) {
114
+ const backend = await client.detectBackend().catch(() => null);
115
+ const host = (() => {
116
+ try {
117
+ return new URL(config.genesisUrl).host;
118
+ }
119
+ catch {
120
+ return config.genesisUrl;
121
+ }
122
+ })();
123
+ const info = {
124
+ genesisHost: host,
125
+ online: !!backend && backend.type !== 'unknown',
126
+ backendType: backend?.type || 'unknown',
127
+ backendName: backend?.name || 'offline',
128
+ services: backend?.services,
129
+ agents: backend?.agents,
130
+ serviceLines: [],
131
+ };
132
+ const probes = buildProbes(config);
133
+ const [status, reasoning, snapshot, probeResults] = await Promise.all([
134
+ client.getStatus().catch(() => null),
135
+ client.get('/reasoning/status').catch(() => null),
136
+ client.getBackendSnapshot().catch(() => null),
137
+ Promise.all(probes.map(async (p) => ({ name: p.name, up: await probeHealth(p.url) }))),
138
+ ]);
139
+ info.serviceLines = probeResults;
140
+ // Re-probe anything that looked down with a much more patient timeout. At
141
+ // launch the fleet is under cold-start load (model warmup + 250+ services), and
142
+ // heavy compounds — notably SecurityCore serving Identity — routinely miss the
143
+ // first 4s window and get false-flagged DOWN even though they answer a second
144
+ // later. This runs only when something looked down, so an all-up fleet stays fast.
145
+ const downNames = info.serviceLines.filter(s => !s.up).map(s => s.name);
146
+ if (downNames.length) {
147
+ await Promise.all(downNames.map(async (name) => {
148
+ const probe = probes.find(p => p.name === name);
149
+ if (probe && await probeHealth(probe.url, 9000)) {
150
+ const sl = info.serviceLines.find(s => s.name === name);
151
+ if (sl)
152
+ sl.up = true;
153
+ }
154
+ }));
155
+ }
156
+ // A successful /status (or /reasoning, or the MS snapshot) is PROOF we're
157
+ // connected to a live Genesis — even when detectBackend()'s /health-shape
158
+ // heuristic mis-classifies it as "unknown/offline" (Genesis minimal-mode health
159
+ // doesn't carry generation_ready/tracked_services). Trust the live data over the
160
+ // probe so the header doesn't say "offline" while showing 258 services + models.
161
+ if (status || reasoning || snapshot) {
162
+ info.online = true;
163
+ if (info.backendType === 'unknown')
164
+ info.backendType = 'genesis';
165
+ if (!info.backendName || info.backendName === 'offline')
166
+ info.backendName = 'Genesis';
167
+ }
168
+ // The endpoint we're talking to is UP by definition — a concurrent health probe
169
+ // can still flake (cold-start load, http-vs-https first try, the warmup hammering
170
+ // the box), which would otherwise show the absurd "DOWN: Genesis".
171
+ if (info.online) {
172
+ const connectedNames = info.backendType === 'genesis'
173
+ ? ['Genesis', 'Gateway']
174
+ : [info.backendName, 'Gateway'];
175
+ for (const sl of info.serviceLines) {
176
+ if (connectedNames.includes(sl.name))
177
+ sl.up = true;
178
+ }
179
+ }
180
+ if (status) {
181
+ info.health = status.health || (info.online ? 'healthy' : undefined);
182
+ info.version = status.version;
183
+ if (info.services == null)
184
+ info.services = status.tracked_services ?? status.count;
185
+ }
186
+ if (reasoning && !reasoning.error) {
187
+ const slots = (reasoning.routing?.model_slots || {});
188
+ info.orchestratorModel = slots.model || reasoning.model;
189
+ info.reasoningModel = slots.reasoning_model;
190
+ info.profile = reasoning.display_name || reasoning.active_profile;
191
+ info.isLocal = !(reasoning.cost_per_1k_tokens > 0);
192
+ }
193
+ if (snapshot?.backends) {
194
+ const pick = pickServingModel(snapshot.backends);
195
+ if (pick)
196
+ info.serving = `${pick.model} @ ${pick.where}`;
197
+ }
198
+ if (snapshot?.pool) {
199
+ info.poolFree = snapshot.pool.available_for_user ?? snapshot.pool.available;
200
+ info.poolTotal = snapshot.pool.total_slots ?? snapshot.pool.total;
201
+ }
202
+ return info;
203
+ }
204
+ /** Compact, aligned status lines for the in-TUI welcome (plain strings with
205
+ * chalk colour — caller pushes them through surface.outputLine). */
206
+ export function formatStatusLines(info) {
207
+ const lines = [];
208
+ const dot = info.online ? chalk.green('●') : chalk.red('●');
209
+ const healthStr = info.online
210
+ ? (info.health === 'ok' || info.health === 'healthy' ? chalk.green('healthy') : chalk.yellow(info.health || 'up'))
211
+ : chalk.red('offline');
212
+ const head = [
213
+ `${dot} ${chalk.bold(info.backendName || 'Genesis')} ${chalk.dim('@ ' + info.genesisHost)}`,
214
+ healthStr + (info.version ? chalk.dim(` v${info.version}`) : ''),
215
+ info.services != null ? chalk.dim(`${info.services} services`) : null,
216
+ info.agents != null ? chalk.dim(`${info.agents} agents`) : null,
217
+ info.poolTotal != null ? chalk.dim(`pool ${info.poolFree ?? '?'}/${info.poolTotal}`) : null,
218
+ ].filter(Boolean);
219
+ lines.push(' ' + head.join(chalk.dim(' · ')));
220
+ if (info.orchestratorModel || info.reasoningModel) {
221
+ const locTag = info.isLocal === false ? chalk.yellow(' (cloud)') : info.isLocal ? chalk.green(' (local)') : '';
222
+ const m = [
223
+ info.orchestratorModel ? `${chalk.dim('orchestrator')} ${chalk.cyan(info.orchestratorModel)}` : null,
224
+ info.reasoningModel ? `${chalk.dim('reasoning')} ${chalk.cyan(info.reasoningModel)}` : null,
225
+ info.profile ? chalk.dim(info.profile) + locTag : null,
226
+ ].filter(Boolean);
227
+ lines.push(' ' + chalk.bold('Models'.padEnd(8)) + m.join(chalk.dim(' · ')));
228
+ }
229
+ if (info.serving) {
230
+ lines.push(' ' + chalk.bold('Serving'.padEnd(8)) + chalk.cyan(info.serving));
231
+ }
232
+ const up = info.serviceLines.filter(s => s.up).map(s => s.name);
233
+ const dn = info.serviceLines.filter(s => !s.up).map(s => s.name);
234
+ if (up.length)
235
+ lines.push(' ' + chalk.dim('UP'.padEnd(8)) + chalk.green(up.join(', ')));
236
+ if (dn.length)
237
+ lines.push(' ' + chalk.dim('DOWN'.padEnd(8)) + chalk.red(dn.join(', ')));
238
+ return lines;
239
+ }
@@ -0,0 +1,3 @@
1
+ import { type StreamRenderer } from '../renderer.js';
2
+ import type { TuiSurface } from './screen.js';
3
+ export declare function createTuiRenderer(surface: TuiSurface, sessionId?: string, prompt?: string): StreamRenderer;
@@ -0,0 +1,310 @@
1
+ /**
2
+ * TUI stream renderer — same shape as the plain `createStreamRenderer`, but
3
+ * routes events into the blessed 3-pane surface instead of stdout.
4
+ *
5
+ * • TRACE pane ← pipeline telemetry + tool results (formatters.formatTrace)
6
+ * • OUTPUT pane ← answer segments, tokens, then a markdown re-render on complete
7
+ * • STATUS line ← agent · model · E{effort} · {stage} · elapsed (composed live)
8
+ */
9
+ import chalk from 'chalk';
10
+ import { renderMarkdown, stripOsc8, autoOpenImagesFromText } from '../renderer.js';
11
+ import { formatStatus, formatTrace, statusUpdate } from '../formatters.js';
12
+ export function createTuiRenderer(surface, sessionId, prompt) {
13
+ let content = '';
14
+ let tokenStreamed = false;
15
+ let eagerActive = false;
16
+ let completePrinted = false;
17
+ let terminalSeen = false; // complete/error/timeout/interrupt reached us
18
+ let answerCheckpoint = null; // OUTPUT offset where the answer text begins
19
+ const traceEvents = [];
20
+ const toolCalls = [];
21
+ const thinking = [];
22
+ const errors = [];
23
+ let model = '';
24
+ let agent = '';
25
+ let effort = '';
26
+ let tier = '';
27
+ let stage = '';
28
+ const startedAt = new Date().toISOString();
29
+ const startTime = Date.now();
30
+ function pushStatus() {
31
+ const elapsed = `${((Date.now() - startTime) / 1000).toFixed(0)}s`;
32
+ const bits = [agent || 'aither', model || '…', effort ? `E${effort}` : '', tier, stage, elapsed]
33
+ .filter(Boolean).join(' · ');
34
+ surface.setStatus(bits);
35
+ }
36
+ function header(kind, reason) {
37
+ const r = reason ? chalk.dim(` (${reason})`) : '';
38
+ if (kind === 'continuation')
39
+ return chalk.cyan('↳ Continuing') + r;
40
+ if (kind === 'refinement')
41
+ return chalk.cyan('↻ Refining') + r;
42
+ return chalk.cyan('💬 Initial') + r;
43
+ }
44
+ /** Summarize a tool_result into the TRACE pane (keeps OUTPUT answer-only). */
45
+ function traceToolResult(result) {
46
+ const name = result.tool || 'tool';
47
+ const ok = result.success !== false;
48
+ const icon = ok ? chalk.green('✓') : chalk.red('✗');
49
+ if (ok && result.output) {
50
+ try {
51
+ const parsed = JSON.parse(result.output);
52
+ if (Array.isArray(parsed.results) && parsed.results.length) {
53
+ surface.traceLine(` ${icon} ${chalk.bold(name)} — ${parsed.results.length} results`);
54
+ parsed.results.slice(0, 3).forEach((r, i) => surface.traceLine(chalk.dim(` ${i + 1}. ${String(r.title || r.url || 'result').slice(0, 50)}`)));
55
+ }
56
+ else if (parsed.content) {
57
+ surface.traceLine(` ${icon} ${chalk.bold(name)} ${chalk.dim(String(parsed.title || '').slice(0, 40))}`);
58
+ }
59
+ else {
60
+ surface.traceLine(` ${icon} ${chalk.bold(name)} ${chalk.dim(JSON.stringify(parsed).slice(0, 60))}`);
61
+ }
62
+ }
63
+ catch {
64
+ surface.traceLine(` ${icon} ${chalk.bold(name)} ${chalk.dim(String(result.output).replace(/\s+/g, ' ').slice(0, 60))}`);
65
+ }
66
+ }
67
+ else if (!ok) {
68
+ surface.traceLine(` ${icon} ${chalk.bold(name)} ${chalk.red(String(result.output || result.error || 'error').slice(0, 50))}`);
69
+ }
70
+ else {
71
+ surface.traceLine(` ${icon} ${name}`);
72
+ }
73
+ }
74
+ return {
75
+ onEvent(event) {
76
+ traceEvents.push(event);
77
+ const d = event.data || {};
78
+ const su = statusUpdate(event);
79
+ if (su.agent)
80
+ agent = su.agent;
81
+ const _realModel = (m) => typeof m === 'string' && m && m !== 'auto' && m !== 'unknown';
82
+ if (_realModel(su.model))
83
+ model = su.model;
84
+ // Generic capture: any event that carries a real model id (model_select,
85
+ // llm_route, llm_done, plan) sets it — so the footer isn't 'unknown'.
86
+ else if (_realModel(d.model))
87
+ model = d.model;
88
+ else if (_realModel(d.model_used))
89
+ model = d.model_used;
90
+ if (su.effort)
91
+ effort = su.effort;
92
+ if (su.tier)
93
+ tier = su.tier;
94
+ const st = formatStatus(event);
95
+ if (st)
96
+ stage = st;
97
+ pushStatus();
98
+ switch (event.type) {
99
+ case 'session_start':
100
+ if (d.agent)
101
+ agent = d.agent;
102
+ if (d.model && d.model !== 'auto' && d.model !== 'unknown')
103
+ model = d.model;
104
+ break;
105
+ case 'answer_segment':
106
+ eagerActive = true;
107
+ surface.outputLine(header(d.kind || 'initial', d.reason));
108
+ tokenStreamed = false;
109
+ break;
110
+ case 'token': {
111
+ const t = d.t || d.token || '';
112
+ if (t) {
113
+ if (answerCheckpoint == null)
114
+ answerCheckpoint = surface.markCheckpoint();
115
+ surface.appendOutput(t);
116
+ content += t;
117
+ tokenStreamed = true;
118
+ }
119
+ break;
120
+ }
121
+ case 'segment_end':
122
+ surface.appendOutput('\n');
123
+ break;
124
+ case 'partial':
125
+ if (!tokenStreamed && d.content) {
126
+ if (answerCheckpoint == null)
127
+ answerCheckpoint = surface.markCheckpoint();
128
+ surface.appendOutput(String(d.content));
129
+ }
130
+ break;
131
+ case 'message':
132
+ case 'answer':
133
+ case 'final_answer': {
134
+ const text = d.answer || d.content || d.message || '';
135
+ if (!eagerActive && !tokenStreamed && text) {
136
+ if (answerCheckpoint == null)
137
+ answerCheckpoint = surface.markCheckpoint();
138
+ surface.appendOutput(String(text));
139
+ content += String(text);
140
+ }
141
+ else if (text) {
142
+ // The grounded follow-up answer (ran tools + gathered context) arrives
143
+ // here AFTER the eager fast-pass streamed. It supersedes that throwaway
144
+ // preview — capture it so `complete` re-renders the authoritative answer
145
+ // (replaceOutputFrom) instead of leaving the weak first pass on screen.
146
+ // Matches renderer.ts (the plain REPL) which does `content = data.answer`.
147
+ content = String(text);
148
+ }
149
+ break;
150
+ }
151
+ case 'thinking': {
152
+ const thought = d.thought || d.content || '';
153
+ if (thought)
154
+ thinking.push(String(thought));
155
+ break;
156
+ }
157
+ case 'tool_call': {
158
+ const tools = d.tools || d.tool_calls || [];
159
+ for (const tool of tools) {
160
+ const name = tool.name || tool.function?.name || 'tool';
161
+ const args = tool.args || tool.arguments;
162
+ toolCalls.push({ name, args: args || {}, timestamp: Date.now() });
163
+ let arg = '';
164
+ if (args) {
165
+ const key = args.query || args.url || args.task || args.prompt || args.path || args.code;
166
+ arg = key ? chalk.dim(` → ${String(key).slice(0, 60)}`) : '';
167
+ }
168
+ surface.traceLine(chalk.yellow(` ⚡ ${name}`) + arg);
169
+ }
170
+ break;
171
+ }
172
+ case 'tool_result':
173
+ for (const r of (d.results || []))
174
+ traceToolResult(r);
175
+ break;
176
+ case 'artifact_delivered': {
177
+ const fn = d.filename || d.file || d.path || 'artifact';
178
+ const idx = d.index != null ? ` (/get ${d.index})` : '';
179
+ surface.outputLine(chalk.cyan(`📦 ${fn}`) + chalk.dim(idx));
180
+ break;
181
+ }
182
+ case 'clarification_needed':
183
+ case 'approval_required': {
184
+ const q = Array.isArray(d.questions)
185
+ ? d.questions.map((x) => (typeof x === 'string' ? x : x.question || x.text || '?')).join(' • ')
186
+ : (d.action || d.message || '');
187
+ surface.outputLine(chalk.yellow(`⏸ ${event.type === 'approval_required' ? 'Approval' : 'Clarify'}: `) + q);
188
+ break;
189
+ }
190
+ case 'image_gen_complete':
191
+ surface.outputLine(chalk.green('✓ image: ') + chalk.dim(d.url || d.path || ''));
192
+ break;
193
+ case 'error':
194
+ case 'llm_error': {
195
+ terminalSeen = true;
196
+ const msg = d.message || d.error || 'error';
197
+ errors.push(String(msg));
198
+ surface.outputLine(chalk.red('✗ ' + msg));
199
+ {
200
+ const line = formatTrace(event);
201
+ if (line)
202
+ surface.traceLine(line);
203
+ }
204
+ surface.finishTraceTurn('error');
205
+ break;
206
+ }
207
+ case 'stream_timeout':
208
+ case 'stream_interrupted': {
209
+ // The transport died mid-turn (backend/LB restart, network reset) or
210
+ // went silent past the chunk timeout. Without this case the turn was
211
+ // stamped ✓ done and the grounded follow-up answer vanished — say so.
212
+ terminalSeen = true;
213
+ const why = event.type === 'stream_timeout'
214
+ ? `no data for ${Math.round((Number(d.timeout_ms) || 120000) / 1000)}s`
215
+ : String(d.error || 'connection lost');
216
+ errors.push(`stream ${event.type === 'stream_timeout' ? 'timeout' : 'interrupted'}: ${why}`);
217
+ surface.outputLine(chalk.yellow(`⚠ stream ${event.type === 'stream_timeout' ? 'timed out' : 'interrupted'} — ${why}`));
218
+ surface.outputLine(chalk.dim(' The backend connection dropped mid-turn (Genesis/LB restart?) — any text above is partial.'));
219
+ surface.finishTraceTurn('error');
220
+ break;
221
+ }
222
+ case 'llm_done':
223
+ case 'llm_end':
224
+ if (_realModel(d.model_used))
225
+ model = d.model_used;
226
+ else if (_realModel(d.model))
227
+ model = d.model;
228
+ {
229
+ const line = formatTrace(event);
230
+ if (line)
231
+ surface.traceLine(line);
232
+ }
233
+ break;
234
+ case 'complete':
235
+ case 'done': {
236
+ terminalSeen = true;
237
+ if (completePrinted)
238
+ break;
239
+ completePrinted = true;
240
+ // Don't let a terminal 'unknown'/'auto' clobber the model we already saw.
241
+ if (d.model && d.model !== 'unknown' && d.model !== 'auto')
242
+ model = d.model;
243
+ // Defensive: if the terminal event itself carries the authoritative
244
+ // answer (and no separate `answer` event landed), prefer it over the
245
+ // eager fast-pass so the grounded result is what gets rendered.
246
+ {
247
+ const fin = d.answer || d.response || d.content || '';
248
+ if (fin)
249
+ content = String(fin);
250
+ }
251
+ // Replace the streamed raw answer with a markdown render (code blocks
252
+ // etc.). stripOsc8: blessed can't render OSC-8 hyperlinks (they show as
253
+ // raw `]8;;…`), so flatten them to plain text in the TUI.
254
+ if (content && answerCheckpoint != null) {
255
+ surface.replaceOutputFrom(answerCheckpoint, '\n' + stripOsc8(renderMarkdown(content)).replace(/\n$/, ''));
256
+ }
257
+ else if (content && answerCheckpoint == null) {
258
+ surface.outputLine(stripOsc8(renderMarkdown(content)).replace(/\n$/, ''));
259
+ }
260
+ const ms = d.duration_ms != null ? `${(Number(d.duration_ms) / 1000).toFixed(1)}s` : '';
261
+ surface.outputLine(chalk.dim(`── ${[agent, model, ms].filter(Boolean).join(' · ')}`));
262
+ autoOpenImagesFromText(content); // pop generated images in the OS viewer
263
+ surface.finishTraceTurn('done');
264
+ break;
265
+ }
266
+ case 'heartbeat':
267
+ case 'keepalive':
268
+ case 'debug':
269
+ case 'thinking_end':
270
+ break;
271
+ default: {
272
+ const line = formatTrace(event);
273
+ if (line)
274
+ surface.traceLine(line);
275
+ }
276
+ }
277
+ },
278
+ getContent() { return content; },
279
+ finish(aborted) {
280
+ surface.appendOutput('\n');
281
+ if (aborted) {
282
+ surface.finishTraceTurn('error');
283
+ }
284
+ else if (!terminalSeen) {
285
+ // Stream EOF'd with no complete/error/timeout event at all — a clean
286
+ // TCP close from a backend that died mid-turn looks exactly like this.
287
+ // Marking it ✓ done hid the loss; flag it so the user knows to resend.
288
+ surface.outputLine(chalk.yellow('⚠ stream ended before completion — the connection closed mid-turn.'));
289
+ surface.outputLine(chalk.dim(' Any text above is partial (the refined answer never arrived).'));
290
+ errors.push('stream ended before completion (no terminal event)');
291
+ surface.finishTraceTurn('error');
292
+ }
293
+ else {
294
+ surface.finishTraceTurn('done'); // fallback: stamp the thread if no terminal event fired
295
+ }
296
+ stage = '';
297
+ pushStatus();
298
+ surface.render();
299
+ },
300
+ getTrace() { return traceEvents; },
301
+ getSessionProfile() {
302
+ return {
303
+ session_id: sessionId || '', prompt: prompt || '', started_at: startedAt,
304
+ duration_ms: Date.now() - startTime, event_count: traceEvents.length,
305
+ model, agent, events: traceEvents, tool_calls: toolCalls,
306
+ thinking_traces: thinking, context_sources: {}, errors,
307
+ };
308
+ },
309
+ };
310
+ }
@@ -0,0 +1,3 @@
1
+ import type { GenesisClient } from '../client.js';
2
+ import type { ShellConfig } from '../config.js';
3
+ export declare function startTuiRepl(client: GenesisClient, config: ShellConfig): Promise<void>;