@illuminis/comprism 0.1.4 → 0.1.5
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/out/agent/command.js +1 -1
- package/out/agent/render.d.ts +39 -6
- package/out/agent/render.js +287 -28
- package/out/agent/session.d.ts +15 -0
- package/out/agent/session.js +64 -9
- package/out/commands/ask.js +12 -3
- package/out/commands/login.js +63 -9
- package/out/commands/repl.d.ts +4 -0
- package/out/commands/repl.js +456 -18
- package/out/commands/welcome.d.ts +25 -1
- package/out/commands/welcome.js +71 -35
- package/out/lib/gateway.d.ts +4 -0
- package/out/lib/keyboard.d.ts +3 -0
- package/out/lib/keyboard.js +7 -0
- package/out/lib/lastlogin.d.ts +14 -0
- package/out/lib/lastlogin.js +105 -0
- package/out/lib/picker.d.ts +1 -0
- package/out/lib/picker.js +158 -0
- package/out/lib/prompt.d.ts +3 -0
- package/out/lib/prompt.js +21 -0
- package/out/lib/startcache.d.ts +13 -0
- package/out/lib/startcache.js +78 -0
- package/out/lib/ui.d.ts +8 -0
- package/out/lib/ui.js +34 -0
- package/out/lib/voice.d.ts +36 -0
- package/out/lib/voice.js +136 -1
- package/out/native/dictate +0 -0
- package/out/thin.js +2 -2
- package/package.json +3 -2
package/out/agent/command.js
CHANGED
|
@@ -428,7 +428,7 @@ ${ui.bold("Options")}
|
|
|
428
428
|
being asked about one at a time; commands still ask
|
|
429
429
|
--model <id> tell it which model you are running, so the receipt can
|
|
430
430
|
price the saving against it. It may still move you up
|
|
431
|
-
--
|
|
431
|
+
--no-optimize Optimize off: run --model and nothing else (also --pin)
|
|
432
432
|
--max-spend <usd> stop at this much rather than running on
|
|
433
433
|
--max-steps <n> stop after this many steps
|
|
434
434
|
--max-minutes <n> start no new step after this many minutes
|
package/out/agent/render.d.ts
CHANGED
|
@@ -77,6 +77,24 @@ export declare function stoppedLines(stopped: string[]): string;
|
|
|
77
77
|
export declare function ending(line: string, detail: string | null | undefined): string;
|
|
78
78
|
/** How the job ended, in the words a person reads. */
|
|
79
79
|
export declare function outcome(o: string, detail: string | null | undefined): string;
|
|
80
|
+
/**
|
|
81
|
+
* A link a person can click, where the terminal supports one.
|
|
82
|
+
*
|
|
83
|
+
* Modern terminals understand an escape that carries an address behind a piece
|
|
84
|
+
* of text; older ones print it as ordinary characters, so the address is shown
|
|
85
|
+
* in words there instead. Never a bare escape with no fallback: a receipt whose
|
|
86
|
+
* proof is unreachable is not proof.
|
|
87
|
+
*/
|
|
88
|
+
export declare function link(text: string, url: string): string;
|
|
89
|
+
/**
|
|
90
|
+
* A file the agent made, as a link that opens it, with where it lives beneath.
|
|
91
|
+
*
|
|
92
|
+
* The name alone is not enough: somebody who asked from their home folder for a
|
|
93
|
+
* diagram cannot tell which folder "saved diagram.png" means. So the full path
|
|
94
|
+
* is written out underneath in gray, where it can be copied into Finder or a
|
|
95
|
+
* terminal, and the name above it opens the file on a click.
|
|
96
|
+
*/
|
|
97
|
+
export declare function savedFile(name: string, fullPath: string, bytes: string): string;
|
|
80
98
|
/** One model's share of a job, as our server reported it. */
|
|
81
99
|
export interface ModelShare {
|
|
82
100
|
model: string | null;
|
|
@@ -84,6 +102,18 @@ export interface ModelShare {
|
|
|
84
102
|
usd: number | null;
|
|
85
103
|
unpriced?: number;
|
|
86
104
|
}
|
|
105
|
+
/** The receipt card, exactly as the server words it for every client. */
|
|
106
|
+
export interface ReceiptCard {
|
|
107
|
+
model: string | null;
|
|
108
|
+
title: string | null;
|
|
109
|
+
vs: string | null;
|
|
110
|
+
amount: string | null;
|
|
111
|
+
saved: boolean;
|
|
112
|
+
/** The label of the link to the whole record. */
|
|
113
|
+
details?: string | null;
|
|
114
|
+
badge: string | null;
|
|
115
|
+
warning: string | null;
|
|
116
|
+
}
|
|
87
117
|
/**
|
|
88
118
|
* The receipt under a finished job.
|
|
89
119
|
*
|
|
@@ -100,13 +130,16 @@ export interface ModelShare {
|
|
|
100
130
|
* it, quieten it, and turn the address in it into something clickable. That is
|
|
101
131
|
* formatting, which belongs to the client, and it is the only part that does.
|
|
102
132
|
*/
|
|
103
|
-
export declare function receipt(text: string, models?: ModelShare[], testsPassed?: boolean): string;
|
|
133
|
+
export declare function receipt(text: string, models?: ModelShare[], testsPassed?: boolean, card?: ReceiptCard | null): string;
|
|
104
134
|
/**
|
|
105
|
-
*
|
|
135
|
+
* An answer, laid out for reading in a terminal (manual 6.17).
|
|
106
136
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
137
|
+
* The approved design (docs/product/design/cli-answer, option B): the whole
|
|
138
|
+
* answer on one left margin behind a violet rail, so where the question ends
|
|
139
|
+
* and the answer begins is never in doubt; words wrapped at a reading width
|
|
140
|
+
* and never split by the window edge; a blank line between every block;
|
|
141
|
+
* headings in the brand violet; code in its own color; wrapped list items
|
|
142
|
+
* continuing under their own text. Piped to a file or another program, the
|
|
143
|
+
* same answer comes out without the rail or the color, as a log wants it.
|
|
111
144
|
*/
|
|
112
145
|
export declare function plain(text: string): string;
|
package/out/agent/render.js
CHANGED
|
@@ -36,10 +36,13 @@ exports.diff = diff;
|
|
|
36
36
|
exports.stoppedLines = stoppedLines;
|
|
37
37
|
exports.ending = ending;
|
|
38
38
|
exports.outcome = outcome;
|
|
39
|
+
exports.link = link;
|
|
40
|
+
exports.savedFile = savedFile;
|
|
39
41
|
exports.receipt = receipt;
|
|
40
42
|
exports.plain = plain;
|
|
41
43
|
/** Built from a character code rather than written as a literal escape, so this
|
|
42
44
|
* file contains no control characters and stays safe to grep, diff and paste. */
|
|
45
|
+
const url_1 = require("url");
|
|
43
46
|
const CSI = String.fromCharCode(27) + "[";
|
|
44
47
|
/** Decided when drawn, so plain mode set at start up applies (manual 6.12). */
|
|
45
48
|
const supportsColour = () => process.stdout.isTTY && !process.env.NO_COLOR;
|
|
@@ -260,6 +263,32 @@ function link(text, url) {
|
|
|
260
263
|
return `${text} (${url})`;
|
|
261
264
|
return `]8;;${url}${text}]8;;`;
|
|
262
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* A file the agent made, as a link that opens it, with where it lives beneath.
|
|
268
|
+
*
|
|
269
|
+
* The name alone is not enough: somebody who asked from their home folder for a
|
|
270
|
+
* diagram cannot tell which folder "saved diagram.png" means. So the full path
|
|
271
|
+
* is written out underneath in gray, where it can be copied into Finder or a
|
|
272
|
+
* terminal, and the name above it opens the file on a click.
|
|
273
|
+
*/
|
|
274
|
+
function savedFile(name, fullPath, bytes) {
|
|
275
|
+
const href = (0, url_1.pathToFileURL)(fullPath).href;
|
|
276
|
+
return `${(0, exports.green)(" saved ")}${link(name, href)}${(0, exports.dim)(` ${bytes} bytes`)}\n`
|
|
277
|
+
+ `${(0, exports.gray)(` ${fullPath}`)}\n`;
|
|
278
|
+
}
|
|
279
|
+
/** The card's two lines, as the approved look draws them. */
|
|
280
|
+
function cardRows(card) {
|
|
281
|
+
const rows = [];
|
|
282
|
+
const head = [card.title ? (0, exports.bold)(card.title) : "", card.badge ? (0, exports.violet)(card.badge) : "",
|
|
283
|
+
card.vs ? (0, exports.dim)(card.vs) : ""].filter(Boolean).join(" ");
|
|
284
|
+
if (head)
|
|
285
|
+
rows.push(head);
|
|
286
|
+
if (card.amount)
|
|
287
|
+
rows.push(card.saved ? (0, exports.bold)((0, exports.green)(card.amount)) : (0, exports.dim)(card.amount));
|
|
288
|
+
if (card.warning)
|
|
289
|
+
rows.push((0, exports.dim)(card.warning));
|
|
290
|
+
return rows;
|
|
291
|
+
}
|
|
263
292
|
/**
|
|
264
293
|
* The receipt under a finished job.
|
|
265
294
|
*
|
|
@@ -276,9 +305,9 @@ function link(text, url) {
|
|
|
276
305
|
* it, quieten it, and turn the address in it into something clickable. That is
|
|
277
306
|
* formatting, which belongs to the client, and it is the only part that does.
|
|
278
307
|
*/
|
|
279
|
-
function receipt(text, models = [], testsPassed) {
|
|
280
|
-
const lines = [];
|
|
281
|
-
for (const line of String(text || "").split("\n")) {
|
|
308
|
+
function receipt(text, models = [], testsPassed, card) {
|
|
309
|
+
const lines = card ? cardRows(card).map((r) => ` ${r}`) : [];
|
|
310
|
+
for (const line of card ? [] : String(text || "").split("\n")) {
|
|
282
311
|
if (!line.trim())
|
|
283
312
|
continue;
|
|
284
313
|
// The address our server put in the sentence becomes a real link, and the
|
|
@@ -309,7 +338,13 @@ function receipt(text, models = [], testsPassed) {
|
|
|
309
338
|
return "";
|
|
310
339
|
// On a screen, the receipt is a highlighted box, as on the website. Piped to
|
|
311
340
|
// a file or another program it stays plain lines, which is what a log wants.
|
|
312
|
-
|
|
341
|
+
if (card) {
|
|
342
|
+
// The link the server put in its sentence still goes last.
|
|
343
|
+
const found = /\[([^\]]+)\]\(([^)]+)\)/.exec(String(text || ""));
|
|
344
|
+
if (found)
|
|
345
|
+
lines.push((0, exports.dim)(` ${link(String(card.details || found[1]), String(found[2]))}`));
|
|
346
|
+
}
|
|
347
|
+
return process.stdout.isTTY ? boxed(text, models, testsPassed, card) : `${lines.join("\n")}\n`;
|
|
313
348
|
}
|
|
314
349
|
/** Words wrapped to a width, never breaking a word. */
|
|
315
350
|
function wrap(text, room) {
|
|
@@ -332,12 +367,20 @@ function wrap(text, room) {
|
|
|
332
367
|
* everything else quiet. The server's words are unchanged; each clause it
|
|
333
368
|
* separated with `|` gets its own line, and its link goes last, clickable.
|
|
334
369
|
*/
|
|
335
|
-
function boxed(text, models, testsPassed) {
|
|
370
|
+
function boxed(text, models, testsPassed, card) {
|
|
336
371
|
// Room for the border, the padding and the `receipt` label on the first line.
|
|
337
372
|
const inner = width() - 16;
|
|
338
|
-
const rows = [];
|
|
373
|
+
const rows = card ? cardRows(card) : [];
|
|
339
374
|
const links = [];
|
|
340
375
|
for (const raw of String(text || "").split("\n")) {
|
|
376
|
+
if (card) {
|
|
377
|
+
// The card carries the words; only the sentence's link is kept.
|
|
378
|
+
raw.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
|
|
379
|
+
links.push({ label: String(card.details || label), url: String(url) });
|
|
380
|
+
return "";
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
341
384
|
const withoutLinks = raw.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => {
|
|
342
385
|
links.push({ label: String(label), url: String(url) });
|
|
343
386
|
return "";
|
|
@@ -369,30 +412,246 @@ function boxed(text, models, testsPassed) {
|
|
|
369
412
|
const body = rows.map((r) => ` ${(0, exports.violet)("\u2502")} ${r}${" ".repeat(widest - visible(r).length)} ${(0, exports.violet)("\u2502")}`);
|
|
370
413
|
return [` ${(0, exports.violet)(`\u256d${bar}\u256e`)}`, ...body, ` ${(0, exports.violet)(`\u2570${bar}\u256f`)}`].join("\n") + "\n";
|
|
371
414
|
}
|
|
415
|
+
/** A markdown table's cells, with emphasis and code marks taken off and a
|
|
416
|
+
* `<br>` kept as a line break inside the cell. */
|
|
417
|
+
function cells(row) {
|
|
418
|
+
return row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()
|
|
419
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/__([^_]+)__/g, "$1").replace(/`([^`]+)`/g, "$1")
|
|
420
|
+
.replace(/<br\s*\/?>/gi, "\n"));
|
|
421
|
+
}
|
|
422
|
+
/** Widths that fit the terminal: each column its natural width when they all
|
|
423
|
+
* fit, otherwise the narrow ones keep theirs and the wide ones share the rest. */
|
|
424
|
+
function fitWidths(natural, room) {
|
|
425
|
+
const out = natural.map(() => 0);
|
|
426
|
+
let left = room;
|
|
427
|
+
let open = natural.map((_, i) => i).sort((a, b) => natural[a] - natural[b]);
|
|
428
|
+
while (open.length) {
|
|
429
|
+
const share = Math.floor(left / open.length);
|
|
430
|
+
const i = open[0];
|
|
431
|
+
if (natural[i] <= share) {
|
|
432
|
+
out[i] = natural[i];
|
|
433
|
+
left -= natural[i];
|
|
434
|
+
open = open.slice(1);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
for (const j of open)
|
|
438
|
+
out[j] = Math.max(6, share);
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
/** One cell's text as lines no wider than its column. */
|
|
444
|
+
function cellLines(text, w) {
|
|
445
|
+
const lines = [];
|
|
446
|
+
for (const part of text.split("\n")) {
|
|
447
|
+
const words = wrap(part, w);
|
|
448
|
+
for (const l of words.length ? words : [""]) {
|
|
449
|
+
// A single word longer than the column is cut, never allowed to push the border.
|
|
450
|
+
for (let k = 0; k < Math.max(1, l.length); k += w)
|
|
451
|
+
lines.push(l.slice(k, k + w));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return lines;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Markdown tables drawn as terminal tables.
|
|
458
|
+
*
|
|
459
|
+
* A model asked for "a summary table" writes pipes and dashes, which a browser
|
|
460
|
+
* draws and a terminal shows as raw markup. Each block of `|` rows with a
|
|
461
|
+
* `|---|` line under its header becomes a boxed table, with long cells wrapped
|
|
462
|
+
* inside their column so the table always fits the window.
|
|
463
|
+
*/
|
|
464
|
+
function tables(text) {
|
|
465
|
+
const lines = text.split("\n");
|
|
466
|
+
const out = [];
|
|
467
|
+
const isRow = (l) => l !== undefined && /^\s*\|.*\|\s*$/.test(l);
|
|
468
|
+
const isRule = (l) => l !== undefined && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(l);
|
|
469
|
+
for (let i = 0; i < lines.length; i++) {
|
|
470
|
+
if (!(isRow(lines[i]) && isRule(lines[i + 1]))) {
|
|
471
|
+
out.push(lines[i]);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const head = cells(lines[i]);
|
|
475
|
+
const body = [];
|
|
476
|
+
i += 2;
|
|
477
|
+
while (isRow(lines[i]))
|
|
478
|
+
body.push(cells(lines[i++]));
|
|
479
|
+
i--;
|
|
480
|
+
const cols = head.length;
|
|
481
|
+
const rows = [head, ...body].map((r) => Array.from({ length: cols }, (_, k) => r[k] ?? ""));
|
|
482
|
+
const natural = Array.from({ length: cols }, (_, k) => Math.max(3, ...rows.map((r) => Math.max(...r[k].split("\n").map((s) => s.length)))));
|
|
483
|
+
const w = fitWidths(natural, width() - 4 - (3 * cols + 1));
|
|
484
|
+
const bar = (l, m, r) => (0, exports.dim)(` ${l}${w.map((n) => "─".repeat(n + 2)).join(m)}${r}`);
|
|
485
|
+
const draw = (r, strong) => {
|
|
486
|
+
const wrapped = r.map((c, k) => cellLines(c, w[k]));
|
|
487
|
+
const height = Math.max(...wrapped.map((c) => c.length));
|
|
488
|
+
for (let h = 0; h < height; h++) {
|
|
489
|
+
out.push(` ${(0, exports.dim)("│")}${wrapped.map((c, k) => {
|
|
490
|
+
const s = (c[h] ?? "").padEnd(w[k]);
|
|
491
|
+
return ` ${strong ? (0, exports.bold)(s) : s} `;
|
|
492
|
+
}).join((0, exports.dim)("│"))}${(0, exports.dim)("│")}`);
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
out.push(bar("┌", "┬", "┐"));
|
|
496
|
+
draw(rows[0], true);
|
|
497
|
+
out.push(bar("├", "┼", "┤"));
|
|
498
|
+
rows.slice(1).forEach((r, n) => {
|
|
499
|
+
draw(r, false);
|
|
500
|
+
if (n < rows.length - 2)
|
|
501
|
+
out.push(bar("├", "┼", "┤"));
|
|
502
|
+
});
|
|
503
|
+
out.push(bar("└", "┴", "┘"));
|
|
504
|
+
}
|
|
505
|
+
return out.join("\n");
|
|
506
|
+
}
|
|
507
|
+
/** The answer's two accents, one pair per terminal background (manual 6.10):
|
|
508
|
+
* the brand violet and amber on a dark terminal, the brand purple and deep
|
|
509
|
+
* blue on a light one, each above 4.5 to 1 against its background. */
|
|
510
|
+
const onLight = () => process.env.COMPRISM_THEME === "light";
|
|
511
|
+
const headingPaint = (t) => paint(onLight() ? "1;38;2;107;91;167" : "1;38;5;141", t);
|
|
512
|
+
const codePaint = (t) => paint(onLight() ? "38;2;31;77;120" : "38;2;217;119;6", t);
|
|
513
|
+
/** Characters of text per line, the width Glow and Claude Code read at. */
|
|
514
|
+
const READING_WIDTH = 80;
|
|
515
|
+
const same = (t) => t;
|
|
516
|
+
/** A line of markdown as words, each piece carrying its own emphasis, so a
|
|
517
|
+
* wrapped line never starts inside a color it cannot see the start of. */
|
|
518
|
+
function words(text) {
|
|
519
|
+
const linked = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, u) => `${t} (${u})`);
|
|
520
|
+
const parts = [];
|
|
521
|
+
const re = /(\*\*[^*]+\*\*|__[^_]+__|`[^`]+`|\*[^*\s][^*]*\*)/g;
|
|
522
|
+
let last = 0;
|
|
523
|
+
for (let m = re.exec(linked); m; m = re.exec(linked)) {
|
|
524
|
+
parts.push({ t: linked.slice(last, m.index), p: same });
|
|
525
|
+
const s = m[0];
|
|
526
|
+
parts.push(s.startsWith("`") ? { t: s.slice(1, -1), p: codePaint }
|
|
527
|
+
: s.startsWith("**") || s.startsWith("__") ? { t: s.slice(2, -2), p: exports.bold }
|
|
528
|
+
: { t: s.slice(1, -1), p: (t) => paint("3", t) });
|
|
529
|
+
last = m.index + s.length;
|
|
530
|
+
}
|
|
531
|
+
parts.push({ t: linked.slice(last), p: same });
|
|
532
|
+
const out = [];
|
|
533
|
+
let cur = [];
|
|
534
|
+
for (const part of parts) {
|
|
535
|
+
for (const piece of part.t.split(/(\s+)/)) {
|
|
536
|
+
if (!piece)
|
|
537
|
+
continue;
|
|
538
|
+
if (/^\s+$/.test(piece)) {
|
|
539
|
+
if (cur.length) {
|
|
540
|
+
out.push(cur);
|
|
541
|
+
cur = [];
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
else
|
|
545
|
+
cur.push({ t: piece, p: part.p });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (cur.length)
|
|
549
|
+
out.push(cur);
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
const show = (w) => w.map((x) => x.p(x.t)).join("");
|
|
553
|
+
const size = (w) => w.reduce((n, x) => n + x.t.length, 0);
|
|
554
|
+
/** Words filled into lines no wider than `room`, a word never split, every
|
|
555
|
+
* line after the first starting under the first line's text. */
|
|
556
|
+
function fill(text, lead, hang, room) {
|
|
557
|
+
const lines = [];
|
|
558
|
+
let line = lead, used = visible(lead).length, empty = true;
|
|
559
|
+
for (const w of words(text)) {
|
|
560
|
+
if (!empty && used + 1 + size(w) > room) {
|
|
561
|
+
lines.push(line);
|
|
562
|
+
line = hang;
|
|
563
|
+
used = visible(hang).length;
|
|
564
|
+
empty = true;
|
|
565
|
+
}
|
|
566
|
+
line += (empty ? "" : " ") + show(w);
|
|
567
|
+
used += (empty ? 0 : 1) + size(w);
|
|
568
|
+
empty = false;
|
|
569
|
+
}
|
|
570
|
+
lines.push(line);
|
|
571
|
+
return lines;
|
|
572
|
+
}
|
|
372
573
|
/**
|
|
373
|
-
*
|
|
574
|
+
* An answer, laid out for reading in a terminal (manual 6.17).
|
|
374
575
|
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
576
|
+
* The approved design (docs/product/design/cli-answer, option B): the whole
|
|
577
|
+
* answer on one left margin behind a violet rail, so where the question ends
|
|
578
|
+
* and the answer begins is never in doubt; words wrapped at a reading width
|
|
579
|
+
* and never split by the window edge; a blank line between every block;
|
|
580
|
+
* headings in the brand violet; code in its own color; wrapped list items
|
|
581
|
+
* continuing under their own text. Piped to a file or another program, the
|
|
582
|
+
* same answer comes out without the rail or the color, as a log wants it.
|
|
379
583
|
*/
|
|
380
584
|
function plain(text) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
.
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
585
|
+
const screen = supportsColour();
|
|
586
|
+
const lead = screen ? ` ${paint(onLight() ? "38;2;107;91;167" : "38;5;141", "│")} ` : " ";
|
|
587
|
+
const railOnly = screen ? lead.trimEnd() : "";
|
|
588
|
+
const room = Math.min(width() - 1, visible(lead).length + READING_WIDTH);
|
|
589
|
+
const src = text.replace(/\r\n/g, "\n").split("\n");
|
|
590
|
+
const out = [];
|
|
591
|
+
const gap = () => { if (out.length && out[out.length - 1] !== railOnly)
|
|
592
|
+
out.push(railOnly); };
|
|
593
|
+
const isTableRow = (l) => l !== undefined && /^\s*\|.*\|\s*$/.test(l);
|
|
594
|
+
const startsBlock = (l) => /^\s*([-*+•]\s|\d+[.)]\s|#{1,6}\s|```|>|\|)/.test(l) || /^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(l);
|
|
595
|
+
for (let i = 0; i < src.length; i++) {
|
|
596
|
+
const l = src[i];
|
|
597
|
+
if (!l.trim())
|
|
598
|
+
continue;
|
|
599
|
+
if (/^\s*```/.test(l)) {
|
|
600
|
+
gap();
|
|
601
|
+
for (i++; i < src.length && !/^\s*```/.test(src[i]); i++)
|
|
602
|
+
out.push(`${lead} ${codePaint(src[i])}`);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const h = /^\s*#{1,6}\s+(.+?)\s*#*\s*$/.exec(l);
|
|
606
|
+
if (h) {
|
|
607
|
+
gap();
|
|
608
|
+
out.push(lead + headingPaint(h[1].replace(/\*\*/g, "")));
|
|
609
|
+
gap();
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(l)) {
|
|
613
|
+
gap();
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (isTableRow(l)) {
|
|
617
|
+
const block = [];
|
|
618
|
+
while (isTableRow(src[i]))
|
|
619
|
+
block.push(src[i++]);
|
|
620
|
+
i--;
|
|
621
|
+
gap();
|
|
622
|
+
// The table's own two space indent gives way to the rail, whether or
|
|
623
|
+
// not a color code comes before it.
|
|
624
|
+
const indent = new RegExp(`^((?:${String.fromCharCode(27)}\\[[0-9;]*m)*) {2}`);
|
|
625
|
+
for (const row of tables(block.join("\n")).split("\n"))
|
|
626
|
+
out.push(lead + row.replace(indent, "$1"));
|
|
627
|
+
gap();
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const bullet = /^(\s*)([-*+•]|\d+[.)])\s+(.*)$/.exec(l);
|
|
631
|
+
if (bullet) {
|
|
632
|
+
if (out.length && !/^\s*([-*+•]|\d+[.)])\s/.test(src[i - 1] ?? ""))
|
|
633
|
+
gap();
|
|
634
|
+
const depth = Math.floor(bullet[1].replace(/\t/g, " ").length / 2);
|
|
635
|
+
const mark = /\d/.test(bullet[2]) ? `${bullet[2].replace(")", ".")} ` : "• ";
|
|
636
|
+
let body = bullet[3];
|
|
637
|
+
// A list item that runs onto an indented next line is one item.
|
|
638
|
+
while (i + 1 < src.length && src[i + 1].trim() && /^\s{2,}\S/.test(src[i + 1]) && !startsBlock(src[i + 1]))
|
|
639
|
+
body += " " + src[++i].trim();
|
|
640
|
+
const indent = " ".repeat(depth);
|
|
641
|
+
out.push(...fill(body, lead + indent + (0, exports.dim)(mark), lead + indent + " ".repeat(mark.length), room));
|
|
642
|
+
if (i + 1 < src.length && !/^\s*([-*+•]|\d+[.)])\s/.test(src[i + 1]))
|
|
643
|
+
gap();
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
// A paragraph is every line up to the next blank line or block.
|
|
647
|
+
let para = l.trim().replace(/^>\s?/, "");
|
|
648
|
+
while (i + 1 < src.length && src[i + 1].trim() && !startsBlock(src[i + 1]))
|
|
649
|
+
para += " " + src[++i].trim();
|
|
650
|
+
gap();
|
|
651
|
+
out.push(...fill(para, lead, lead, room));
|
|
652
|
+
gap();
|
|
653
|
+
}
|
|
654
|
+
while (out.length && out[out.length - 1] === railOnly)
|
|
655
|
+
out.pop();
|
|
656
|
+
return out.join("\n");
|
|
398
657
|
}
|
package/out/agent/session.d.ts
CHANGED
|
@@ -122,6 +122,8 @@ export interface JobResult {
|
|
|
122
122
|
* model and the arithmetic are all on the server, so a client that wrote its
|
|
123
123
|
* own wording would be a second answer to the same question. */
|
|
124
124
|
receipt: string;
|
|
125
|
+
/** The receipt card, worded by the server. Drawn as sent. */
|
|
126
|
+
card?: ui.ReceiptCard | null;
|
|
125
127
|
/** Which models did the work, and each one's share, as the server reported
|
|
126
128
|
* it. Taken as sent, never recalculated here. */
|
|
127
129
|
models: ui.ModelShare[];
|
|
@@ -169,6 +171,15 @@ export declare class TerminalSession {
|
|
|
169
171
|
* with nothing between the copies. */
|
|
170
172
|
private lastSaid;
|
|
171
173
|
private canceled;
|
|
174
|
+
/** The live "thinking 12s" line while the model works on a step. Drawn in
|
|
175
|
+
* place and erased before anything else prints, so it never lands in the
|
|
176
|
+
* answer. Without it a step is a blank screen for as long as the model takes,
|
|
177
|
+
* and "thinking" and "hung" look identical. */
|
|
178
|
+
private working;
|
|
179
|
+
/** When the current wait began, kept across the line being redrawn. Cleared
|
|
180
|
+
* when a step finishes, so the next step counts from its own start. */
|
|
181
|
+
private workFrom;
|
|
182
|
+
private readonly write;
|
|
172
183
|
constructor(opts: SessionOptions, write?: (s: string) => void);
|
|
173
184
|
/** Where the socket lives, derived from the workspace URL.
|
|
174
185
|
*
|
|
@@ -292,6 +303,10 @@ export declare class TerminalSession {
|
|
|
292
303
|
/** An action as approved: its name, its arguments and the real folder it
|
|
293
304
|
* runs in, so a changed argument or a moved folder is caught. */
|
|
294
305
|
private fingerprint;
|
|
306
|
+
/** Starts the working line with the service's own words. Only on a real
|
|
307
|
+
* terminal: a piped or unattended run gets the answer and nothing else. */
|
|
308
|
+
private startWorking;
|
|
309
|
+
private stopWorking;
|
|
295
310
|
private narrate;
|
|
296
311
|
private targetOf;
|
|
297
312
|
/** Put a change in front of the person and wait.
|
package/out/agent/session.js
CHANGED
|
@@ -97,9 +97,19 @@ class TerminalSession {
|
|
|
97
97
|
* with nothing between the copies. */
|
|
98
98
|
lastSaid = "";
|
|
99
99
|
canceled = false;
|
|
100
|
+
/** The live "thinking 12s" line while the model works on a step. Drawn in
|
|
101
|
+
* place and erased before anything else prints, so it never lands in the
|
|
102
|
+
* answer. Without it a step is a blank screen for as long as the model takes,
|
|
103
|
+
* and "thinking" and "hung" look identical. */
|
|
104
|
+
working = null;
|
|
105
|
+
/** When the current wait began, kept across the line being redrawn. Cleared
|
|
106
|
+
* when a step finishes, so the next step counts from its own start. */
|
|
107
|
+
workFrom = null;
|
|
108
|
+
write;
|
|
100
109
|
constructor(opts, write = (s) => process.stdout.write(s)) {
|
|
101
110
|
this.opts = opts;
|
|
102
|
-
this.
|
|
111
|
+
this.write = write;
|
|
112
|
+
this.out = (s) => { this.stopWorking(); write(s); };
|
|
103
113
|
this.executor = new executor_1.NativeExecutor({
|
|
104
114
|
root: opts.root,
|
|
105
115
|
added: opts.project?.added ?? [],
|
|
@@ -335,6 +345,13 @@ class TerminalSession {
|
|
|
335
345
|
request += selectionText(await this.editor.context());
|
|
336
346
|
}
|
|
337
347
|
}
|
|
348
|
+
// Working from the moment the request leaves, not from the moment the
|
|
349
|
+
// model starts. The pass, the stream and the service's own preparation
|
|
350
|
+
// take several seconds before the first step, and a blank screen for that
|
|
351
|
+
// long reads as nothing happening. The service's words replace this as
|
|
352
|
+
// soon as its first step starts.
|
|
353
|
+
this.workFrom = null;
|
|
354
|
+
this.startWorking("thinking");
|
|
338
355
|
const granted = await this.pass(request);
|
|
339
356
|
if (this.olderWorkspace) {
|
|
340
357
|
// Straight on to the stream with the credential, silently. A person does
|
|
@@ -621,6 +638,7 @@ class TerminalSession {
|
|
|
621
638
|
// The sentence the server wrote. Everything a reader is told about
|
|
622
639
|
// cost and saving comes from here.
|
|
623
640
|
result.receipt = String(frame.receipt ?? "");
|
|
641
|
+
result.card = frame.card ?? null;
|
|
624
642
|
result.reusedPct = ui.reusePct(0, frame.cached_tokens, Math.max(0, Number(frame.context_tokens ?? 0) - Number(frame.cached_tokens ?? 0)));
|
|
625
643
|
result.text = String(frame.text ?? "");
|
|
626
644
|
result.why = this.lastWhy;
|
|
@@ -685,6 +703,7 @@ class TerminalSession {
|
|
|
685
703
|
socket.onclose = () => {
|
|
686
704
|
if (socket !== this.socket)
|
|
687
705
|
return;
|
|
706
|
+
this.stopWorking();
|
|
688
707
|
if (finished || this.canceled || !this.jobId) {
|
|
689
708
|
// One job on its own is its session, so what it started ends with it
|
|
690
709
|
// and is listed (manual 5.13). Inside a session they carry on.
|
|
@@ -882,8 +901,35 @@ class TerminalSession {
|
|
|
882
901
|
: v);
|
|
883
902
|
return JSON.stringify([name, sorted(input), folder]);
|
|
884
903
|
}
|
|
904
|
+
/** Starts the working line with the service's own words. Only on a real
|
|
905
|
+
* terminal: a piped or unattended run gets the answer and nothing else. */
|
|
906
|
+
startWorking(line) {
|
|
907
|
+
this.stopWorking();
|
|
908
|
+
if (!line || this.opts.headless || !process.stdout.isTTY)
|
|
909
|
+
return;
|
|
910
|
+
// One count for one wait: the service's words taking over from the
|
|
911
|
+
// request's own do not start the clock again.
|
|
912
|
+
const started = this.workFrom ??= Date.now();
|
|
913
|
+
const draw = () => this.write(`\r\x1b[2K ${ui.dim(`${line} ${ui.duration(Date.now() - started)}`)}`);
|
|
914
|
+
draw();
|
|
915
|
+
const timer = setInterval(draw, 1000);
|
|
916
|
+
timer.unref?.();
|
|
917
|
+
this.working = { timer };
|
|
918
|
+
}
|
|
919
|
+
stopWorking() {
|
|
920
|
+
if (!this.working)
|
|
921
|
+
return;
|
|
922
|
+
clearInterval(this.working.timer);
|
|
923
|
+
this.working = null;
|
|
924
|
+
this.write("\r\x1b[2K");
|
|
925
|
+
}
|
|
885
926
|
narrate(frame) {
|
|
886
927
|
switch (frame.kind) {
|
|
928
|
+
// The model is working on a step. Shown at once, so the wait is visibly
|
|
929
|
+
// work (the web app draws the same moment as its "thinking" row).
|
|
930
|
+
case "step_started":
|
|
931
|
+
this.startWorking(String(frame.line ?? ""));
|
|
932
|
+
break;
|
|
887
933
|
// Helpers, in the service's words (manual 5.23, 5.25).
|
|
888
934
|
case "helper_started":
|
|
889
935
|
this.helpers.set(String(frame.id), {
|
|
@@ -927,6 +973,8 @@ class TerminalSession {
|
|
|
927
973
|
if (this.allowanceLine)
|
|
928
974
|
this.out(this.allowanceLine);
|
|
929
975
|
this.allowanceLine = "";
|
|
976
|
+
// Still working: the first step has not started yet.
|
|
977
|
+
this.startWorking("thinking");
|
|
930
978
|
break;
|
|
931
979
|
// Planning and plan approval, in the service's words (manual 4.1, 4.2).
|
|
932
980
|
case "planning":
|
|
@@ -940,6 +988,7 @@ class TerminalSession {
|
|
|
940
988
|
this.out(ui.dim(` ${String(frame.reason ?? "")}\n`));
|
|
941
989
|
break;
|
|
942
990
|
case "step_finished": {
|
|
991
|
+
this.workFrom = null;
|
|
943
992
|
this.out(ui.step(Number(frame.index ?? 0), String(frame.model ?? ""), frame.usd, frame.latency_ms, ui.reusePct(frame.tokens_in, frame.cache_read_tokens, frame.cache_write_tokens), String(frame.reason ?? "")) + "\n");
|
|
944
993
|
if (Array.isArray(frame.why_lines))
|
|
945
994
|
this.lastWhy = frame.why_lines;
|
|
@@ -947,8 +996,8 @@ class TerminalSession {
|
|
|
947
996
|
// Markdown is for a browser. A terminal shows the asterisks.
|
|
948
997
|
if (said) {
|
|
949
998
|
// The plan is titled so it reads as the plan (manual 4.1).
|
|
950
|
-
const
|
|
951
|
-
this.out(frame.plan ? ` ${ui.bold("Plan")}\n${
|
|
999
|
+
const plan = ui.plain(said.replace(/^\s*(#+\s*)?\**plan\**:?\s*\n/i, ""));
|
|
1000
|
+
this.out(frame.plan ? ` ${ui.bold("Plan")}\n${plan}\n` : `\n${ui.plain(said)}\n`);
|
|
952
1001
|
this.lastSaid = said;
|
|
953
1002
|
}
|
|
954
1003
|
break;
|
|
@@ -994,10 +1043,16 @@ class TerminalSession {
|
|
|
994
1043
|
case "document_made": {
|
|
995
1044
|
const saved = frame.saved_to ? String(frame.saved_to) : "";
|
|
996
1045
|
const bytes = Number(frame.bytes ?? 0).toLocaleString();
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1046
|
+
const name = String(frame.filename ?? "a file");
|
|
1047
|
+
if (saved) {
|
|
1048
|
+
this.out(ui.savedFile(saved, path.resolve(this.opts.root, saved), bytes));
|
|
1049
|
+
}
|
|
1050
|
+
else {
|
|
1051
|
+
// Not written here, so the stored copy is the only way to it.
|
|
1052
|
+
const url = frame.url ? String(frame.url) : "";
|
|
1053
|
+
this.out(ui.yellow(` made `) + (url ? ui.link(name, url) : name)
|
|
1054
|
+
+ ui.dim(` ${bytes} bytes, not saved here${url ? ", click to download" : ""}\n`));
|
|
1055
|
+
}
|
|
1001
1056
|
break;
|
|
1002
1057
|
}
|
|
1003
1058
|
case "compacting":
|
|
@@ -1141,13 +1196,13 @@ class TerminalSession {
|
|
|
1141
1196
|
this.out(ui.ending(r.ending.line, r.detail));
|
|
1142
1197
|
for (const line of r.ending.proof)
|
|
1143
1198
|
this.out(ui.dim(` ${line}\n`));
|
|
1144
|
-
this.out(ui.receipt(r.receipt, r.models));
|
|
1199
|
+
this.out(ui.receipt(r.receipt, r.models, undefined, r.card));
|
|
1145
1200
|
}
|
|
1146
1201
|
else {
|
|
1147
1202
|
this.out(ui.outcome(r.outcome, r.detail));
|
|
1148
1203
|
for (const line of r.ending?.proof ?? [])
|
|
1149
1204
|
this.out(ui.dim(` ${line}\n`));
|
|
1150
|
-
this.out(ui.receipt(r.receipt, r.models, r.ending ? undefined : r.testsPassed));
|
|
1205
|
+
this.out(ui.receipt(r.receipt, r.models, r.ending ? undefined : r.testsPassed, r.card));
|
|
1151
1206
|
}
|
|
1152
1207
|
if (r.jobId) {
|
|
1153
1208
|
// The job's own reference, so a person can quote it. The link to the
|
package/out/commands/ask.js
CHANGED
|
@@ -50,6 +50,7 @@ const gateway_1 = require("../lib/gateway");
|
|
|
50
50
|
const connection_1 = require("../lib/connection");
|
|
51
51
|
const config_1 = require("../lib/config");
|
|
52
52
|
const ui = __importStar(require("../lib/ui"));
|
|
53
|
+
const render_1 = require("../agent/render");
|
|
53
54
|
const prompt_1 = require("../lib/prompt");
|
|
54
55
|
const output_1 = require("../lib/output");
|
|
55
56
|
const stdout = (s = '') => process.stdout.write(s + '\n');
|
|
@@ -177,10 +178,18 @@ async function cmdAsk(args) {
|
|
|
177
178
|
return 0;
|
|
178
179
|
}
|
|
179
180
|
out();
|
|
180
|
-
// The answer itself always reaches standard output (manual 10.2).
|
|
181
|
-
|
|
181
|
+
// The answer itself always reaches standard output (manual 10.2). On a
|
|
182
|
+
// screen it is laid out for reading (manual 6.17); piped, it stays exactly
|
|
183
|
+
// as the model wrote it, for the program reading it.
|
|
184
|
+
const answer = reply.answer.trim();
|
|
185
|
+
stdout(process.stdout.isTTY ? (0, render_1.plain)(answer) : answer);
|
|
182
186
|
out();
|
|
183
|
-
if (reply.
|
|
187
|
+
if (reply.card) {
|
|
188
|
+
// The server's card, drawn as the approved look draws it.
|
|
189
|
+
out((0, render_1.receipt)(reply.receipt ?? '', [], undefined, reply.card).trimEnd());
|
|
190
|
+
out();
|
|
191
|
+
}
|
|
192
|
+
else if (reply.receipt) {
|
|
184
193
|
out(ui.c.dim(' ' + reply.receipt.split('\n').join('\n ')));
|
|
185
194
|
out();
|
|
186
195
|
}
|