@vincemakes/kiso-tui 0.1.23 → 0.1.25

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/body.d.ts CHANGED
@@ -84,6 +84,15 @@ export declare class Body {
84
84
  constructor(opts: BodyOptions);
85
85
  /** Teardown — flush a pending frame, stop the timers. */
86
86
  close(): void;
87
+ /**
88
+ * TUI v4 #16a: a resize reflows the terminal's scrollback — the frozen
89
+ * rows' positions are no longer what we tracked (and the frozen CONTENT
90
+ * is never re-emitted: the terminal reflowed it, we only redraw the
91
+ * dock + active tail). The counters reset so the next render writes new
92
+ * frozen lines through the REAL-LF scroll path above the tail, never at
93
+ * a stale CUP row (the drag-garbage the #16 user saw).
94
+ */
95
+ onResize(): void;
87
96
  /** The last COMPLETE thinking block, for /think. */
88
97
  lastThinking(): string | null;
89
98
  /** The last completed tool call, for /last. */
package/dist/body.js CHANGED
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { truncateDiff } from "./diff.js";
27
27
  import { displayWidth } from "./editor.js";
28
- import { escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, } from "./render.js";
28
+ import { colorInlineCode, escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, } from "./render.js";
29
29
  /** The spinner glyphs, cycled by the heartbeat (v3 §05 — the working family). */
30
30
  const SPINNER = ["▖", "▘", "▝", "▗"];
31
31
  const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
@@ -48,11 +48,21 @@ export class Body {
48
48
  #pipeBuf = ""; // the passthrough's thinking buffer — the cell model needs no buffer
49
49
  #toolCells = new Map(); // callId → cell index (parallel tools)
50
50
  #write;
51
+ #resizeHandler = null;
51
52
  constructor(opts) {
52
53
  this.#opts = opts;
53
54
  this.#write = opts.write ?? ((s) => process.stdout.write(s));
54
55
  this.#active = opts.active();
55
56
  if (this.#isActive()) {
57
+ // TUI v4 #16a: the resize event — the terminal reflows its
58
+ // scrollback, so OUR row bookkeeping is stale. The frozen
59
+ // content is never re-emitted (the terminal reflowed it); the
60
+ // counters reset so the next render lands new frozen lines via
61
+ // the REAL-LF scroll path (just above the tail), never at stale
62
+ // CUP rows — the overwrite garbage after a drag (the #16
63
+ // defect). The dock redraws its own chrome on the same event.
64
+ this.#resizeHandler = () => this.onResize();
65
+ process.stdout.on("resize", this.#resizeHandler);
56
66
  this.#heartbeat = setInterval(() => {
57
67
  // #14/#15: the idle heartbeat PAINTS NOTHING unless an
58
68
  // ANIMATION advances — only a RUNNING tool's glyph/elapsed
@@ -86,9 +96,32 @@ export class Body {
86
96
  clearInterval(this.#heartbeat);
87
97
  this.#heartbeat = null;
88
98
  }
99
+ if (this.#resizeHandler !== null) {
100
+ process.stdout.off("resize", this.#resizeHandler);
101
+ this.#resizeHandler = null;
102
+ }
89
103
  if (this.#dirty)
90
104
  this.render();
91
105
  }
106
+ /**
107
+ * TUI v4 #16a: a resize reflows the terminal's scrollback — the frozen
108
+ * rows' positions are no longer what we tracked (and the frozen CONTENT
109
+ * is never re-emitted: the terminal reflowed it, we only redraw the
110
+ * dock + active tail). The counters reset so the next render writes new
111
+ * frozen lines through the REAL-LF scroll path above the tail, never at
112
+ * a stale CUP row (the drag-garbage the #16 user saw).
113
+ */
114
+ onResize() {
115
+ if (!this.#isActive())
116
+ return;
117
+ this.#frozenRows = Number.MAX_SAFE_INTEGER; // the frozen area is "full" — the next line scrolls
118
+ this.#oldTailTop = 0; // the reflowed tail's old rows are gone — clear only the new area
119
+ // NO #dirty: an immediate frame would re-render the ACTIVE TAIL —
120
+ // re-emitting its bytes (the terminal already reflowed the tail's
121
+ // content; a re-print duplicates the text in the byte stream — the
122
+ // #16 storm gate's "response text exactly once"). The geometry
123
+ // reset takes effect at the next NATURAL render (the next event).
124
+ }
92
125
  /** The last COMPLETE thinking block, for /think. */
93
126
  lastThinking() {
94
127
  return this.#lastThinking;
@@ -103,7 +136,7 @@ export class Body {
103
136
  this.#closeOpenThinking();
104
137
  this.#closeOpenText();
105
138
  const p = palette();
106
- this.#write(`${p.blue}you> ${escapeTerminal(text)}${p.reset}\n`);
139
+ this.#write(`${p.bold}you> ${escapeTerminal(text)}${p.reset}\n`);
107
140
  return;
108
141
  }
109
142
  this.#closeOpenThinking();
@@ -372,10 +405,12 @@ export class Body {
372
405
  const p = palette();
373
406
  switch (cell.kind) {
374
407
  case "user":
375
- // v3 §02: the user message is a SGR BACKGROUND block, no
376
- // prefixevery line carries the block's background
377
- // (multi-line whole; resize-safe). Pipes stay plain.
378
- return cell.text.split("\n").map((l) => `${p.bg}${escapeTerminal(l)}${p.reset}`);
408
+ // v3 §02, TUI v5 #16f (v4.1 design): the user message is a left
409
+ // raila bright-white BOLD ▍ per line, then the text (the
410
+ // reverse-video block is RETIRED: it washed out on light
411
+ // themes). Multi-line whole: every line carries the rail
412
+ // (多行连贯); resize-safe; NO_COLOR → the rail renders plain.
413
+ return cell.text.split("\n").map((l) => `${p.bold}▍${p.reset} ${escapeTerminal(l)}`);
379
414
  case "thinking": {
380
415
  const block = cell.text;
381
416
  const trimmed = escapeTerminal(block.trim());
@@ -393,11 +428,11 @@ export class Body {
393
428
  return [`${p.red}✗ ${name} (${err}, ${elapsed}s)${p.reset}`];
394
429
  }
395
430
  const delta = cell.added + cell.removed > 0 ? `, +${cell.added} -${cell.removed}` : "";
396
- return [`${p.blue}✓ ${name}${p.reset} (${summary}${delta}, ${elapsed}s)`];
431
+ return [`${p.bold}✓ ${name}${p.reset} (${summary}${delta}, ${elapsed}s)`];
397
432
  }
398
433
  if (cell.state === "approval") {
399
- const lines = [`→ ${name} ${summary} ${p.blue}⏸${p.reset}`];
400
- // v2e: the mini-diff — ▎ blue edge (the brick motif), - red /
434
+ const lines = [`→ ${name} ${summary} ${p.bold}⏸${p.reset}`];
435
+ // v2e: the mini-diff — ▎ bold edge (the brick motif), - red /
401
436
  // + green / context dim; NO_COLOR keeps the ± prefixes plain.
402
437
  if (cell.diff !== null) {
403
438
  for (const d of cell.diff) {
@@ -406,26 +441,39 @@ export class Body {
406
441
  : d.kind === "+"
407
442
  ? `${p.green}+ ${escapeTerminal(d.text)}${p.reset}`
408
443
  : `${p.dim} ${escapeTerminal(d.text)}${p.reset}`;
409
- lines.push(`${p.blue}▎${p.reset}${body}`);
444
+ lines.push(`${p.bold}▎${p.reset}${body}`);
410
445
  }
411
446
  }
412
447
  return lines;
413
448
  }
414
449
  if (cell.state === "running") {
415
450
  const elapsed = cell.startedAt !== null ? Math.max(1, Math.round((Date.now() - cell.startedAt) / 1000)) : 1;
416
- return [`→ ${name} ${summary} ${p.blue}${SPINNER[this.#spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`];
451
+ return [`→ ${name} ${summary} ${p.bold}${SPINNER[this.#spinnerI % SPINNER.length]}${p.reset} ${elapsed}s`];
417
452
  }
418
453
  return [`→ ${name} ${summary}`];
419
454
  }
420
455
  case "text": {
421
456
  const text = escapeTerminal(cell.text);
422
457
  const wrapped = this.#wrap(text, W);
423
- return wrapped.length > 0 ? wrapped : [""];
458
+ // TUI v5 #16e: the inline-code tint — backtick spans in
459
+ // assistant body text, matched PER LINE after the wrap (a
460
+ // span opened on one line and closed on another does NOT
461
+ // match — 跨行不匹配). NO_COLOR → the codes are empty →
462
+ // byte-identical.
463
+ return wrapped.length > 0 ? wrapped.map((l) => colorInlineCode(l)) : [""];
424
464
  }
425
465
  case "notice":
426
466
  return [escapeTerminal(cell.text)];
427
467
  case "raw":
428
- return cell.lines.map((l) => escapeTerminal(l));
468
+ // TUI v4 #16b: the raw cell carries the CLI's OWN pre-rendered
469
+ // lines (the banner, the recap, slash-command output) — the
470
+ // SGR is applied at COMPOSITION time (renderRecap/startupBanner),
471
+ // and model/tool content was already escapeTerminal'd there.
472
+ // Re-escaping at render STRIPPED the ESC from the SGR — the
473
+ // literal "[38;5;75m▞[0m" garbage the user saw (the #16 乱码,
474
+ // also the banner's dim). Verbatim: the injection guard lives
475
+ // at composition, not here.
476
+ return cell.lines;
429
477
  case "terminal":
430
478
  // the honest label (done / aborted / error) + the status + the
431
479
  // rhythm gap blank
package/dist/dock.d.ts CHANGED
@@ -69,7 +69,8 @@ export declare class Dock {
69
69
  /** The bottom four rows, wrapped in CSI 2026 (synchronized output —
70
70
  * the pi trick against flicker). The cursor ends at the input line's
71
71
  * edit position. v3 §03: the upper ╌ row, the input row, the lower
72
- * ╌ row, the status row — the status is dim (blue accents inside
73
- * come from the CLI's composition). */
72
+ * ╌ row, the status row — the status is dim (bold accents inside
73
+ * come from the CLI's composition). TUI v5 #16g: the idle status
74
+ * row carries the right-aligned "/ commands · ↑ history" hint. */
74
75
  redraw(): void;
75
76
  }
package/dist/dock.js CHANGED
@@ -52,7 +52,7 @@ export class Dock {
52
52
  * mode — the bottom three rows need room to exist. */
53
53
  enter() {
54
54
  const rows = process.stdout.rows ?? 0;
55
- if (process.stdout.isTTY !== true || palette().blue === "" || rows < 4)
55
+ if (process.stdout.isTTY !== true || palette().bold === "" || rows < 4)
56
56
  return;
57
57
  this.#active = true;
58
58
  this.#height = rows;
@@ -134,8 +134,9 @@ export class Dock {
134
134
  /** The bottom four rows, wrapped in CSI 2026 (synchronized output —
135
135
  * the pi trick against flicker). The cursor ends at the input line's
136
136
  * edit position. v3 §03: the upper ╌ row, the input row, the lower
137
- * ╌ row, the status row — the status is dim (blue accents inside
138
- * come from the CLI's composition). */
137
+ * ╌ row, the status row — the status is dim (bold accents inside
138
+ * come from the CLI's composition). TUI v5 #16g: the idle status
139
+ * row carries the right-aligned "/ commands · ↑ history" hint. */
139
140
  redraw() {
140
141
  if (!this.#active)
141
142
  return;
@@ -144,7 +145,7 @@ export class Dock {
144
145
  const W = this.#width;
145
146
  const sep = `${p.dim}${"╌".repeat(W)}${p.reset}`;
146
147
  const status = `${this.#status}${this.#tail === "" ? "" : ` · ${this.#tail}`}`;
147
- const statusLine = this.#question ?? `${p.dim}${status}${p.reset}`;
148
+ const statusLine = this.#question ?? this.#statusRow(status, p, W);
148
149
  const inp = this.#inputState();
149
150
  const out = [];
150
151
  // P3 (审查): the DEC private-mode SET/RESET needs the "?" prefix —
@@ -160,7 +161,7 @@ export class Dock {
160
161
  const item = menu.items[i];
161
162
  const row = H - 4 - (menu.items.length - 1 - i);
162
163
  const text = i === menu.selected
163
- ? `${p.blue}▸ ${item.name}${p.reset} ${item.desc}`
164
+ ? `${p.bold}▸ ${item.name}${p.reset} ${item.desc}`
164
165
  : `${p.dim} ${item.name} ${item.desc}${p.reset}`;
165
166
  out.push(`\x1b[${row};1H\x1b[0K${text}`);
166
167
  }
@@ -173,4 +174,20 @@ export class Dock {
173
174
  out.push("\x1b[?2026l"); // synchronized output OFF
174
175
  process.stdout.write(out.join(""));
175
176
  }
177
+ /** TUI v5 #16g: the status row — the base status left-aligned, the
178
+ * "/ commands · ↑ history" hint right-aligned in the idle state
179
+ * (tail empty, no takeover question). The hint is CUT FIRST when
180
+ * the width is short — the status itself is never truncated for it;
181
+ * the running state carries its own esc hint in the status text, so
182
+ * the non-empty tail suppresses this one. */
183
+ #statusRow(status, p, W) {
184
+ const hint = this.#tail === "" && this.#question === null ? " / commands · ↑ history" : "";
185
+ if (hint === "")
186
+ return `${p.dim}${status}${p.reset}`;
187
+ const statusW = displayWidth(status.replace(/\x1b\[[0-9;]*m/g, ""));
188
+ const hintW = displayWidth(hint);
189
+ if (statusW + hintW > W)
190
+ return `${p.dim}${status}${p.reset}`;
191
+ return `${p.dim}${status}${" ".repeat(W - statusW - hintW)}${hint}${p.reset}`;
192
+ }
176
193
  }
package/dist/editor.d.ts CHANGED
@@ -22,7 +22,7 @@ export declare function charWidth(cp: number): number;
22
22
  export declare function widthOf(chars: readonly number[]): number;
23
23
  /** Display width of a string. */
24
24
  export declare function displayWidth(text: string): number;
25
- export declare const PROMPT = "\u258Cyou> ";
25
+ export declare const PROMPT = "\u258C ";
26
26
  export declare const PROMPT_WIDTH: number;
27
27
  /** v3 §04 — the slash-command menu's command table (English one-liners). */
28
28
  export interface MenuItem {
package/dist/editor.js CHANGED
@@ -67,7 +67,10 @@ export function displayWidth(text) {
67
67
  return w;
68
68
  }
69
69
  import { palette } from "./render.js";
70
- export const PROMPT = "▌you> "; // the kiso brick motif: one blue half-block, then you>
70
+ // TUI v4 #16d: the input row is the blue brick + the edit area the
71
+ // "you>" text is gone (the brick IS the prompt; the pipe path's readline
72
+ // prompt keeps its own "you> " — v2a line mode, byte-for-byte).
73
+ export const PROMPT = "▌ ";
71
74
  export const PROMPT_WIDTH = displayWidth(PROMPT);
72
75
  export const MENU_ITEMS = [
73
76
  { name: "/mode", desc: "switch the approval tier (manual/default/accept-edits/plan/bypass)" },
@@ -209,7 +212,7 @@ export class Editor {
209
212
  const st = this.dockState();
210
213
  const W = (process.stdout.columns ?? 0) || 80; // a degenerate 0 size (no TIOCSWINSZ) falls back
211
214
  const cursorCol = Math.min(1 + PROMPT_WIDTH + st.cursor, W);
212
- process.stdout.write(`\r\x1b[0K${p.blue}${PROMPT}${p.reset}${st.line}\x1b[${cursorCol}G`);
215
+ process.stdout.write(`\r\x1b[0K${p.bold}${PROMPT}${p.reset}${st.line}\x1b[${cursorCol}G`);
213
216
  }
214
217
  // ---- input ----
215
218
  /** Feed raw stdin bytes — the parser. Public for unit tests. */
package/dist/render.d.ts CHANGED
@@ -8,20 +8,23 @@
8
8
  * package has ZERO kiso-core imports: input is data, output is bytes.
9
9
  */
10
10
  /**
11
- * v2a — the palette, centralized (no hard-coded codes elsewhere): ONE
12
- * accent blue, ANSI 256 color 75 — for the identity accents (the you>
13
- * prompt, the banner tagline, marks, slash-command names); red for
14
- * errors; dim for metadata. NO_COLOR set, or a non-TTY output every
15
- * code is empty, so pipes and CI carry ZERO ANSI (the existing byte-level
16
- * e2e assertions guard it). Everything not listed here is plain.
17
- * v3: `bg` — the user-message block background (SGR 48, dark gray 237).
11
+ * v2a — the palette, centralized (no hard-coded codes elsewhere); v5
12
+ * (TUI v5 #16e, the v4.1 design): the decorative blue (38;5;75) is
13
+ * RETIRED the identity accents (the you> prompt, the banner tagline,
14
+ * marks, slash-command names, the user rail, the input brick) are
15
+ * bright-white BOLD (SGR 1); `code` is the content semantic tint for
16
+ * inline code spans in assistant text (256-color 110 the cube color
17
+ * nearest the design's #8fb4d8); red for errors, dim for metadata,
18
+ * green for the diff additions. NO_COLOR set, or a non-TTY output →
19
+ * every code is empty, so pipes and CI carry ZERO ANSI (the existing
20
+ * byte-level e2e assertions guard it). Everything not listed is plain.
18
21
  */
19
22
  export interface Palette {
20
- readonly blue: string;
23
+ readonly bold: string;
21
24
  readonly dim: string;
22
25
  readonly red: string;
23
26
  readonly green: string;
24
- readonly bg: string;
27
+ readonly code: string;
25
28
  readonly reset: string;
26
29
  }
27
30
  export declare const COLOR_ON: Palette;
@@ -34,6 +37,15 @@ export declare function palette(): Palette;
34
37
  * EVERY externally-sourced string must pass through this before any output.
35
38
  */
36
39
  export declare function escapeTerminal(text: string): string;
40
+ /**
41
+ * TUI v5 #16e: the inline-code tint — backtick spans in ONE line of
42
+ * assistant body text get the `code` color. Deliberately NOT a markdown
43
+ * engine: single level only (`[^`]*` cannot nest), a span never matches
44
+ * across lines (the caller passes one line; an opener without a closer
45
+ * on the same line stays plain). NO_COLOR / pipes → the codes are empty
46
+ * strings → the line passes through byte-identical.
47
+ */
48
+ export declare function colorInlineCode(line: string): string;
37
49
  /** The canonical-path resolver for the approval detail — injected by the
38
50
  * caller (the CLI passes the tools' own resolution). The tui package is
39
51
  * pure terminal: input is data, output is bytes, zero runtime deps. */
package/dist/render.js CHANGED
@@ -7,8 +7,8 @@
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 = { blue: "\x1b[38;5;75m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", bg: "\x1b[48;5;237m", reset: "\x1b[0m" };
11
- export const COLOR_OFF = { blue: "", dim: "", red: "", green: "", bg: "", reset: "" };
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: "" };
12
12
  export function palette() {
13
13
  return process.env.NO_COLOR === undefined && process.stdout.isTTY ? COLOR_ON : COLOR_OFF;
14
14
  }
@@ -26,6 +26,20 @@ export function escapeTerminal(text) {
26
26
  .replace(/[\u0080-\u009f]/g, "") // C1
27
27
  .replace(/[\u202a-\u202e\u2066-\u2069]/g, ""); // bidi
28
28
  }
29
+ /**
30
+ * TUI v5 #16e: the inline-code tint — backtick spans in ONE line of
31
+ * assistant body text get the `code` color. Deliberately NOT a markdown
32
+ * engine: single level only (`[^`]*` cannot nest), a span never matches
33
+ * across lines (the caller passes one line; an opener without a closer
34
+ * on the same line stays plain). NO_COLOR / pipes → the codes are empty
35
+ * strings → the line passes through byte-identical.
36
+ */
37
+ export function colorInlineCode(line) {
38
+ const p = palette();
39
+ if (p.code === "" || p.reset === "")
40
+ return line;
41
+ return line.replace(/`([^`]*)`/g, `${p.code}\`$1\`${p.reset}`);
42
+ }
29
43
  /**
30
44
  * Render one event. `text` may be a continuation (text_delta appends to the
31
45
  * current line); `newline` says whether the line is complete.
@@ -58,9 +72,9 @@ export function renderEvent(ev, prevThinking = false, resolvePath = (p) => p) {
58
72
  const p = palette();
59
73
  switch (ev.type) {
60
74
  case "user_input":
61
- // v2a: blue (the identity accent — the interactive prompt echoes
62
- // itself; this render is the REPLAY path).
63
- return { text: `${p.blue}you> ${escapeTerminal(typeof ev.content === "string" ? ev.content : "(content)")}${p.reset}\n`, newline: true, prompt: false };
75
+ // v2a/v5: bold (the identity accent — the interactive prompt
76
+ // echoes itself; this render is the REPLAY path).
77
+ return { text: `${p.bold}you> ${escapeTerminal(typeof ev.content === "string" ? ev.content : "(content)")}${p.reset}\n`, newline: true, prompt: false };
64
78
  case "text_delta":
65
79
  return { text: escapeTerminal(ev.text), newline: false, prompt: false };
66
80
  case "text_end":
@@ -164,9 +178,9 @@ function approvalDetail(name, input, resolvePath) {
164
178
  * code; failures (isError) are ✗. Pure and deterministic.
165
179
  */
166
180
  export function renderToolSummary(name, input, result) {
167
- // v2a: ✓ is a blue identity accent; ✗ stays red.
181
+ // v2a/v5: ✓ is a bold identity accent; ✗ stays red.
168
182
  const p = palette();
169
- const mark = result.isError ? `${p.red}✗${p.reset}` : `${p.blue}✓${p.reset}`;
183
+ const mark = result.isError ? `${p.red}✗${p.reset}` : `${p.bold}✓${p.reset}`;
170
184
  const shortName = name.replace("_file", "");
171
185
  const detail = toolSummaryDetail(name, input, result);
172
186
  return `${mark} ${escapeTerminal(`${shortName} ${detail}`)}`;
@@ -315,7 +329,7 @@ export function renderRecap(s) {
315
329
  }
316
330
  if (s.ctxLeftPct !== null)
317
331
  parts.push(`ctx left ~${Math.round(s.ctxLeftPct)}%`);
318
- return `${p.blue}▞${p.reset} ${parts.join(" · ")}\n`;
332
+ return `${p.bold}▞${p.reset} ${parts.join(" · ")}\n`;
319
333
  }
320
334
  /** One-line summary of a session, for `kiso sessions`. */
321
335
  export function renderSessionLine(meta) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "kiso tui \u2014 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",