@vincemakes/kiso-tui 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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). */
@@ -45,6 +41,7 @@ export declare class Editor {
45
41
  onSigint(cb: () => void): void;
46
42
  onEot(cb: () => void): void;
47
43
  onEscape(cb: () => void): void;
44
+ onExpand(cb: () => void): void;
48
45
  /** The whole buffer as text (the CLI's line()/clearLine()). */
49
46
  line(): string;
50
47
  clearLine(): void;
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,15 @@ 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 = [];
59
+ // W15: the expand-key list (ctrl+r) — the CLI's dispatch decides the
60
+ // target (a live cell toggles in place; a committed cell appends the
61
+ // expanded block). Mirrors the escape list: multiple listeners can
62
+ // coexist; the editor never interprets the key itself.
63
+ #expandCbs = [];
102
64
  #onRender;
103
65
  #menuOpen = false; // v3 §04: the slash-command menu
104
66
  #menuSel = 0;
@@ -136,7 +98,10 @@ export class Editor {
136
98
  this.#eotCb = cb;
137
99
  }
138
100
  onEscape(cb) {
139
- this.#escapeCb = cb;
101
+ this.#escapeCbs.push(cb);
102
+ }
103
+ onExpand(cb) {
104
+ this.#expandCbs.push(cb);
140
105
  }
141
106
  /** The whole buffer as text (the CLI's line()/clearLine()). */
142
107
  line() {
@@ -253,7 +218,8 @@ export class Editor {
253
218
  this.#onRender();
254
219
  }
255
220
  else {
256
- this.#escapeCb?.();
221
+ for (const cb of [...this.#escapeCbs])
222
+ cb();
257
223
  i += 1;
258
224
  }
259
225
  }
@@ -314,6 +280,13 @@ export class Editor {
314
280
  }
315
281
  i += 1;
316
282
  }
283
+ else if (c === "\x12") {
284
+ // W15: the expand key (ctrl+r) — rides the chain like a
285
+ // command, the editor just forwards it.
286
+ for (const cb of [...this.#expandCbs])
287
+ cb();
288
+ i += 1;
289
+ }
317
290
  else if (c !== undefined && c < " ") {
318
291
  i += 1; // other control — ignored
319
292
  }
@@ -497,7 +470,7 @@ export class Editor {
497
470
  // ---- width-based horizontal scroll ----
498
471
  #reflow() {
499
472
  const W = (process.stdout.columns ?? 0) || 80; // degenerate 0 falls back to 80
500
- const maxW = Math.max(1, W - PROMPT_WIDTH - 1); // 1 col for the "…"
473
+ const maxW = Math.max(1, W - PROMPT_WIDTH - 4); // W6: the box's walls (2+2) — the visible line fits the box's inner width; the "…" rides inside
501
474
  const curCol = widthOf(this.#chars.slice(0, this.#cursor));
502
475
  const scrolledW = widthOf(this.#chars.slice(0, this.#scroll));
503
476
  if (curCol < scrolledW) {
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;
@@ -175,7 +177,12 @@ export declare function renderEvent(ev: RenderInput, prevThinking?: boolean, res
175
177
  export declare function renderToolSummary(name: string, input: Record<string, unknown>, result: {
176
178
  content: string;
177
179
  isError: boolean;
178
- }): string;
180
+ }, reason?: string | null): string;
181
+ /** W15 — the expand header's target: the tool call's subject (the path
182
+ * for the *_file tools, the command for shell) — the same extraction
183
+ * the summary detail uses, WITHOUT the counts (the header names what
184
+ * was expanded, not its size). */
185
+ export declare function toolTarget(name: string, input: Record<string, unknown>): string;
179
186
  /** k-units for the status line: 12345 → 12.3k, 800 → 800, null → ?. */
180
187
  export declare function kUnit(value: number | null): string;
181
188
  /** B area: usage data gathered from the run's usage events. */
@@ -212,11 +219,24 @@ export declare function renderTerminalGap(statusLine: string | null): string;
212
219
  * Pure.
213
220
  */
214
221
  export declare const TAGLINE = "the coding agent that survives kill -9";
215
- /** v3 §01: truncate a row at `width`, marking the hidden span " (+N)". */
222
+ /** v3 §01 (W1): truncate a row at `width`, marking the hidden span
223
+ * " (+N)". W1: the width math is the charWidth authority (the banner's
224
+ * brick glyphs are 1 cell — the art's 38 columns clear 40), and the
225
+ * marker's own cells are part of the row — the visible cut leaves room
226
+ * for it, so a truncated row never exceeds W (a cut row carries the
227
+ * marker INSIDE the width; a row that fits is returned untouched). */
216
228
  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[];
229
+ /** v3 §01 (V6-2) + W1: the banner lines for a width W and height H
230
+ * the tier table (extends the existing "under 40 columns, skip the
231
+ * logo" rule with a HEIGHT input):
232
+ * W ≥ 40 and H ≥ 20 → BIG (the 36x6 wordmark, 2-column indent)
233
+ * W ≥ 40 and 14–19 rows → COMPACT (v6's LOGO_ROWS, byte-identical)
234
+ * anything smaller → text rows only
235
+ * then the blank, then "vX — tagline" — the art IS the wordmark, so the
236
+ * text row does not repeat the name — then extensions — then the W5
237
+ * resume list (BIG only, W5). Every row truncates at the terminal width
238
+ * with a " (+N)" marker. Pure. */
239
+ export declare function bannerLines(W: number, H: number, version: string, extensionsText: string, resume?: readonly ResumeMeta[], now?: number): string[];
220
240
  /** v3 §02 — the recap line that ends a run, replacing the "done" label +
221
241
  * the old status line. All fields derive LOCALLY from the event stream
222
242
  * (zero tokens): wall seconds, tool counts, usage, cache hit %, ctx left.
@@ -227,6 +247,11 @@ export interface RecapStats {
227
247
  readonly edits: number;
228
248
  readonly usage: RunUsage;
229
249
  readonly ctxLeftPct: number | null;
250
+ /** W19 — the mode the turn ran under. Under "plan" the recap becomes
251
+ * the way-forward row (the claimed shape): a plan turn's currency is
252
+ * the plan, not the tool count — the timing and tool-count parts
253
+ * drop, and the two /mode hints replace them. */
254
+ readonly mode?: string;
230
255
  }
231
256
  export declare function renderRecap(s: RecapStats): string;
232
257
  /** One-line summary of a session, for `kiso sessions`. */
@@ -237,3 +262,19 @@ export declare function renderSessionLine(meta: {
237
262
  runs: number;
238
263
  updatedAt: number;
239
264
  }): string;
265
+ /** W5 — the opening-screen resume list. Every field already exists
266
+ * behind renderSessionLine / `kiso sessions`: the relative time, the
267
+ * title, then the right-aligned "N events · M runs". The columns are
268
+ * fixed per W: 4 indent + 7 when + 1 + the title (the ONLY flexible
269
+ * field — cut with the ellipsis marker INSIDE the width) + 1 + the meta
270
+ * (padStart to metaW). The done-when: the meta's right edge lands at
271
+ * exactly W on every row. Returns PLAIN rows — the banner's uniform dim
272
+ * wrap styles them (no dim+bold SGR composition). */
273
+ export interface ResumeMeta {
274
+ readonly title: string;
275
+ readonly events: number;
276
+ readonly runs: number;
277
+ readonly updatedAt: number;
278
+ }
279
+ export declare function relativeTime(updatedAt: number, now: number): string;
280
+ 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
  }
@@ -188,9 +189,16 @@ function approvalDetail(name, input, resolvePath) {
188
189
  * edit/write show +/- line counts, read shows lines, shell shows the exit
189
190
  * code; failures (isError) are ✗. Pure and deterministic.
190
191
  */
191
- export function renderToolSummary(name, input, result) {
192
+ export function renderToolSummary(name, input, result, reason = null) {
192
193
  // v2a/v5: ✓ is a bold identity accent; ✗ stays red.
193
194
  const p = palette();
195
+ // W19: a DENIED call (the "denied" tag) renders the pinned row — the
196
+ // FULL call name, the target, the reason in the W4 parentheses idiom,
197
+ // and NO timing metadata (the call never ran — (0.0s) would be noise).
198
+ // The same row in the interactive and pipe paths, byte-clean on a pipe.
199
+ if (reason !== null) {
200
+ return `${p.red}✗${p.reset} ${escapeTerminal(`${name} ${toolTarget(name, input)} (${reason})`)}`;
201
+ }
194
202
  const mark = result.isError ? `${p.red}✗${p.reset}` : `${p.bold}✓${p.reset}`;
195
203
  const shortName = name.replace("_file", "");
196
204
  const detail = toolSummaryDetail(name, input, result);
@@ -232,6 +240,24 @@ function toolSummaryDetail(name, input, result) {
232
240
  return String(input.path ?? input.command ?? "");
233
241
  }
234
242
  }
243
+ /** W15 — the expand header's target: the tool call's subject (the path
244
+ * for the *_file tools, the command for shell) — the same extraction
245
+ * the summary detail uses, WITHOUT the counts (the header names what
246
+ * was expanded, not its size). */
247
+ export function toolTarget(name, input) {
248
+ switch (name) {
249
+ case "read_file":
250
+ case "write_file":
251
+ case "edit_file":
252
+ return String(input.path ?? "?");
253
+ case "shell":
254
+ return String(input.command ?? "?");
255
+ case "list_dir":
256
+ return String(input.path ?? "(root)");
257
+ default:
258
+ return String(input.path ?? input.command ?? "");
259
+ }
260
+ }
235
261
  /** The exit code of a shell result: parsed from the failure text, 0 on success. */
236
262
  function exitCodeOf(result) {
237
263
  if (!result.isError)
@@ -293,47 +319,94 @@ export function renderTerminalGap(statusLine) {
293
319
  * Pure.
294
320
  */
295
321
  export const TAGLINE = "the coding agent that survives kill -9";
322
+ /** v6's existing logo — W1's COMPACT tier, byte-identical, no redraw. */
296
323
  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)". */
324
+ /** W1's BIG tier (36x6, `█` and space only — no half-blocks, so there is
325
+ * no tile seam to lose in a font that renders ▀ ▄ at the wrong height).
326
+ * Each pixel is two cells wide on purpose (a terminal cell is ~1:2);
327
+ * the render indents two 38 columns total, clears 40. */
328
+ const BIG_LOGO_ROWS = [
329
+ "██ ██ ██████ ████████ ████████",
330
+ "██ ██ ██ ██ ██ ██",
331
+ "████ ██ ████████ ██ ██",
332
+ "████ ██ ██ ██ ██",
333
+ "██ ██ ██ ██ ██ ██",
334
+ "██ ██ ██████ ████████ ████████",
335
+ ];
336
+ /** v3 §01 (W1): truncate a row at `width`, marking the hidden span
337
+ * " (+N)". W1: the width math is the charWidth authority (the banner's
338
+ * brick glyphs are 1 cell — the art's 38 columns clear 40), and the
339
+ * marker's own cells are part of the row — the visible cut leaves room
340
+ * for it, so a truncated row never exceeds W (a cut row carries the
341
+ * marker INSIDE the width; a row that fits is returned untouched). */
307
342
  export function truncateRow(row, width) {
308
- if (displayW(row) <= width)
343
+ const total = displayWidth(row);
344
+ if (total <= width)
309
345
  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;
346
+ // iterate the marker to a fixpoint: the marker's width changes the
347
+ // cut, the cut changes the hidden count the marker reports
348
+ let marker = " (+0)";
349
+ for (;;) {
350
+ const cut = Math.max(0, width - displayWidth(marker));
351
+ let w = 0;
352
+ let i = 0;
353
+ while (i < row.length) {
354
+ const cp = row.codePointAt(i);
355
+ const cw = charWidth(cp);
356
+ if (w + cw > cut)
357
+ break;
358
+ w += cw;
359
+ i += cp > 0xffff ? 2 : 1; // code-point stepping — never split a pair
360
+ }
361
+ const next = ` (+${total - w})`;
362
+ if (next === marker)
363
+ return `${row.slice(0, i)}${next}`;
364
+ marker = next;
318
365
  }
319
- return `${row.slice(0, i)} (+${displayW(row) - w})`;
320
366
  }
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) {
367
+ /** v3 §01 (V6-2) + W1: the banner lines for a width W and height H
368
+ * the tier table (extends the existing "under 40 columns, skip the
369
+ * logo" rule with a HEIGHT input):
370
+ * W ≥ 40 and H ≥ 20 → BIG (the 36x6 wordmark, 2-column indent)
371
+ * W ≥ 40 and 14–19 rows → COMPACT (v6's LOGO_ROWS, byte-identical)
372
+ * anything smaller → text rows only
373
+ * then the blank, then "vX — tagline" — the art IS the wordmark, so the
374
+ * text row does not repeat the name — then extensions — then the W5
375
+ * resume list (BIG only, W5). Every row truncates at the terminal width
376
+ * with a " (+N)" marker. Pure. */
377
+ export function bannerLines(W, H, version, extensionsText, resume = [], now = Date.now()) {
324
378
  const rows = [];
325
379
  if (W >= 40) {
326
- for (const r of LOGO_ROWS)
327
- rows.push(truncateRow(r, W));
328
- rows.push("");
380
+ if (H >= 20) {
381
+ for (const r of BIG_LOGO_ROWS)
382
+ rows.push(truncateRow(` ${r}`, W));
383
+ }
384
+ else if (H >= 14) {
385
+ for (const r of LOGO_ROWS)
386
+ rows.push(truncateRow(r, W));
387
+ }
329
388
  }
330
- rows.push(truncateRow(`kiso v${version} — ${TAGLINE}`, W));
389
+ if (rows.length > 0)
390
+ rows.push("");
391
+ rows.push(truncateRow(`v${version} — ${TAGLINE}`, W));
331
392
  if (extensionsText !== "")
332
393
  rows.push(truncateRow(extensionsText, W));
394
+ if (W >= 40 && H >= 20 && resume.length > 0) {
395
+ rows.push("", ...renderResumeList(resume, W, now));
396
+ }
333
397
  return rows;
334
398
  }
335
399
  export function renderRecap(s) {
336
400
  const p = palette();
401
+ // W19: under plan the recap is the way out of the mode — the header
402
+ // names the mode's posture, the hints name the exits (the ONLY
403
+ // controls — /mode is the only way to leave plan mode).
404
+ if (s.mode === "plan") {
405
+ const parts = ["plan ready", "/mode default executes", "/mode accept-edits auto-approves edits"];
406
+ if (s.ctxLeftPct !== null)
407
+ parts.push(`ctx left ~${Math.round(s.ctxLeftPct)}%`);
408
+ return `${p.bold}▞${p.reset} ${parts.join(" · ")}\n`;
409
+ }
337
410
  const parts = [`${s.seconds}s`, `${s.tools} tool${s.tools === 1 ? "" : "s"}${s.edits > 0 ? ` (${s.edits} edit${s.edits === 1 ? "" : "s"})` : ""}`];
338
411
  if (s.usage.known) {
339
412
  const seg = `${s.usage.in !== null ? `in ${kUnit(s.usage.in)}` : ""}${s.usage.in !== null && s.usage.out !== null ? " " : ""}${s.usage.out !== null ? `out ${kUnit(s.usage.out)}` : ""}`;
@@ -353,3 +426,50 @@ export function renderSessionLine(meta) {
353
426
  // round 8: the title is the user's first prompt — model/user text, escaped.
354
427
  return `${meta.id.padEnd(24)} ${meta.runs} runs ${String(meta.events).padStart(5)} events ${when} ${escapeTerminal(meta.title)}`;
355
428
  }
429
+ export function relativeTime(updatedAt, now) {
430
+ const s = Math.max(0, now - updatedAt) / 1000;
431
+ if (s < 60)
432
+ return "now";
433
+ const m = Math.floor(s / 60);
434
+ if (m < 60)
435
+ return `${m}m ago`;
436
+ const h = Math.floor(m / 60);
437
+ if (h < 24)
438
+ return `${h}h ago`;
439
+ const d = Math.floor(h / 24);
440
+ if (d < 7)
441
+ return `${d}d ago`;
442
+ return `${Math.floor(d / 7)}w ago`;
443
+ }
444
+ function titleCut(text, max) {
445
+ if (displayWidth(text) <= max)
446
+ return text;
447
+ const room = max - displayWidth("…");
448
+ let w = 0;
449
+ let i = 0;
450
+ while (i < text.length) {
451
+ const cp = text.codePointAt(i);
452
+ const cw = charWidth(cp);
453
+ if (w + cw > room)
454
+ break;
455
+ w += cw;
456
+ i += cp > 0xffff ? 2 : 1;
457
+ }
458
+ return text.slice(0, i) + "…";
459
+ }
460
+ export function renderResumeList(metas, W, now) {
461
+ if (metas.length === 0)
462
+ return [];
463
+ const rows = [" ▞ resume"];
464
+ const whens = metas.map((m) => relativeTime(m.updatedAt, now));
465
+ const metaTexts = metas.map((m) => `${m.events} events · ${m.runs} runs`);
466
+ const metaW = Math.max(...metaTexts.map((t) => t.length));
467
+ const titleW = Math.max(1, W - 13 - metaW);
468
+ for (let i = 0; i < metas.length; i += 1) {
469
+ const title = escapeTerminal(metas[i].title);
470
+ const shown = titleCut(title, titleW);
471
+ const pad = titleW - displayWidth(shown);
472
+ rows.push(` ${whens[i].padEnd(7)} ${shown}${" ".repeat(pad)} ${metaTexts[i].padStart(metaW)}`);
473
+ }
474
+ return rows;
475
+ }
@@ -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;
package/dist/width.js ADDED
@@ -0,0 +1,59 @@
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 function charWidth(cp) {
12
+ if (cp >= 0x1100 && cp <= 0x115f)
13
+ return 2; // hangul jamo
14
+ if (cp >= 0x2e80 && cp <= 0x303e)
15
+ return 2; // radicals .. CJK punctuation
16
+ if (cp >= 0x3041 && cp <= 0x33ff)
17
+ return 2; // kana, CJK compat
18
+ if (cp >= 0x3400 && cp <= 0x4dbf)
19
+ return 2; // CJK ext A
20
+ if (cp >= 0x4e00 && cp <= 0x9fff)
21
+ return 2; // CJK unified
22
+ if (cp >= 0xa000 && cp <= 0xa4cf)
23
+ return 2; // yi
24
+ if (cp >= 0xa960 && cp <= 0xa97f)
25
+ return 2; // hangul jamo ext
26
+ if (cp >= 0xac00 && cp <= 0xd7a3)
27
+ return 2; // hangul syllables
28
+ if (cp >= 0xf900 && cp <= 0xfaff)
29
+ return 2; // CJK compat ideographs
30
+ if (cp >= 0xfe10 && cp <= 0xfe19)
31
+ return 2; // vertical forms
32
+ if (cp >= 0xfe30 && cp <= 0xfe6f)
33
+ return 2; // CJK compat forms
34
+ if (cp >= 0xff00 && cp <= 0xff60)
35
+ return 2; // fullwidth forms
36
+ if (cp >= 0xffe0 && cp <= 0xffe6)
37
+ return 2; // fullwidth signs
38
+ if (cp >= 0x1f300 && cp <= 0x1f64f)
39
+ return 2; // emoji (misc + emoticons)
40
+ if (cp >= 0x1f900 && cp <= 0x1f9ff)
41
+ return 2; // supplemental emoji
42
+ if (cp >= 0x20000 && cp <= 0x3fffd)
43
+ return 2; // CJK ext B..G
44
+ return 1;
45
+ }
46
+ /** Display width of a code-point array (cursor math, scrolling). */
47
+ export function widthOf(chars) {
48
+ let w = 0;
49
+ for (const cp of chars)
50
+ w += charWidth(cp);
51
+ return w;
52
+ }
53
+ /** Display width of a string. */
54
+ export function displayWidth(text) {
55
+ let w = 0;
56
+ for (const ch of text)
57
+ w += charWidth(ch.codePointAt(0));
58
+ return w;
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "kiso tui — the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",