@aitherium/shell-cli 1.11.1 → 1.12.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.
- package/dist/client.d.ts +10 -0
- package/dist/client.js +62 -6
- package/dist/commands.js +153 -0
- package/dist/doc-view.d.ts +35 -0
- package/dist/doc-view.js +144 -0
- package/dist/image-preview.d.ts +45 -0
- package/dist/image-preview.js +250 -0
- package/dist/install-wizard.d.ts +94 -0
- package/dist/install-wizard.js +397 -0
- package/dist/main.js +36 -9
- package/dist/renderer.js +52 -4
- package/dist/repl.js +204 -10
- package/dist/status-banner.js +22 -2
- package/dist/tui/controller.js +37 -0
- package/dist/tui/repl-tui.js +222 -10
- package/dist/tui/screen.d.ts +12 -0
- package/dist/tui/screen.js +326 -1
- package/package.json +1 -1
package/dist/repl.js
CHANGED
|
@@ -602,7 +602,7 @@ export async function startRepl(client, config) {
|
|
|
602
602
|
}
|
|
603
603
|
// ── /jobs — built-in job management ──
|
|
604
604
|
if (cmdName === 'jobs') {
|
|
605
|
-
handleJobsCommand(cmdArgs);
|
|
605
|
+
await handleJobsCommand(cmdArgs);
|
|
606
606
|
return;
|
|
607
607
|
}
|
|
608
608
|
// ── /relay — native group chat (humans + agents) ──
|
|
@@ -1046,9 +1046,47 @@ export async function startRepl(client, config) {
|
|
|
1046
1046
|
}
|
|
1047
1047
|
}
|
|
1048
1048
|
/* ── /jobs command handler ──────────────────────────────────── */
|
|
1049
|
-
function handleJobsCommand(args) {
|
|
1049
|
+
async function handleJobsCommand(args) {
|
|
1050
1050
|
const parts = args.trim().split(/\s+/);
|
|
1051
1051
|
const sub = parts[0] || '';
|
|
1052
|
+
// ── Cloud expeditions ──
|
|
1053
|
+
if (sub === 'cloud' || sub === 'cjobs') {
|
|
1054
|
+
const expId = parts[1] || '';
|
|
1055
|
+
if (expId) {
|
|
1056
|
+
// /jobs cloud <id>
|
|
1057
|
+
await handleExpeditionDetail(client, expId);
|
|
1058
|
+
}
|
|
1059
|
+
else {
|
|
1060
|
+
// /jobs cloud
|
|
1061
|
+
await handleExpeditionList(client);
|
|
1062
|
+
}
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
// ── Steer & hint ──
|
|
1066
|
+
if (sub === 'steer' || sub === 'hint') {
|
|
1067
|
+
const expId = parts[1];
|
|
1068
|
+
if (!expId) {
|
|
1069
|
+
console.log(chalk.yellow(` Usage: /jobs ${sub} <expedition-id> <message...>`));
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
const message = parts.slice(2).join(' ');
|
|
1073
|
+
if (!message) {
|
|
1074
|
+
console.log(chalk.yellow(` Usage: /jobs ${sub} <expedition-id> <message...>`));
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
await handleExpeditionSteer(client, expId, message, sub === 'hint' ? 'hint' : 'append');
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
// ── Watch ──
|
|
1081
|
+
if (sub === 'watch') {
|
|
1082
|
+
const expId = parts[1];
|
|
1083
|
+
if (!expId) {
|
|
1084
|
+
console.log(chalk.yellow(' Usage: /jobs watch <expedition-id>'));
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
await handleExpeditionWatch(client, expId);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1052
1090
|
// /jobs cancel <id>
|
|
1053
1091
|
if (sub === 'cancel' || sub === 'kill') {
|
|
1054
1092
|
const id = Number(parts[1]);
|
|
@@ -1076,19 +1114,175 @@ export async function startRepl(client, config) {
|
|
|
1076
1114
|
console.log(formatJobOutput(job));
|
|
1077
1115
|
return;
|
|
1078
1116
|
}
|
|
1079
|
-
// /jobs — list all
|
|
1117
|
+
// /jobs — list all local + help
|
|
1080
1118
|
const allJobs = listJobs();
|
|
1081
1119
|
if (allJobs.length === 0) {
|
|
1082
|
-
console.log(chalk.dim(' No background jobs.'));
|
|
1083
|
-
return;
|
|
1120
|
+
console.log(chalk.dim(' No local background jobs.'));
|
|
1084
1121
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1122
|
+
else {
|
|
1123
|
+
console.log(chalk.bold('\n Local Background Jobs\n'));
|
|
1124
|
+
for (const job of allJobs) {
|
|
1125
|
+
console.log(formatJobLine(job));
|
|
1126
|
+
}
|
|
1088
1127
|
}
|
|
1089
|
-
console.log(chalk.dim(`\n /jobs <id> View output`));
|
|
1090
|
-
console.log(chalk.dim(` /jobs cancel <id> Cancel a running job`));
|
|
1091
1128
|
console.log();
|
|
1129
|
+
console.log(chalk.bold(' Commands\n'));
|
|
1130
|
+
console.log(chalk.dim(' Local jobs:'));
|
|
1131
|
+
console.log(` ${chalk.cyan('/jobs')} List local background jobs`);
|
|
1132
|
+
console.log(` ${chalk.cyan('/jobs <id>')} View job output`);
|
|
1133
|
+
console.log(` ${chalk.cyan('/jobs cancel <id>')} Cancel a running job`);
|
|
1134
|
+
console.log();
|
|
1135
|
+
console.log(chalk.dim(' Cloud expeditions (durable):'));
|
|
1136
|
+
console.log(` ${chalk.cyan('/jobs cloud')} List expeditions`);
|
|
1137
|
+
console.log(` ${chalk.cyan('/jobs cloud <id>')} View expedition details`);
|
|
1138
|
+
console.log(` ${chalk.cyan('/jobs steer <id> msg')} Send message to expedition`);
|
|
1139
|
+
console.log(` ${chalk.cyan('/jobs hint <id> msg')} Send invisible hint to expedition`);
|
|
1140
|
+
console.log(` ${chalk.cyan('/jobs watch <id>')} Watch expedition stream live`);
|
|
1141
|
+
console.log();
|
|
1142
|
+
}
|
|
1143
|
+
/* ── Expedition helpers ────────────────────────────────────── */
|
|
1144
|
+
async function handleExpeditionList(client) {
|
|
1145
|
+
try {
|
|
1146
|
+
const result = await client.listExpeditions();
|
|
1147
|
+
if (!result || result.error) {
|
|
1148
|
+
console.log(chalk.red(` Error: ${result?.error || 'Failed to list expeditions'}`));
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
const expeditions = result.expeditions || [];
|
|
1152
|
+
if (!expeditions.length) {
|
|
1153
|
+
console.log(chalk.dim(' No expeditions.'));
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
console.log(chalk.bold('\n Cloud Expeditions (Durable Jobs)\n'));
|
|
1157
|
+
// Format as a table: ID | Status | Title | Owner
|
|
1158
|
+
const headers = ['ID', 'Status', 'Title', 'Owner'];
|
|
1159
|
+
const rows = expeditions.map((exp) => [
|
|
1160
|
+
exp.id.slice(0, 8),
|
|
1161
|
+
formatExpeditionStatus(exp.status),
|
|
1162
|
+
(exp.title || '').slice(0, 40),
|
|
1163
|
+
exp.owner || 'unknown',
|
|
1164
|
+
]);
|
|
1165
|
+
// Simple table format
|
|
1166
|
+
const maxLens = [8, 12, 40, 15];
|
|
1167
|
+
console.log(` ${headers.map((h, i) => h.padEnd(maxLens[i])).join(' ')}`);
|
|
1168
|
+
console.log(` ${headers.map(() => '─'.repeat(10)).join(' ')}`);
|
|
1169
|
+
for (const row of rows) {
|
|
1170
|
+
console.log(` ${row.map((c, i) => String(c).padEnd(maxLens[i])).join(' ')}`);
|
|
1171
|
+
}
|
|
1172
|
+
console.log(chalk.dim(`\n /jobs cloud <id> View expedition details`));
|
|
1173
|
+
console.log();
|
|
1174
|
+
}
|
|
1175
|
+
catch (err) {
|
|
1176
|
+
console.log(chalk.red(` Error: ${err.message}`));
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
async function handleExpeditionDetail(client, expId) {
|
|
1180
|
+
try {
|
|
1181
|
+
const status = await client.getExpeditionStatus(expId);
|
|
1182
|
+
if (!status || status.error) {
|
|
1183
|
+
console.log(chalk.red(` Expedition not found: ${expId}`));
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
const tasks = await client.getExpeditionTasks(expId);
|
|
1187
|
+
const taskList = tasks?.tasks || [];
|
|
1188
|
+
console.log(chalk.bold(`\n Expedition: ${expId}\n`));
|
|
1189
|
+
console.log(` Status: ${formatExpeditionStatus(status.status || 'unknown')}`);
|
|
1190
|
+
if (status.title)
|
|
1191
|
+
console.log(` Title: ${status.title}`);
|
|
1192
|
+
if (status.owner)
|
|
1193
|
+
console.log(` Owner: ${status.owner}`);
|
|
1194
|
+
if (status.created_at)
|
|
1195
|
+
console.log(` Created: ${new Date(status.created_at).toLocaleString()}`);
|
|
1196
|
+
if (taskList.length > 0) {
|
|
1197
|
+
console.log(chalk.bold('\n Tasks\n'));
|
|
1198
|
+
for (const task of taskList) {
|
|
1199
|
+
const icon = task.status === 'completed' ? chalk.green('✓') : task.status === 'failed' ? chalk.red('✗') : chalk.blue('•');
|
|
1200
|
+
const title = task.title || task.id.slice(0, 8);
|
|
1201
|
+
console.log(` ${icon} ${title.padEnd(30)} ${chalk.dim(task.status)}`);
|
|
1202
|
+
if (task.result_summary) {
|
|
1203
|
+
const summary = typeof task.result_summary === 'string'
|
|
1204
|
+
? task.result_summary
|
|
1205
|
+
: JSON.stringify(task.result_summary, null, 2);
|
|
1206
|
+
const lines = summary.split('\n').slice(0, 5);
|
|
1207
|
+
for (const line of lines) {
|
|
1208
|
+
console.log(` ${chalk.dim(line)}`);
|
|
1209
|
+
}
|
|
1210
|
+
if (summary.split('\n').length > 5) {
|
|
1211
|
+
console.log(` ${chalk.dim('...')}`);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (task.error) {
|
|
1215
|
+
console.log(` ${chalk.red(task.error)}`);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
console.log();
|
|
1220
|
+
}
|
|
1221
|
+
catch (err) {
|
|
1222
|
+
console.log(chalk.red(` Error: ${err.message}`));
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
async function handleExpeditionSteer(client, expId, message, action) {
|
|
1226
|
+
try {
|
|
1227
|
+
const result = await client.steerExpedition(expId, message, action);
|
|
1228
|
+
if (!result || result.error) {
|
|
1229
|
+
console.log(chalk.red(` Error: ${result?.error || 'Failed to steer expedition'}`));
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const verb = action === 'hint' ? 'hint sent' : 'message sent';
|
|
1233
|
+
console.log(chalk.green(` ✓ ${verb} to expedition ${result.expedition_id}`));
|
|
1234
|
+
refreshPrompt();
|
|
1235
|
+
}
|
|
1236
|
+
catch (err) {
|
|
1237
|
+
console.log(chalk.red(` Error: ${err.message}`));
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
async function handleExpeditionWatch(client, expId) {
|
|
1241
|
+
try {
|
|
1242
|
+
console.log(chalk.cyan(` Watching expedition ${expId}... (Ctrl+C to stop)\n`));
|
|
1243
|
+
let eventCount = 0;
|
|
1244
|
+
for await (const event of client.streamExpeditionEvents(expId)) {
|
|
1245
|
+
eventCount++;
|
|
1246
|
+
if (event.type === 'error') {
|
|
1247
|
+
console.log(chalk.red(` Error: ${event.data.error}`));
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
// Print event summaries
|
|
1251
|
+
const ts = new Date().toLocaleTimeString();
|
|
1252
|
+
const typeIcon = event.type === 'EXPEDITION_COMPLETED' ? chalk.green('✓')
|
|
1253
|
+
: event.type === 'EXPEDITION_FAILED' ? chalk.red('✗')
|
|
1254
|
+
: '•';
|
|
1255
|
+
console.log(` ${ts} ${typeIcon} ${event.type}`);
|
|
1256
|
+
if (event.data.message) {
|
|
1257
|
+
console.log(` ${chalk.dim(event.data.message)}`);
|
|
1258
|
+
}
|
|
1259
|
+
if (event.type === 'EXPEDITION_COMPLETED' || event.type === 'EXPEDITION_FAILED') {
|
|
1260
|
+
console.log(chalk.cyan(`\n Expedition ${event.data.status || 'done'}`));
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (eventCount === 0) {
|
|
1265
|
+
console.log(chalk.yellow(' No events received (expedition may not exist or is already done).'));
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
catch (err) {
|
|
1269
|
+
if (err.name === 'AbortError' || err.message?.includes('abort')) {
|
|
1270
|
+
console.log(chalk.yellow('\n Watch cancelled.'));
|
|
1271
|
+
}
|
|
1272
|
+
else {
|
|
1273
|
+
console.log(chalk.red(` Error: ${err.message}`));
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
function formatExpeditionStatus(status) {
|
|
1278
|
+
const normalized = (status || '').toLowerCase();
|
|
1279
|
+
if (normalized === 'completed' || normalized === 'done')
|
|
1280
|
+
return chalk.green(status);
|
|
1281
|
+
if (normalized === 'failed' || normalized === 'error')
|
|
1282
|
+
return chalk.red(status);
|
|
1283
|
+
if (normalized === 'running' || normalized === 'active')
|
|
1284
|
+
return chalk.blue(status);
|
|
1285
|
+
return chalk.yellow(status);
|
|
1092
1286
|
}
|
|
1093
1287
|
/* ── Background forge launcher ──────────────────────────────── */
|
|
1094
1288
|
function launchBgForge(client, args) {
|
package/dist/status-banner.js
CHANGED
|
@@ -13,14 +13,34 @@
|
|
|
13
13
|
* connect banner and the TUI share one implementation.
|
|
14
14
|
*/
|
|
15
15
|
import chalk from 'chalk';
|
|
16
|
+
/** Loopback/private hosts serve the internal self-signed CA. Bun's `fetch`
|
|
17
|
+
* ignores NODE_EXTRA_CA_CERTS (set in main.ts), so an https probe to an internal
|
|
18
|
+
* service — notably Identity at :8115, which is https-ONLY (http → 400) — fails
|
|
19
|
+
* cert validation and gets false-flagged DOWN even though it's up. Health probes
|
|
20
|
+
* carry no secrets, so accept the internal cert for private hosts; PUBLIC remote
|
|
21
|
+
* endpoints (idp/gateway, real valid certs) keep strict TLS validation. */
|
|
22
|
+
function insecureTlsFor(url) {
|
|
23
|
+
try {
|
|
24
|
+
const h = new URL(url).hostname;
|
|
25
|
+
const isPrivate = h === 'localhost' || h === '127.0.0.1' || h === '::1' ||
|
|
26
|
+
/^10\./.test(h) || /^192\.168\./.test(h) ||
|
|
27
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(h) ||
|
|
28
|
+
h.endsWith('.local') || h.endsWith('.internal');
|
|
29
|
+
// `tls` is Bun's per-request option (the shipped binary is Bun-compiled);
|
|
30
|
+
// Node's fetch ignores it harmlessly.
|
|
31
|
+
return isPrivate ? { tls: { rejectUnauthorized: false } } : {};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
16
37
|
/** Probe a health endpoint, tolerating http↔https and a single cold-start miss. */
|
|
17
38
|
export async function probeHealth(url, timeoutMs = 4000) {
|
|
18
39
|
const tryFetch = async (u) => {
|
|
19
40
|
try {
|
|
20
41
|
const r = await fetch(u, {
|
|
21
42
|
signal: AbortSignal.timeout(timeoutMs),
|
|
22
|
-
|
|
23
|
-
...(u.startsWith('https') ? { dispatcher: undefined } : {}),
|
|
43
|
+
...insecureTlsFor(u),
|
|
24
44
|
});
|
|
25
45
|
return r.ok;
|
|
26
46
|
}
|
package/dist/tui/controller.js
CHANGED
|
@@ -16,6 +16,7 @@ export function createTuiRenderer(surface, sessionId, prompt) {
|
|
|
16
16
|
let completePrinted = false;
|
|
17
17
|
let terminalSeen = false; // complete/error/timeout/interrupt reached us
|
|
18
18
|
let answerCheckpoint = null; // OUTPUT offset where the answer text begins
|
|
19
|
+
let pendingFollowups = []; // stashed on suggested_followups; rendered AFTER complete
|
|
19
20
|
const traceEvents = [];
|
|
20
21
|
const toolCalls = [];
|
|
21
22
|
const thinking = [];
|
|
@@ -145,6 +146,16 @@ export function createTuiRenderer(surface, sessionId, prompt) {
|
|
|
145
146
|
// (replaceOutputFrom) instead of leaving the weak first pass on screen.
|
|
146
147
|
// Matches renderer.ts (the plain REPL) which does `content = data.answer`.
|
|
147
148
|
content = String(text);
|
|
149
|
+
// If this eager segment printed a header ("💬 Initial …") but never
|
|
150
|
+
// streamed any tokens (the engine returned the answer as one event,
|
|
151
|
+
// e.g. the search fastpath), show it NOW rather than waiting for
|
|
152
|
+
// 'complete' — an interrupted/timed-out stream would otherwise leave
|
|
153
|
+
// the user staring at just the preamble. 'complete' re-renders via
|
|
154
|
+
// replaceOutputFrom(answerCheckpoint).
|
|
155
|
+
if (!tokenStreamed && answerCheckpoint == null) {
|
|
156
|
+
answerCheckpoint = surface.markCheckpoint();
|
|
157
|
+
surface.appendOutput(String(text));
|
|
158
|
+
}
|
|
148
159
|
}
|
|
149
160
|
break;
|
|
150
161
|
}
|
|
@@ -259,10 +270,36 @@ export function createTuiRenderer(surface, sessionId, prompt) {
|
|
|
259
270
|
}
|
|
260
271
|
const ms = d.duration_ms != null ? `${(Number(d.duration_ms) / 1000).toFixed(1)}s` : '';
|
|
261
272
|
surface.outputLine(chalk.dim(`── ${[agent, model, ms].filter(Boolean).join(' · ')}`));
|
|
273
|
+
// Follow-up chips AFTER the answer (complete just rewrote the answer
|
|
274
|
+
// region via replaceOutputFrom, so they couldn't be printed earlier).
|
|
275
|
+
if (pendingFollowups.length) {
|
|
276
|
+
surface.outputLine(chalk.dim(' Next →'));
|
|
277
|
+
pendingFollowups.forEach((t, i) => surface.outputLine(' ' + chalk.cyan(`[${i + 1}]`) + ' ' + chalk.dim(String(t))));
|
|
278
|
+
surface.setPendingFollowups(pendingFollowups); // enable digit-select for next input
|
|
279
|
+
}
|
|
262
280
|
autoOpenImagesFromText(content); // pop generated images in the OS viewer
|
|
263
281
|
surface.finishTraceTurn('done');
|
|
264
282
|
break;
|
|
265
283
|
}
|
|
284
|
+
case 'suggested_followups': {
|
|
285
|
+
// Stash now; render in 'complete' (after the answer is finalised).
|
|
286
|
+
const items = Array.isArray(d.followups) ? d.followups.map((x) => String(x)) : [];
|
|
287
|
+
if (items.length)
|
|
288
|
+
pendingFollowups = items;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
case 'notebook_ready': {
|
|
292
|
+
// A clarified plan was turned into a notebook. formatTrace drops this
|
|
293
|
+
// (no message/stage field), so surface the id or the user never knows.
|
|
294
|
+
const id = String(d.notebook_id || d.plan_id || '').trim();
|
|
295
|
+
surface.outputLine(chalk.cyan(` 📓 Notebook ready${id ? `: ${id}` : ''}`));
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case 'stream_warning':
|
|
299
|
+
// 60s+ of stream silence (e.g. reasoning model cold-start). Reassure
|
|
300
|
+
// the user the turn isn't dead.
|
|
301
|
+
surface.traceLine(chalk.yellow(` ⏳ ${d.message || 'still working — model may be warming up…'}`));
|
|
302
|
+
break;
|
|
266
303
|
case 'heartbeat':
|
|
267
304
|
case 'keepalive':
|
|
268
305
|
case 'debug':
|
package/dist/tui/repl-tui.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* for those; everything else runs in-pane.
|
|
11
11
|
*/
|
|
12
12
|
import { execSync } from 'node:child_process';
|
|
13
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { dirname } from 'node:path';
|
|
15
15
|
import chalk from 'chalk';
|
|
16
16
|
import { setActiveConfig } from '../config.js';
|
|
@@ -21,6 +21,9 @@ import { collectArgs } from '../interactive.js';
|
|
|
21
21
|
import { gatherStatus, formatStatusLines } from '../status-banner.js';
|
|
22
22
|
import { setJobNotifier, listJobs, getJob, cancelJob, runningCount, launchChatJob, launchForgeJob, launchSwarmJob, launchCommandJob, formatJobLine, formatJobOutput, } from '../jobs.js';
|
|
23
23
|
import { configureRemoteSync, recordTurn, loadSession, buildContextSummary } from '../session-store.js';
|
|
24
|
+
import { openLocalImage } from '../renderer.js';
|
|
25
|
+
import { loadDoc, renderDocLines, resolveDocPath } from '../doc-view.js';
|
|
26
|
+
import { imageToAnsi, imageDimensions } from '../image-preview.js';
|
|
24
27
|
import { createTuiScreen } from './screen.js';
|
|
25
28
|
import { createTuiRenderer } from './controller.js';
|
|
26
29
|
import { setTuiActive } from '../crash-reporter.js';
|
|
@@ -106,6 +109,11 @@ export async function startTuiRepl(client, config) {
|
|
|
106
109
|
let histIdx = history.length;
|
|
107
110
|
let activeAbort = null;
|
|
108
111
|
let processing = false;
|
|
112
|
+
// Type-ahead queue: messages typed while a turn is streaming. Run as fresh
|
|
113
|
+
// turns the instant the current one finishes (Claude-Code style) instead of
|
|
114
|
+
// being dropped into a steer that oneshot/trivial turns silently discard.
|
|
115
|
+
let queuedInputs = [];
|
|
116
|
+
let draining = false;
|
|
109
117
|
let sigintCount = 0;
|
|
110
118
|
let sigintTimer = null;
|
|
111
119
|
let gpuTag = '';
|
|
@@ -295,8 +303,13 @@ export async function startTuiRepl(client, config) {
|
|
|
295
303
|
}
|
|
296
304
|
sigintCount++;
|
|
297
305
|
if (sigintCount >= 2) {
|
|
306
|
+
const q = queuedInputs.length;
|
|
298
307
|
setTuiActive(null);
|
|
299
308
|
surface.destroy();
|
|
309
|
+
// Write AFTER destroy so it lands in the restored terminal, not the
|
|
310
|
+
// torn-down blessed screen — otherwise the user never sees it.
|
|
311
|
+
if (q)
|
|
312
|
+
process.stderr.write(`\n⚠ Discarded ${q} queued message(s).\n`);
|
|
300
313
|
process.exit(0);
|
|
301
314
|
}
|
|
302
315
|
surface.setStatus('Ctrl+C again to exit');
|
|
@@ -387,18 +400,54 @@ export async function startTuiRepl(client, config) {
|
|
|
387
400
|
relayStatus('sent · agents may reply…');
|
|
388
401
|
return;
|
|
389
402
|
}
|
|
390
|
-
//
|
|
403
|
+
// A turn is in flight. cancel/stop/abort interrupt it; anything else is
|
|
404
|
+
// queued and run as a fresh turn the moment this one finishes. (The old
|
|
405
|
+
// fire-and-forget steer was silently dropped on oneshot/trivial turns —
|
|
406
|
+
// which is every plain question — so the message just vanished.)
|
|
391
407
|
if (activeAbort && processing) {
|
|
392
408
|
const lower = clean.toLowerCase();
|
|
393
409
|
if (lower === 'cancel' || lower === 'stop' || lower === '@abort') {
|
|
394
410
|
onInterrupt();
|
|
395
411
|
return;
|
|
396
412
|
}
|
|
397
|
-
|
|
398
|
-
|
|
413
|
+
queuedInputs.push(clean);
|
|
414
|
+
surface.traceLine(chalk.cyan(` ⏳ queued (${queuedInputs.length}) — sends after this turn`));
|
|
399
415
|
return;
|
|
400
416
|
}
|
|
401
|
-
|
|
417
|
+
// Bare digit (1-9) → expand to the Nth follow-up suggestion from the last turn.
|
|
418
|
+
const _pick = clean.match(/^([1-9])$/);
|
|
419
|
+
if (_pick) {
|
|
420
|
+
const fups = surface.getPendingFollowups();
|
|
421
|
+
const idx = parseInt(_pick[1], 10) - 1;
|
|
422
|
+
if (idx >= 0 && idx < fups.length) {
|
|
423
|
+
const chosen = fups[idx];
|
|
424
|
+
surface.setPendingFollowups([]); // consume
|
|
425
|
+
surface.outputLine(chalk.dim(` → ${chosen}`));
|
|
426
|
+
await runChatDraining(chosen, explicitBg);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
surface.setPendingFollowups([]); // any new message invalidates last turn's chips
|
|
431
|
+
await runChatDraining(clean, explicitBg);
|
|
432
|
+
}
|
|
433
|
+
/** Run a chat turn, then drain any messages the user typed while it streamed,
|
|
434
|
+
* running each as its own turn in order. The `draining` guard keeps two
|
|
435
|
+
* rapid submits from spinning up overlapping drain loops. */
|
|
436
|
+
async function runChatDraining(input, bg = false) {
|
|
437
|
+
await runChat(input, bg);
|
|
438
|
+
if (draining)
|
|
439
|
+
return;
|
|
440
|
+
draining = true;
|
|
441
|
+
try {
|
|
442
|
+
while (queuedInputs.length && !processing) {
|
|
443
|
+
const next = queuedInputs.shift();
|
|
444
|
+
surface.outputLine(chalk.green('› ') + chalk.dim('(queued) ') + next);
|
|
445
|
+
await runChat(next, false);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
draining = false;
|
|
450
|
+
}
|
|
402
451
|
}
|
|
403
452
|
function showHelp() {
|
|
404
453
|
surface.outputLine(chalk.bold('Keys: ') + chalk.dim('Edit: ←/→ move · Ctrl+A/E line start/end · Ctrl+W del word · Ctrl+U/K kill · Home/End (empty line=scroll) | Panes: wheel/PgUp scroll · Ctrl+←/→ resize split · Ctrl+T trace · Ctrl+E expand all · click thread | Ctrl+C abort/exit · Tab complete'));
|
|
@@ -424,9 +473,76 @@ export async function startTuiRepl(client, config) {
|
|
|
424
473
|
}
|
|
425
474
|
surface.focusInput();
|
|
426
475
|
}
|
|
427
|
-
function handleJobs(args) {
|
|
476
|
+
async function handleJobs(args) {
|
|
428
477
|
const parts = args.trim().split(/\s+/);
|
|
429
478
|
const sub = parts[0] || '';
|
|
479
|
+
// ── Cloud (durable expedition) jobs — mirror the classic REPL surface ──
|
|
480
|
+
if (sub === 'cloud') {
|
|
481
|
+
const expId = parts[1];
|
|
482
|
+
if (!expId) {
|
|
483
|
+
const result = await client.listExpeditions();
|
|
484
|
+
if (!result || result.error) {
|
|
485
|
+
surface.outputLine(chalk.red(` Error: ${result?.error || 'Failed to list expeditions'}`));
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const exps = result.expeditions || [];
|
|
489
|
+
if (!exps.length) {
|
|
490
|
+
surface.outputLine(chalk.dim(' No cloud expeditions.'));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
surface.outputLine(chalk.bold(' Cloud Expeditions (Durable Jobs)'));
|
|
494
|
+
for (const e of exps) {
|
|
495
|
+
surface.outputLine(` ${chalk.bold(String(e.id).slice(0, 8))} ${e.status || '?'} ${(e.title || '').slice(0, 44)}`);
|
|
496
|
+
}
|
|
497
|
+
surface.outputLine(chalk.dim(' /jobs cloud <id> · /jobs steer <id> <msg> · /jobs watch <id>'));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const status = await client.getExpeditionStatus(expId);
|
|
501
|
+
if (!status || status.error) {
|
|
502
|
+
surface.outputLine(chalk.red(` Expedition not found: ${expId}`));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
surface.outputLine(` ${chalk.bold(expId)} status: ${status.status || '?'}`);
|
|
506
|
+
const tasks = await client.getExpeditionTasks(expId);
|
|
507
|
+
for (const t of (tasks?.tasks || [])) {
|
|
508
|
+
const out = (t.result_summary || t.error || '').slice(0, 500);
|
|
509
|
+
surface.outputLine(` • ${t.title || t.id}: ${t.status}${out ? `\n ${out}` : ''}`);
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (sub === 'steer' || sub === 'hint') {
|
|
514
|
+
const expId = parts[1];
|
|
515
|
+
const msg = parts.slice(2).join(' ');
|
|
516
|
+
if (!expId || !msg) {
|
|
517
|
+
surface.outputLine(chalk.yellow(`Usage: /jobs ${sub} <id> <message>`));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const r = await client.steerExpedition(expId, msg, sub === 'hint' ? 'hint' : 'append');
|
|
521
|
+
surface.outputLine(r?.ok ? chalk.green(` Steered ${expId} (${sub}).`) : chalk.red(` Steer failed: ${r?.error || r?.detail || 'unknown'}`));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (sub === 'watch') {
|
|
525
|
+
const expId = parts[1];
|
|
526
|
+
if (!expId) {
|
|
527
|
+
surface.outputLine(chalk.yellow('Usage: /jobs watch <id>'));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
surface.outputLine(chalk.dim(` Watching ${expId}…`));
|
|
531
|
+
try {
|
|
532
|
+
for await (const ev of client.streamExpeditionEvents(expId)) {
|
|
533
|
+
const d = ev.data || {};
|
|
534
|
+
const line = d.message || d.error || ev.type;
|
|
535
|
+
if (line)
|
|
536
|
+
surface.outputLine(` [${ev.type}] ${line}`);
|
|
537
|
+
if (ev.type === 'error' || d.status === 'completed' || d.status === 'failed')
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
surface.outputLine(chalk.red(` Watch error: ${err.message}`));
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
430
546
|
if (sub === 'cancel' || sub === 'kill') {
|
|
431
547
|
const id = Number(parts[1]);
|
|
432
548
|
if (!id) {
|
|
@@ -625,6 +741,89 @@ export async function startTuiRepl(client, config) {
|
|
|
625
741
|
surface.outputLine(chalk.dim(' 📡 Left relay — back to agent chat.'));
|
|
626
742
|
idleStatus();
|
|
627
743
|
}
|
|
744
|
+
/** `/view <path>` — open a file in the in-TUI document viewer. Markdown/code/
|
|
745
|
+
* text render in a full-screen scrollable overlay; PNG images render inline as
|
|
746
|
+
* half-blocks on the real terminal (via runDetached, for true 24-bit colour);
|
|
747
|
+
* other image formats open in the OS viewer. */
|
|
748
|
+
async function handleView(args) {
|
|
749
|
+
const path = args.trim();
|
|
750
|
+
if (!path) {
|
|
751
|
+
surface.outputLine(chalk.yellow('Usage: /view <file>') + chalk.dim(' — markdown, code, text, or a PNG image'));
|
|
752
|
+
surface.focusInput();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const doc = loadDoc(path);
|
|
756
|
+
if (doc.kind === 'missing' || doc.kind === 'binary') {
|
|
757
|
+
surface.outputLine(chalk.yellow(` ${doc.note || doc.kind}: `) + chalk.dim(doc.absPath));
|
|
758
|
+
surface.focusInput();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (doc.kind === 'image') {
|
|
762
|
+
const cols = Math.max(20, (process.stdout.columns || 100) - 4);
|
|
763
|
+
const ansi = imageToAnsi(doc.absPath, Math.min(cols, 120), 50);
|
|
764
|
+
if (ansi) {
|
|
765
|
+
const dims = imageDimensions(doc.absPath);
|
|
766
|
+
await surface.runDetached(`view ${doc.title}`, () => {
|
|
767
|
+
process.stdout.write(chalk.dim(` ${doc.title}${dims ? ` ${dims.width}×${dims.height}` : ''}\n\n`));
|
|
768
|
+
for (const l of ansi)
|
|
769
|
+
process.stdout.write(' ' + l + '\n');
|
|
770
|
+
});
|
|
771
|
+
idleStatus();
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
openLocalImage(doc.absPath);
|
|
775
|
+
surface.outputLine(chalk.dim(` 🖼 ${doc.ext.replace('.', '').toUpperCase()} opened in OS viewer (only PNG renders inline): `) + chalk.dim(doc.absPath));
|
|
776
|
+
surface.focusInput();
|
|
777
|
+
}
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const width = (process.stdout.columns || 100);
|
|
781
|
+
const { lines, fallbackOpenPath } = renderDocLines(doc, width);
|
|
782
|
+
if (fallbackOpenPath)
|
|
783
|
+
openLocalImage(fallbackOpenPath);
|
|
784
|
+
await surface.showViewer(doc.title, lines);
|
|
785
|
+
idleStatus();
|
|
786
|
+
}
|
|
787
|
+
/** `/edit <path>` — open a file in the in-TUI editor (notepad/vim-lite). Opens
|
|
788
|
+
* an empty buffer for a non-existent path (create-on-save). Refuses image/
|
|
789
|
+
* binary files. Ctrl+S writes the file; Esc/Ctrl+Q closes. */
|
|
790
|
+
async function handleEdit(args) {
|
|
791
|
+
const path = args.trim();
|
|
792
|
+
if (!path) {
|
|
793
|
+
surface.outputLine(chalk.yellow('Usage: /edit <file>') + chalk.dim(' — opens the in-shell editor (^S save · ^Q quit)'));
|
|
794
|
+
surface.focusInput();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const absPath = resolveDocPath(path);
|
|
798
|
+
let initial = '';
|
|
799
|
+
if (existsSync(absPath)) {
|
|
800
|
+
const doc = loadDoc(path);
|
|
801
|
+
if (doc.kind === 'image' || doc.kind === 'binary') {
|
|
802
|
+
surface.outputLine(chalk.yellow(` cannot edit a ${doc.kind} file: `) + chalk.dim(absPath));
|
|
803
|
+
surface.focusInput();
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
initial = doc.text ?? '';
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
surface.outputLine(chalk.dim(` new file: ${absPath}`));
|
|
810
|
+
}
|
|
811
|
+
const title = absPath.split(/[/\\]/).pop() || path;
|
|
812
|
+
await surface.showEditor(title, initial, async (text) => {
|
|
813
|
+
try {
|
|
814
|
+
const dir = dirname(absPath);
|
|
815
|
+
if (!existsSync(dir))
|
|
816
|
+
mkdirSync(dir, { recursive: true });
|
|
817
|
+
writeFileSync(absPath, text, 'utf-8');
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
catch (e) {
|
|
821
|
+
return e?.message || 'write failed';
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
surface.outputLine(chalk.dim(` edited ${absPath}`));
|
|
825
|
+
idleStatus();
|
|
826
|
+
}
|
|
628
827
|
async function runCommand(input, bg = false) {
|
|
629
828
|
const sp = input.indexOf(' ');
|
|
630
829
|
const name = sp > 0 ? input.slice(1, sp) : input.slice(1);
|
|
@@ -643,8 +842,16 @@ export async function startTuiRepl(client, config) {
|
|
|
643
842
|
showHelp();
|
|
644
843
|
return;
|
|
645
844
|
}
|
|
845
|
+
if (name === 'view' || name === 'open' || name === 'cat') {
|
|
846
|
+
await handleView(args);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (name === 'edit' || name === 'e' || name === 'nano') {
|
|
850
|
+
await handleEdit(args);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
646
853
|
if (name === 'jobs') {
|
|
647
|
-
handleJobs(args);
|
|
854
|
+
await handleJobs(args);
|
|
648
855
|
return;
|
|
649
856
|
}
|
|
650
857
|
if (name === 'relay') {
|
|
@@ -774,7 +981,9 @@ export async function startTuiRepl(client, config) {
|
|
|
774
981
|
surface.traceLine(chalk.yellow(' ⚠ LLM pool busy (0 slots) — sending anyway'));
|
|
775
982
|
}
|
|
776
983
|
}
|
|
777
|
-
catch {
|
|
984
|
+
catch {
|
|
985
|
+
surface.traceLine(chalk.yellow(' ⚠ pre-flight check failed (backend slow or down) — sending anyway'));
|
|
986
|
+
}
|
|
778
987
|
surface.outputLine(chalk.green('› ') + input);
|
|
779
988
|
surface.setStatus('working…');
|
|
780
989
|
// Open a fresh, collapsible trace thread for this turn (older turns collapse).
|
|
@@ -842,8 +1051,11 @@ export async function startTuiRepl(client, config) {
|
|
|
842
1051
|
aborted = true;
|
|
843
1052
|
surface.outputLine(chalk.dim(' (interrupted)'));
|
|
844
1053
|
}
|
|
845
|
-
else
|
|
846
|
-
|
|
1054
|
+
else {
|
|
1055
|
+
// First line only, bounded — never spray a raw stack/JSON at the user.
|
|
1056
|
+
const raw = typeof err?.message === 'string' ? err.message : String(err);
|
|
1057
|
+
surface.outputLine(chalk.red(` Error: ${raw.split('\n')[0].slice(0, 200)}`));
|
|
1058
|
+
}
|
|
847
1059
|
renderer.finish(aborted);
|
|
848
1060
|
break;
|
|
849
1061
|
}
|