@vincemakes/kiso-tui 0.1.36 → 0.1.37

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.
@@ -42,8 +42,8 @@
42
42
  */
43
43
  import { truncateDiff } from "./diff.js";
44
44
  import { displayWidth } from "./editor.js";
45
- import { Container, SPINNER, cellComponent, foldLine, footerLine, statusLine, visibleWidth, } from "./components.js";
46
- import { escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary } from "./render.js";
45
+ import { Container, SPINNER, bodySpacing, cellComponent, foldLine, footerLine, statusLine, visibleWidth, } from "./components.js";
46
+ import { bannerLines, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary } from "./render.js";
47
47
  /** The cursor marker — an APC private sequence the focus component
48
48
  * embeds at the edit position; the compositor strips it and moves
49
49
  * relatively (it never reaches the terminal). */
@@ -78,6 +78,7 @@ export class Body {
78
78
  #resizeHandler = null;
79
79
  // the chrome state (the Dock façade)
80
80
  #status = "";
81
+ #statusHint = null;
81
82
  #tail = "";
82
83
  #question = null;
83
84
  #inputState = () => ({ line: "", cursor: 0 });
@@ -154,8 +155,18 @@ export class Body {
154
155
  this.#write(`→ ${escapeTerminal(name)}(${escapeTerminal(JSON.stringify(input).slice(0, 200))})\n`);
155
156
  return;
156
157
  }
158
+ // W12: the cell carries the delegate's child roles from the FULL
159
+ // input — the display summary is sliced at 60 chars (unparseable);
160
+ // the roles are the only running-state data the parent holds (there
161
+ // is no live channel to a running child session).
162
+ const childRoles = [];
163
+ for (const t of Array.isArray(input.tasks) ? input.tasks : []) {
164
+ if (typeof t === "object" && t !== null && typeof t.role === "string") {
165
+ childRoles.push(t.role);
166
+ }
167
+ }
157
168
  this.#toolCells.set(callId, this.#cells.length);
158
- this.#cells.push({ kind: "tool", name, input: summary, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false });
169
+ this.#cells.push({ kind: "tool", name, input: summary, childRoles, state: "pending", isError: false, resultText: "", diff: null, added: 0, removed: 0, startedAt: null, doneAt: null, done: false });
159
170
  this.#mark();
160
171
  }
161
172
  toolApproval(callId, diff) {
@@ -286,6 +297,30 @@ export class Body {
286
297
  this.#cells.push({ kind: "checklist", header, items, done: true });
287
298
  this.#mark();
288
299
  }
300
+ /** The startup banner (W1): a LIVE cell — the tier re-derives per
301
+ * frame (bannerLines with the CURRENT W and H), so a resize re-tiers
302
+ * the art instead of re-folding frozen rows (a window below 40 cols
303
+ * never paints the logo). W5: the resume metas ride the cell — the
304
+ * list re-gates with the tier (BIG only) and re-times with the
305
+ * frame. The inactive path keeps the historical bytes (no resume —
306
+ * the pipe contract). */
307
+ banner(version, extensionsText, resume = []) {
308
+ if (!this.#isActive()) {
309
+ this.#closeOpenThinking();
310
+ this.#closeOpenText();
311
+ const W = this.#opts.width() || 80; // a 0-size pty falls back
312
+ const H = this.#opts.height();
313
+ const p = palette();
314
+ for (const r of bannerLines(W, H, version, extensionsText))
315
+ this.#write(`${p.dim}${r}${p.reset}\n`);
316
+ this.#write("\n");
317
+ return;
318
+ }
319
+ this.#closeOpenThinking();
320
+ this.#closeOpenText();
321
+ this.#cells.push({ kind: "banner", version, extensionsText, resume, done: true });
322
+ this.#mark();
323
+ }
289
324
  raw(lines) {
290
325
  if (!this.#isActive()) {
291
326
  this.#closeOpenThinking();
@@ -360,8 +395,12 @@ export class Body {
360
395
  this.#dirty = true;
361
396
  this.render(); // the immediate redraw at the NEW geometry
362
397
  }
363
- setStatus(text) {
398
+ /** W18: the status row's right-aligned hint is part of the status
399
+ * state — the compacting row passes "esc to cancel" (the affordance
400
+ * must survive repaints). */
401
+ setStatus(text, hint = null) {
364
402
  this.#status = text;
403
+ this.#statusHint = hint;
365
404
  this.redraw();
366
405
  }
367
406
  setTail(tail) {
@@ -452,19 +491,29 @@ export class Body {
452
491
  }
453
492
  // ---- the one writer ----
454
493
  /** The live region's scalar — the unit tests assert the cap directly
455
- * (the e2e gate pins the screen consequence). */
494
+ * (the e2e gate pins the screen consequence). W11: the formula's
495
+ * blanks are join artifacts — the count includes them (they are real
496
+ * screen rows), threaded against the previous sibling's OWN rows. */
456
497
  liveCount() {
457
498
  const live = this.#cells.slice(this.#committed);
458
- const ctx = { spinnerI: this.#spinnerI, now: Date.now() };
499
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: this.#opts.height() };
459
500
  const W = this.#opts.width();
460
501
  let lines = 0;
461
- for (const cell of live)
462
- lines += cellComponent(cell).render(W, ctx).length;
502
+ let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
503
+ for (const cell of live) {
504
+ const rows = cellComponent(cell).render(W, ctx);
505
+ lines += bodySpacing(prev, rows).length;
506
+ prev = rows;
507
+ }
463
508
  return lines + CHROME_ROWS + this.#menuRows(W).length;
464
509
  }
465
510
  /** The lines committed THIS frame — the writes land in the frame's
466
511
  * committed section (the rows just above the live region). */
467
512
  #committedLinesThisFrame = [];
513
+ // the CELL count when the frame began — the drawFull frozen bound's
514
+ // unit (the cells committed BEFORE this frame; a force-commit frame's
515
+ // placed LINES outnumber its cells)
516
+ #committedAtFrameStart = 0;
468
517
  render() {
469
518
  if (!this.#isActive())
470
519
  return;
@@ -473,7 +522,7 @@ export class Body {
473
522
  if (H < 4)
474
523
  return;
475
524
  this.#lastH = H;
476
- const ctx = { spinnerI: this.#spinnerI, now: Date.now() };
525
+ const ctx = { spinnerI: this.#spinnerI, now: Date.now(), height: H };
477
526
  // V6-1 (the screen-state == frame-state rule): the resize's first
478
527
  // frame — the terminal's reflow re-wrapped the committed content at
479
528
  // the NEW width, so the cached folds are stale. Re-fold the
@@ -485,8 +534,8 @@ export class Body {
485
534
  for (let i = 0; i < this.#committed; i += 1) {
486
535
  const cell = this.#cells[i];
487
536
  const lines = cellComponent(cell).render(W, ctx);
488
- this.#lineCache[i] = lines;
489
- this.#committedLines += lines.length;
537
+ this.#lineCache[i] = lines; // the cell's OWN rows — the cache stays raw
538
+ this.#committedLines += bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines).length;
490
539
  }
491
540
  }
492
541
  // 1. the natural commits — the leading DONE cells freeze: their
@@ -495,16 +544,24 @@ export class Body {
495
544
  // short sessions included — the frame coalescing keeps a
496
545
  // cell's first frame its freeze frame, so the frozen bytes
497
546
  // emit exactly once).
547
+ this.#committedAtFrameStart = this.#committed;
498
548
  this.#committedLinesThisFrame = [];
499
549
  while (this.#committed < this.#cells.length && this.#cells[this.#committed].done) {
500
550
  this.#commitCell(this.#committed, W, ctx);
501
551
  }
502
552
  // 2. the live lines — the unfinished cells (the tail) + the chrome.
553
+ // W11: the formula's blank above the first live cell hangs off
554
+ // the last COMMITTED sibling (the join spans the boundary).
503
555
  const menuRows = this.#menuRows(W);
504
556
  const chromeRows = CHROME_ROWS + menuRows.length;
505
557
  let liveLines = [];
506
- for (const cell of this.#cells.slice(this.#committed)) {
507
- liveLines.push(...cellComponent(cell).render(W, ctx));
558
+ {
559
+ let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
560
+ for (const cell of this.#cells.slice(this.#committed)) {
561
+ const rows = cellComponent(cell).render(W, ctx);
562
+ liveLines.push(...bodySpacing(prev, rows));
563
+ prev = rows;
564
+ }
508
565
  }
509
566
  // 3. the FORCE commits — the live region's hard cap H−1: overflow
510
567
  // commits the oldest live cell UNCONDITIONALLY (the one sharp
@@ -512,8 +569,13 @@ export class Body {
512
569
  while (liveLines.length > H - 4 && this.#committed < this.#cells.length) { // V6-3: the content cap H−4
513
570
  this.#commitCell(this.#committed, W, ctx);
514
571
  liveLines = [];
515
- for (const cell of this.#cells.slice(this.#committed)) {
516
- liveLines.push(...cellComponent(cell).render(W, ctx));
572
+ {
573
+ let prev = this.#committed > 0 ? this.#lineCache[this.#committed - 1] : null;
574
+ for (const cell of this.#cells.slice(this.#committed)) {
575
+ const rows = cellComponent(cell).render(W, ctx);
576
+ liveLines.push(...bodySpacing(prev, rows));
577
+ prev = rows;
578
+ }
517
579
  }
518
580
  }
519
581
  // 4. the geometry — the live region's first row:
@@ -539,14 +601,17 @@ export class Body {
539
601
  /** Commit the cell at index i: render + cache its lines (immutable —
540
602
  * the force-committed form freezes at the current render), advance
541
603
  * the bookkeeping — and collect the lines for this frame's writes.
542
- * Pure accounting + the write list; the BYTES emit in the frame. */
604
+ * Pure accounting + the write list; the BYTES emit in the frame.
605
+ * W11: the formula's blank above the cell (when one belongs) rides
606
+ * this cell's commit — the cache stays raw, the placed rows count. */
543
607
  #commitCell(i, W, ctx) {
544
608
  const cell = this.#cells[i];
545
609
  const lines = cellComponent(cell).render(W, ctx);
546
610
  this.#lineCache[i] = lines;
611
+ const placed = bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, lines);
547
612
  this.#committed += 1;
548
- this.#committedLines += lines.length;
549
- this.#committedLinesThisFrame.push(...lines);
613
+ this.#committedLines += placed.length;
614
+ this.#committedLinesThisFrame.push(...placed);
550
615
  }
551
616
  /** The slot occupant's extra rows — the slash-command menu (above the
552
617
  * status, in the rhythm gap + the content's spare rows — the old
@@ -633,20 +698,24 @@ export class Body {
633
698
  const committed = this.#committedLinesThisFrame;
634
699
  // 0. the FROZEN rows — the re-folded committed content (re-flowed
635
700
  // at the new width by the terminal): re-painted at [1..frozen],
636
- // so the reflow's shifted copies can never ghost.
637
- const frozen = this.#lineCache.slice(0, this.#committed - committed.length).flat().filter((l) => l !== null);
638
- let r = 1;
639
- for (const line of frozen) {
640
- out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
641
- r += 1;
642
- }
643
- // 1. the committed lines (this frame's).
644
- for (const line of committed) {
645
- out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
646
- r += 1;
701
+ // so the reflow's shifted copies can never ghost. W11: the
702
+ // formula's blanks between the cells are re-inserted here the
703
+ // cache holds each cell's OWN rows; a missing blank would paint
704
+ // every row below it one row too high (the V6-1 idempotence
705
+ // rule: this draw must reproduce the screen, row by row).
706
+ // The bound is the CELL count at the frame's start — the force
707
+ // commit's placed LINES can exceed the cell count, and the old
708
+ // lines-bound went negative, skipping every previously-committed
709
+ // cell (the V6-1 frozen-loop finding — the banner vanished).
710
+ const frozen = [];
711
+ for (let i = 0; i < this.#committedAtFrameStart; i += 1) {
712
+ frozen.push(...bodySpacing(i > 0 ? this.#lineCache[i - 1] : null, this.#lineCache[i]));
647
713
  }
648
- // 2. the live lines.
649
- for (const line of liveLines) {
714
+ let r = 1;
715
+ // one march the frozen, the committed (this frame's), the live —
716
+ // in write order, r monotone (three byte-identical loops merged for
717
+ // the gate; the exact write sequence preserved)
718
+ for (const line of [...frozen, ...committed, ...liveLines]) {
650
719
  out.push(`\x1b[${r};1H\x1b[0K${this.#checked(line, W)}`);
651
720
  r += 1;
652
721
  }
@@ -664,7 +733,7 @@ export class Body {
664
733
  const editor = this.#inputRow(W, ctx);
665
734
  out.push(`\x1b[${H - 2};1H\x1b[0K${this.#checked(editor.stripped, W)}`);
666
735
  out.push(`\x1b[${H - 1};1H\x1b[0K${footerLine(W)}`);
667
- out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W), W)}`);
736
+ out.push(`\x1b[${H};1H\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`);
668
737
  // the cursor: up two (the input row at H−2) + left to the marker
669
738
  out.push("\x1b[2A");
670
739
  if (editor.afterW > 0)
@@ -689,7 +758,7 @@ export class Body {
689
758
  out.push("\n");
690
759
  // the bottom-up repaint, from the last row up — V6-3: the design
691
760
  // §03 chrome: status (H), lower ╌ (H−1), input (H−2), upper ╌ (H−3)
692
- out.push(`\x1b[1G\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W), W)}`); // H — the status
761
+ out.push(`\x1b[1G\x1b[0K${this.#checked(statusLine(this.#status, this.#tail, this.#question !== null, W, this.#statusHint ?? undefined), W)}`); // H — the status
693
762
  out.push(`\x1b[1A\x1b[1G\x1b[0K${footerLine(W)}`); // H−1 — the lower ╌
694
763
  out.push(`\x1b[1A\x1b[1G\x1b[0K${this.#checked(editor.stripped, W)}`); // H−2 — the input
695
764
  out.push(`\x1b[1A\x1b[1G\x1b[0K${footerLine(W)}`); // H−3 — the upper ╌
@@ -806,8 +875,8 @@ export class Dock {
806
875
  onResize() {
807
876
  compositorRef?.onResize();
808
877
  }
809
- setStatus(text) {
810
- compositorRef?.setStatus(text);
878
+ setStatus(text, hint) {
879
+ compositorRef?.setStatus(text, hint ?? null);
811
880
  }
812
881
  setTail(tail) {
813
882
  compositorRef?.setTail(tail);
package/dist/editor.d.ts CHANGED
@@ -16,12 +16,8 @@
16
16
  * The editor is a SINGLE line: bracketed paste (?2004h) unwraps and
17
17
  * inserts, internal newlines become spaces.
18
18
  */
19
- /** A code point's display width: 2 for the wide ranges, 1 otherwise. */
20
- export declare function charWidth(cp: number): number;
21
- /** Display width of a code-point array (cursor math, scrolling). */
22
- export declare function widthOf(chars: readonly number[]): number;
23
- /** Display width of a string. */
24
- export declare function displayWidth(text: string): number;
19
+ import { charWidth, displayWidth, widthOf } from "./width.js";
20
+ export { charWidth, displayWidth, widthOf };
25
21
  export declare const PROMPT = "\u258C ";
26
22
  export declare const PROMPT_WIDTH: number;
27
23
  /** v3 §04 — the slash-command menu's command table (English one-liners). */
package/dist/editor.js CHANGED
@@ -16,56 +16,10 @@
16
16
  * The editor is a SINGLE line: bracketed paste (?2004h) unwraps and
17
17
  * inserts, internal newlines become spaces.
18
18
  */
19
- /** A code point's display width: 2 for the wide ranges, 1 otherwise. */
20
- export function charWidth(cp) {
21
- if (cp >= 0x1100 && cp <= 0x115f)
22
- return 2; // hangul jamo
23
- if (cp >= 0x2e80 && cp <= 0x303e)
24
- return 2; // radicals .. CJK punctuation
25
- if (cp >= 0x3041 && cp <= 0x33ff)
26
- return 2; // kana, CJK compat
27
- if (cp >= 0x3400 && cp <= 0x4dbf)
28
- return 2; // CJK ext A
29
- if (cp >= 0x4e00 && cp <= 0x9fff)
30
- return 2; // CJK unified
31
- if (cp >= 0xa000 && cp <= 0xa4cf)
32
- return 2; // yi
33
- if (cp >= 0xa960 && cp <= 0xa97f)
34
- return 2; // hangul jamo ext
35
- if (cp >= 0xac00 && cp <= 0xd7a3)
36
- return 2; // hangul syllables
37
- if (cp >= 0xf900 && cp <= 0xfaff)
38
- return 2; // CJK compat ideographs
39
- if (cp >= 0xfe10 && cp <= 0xfe19)
40
- return 2; // vertical forms
41
- if (cp >= 0xfe30 && cp <= 0xfe6f)
42
- return 2; // CJK compat forms
43
- if (cp >= 0xff00 && cp <= 0xff60)
44
- return 2; // fullwidth forms
45
- if (cp >= 0xffe0 && cp <= 0xffe6)
46
- return 2; // fullwidth signs
47
- if (cp >= 0x1f300 && cp <= 0x1f64f)
48
- return 2; // emoji (misc + emoticons)
49
- if (cp >= 0x1f900 && cp <= 0x1f9ff)
50
- return 2; // supplemental emoji
51
- if (cp >= 0x20000 && cp <= 0x3fffd)
52
- return 2; // CJK ext B..G
53
- return 1;
54
- }
55
- /** Display width of a code-point array (cursor math, scrolling). */
56
- export function widthOf(chars) {
57
- let w = 0;
58
- for (const cp of chars)
59
- w += charWidth(cp);
60
- return w;
61
- }
62
- /** Display width of a string. */
63
- export function displayWidth(text) {
64
- let w = 0;
65
- for (const ch of text)
66
- w += charWidth(ch.codePointAt(0));
67
- return w;
68
- }
19
+ import { charWidth, displayWidth, widthOf } from "./width.js";
20
+ // the width primitives moved to width.ts (W1, the single width
21
+ // authority) re-exported so the editor's public surface is unchanged.
22
+ export { charWidth, displayWidth, widthOf };
69
23
  import { palette } from "./render.js";
70
24
  // TUI v4 #16d: the input row is the blue brick + the edit area — the
71
25
  // "you>" text is gone (the brick IS the prompt; the pipe path's readline
@@ -98,7 +52,10 @@ export class Editor {
98
52
  #pendingLines = []; // submits before onLine is wired (startup) — never dropped
99
53
  #sigintCb = null;
100
54
  #eotCb = null;
101
- #escapeCb = null;
55
+ // W18: the escape LIST — the run-abort (chat) and the /compact cancel
56
+ // (dispatch) coexist; a listener removes itself via an unarmed guard
57
+ // (the compact's handler no-ops after its abort has fired).
58
+ #escapeCbs = [];
102
59
  #onRender;
103
60
  #menuOpen = false; // v3 §04: the slash-command menu
104
61
  #menuSel = 0;
@@ -136,7 +93,7 @@ export class Editor {
136
93
  this.#eotCb = cb;
137
94
  }
138
95
  onEscape(cb) {
139
- this.#escapeCb = cb;
96
+ this.#escapeCbs.push(cb);
140
97
  }
141
98
  /** The whole buffer as text (the CLI's line()/clearLine()). */
142
99
  line() {
@@ -253,7 +210,8 @@ export class Editor {
253
210
  this.#onRender();
254
211
  }
255
212
  else {
256
- this.#escapeCb?.();
213
+ for (const cb of [...this.#escapeCbs])
214
+ cb();
257
215
  i += 1;
258
216
  }
259
217
  }
package/dist/index.d.ts CHANGED
@@ -8,5 +8,5 @@
8
8
  export { Body, Dock, CURSOR_MARKER, type BodyOptions } from "./compositor.js";
9
9
  export { Container, foldLine, visibleWidth, SPINNER, type Component, type FrameCtx } from "./components.js";
10
10
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
11
- export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderSessionLine, renderStatusLine, renderTerminalGap, renderToolSummary, TAGLINE, truncateRow, type Palette, type PathResolver, type RecapStats, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
11
+ export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./render.js";
12
12
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
package/dist/index.js CHANGED
@@ -8,5 +8,5 @@
8
8
  export { Body, Dock, CURSOR_MARKER } from "./compositor.js";
9
9
  export { Container, foldLine, visibleWidth, SPINNER } from "./components.js";
10
10
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, } from "./editor.js";
11
- export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderSessionLine, renderStatusLine, renderTerminalGap, renderToolSummary, TAGLINE, truncateRow, } from "./render.js";
11
+ export { bannerLines, COLOR_OFF, COLOR_ON, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, truncateRow, } from "./render.js";
12
12
  export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
package/dist/render.d.ts CHANGED
@@ -25,6 +25,8 @@ export interface Palette {
25
25
  readonly red: string;
26
26
  readonly green: string;
27
27
  readonly code: string;
28
+ readonly rv: string;
29
+ readonly rvEnd: string;
28
30
  readonly reset: string;
29
31
  }
30
32
  export declare const COLOR_ON: Palette;
@@ -212,11 +214,24 @@ export declare function renderTerminalGap(statusLine: string | null): string;
212
214
  * Pure.
213
215
  */
214
216
  export declare const TAGLINE = "the coding agent that survives kill -9";
215
- /** v3 §01: truncate a row at `width`, marking the hidden span " (+N)". */
217
+ /** v3 §01 (W1): truncate a row at `width`, marking the hidden span
218
+ * " (+N)". W1: the width math is the charWidth authority (the banner's
219
+ * brick glyphs are 1 cell — the art's 38 columns clear 40), and the
220
+ * marker's own cells are part of the row — the visible cut leaves room
221
+ * for it, so a truncated row never exceeds W (a cut row carries the
222
+ * marker INSIDE the width; a row that fits is returned untouched). */
216
223
  export declare function truncateRow(row: string, width: number): string;
217
- /** v3 §01 (V6-2): the banner lines for a width W logo (skipped under
218
- * 40 columns) + blank + "kiso vX tagline" + extensions. */
219
- export declare function bannerLines(W: number, version: string, extensionsText: string): string[];
224
+ /** v3 §01 (V6-2) + W1: the banner lines for a width W and height H
225
+ * the tier table (extends the existing "under 40 columns, skip the
226
+ * logo" rule with a HEIGHT input):
227
+ * W ≥ 40 and H ≥ 20 → BIG (the 36x6 wordmark, 2-column indent)
228
+ * W ≥ 40 and 14–19 rows → COMPACT (v6's LOGO_ROWS, byte-identical)
229
+ * anything smaller → text rows only
230
+ * then the blank, then "vX — tagline" — the art IS the wordmark, so the
231
+ * text row does not repeat the name — then extensions — then the W5
232
+ * resume list (BIG only, W5). Every row truncates at the terminal width
233
+ * with a " (+N)" marker. Pure. */
234
+ export declare function bannerLines(W: number, H: number, version: string, extensionsText: string, resume?: readonly ResumeMeta[], now?: number): string[];
220
235
  /** v3 §02 — the recap line that ends a run, replacing the "done" label +
221
236
  * the old status line. All fields derive LOCALLY from the event stream
222
237
  * (zero tokens): wall seconds, tool counts, usage, cache hit %, ctx left.
@@ -237,3 +252,19 @@ export declare function renderSessionLine(meta: {
237
252
  runs: number;
238
253
  updatedAt: number;
239
254
  }): string;
255
+ /** W5 — the opening-screen resume list. Every field already exists
256
+ * behind renderSessionLine / `kiso sessions`: the relative time, the
257
+ * title, then the right-aligned "N events · M runs". The columns are
258
+ * fixed per W: 4 indent + 7 when + 1 + the title (the ONLY flexible
259
+ * field — cut with the ellipsis marker INSIDE the width) + 1 + the meta
260
+ * (padStart to metaW). The done-when: the meta's right edge lands at
261
+ * exactly W on every row. Returns PLAIN rows — the banner's uniform dim
262
+ * wrap styles them (no dim+bold SGR composition). */
263
+ export interface ResumeMeta {
264
+ readonly title: string;
265
+ readonly events: number;
266
+ readonly runs: number;
267
+ readonly updatedAt: number;
268
+ }
269
+ export declare function relativeTime(updatedAt: number, now: number): string;
270
+ export declare function renderResumeList(metas: readonly ResumeMeta[], W: number, now: number): string[];
package/dist/render.js CHANGED
@@ -7,8 +7,9 @@
7
7
  * kiso-core's Event — the CLI translates Event → RenderInput. The tui
8
8
  * package has ZERO kiso-core imports: input is data, output is bytes.
9
9
  */
10
- export const COLOR_ON = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", code: "\x1b[38;5;110m", reset: "\x1b[0m" };
11
- export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", code: "", reset: "" };
10
+ import { charWidth, displayWidth } from "./width.js";
11
+ export const COLOR_ON = { bold: "\x1b[1m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", code: "\x1b[38;5;110m", rv: "\x1b[7m", rvEnd: "\x1b[27m", reset: "\x1b[0m" };
12
+ export const COLOR_OFF = { bold: "", dim: "", red: "", green: "", code: "", rv: "", rvEnd: "", reset: "" };
12
13
  export function palette() {
13
14
  return process.env.NO_COLOR === undefined && process.stdout.isTTY ? COLOR_ON : COLOR_OFF;
14
15
  }
@@ -293,43 +294,81 @@ export function renderTerminalGap(statusLine) {
293
294
  * Pure.
294
295
  */
295
296
  export const TAGLINE = "the coding agent that survives kill -9";
297
+ /** v6's existing logo — W1's COMPACT tier, byte-identical, no redraw. */
296
298
  const LOGO_ROWS = ["█ █ ▀█▀ █▀▀ █▀█", "█▀▄ █ ▀▀█ █ █", "▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀"];
297
- /** Display width of a row (1-cell ASCII; 2-cell CJK/wide). */
298
- function displayW(row) {
299
- let w = 0;
300
- for (let i = 0; i < row.length; i += 1) {
301
- const cp = row.codePointAt(i);
302
- w += cp > 0xff ? 2 : 1;
303
- }
304
- return w;
305
- }
306
- /** v3 §01: truncate a row at `width`, marking the hidden span " (+N)". */
299
+ /** W1's BIG tier (36x6, `█` and space only — no half-blocks, so there is
300
+ * no tile seam to lose in a font that renders ▀ ▄ at the wrong height).
301
+ * Each pixel is two cells wide on purpose (a terminal cell is ~1:2);
302
+ * the render indents two 38 columns total, clears 40. */
303
+ const BIG_LOGO_ROWS = [
304
+ "██ ██ ██████ ████████ ████████",
305
+ "██ ██ ██ ██ ██ ██",
306
+ "████ ██ ████████ ██ ██",
307
+ "████ ██ ██ ██ ██",
308
+ "██ ██ ██ ██ ██ ██",
309
+ "██ ██ ██████ ████████ ████████",
310
+ ];
311
+ /** v3 §01 (W1): truncate a row at `width`, marking the hidden span
312
+ * " (+N)". W1: the width math is the charWidth authority (the banner's
313
+ * brick glyphs are 1 cell — the art's 38 columns clear 40), and the
314
+ * marker's own cells are part of the row — the visible cut leaves room
315
+ * for it, so a truncated row never exceeds W (a cut row carries the
316
+ * marker INSIDE the width; a row that fits is returned untouched). */
307
317
  export function truncateRow(row, width) {
308
- if (displayW(row) <= width)
318
+ const total = displayWidth(row);
319
+ if (total <= width)
309
320
  return row;
310
- const cut = Math.max(0, width - 4);
311
- let w = 0;
312
- let i = 0;
313
- for (; i < row.length; i += 1) {
314
- const cw = row.codePointAt(i) > 0xff ? 2 : 1;
315
- if (w + cw > cut)
316
- break;
317
- w += cw;
321
+ // iterate the marker to a fixpoint: the marker's width changes the
322
+ // cut, the cut changes the hidden count the marker reports
323
+ let marker = " (+0)";
324
+ for (;;) {
325
+ const cut = Math.max(0, width - displayWidth(marker));
326
+ let w = 0;
327
+ let i = 0;
328
+ while (i < row.length) {
329
+ const cp = row.codePointAt(i);
330
+ const cw = charWidth(cp);
331
+ if (w + cw > cut)
332
+ break;
333
+ w += cw;
334
+ i += cp > 0xffff ? 2 : 1; // code-point stepping — never split a pair
335
+ }
336
+ const next = ` (+${total - w})`;
337
+ if (next === marker)
338
+ return `${row.slice(0, i)}${next}`;
339
+ marker = next;
318
340
  }
319
- return `${row.slice(0, i)} (+${displayW(row) - w})`;
320
341
  }
321
- /** v3 §01 (V6-2): the banner lines for a width W logo (skipped under
322
- * 40 columns) + blank + "kiso vX tagline" + extensions. */
323
- export function bannerLines(W, version, extensionsText) {
342
+ /** v3 §01 (V6-2) + W1: the banner lines for a width W and height H
343
+ * the tier table (extends the existing "under 40 columns, skip the
344
+ * logo" rule with a HEIGHT input):
345
+ * W ≥ 40 and H ≥ 20 → BIG (the 36x6 wordmark, 2-column indent)
346
+ * W ≥ 40 and 14–19 rows → COMPACT (v6's LOGO_ROWS, byte-identical)
347
+ * anything smaller → text rows only
348
+ * then the blank, then "vX — tagline" — the art IS the wordmark, so the
349
+ * text row does not repeat the name — then extensions — then the W5
350
+ * resume list (BIG only, W5). Every row truncates at the terminal width
351
+ * with a " (+N)" marker. Pure. */
352
+ export function bannerLines(W, H, version, extensionsText, resume = [], now = Date.now()) {
324
353
  const rows = [];
325
354
  if (W >= 40) {
326
- for (const r of LOGO_ROWS)
327
- rows.push(truncateRow(r, W));
328
- rows.push("");
355
+ if (H >= 20) {
356
+ for (const r of BIG_LOGO_ROWS)
357
+ rows.push(truncateRow(` ${r}`, W));
358
+ }
359
+ else if (H >= 14) {
360
+ for (const r of LOGO_ROWS)
361
+ rows.push(truncateRow(r, W));
362
+ }
329
363
  }
330
- rows.push(truncateRow(`kiso v${version} — ${TAGLINE}`, W));
364
+ if (rows.length > 0)
365
+ rows.push("");
366
+ rows.push(truncateRow(`v${version} — ${TAGLINE}`, W));
331
367
  if (extensionsText !== "")
332
368
  rows.push(truncateRow(extensionsText, W));
369
+ if (W >= 40 && H >= 20 && resume.length > 0) {
370
+ rows.push("", ...renderResumeList(resume, W, now));
371
+ }
333
372
  return rows;
334
373
  }
335
374
  export function renderRecap(s) {
@@ -353,3 +392,50 @@ export function renderSessionLine(meta) {
353
392
  // round 8: the title is the user's first prompt — model/user text, escaped.
354
393
  return `${meta.id.padEnd(24)} ${meta.runs} runs ${String(meta.events).padStart(5)} events ${when} ${escapeTerminal(meta.title)}`;
355
394
  }
395
+ export function relativeTime(updatedAt, now) {
396
+ const s = Math.max(0, now - updatedAt) / 1000;
397
+ if (s < 60)
398
+ return "now";
399
+ const m = Math.floor(s / 60);
400
+ if (m < 60)
401
+ return `${m}m ago`;
402
+ const h = Math.floor(m / 60);
403
+ if (h < 24)
404
+ return `${h}h ago`;
405
+ const d = Math.floor(h / 24);
406
+ if (d < 7)
407
+ return `${d}d ago`;
408
+ return `${Math.floor(d / 7)}w ago`;
409
+ }
410
+ function titleCut(text, max) {
411
+ if (displayWidth(text) <= max)
412
+ return text;
413
+ const room = max - displayWidth("…");
414
+ let w = 0;
415
+ let i = 0;
416
+ while (i < text.length) {
417
+ const cp = text.codePointAt(i);
418
+ const cw = charWidth(cp);
419
+ if (w + cw > room)
420
+ break;
421
+ w += cw;
422
+ i += cp > 0xffff ? 2 : 1;
423
+ }
424
+ return text.slice(0, i) + "…";
425
+ }
426
+ export function renderResumeList(metas, W, now) {
427
+ if (metas.length === 0)
428
+ return [];
429
+ const rows = [" ▞ resume"];
430
+ const whens = metas.map((m) => relativeTime(m.updatedAt, now));
431
+ const metaTexts = metas.map((m) => `${m.events} events · ${m.runs} runs`);
432
+ const metaW = Math.max(...metaTexts.map((t) => t.length));
433
+ const titleW = Math.max(1, W - 13 - metaW);
434
+ for (let i = 0; i < metas.length; i += 1) {
435
+ const title = escapeTerminal(metas[i].title);
436
+ const shown = titleCut(title, titleW);
437
+ const pad = titleW - displayWidth(shown);
438
+ rows.push(` ${whens[i].padEnd(7)} ${shown}${" ".repeat(pad)} ${metaTexts[i].padStart(metaW)}`);
439
+ }
440
+ return rows;
441
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The display-width primitives — the SINGLE width authority (TUI v5
3
+ * #16e: "charWidth is the width authority"). The eastAsianWidth table
4
+ * is a ~40-line subset (CJK ideographs/kana/hangul/fullwidth/common
5
+ * wide symbols = 2, everything else = 1 — the box-drawing/brick glyphs
6
+ * █▀▄▞▸ are narrow). Known limitation, documented in the README: emoji
7
+ * ZWJ clusters are not guaranteed perfect — each code point counts as
8
+ * its width. Zero dependencies (importable from any module).
9
+ */
10
+ /** A code point's display width: 2 for the wide ranges, 1 otherwise. */
11
+ export declare function charWidth(cp: number): number;
12
+ /** Display width of a code-point array (cursor math, scrolling). */
13
+ export declare function widthOf(chars: readonly number[]): number;
14
+ /** Display width of a string. */
15
+ export declare function displayWidth(text: string): number;