@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.
@@ -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
- --pin run --model and nothing else, whatever it would prefer
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
@@ -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
- * Plain text, from a model that writes markdown.
135
+ * An answer, laid out for reading in a terminal (manual 6.17).
106
136
  *
107
- * A terminal has no bold and no bullet glyphs, so `**like this**` arrives on
108
- * screen as literal asterisks and a heading looks like a typing mistake. The
109
- * emphasis is real, so it is kept as terminal bold rather than thrown away;
110
- * everything that only exists to be rendered by a browser is removed.
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;
@@ -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
- return process.stdout.isTTY ? boxed(text, models, testsPassed) : `${lines.join("\n")}\n`;
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
- * Plain text, from a model that writes markdown.
574
+ * An answer, laid out for reading in a terminal (manual 6.17).
374
575
  *
375
- * A terminal has no bold and no bullet glyphs, so `**like this**` arrives on
376
- * screen as literal asterisks and a heading looks like a typing mistake. The
377
- * emphasis is real, so it is kept as terminal bold rather than thrown away;
378
- * everything that only exists to be rendered by a browser is removed.
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
- return text
382
- // Fenced code keeps its content and loses its fence markers.
383
- .replace(/^```[a-zA-Z0-9]*\n?/gm, '')
384
- .replace(/^```$/gm, '')
385
- // Headings become their own words, emphasized.
386
- .replace(/^#{1,6}\s+(.+)$/gm, (_m, t) => (0, exports.bold)(String(t)))
387
- // Bold and italic become the terminal's own emphasis.
388
- .replace(/\*\*([^*]+)\*\*/g, (_m, t) => (0, exports.bold)(String(t)))
389
- .replace(/__([^_]+)__/g, (_m, t) => (0, exports.bold)(String(t)))
390
- .replace(/(^|[^*])\*([^*\n]+)\*/g, (_m, pre, t) => pre + String(t))
391
- // A bullet is a bullet, not an asterisk.
392
- .replace(/^\s*[*-]\s+/gm, ' . ')
393
- // Inline code keeps its text; the backticks were never for a terminal.
394
- .replace(/`([^`]+)`/g, (_m, t) => String(t))
395
- // A link reads as its words, with the address after it.
396
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, u) => `${t} (${u})`)
397
- .replace(/\n{3,}/g, '\n\n');
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
  }
@@ -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.
@@ -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.out = write;
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 plain = ui.plain(said).replace(/^\s*plan\s*\n/i, "");
951
- this.out(frame.plan ? ` ${ui.bold("Plan")}\n${plain}\n` : `\n${ui.plain(said)}\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
- this.out(saved
998
- ? ui.green(` saved ${saved}`) + ui.dim(` ${bytes} bytes\n`)
999
- : ui.yellow(` made ${String(frame.filename ?? "a file")}`)
1000
- + ui.dim(` ${bytes} bytes, not saved here\n`));
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
@@ -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
- stdout(toErr ? reply.answer.trim() : reply.answer.trim());
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.receipt) {
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
  }