@vincemakes/kiso-code 0.1.16 → 0.1.18

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.js CHANGED
@@ -26,8 +26,8 @@
26
26
  import { truncateDiff } from "./diff.js";
27
27
  import { displayWidth } from "./editor.js";
28
28
  import { escapeTerminal, foldResult, foldThinking, palette, renderTerminalGap, renderToolSummary, } from "./render.js";
29
- /** The spinner glyphs, cycled by the heartbeat. */
30
- const SPINNER = ["", "", "", ""];
29
+ /** The spinner glyphs, cycled by the heartbeat (v3 §05 — the working family). */
30
+ const SPINNER = ["", "", "", ""];
31
31
  const TOOL_SUMMARY_MAX = 60; // the tool line's parameter summary, chars
32
32
  const FRAME_MS = 16; // state changes coalesce to ≥16ms frames
33
33
  const HEARTBEAT_MS = 200; // spinner / elapsed cadence
@@ -54,6 +54,16 @@ export class Body {
54
54
  this.#active = opts.active();
55
55
  if (this.#isActive()) {
56
56
  this.#heartbeat = setInterval(() => {
57
+ // #14/#15: the idle heartbeat PAINTS NOTHING unless an
58
+ // ANIMATION advances — only a RUNNING tool's glyph/elapsed
59
+ // changes between beats. The #14 fix skipped an all-frozen
60
+ // body; #15 widened the skip to ANY no-change body: a cell
61
+ // that stays unfinished without animating (an unclosed text
62
+ // or thinking block) would otherwise re-paint the tail AND
63
+ // the dock every 200ms with zero change — the short-session
64
+ // leak (measured: 51KB / 46 beats after the recap, LF=0).
65
+ if (!this.#cells.some((c) => c.kind === "tool" && c.state === "running"))
66
+ return;
57
67
  this.#spinnerI = (this.#spinnerI + 1) % SPINNER.length;
58
68
  this.#dirty = true; // the running cells' glyph/elapsed advance
59
69
  this.#scheduleFrame();
@@ -318,7 +328,7 @@ export class Body {
318
328
  nextFrozen += 1;
319
329
  const tail = this.#cells.slice(nextFrozen);
320
330
  const tailHeight = tail.reduce((n, c) => n + this.#cellHeight(c, W), 0);
321
- const tailTop = Math.max(1, H - 2 - tailHeight);
331
+ const tailTop = Math.max(1, H - 3 - tailHeight); // v3 §03: 4 dock rows below
322
332
  const writeRow = Math.max(1, tailTop - 1); // the frozen area's bottom row
323
333
  let scrolled = 0;
324
334
  for (let i = this.#nextFrozen; i < nextFrozen; i += 1) {
@@ -339,7 +349,7 @@ export class Body {
339
349
  // scrolls) and the current area, draw the cells at the body's bottom.
340
350
  out.push("\x1b[?2026h");
341
351
  const clearFrom = Math.min(this.#oldTailTop === 0 ? tailTop : this.#oldTailTop - scrolled, tailTop);
342
- for (let row = clearFrom; row <= H - 3; row += 1) {
352
+ for (let row = clearFrom; row <= H - 4; row += 1) {
343
353
  out.push(`\x1b[${row};1H\x1b[0K`);
344
354
  }
345
355
  let row = tailTop;
@@ -362,13 +372,16 @@ export class Body {
362
372
  const p = palette();
363
373
  switch (cell.kind) {
364
374
  case "user":
365
- return [`${p.blue}you> ${escapeTerminal(cell.text)}${p.reset}`];
375
+ // v3 §02: the user message is a SGR BACKGROUND block, no
376
+ // prefix — every 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}`);
366
379
  case "thinking": {
367
380
  const block = cell.text;
368
381
  const trimmed = escapeTerminal(block.trim());
369
382
  if (trimmed.length <= 100)
370
383
  return [`${p.dim}…${trimmed}${p.reset}`];
371
- return [`${p.dim}…${trimmed.slice(0, 100)} (${block.length} chars · /think shows full)${p.reset}`];
384
+ return [`${p.dim}…${trimmed.slice(0, 100)} (${block.length} chars · /think)${p.reset}`];
372
385
  }
373
386
  case "tool": {
374
387
  const name = escapeTerminal(cell.name);
package/dist/dock.d.ts CHANGED
@@ -4,14 +4,16 @@
4
4
  * implementation: zero dependencies, line-level ANSI, no differential
5
5
  * renderer.
6
6
  *
7
- * Layout (H = terminal height): rows 1..H-3 = the scroll region (the body
8
- * streams and scrolls here, never touching the bottom), row H-2 = the dim
9
- * dotted separator (╌), row H-1 = the live status bar (or a takeover
10
- * question), row H = the input line (the blue brick ▌you> + the v2c
11
- * editor's row readline is gone from the TTY path). Bottom redraws are
12
- * wrapped in CSI 2026 (synchronized output) to avoid flicker the pi
13
- * trick. The visual identity is the kiso brick motif half-block,
14
- * dotted separator deliberately NOT the CC rounded frame nor the pi
7
+ * Layout (H = terminal height): rows 1..H-4 = the scroll region (the body
8
+ * streams and scrolls here, never touching the bottom), row H-3 = the
9
+ * upper dim dotted separator (╌), row H-2 = the input line (the blue
10
+ * brick ▌you> + the v2c editor's row readline is gone from the TTY
11
+ * path), row H-1 = the lower dotted separator, row H = the live status
12
+ * bar (v3 §03: idle "▸ <mode> · /mode to switch · …", running
13
+ * "▖ working Ns · …"; a takeover question replaces it). Bottom redraws
14
+ * are wrapped in CSI 2026 (synchronized output) to avoid flicker the
15
+ * pi trick. The visual identity is the kiso brick motif — ▌ half-block,
16
+ * dotted separators — deliberately NOT the CC rounded frame nor the pi
15
17
  * editor (ADR-0039 Amendment 2).
16
18
  *
17
19
  * Pipes / NO_COLOR: the dock never activates; the v2a line mode stays
@@ -42,6 +44,13 @@ export declare class Dock {
42
44
  exit(): void;
43
45
  /** SIGWINCH: recompute the size, redraw the chrome. */
44
46
  onResize(): void;
47
+ /** v3 §04: bind the editor's slash-command menu state — the menu rows
48
+ * render ABOVE the chrome (over the body's bottom rows; the menu
49
+ * opens while the buffer is a "/" prefix, when no tail is live). */
50
+ bindMenu(state: () => {
51
+ items: readonly import("./editor.js").MenuItem[];
52
+ selected: number;
53
+ } | null): void;
45
54
  /** The input line's edit column — prompt width + cursor + 1. The
46
55
  * dock's redraw and the body's cursor return both end here, so the
47
56
  * ACTUAL cursor always equals what the editor tracks. The width is
@@ -57,11 +66,10 @@ export declare class Dock {
57
66
  * input line by the caller's readline); clearQuestion() restores. */
58
67
  showQuestion(question: string): void;
59
68
  clearQuestion(): void;
60
- /** The bottom three rows, wrapped in CSI 2026 (synchronized output —
69
+ /** The bottom four rows, wrapped in CSI 2026 (synchronized output —
61
70
  * the pi trick against flicker). The cursor ends at the input line's
62
- * edit position. v2c: the separator is the dim dotted (a weaker
63
- * presence than the solid ─), the status line is dim (blue accents
64
- * inside come from the CLI's composition), the input row is the blue
65
- * brick ▌you> + the editor's visible slice. */
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). */
66
74
  redraw(): void;
67
75
  }
package/dist/dock.js CHANGED
@@ -4,14 +4,16 @@
4
4
  * implementation: zero dependencies, line-level ANSI, no differential
5
5
  * renderer.
6
6
  *
7
- * Layout (H = terminal height): rows 1..H-3 = the scroll region (the body
8
- * streams and scrolls here, never touching the bottom), row H-2 = the dim
9
- * dotted separator (╌), row H-1 = the live status bar (or a takeover
10
- * question), row H = the input line (the blue brick ▌you> + the v2c
11
- * editor's row readline is gone from the TTY path). Bottom redraws are
12
- * wrapped in CSI 2026 (synchronized output) to avoid flicker the pi
13
- * trick. The visual identity is the kiso brick motif half-block,
14
- * dotted separator deliberately NOT the CC rounded frame nor the pi
7
+ * Layout (H = terminal height): rows 1..H-4 = the scroll region (the body
8
+ * streams and scrolls here, never touching the bottom), row H-3 = the
9
+ * upper dim dotted separator (╌), row H-2 = the input line (the blue
10
+ * brick ▌you> + the v2c editor's row readline is gone from the TTY
11
+ * path), row H-1 = the lower dotted separator, row H = the live status
12
+ * bar (v3 §03: idle "▸ <mode> · /mode to switch · …", running
13
+ * "▖ working Ns · …"; a takeover question replaces it). Bottom redraws
14
+ * are wrapped in CSI 2026 (synchronized output) to avoid flicker the
15
+ * pi trick. The visual identity is the kiso brick motif — ▌ half-block,
16
+ * dotted separators — deliberately NOT the CC rounded frame nor the pi
15
17
  * editor (ADR-0039 Amendment 2).
16
18
  *
17
19
  * Pipes / NO_COLOR: the dock never activates; the v2a line mode stays
@@ -75,8 +77,8 @@ export class Dock {
75
77
  }
76
78
  const H = this.#height;
77
79
  process.stdout.write("\x1b[r"); // reset the scroll region
78
- for (let row = H - 2; row <= H; row += 1) {
79
- process.stdout.write(`\x1b[${row};1H\x1b[0K`); // clear the three rows
80
+ for (let row = H - 3; row <= H; row += 1) {
81
+ process.stdout.write(`\x1b[${row};1H\x1b[0K`); // clear the four rows
80
82
  }
81
83
  process.stdout.write(`\x1b[${H};1H`);
82
84
  }
@@ -88,6 +90,13 @@ export class Dock {
88
90
  this.#width = process.stdout.columns ?? this.#width;
89
91
  this.redraw();
90
92
  }
93
+ #menuState = null;
94
+ /** v3 §04: bind the editor's slash-command menu state — the menu rows
95
+ * render ABOVE the chrome (over the body's bottom rows; the menu
96
+ * opens while the buffer is a "/" prefix, when no tail is live). */
97
+ bindMenu(state) {
98
+ this.#menuState = state;
99
+ }
91
100
  /** The input line's edit column — prompt width + cursor + 1. The
92
101
  * dock's redraw and the body's cursor return both end here, so the
93
102
  * ACTUAL cursor always equals what the editor tracks. The width is
@@ -122,12 +131,11 @@ export class Dock {
122
131
  this.#question = null;
123
132
  this.redraw();
124
133
  }
125
- /** The bottom three rows, wrapped in CSI 2026 (synchronized output —
134
+ /** The bottom four rows, wrapped in CSI 2026 (synchronized output —
126
135
  * the pi trick against flicker). The cursor ends at the input line's
127
- * edit position. v2c: the separator is the dim dotted (a weaker
128
- * presence than the solid ─), the status line is dim (blue accents
129
- * inside come from the CLI's composition), the input row is the blue
130
- * brick ▌you> + the editor's visible slice. */
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). */
131
139
  redraw() {
132
140
  if (!this.#active)
133
141
  return;
@@ -143,10 +151,25 @@ export class Dock {
143
151
  // \x1b[?2026h/l, the pi source's exact form. Without it terminals
144
152
  // silently ignore the mode and the anti-flicker never engages.
145
153
  out.push("\x1b[?2026h"); // synchronized output ON (DEC 2026)
146
- out.push(`\x1b[${H - 2};1H\x1b[0K${sep}`);
147
- out.push(`\x1b[${H - 1};1H\x1b[0K${statusLine}`);
148
- out.push(`\x1b[${H};1H\x1b[0K${this.#inputPrompt}${inp.line}`);
149
- out.push(`\x1b[${H};${this.#inputCol()}H`); // back to the edit position
154
+ // v3 §04: the slash-command menu — above the chrome, one row per
155
+ // filtered command, the selection highlighted. Drawn first so the
156
+ // chrome rows repaint on top of any overlap.
157
+ const menu = this.#menuState?.();
158
+ if (menu !== null && menu !== undefined) {
159
+ for (let i = 0; i < menu.items.length; i += 1) {
160
+ const item = menu.items[i];
161
+ const row = H - 4 - (menu.items.length - 1 - i);
162
+ const text = i === menu.selected
163
+ ? `${p.blue}▸ ${item.name}${p.reset} ${item.desc}`
164
+ : `${p.dim} ${item.name} ${item.desc}${p.reset}`;
165
+ out.push(`\x1b[${row};1H\x1b[0K${text}`);
166
+ }
167
+ }
168
+ out.push(`\x1b[${H - 3};1H\x1b[0K${sep}`);
169
+ out.push(`\x1b[${H - 2};1H\x1b[0K${this.#inputPrompt}${inp.line}`);
170
+ out.push(`\x1b[${H - 1};1H\x1b[0K${sep}`);
171
+ out.push(`\x1b[${H};1H\x1b[0K${statusLine}`);
172
+ out.push(`\x1b[${H - 2};${this.#inputCol()}H`); // back to the edit position
150
173
  out.push("\x1b[?2026l"); // synchronized output OFF
151
174
  process.stdout.write(out.join(""));
152
175
  }
package/dist/editor.d.ts CHANGED
@@ -24,6 +24,12 @@ export declare function widthOf(chars: readonly number[]): number;
24
24
  export declare function displayWidth(text: string): number;
25
25
  export declare const PROMPT = "\u258Cyou> ";
26
26
  export declare const PROMPT_WIDTH: number;
27
+ /** v3 §04 — the slash-command menu's command table (English one-liners). */
28
+ export interface MenuItem {
29
+ readonly name: string;
30
+ readonly desc: string;
31
+ }
32
+ export declare const MENU_ITEMS: readonly MenuItem[];
27
33
  /**
28
34
  * The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
29
35
  * exit. The input row is rendered by `onRender` (the CLI wires it to the
@@ -48,6 +54,11 @@ export declare class Editor {
48
54
  line: string;
49
55
  cursor: number;
50
56
  };
57
+ /** v3 §04: the menu's visible state for the dock — null when closed. */
58
+ menuState(): {
59
+ items: readonly MenuItem[];
60
+ selected: number;
61
+ } | null;
51
62
  /** One-shot question mode: the NEXT submit answers, not a turn. */
52
63
  question(_query: string, cb: (answer: string) => void): void;
53
64
  /** Cancel a pending question — the buffer stays (its text becomes the
package/dist/editor.js CHANGED
@@ -69,6 +69,13 @@ export function displayWidth(text) {
69
69
  import { palette } from "./render.js";
70
70
  export const PROMPT = "▌you> "; // the kiso brick motif: one blue half-block, then you>
71
71
  export const PROMPT_WIDTH = displayWidth(PROMPT);
72
+ export const MENU_ITEMS = [
73
+ { name: "/mode", desc: "switch the approval tier (manual/default/accept-edits/plan/bypass)" },
74
+ { name: "/think", desc: "show the last full thinking block" },
75
+ { name: "/last", desc: "show the most recent tool call's input and output" },
76
+ { name: "/status", desc: "show session id, event count, and context estimate" },
77
+ { name: "/help", desc: "print this list of commands" },
78
+ ];
72
79
  /**
73
80
  * The editor. Raw mode + bracketed paste (?2004h) on enter, restored on
74
81
  * exit. The input row is rendered by `onRender` (the CLI wires it to the
@@ -88,6 +95,8 @@ export class Editor {
88
95
  #eotCb = null;
89
96
  #escapeCb = null;
90
97
  #onRender;
98
+ #menuOpen = false; // v3 §04: the slash-command menu
99
+ #menuSel = 0;
91
100
  #pending = ""; // an incomplete ESC/CSI prefix across chunks
92
101
  #decoder = new TextDecoder();
93
102
  #entered = false;
@@ -136,6 +145,27 @@ export class Editor {
136
145
  const col = (this.#scroll > 0 ? 1 : 0) + widthOf(this.#chars.slice(this.#scroll, this.#cursor));
137
146
  return { line: `${prefix}${visible}`, cursor: col };
138
147
  }
148
+ /** v3 §04: the menu's visible state for the dock — null when closed. */
149
+ menuState() {
150
+ if (!this.#menuOpen)
151
+ return null;
152
+ return { items: this.#menuFiltered(), selected: this.#menuSel };
153
+ }
154
+ /** v3 §04: the filtered command list for the current buffer — open
155
+ * only while the line is "/" + something (a bare "/" waits). */
156
+ #menuFiltered() {
157
+ const line = this.line();
158
+ if (!line.startsWith("/") || line === "/")
159
+ return [];
160
+ return MENU_ITEMS.filter((m) => m.name.startsWith(line));
161
+ }
162
+ #refreshMenu() {
163
+ const f = this.#menuFiltered();
164
+ this.#menuOpen = f.length > 0;
165
+ if (this.#menuSel >= f.length)
166
+ this.#menuSel = 0;
167
+ this.#onRender();
168
+ }
139
169
  /** One-shot question mode: the NEXT submit answers, not a turn. */
140
170
  question(_query, cb) {
141
171
  this.#questionCb = cb;
@@ -195,6 +225,13 @@ export class Editor {
195
225
  else if (rest.startsWith("O")) {
196
226
  i += 3; // SS3 (function keys) — ignored
197
227
  }
228
+ else if (this.#menuOpen) {
229
+ // v3 §04: Esc closes the menu and clears the buffer.
230
+ this.#chars = [];
231
+ this.#cursor = 0;
232
+ this.#scroll = 0;
233
+ this.#refreshMenu();
234
+ }
198
235
  else {
199
236
  this.#escapeCb?.();
200
237
  i += 1;
@@ -245,6 +282,18 @@ export class Editor {
245
282
  this.#onRender();
246
283
  i += 1;
247
284
  }
285
+ else if (c === "\t" && this.#menuOpen) {
286
+ // v3 §04: Tab completes the buffer to the selected command.
287
+ const f = this.#menuFiltered();
288
+ const m = f[this.#menuSel];
289
+ if (m !== undefined) {
290
+ this.#chars = [...m.name].map((ch) => ch.codePointAt(0));
291
+ this.#cursor = this.#chars.length;
292
+ this.#reflow();
293
+ this.#refreshMenu();
294
+ }
295
+ i += 1;
296
+ }
248
297
  else if (c !== undefined && c < " ") {
249
298
  i += 1; // other control — ignored
250
299
  }
@@ -266,6 +315,15 @@ export class Editor {
266
315
  this.#onRender();
267
316
  }
268
317
  }
318
+ else if (final === "A" && this.#menuOpen) {
319
+ // v3 §04: ↑↓ move the menu selection, never the cursor.
320
+ this.#menuSel = Math.max(0, this.#menuSel - 1);
321
+ this.#onRender();
322
+ }
323
+ else if (final === "B" && this.#menuOpen) {
324
+ this.#menuSel = Math.min(this.#menuFiltered().length - 1, this.#menuSel + 1);
325
+ this.#onRender();
326
+ }
269
327
  else if (final === "D") {
270
328
  this.#move(-1);
271
329
  }
@@ -287,7 +345,7 @@ export class Editor {
287
345
  this.#cursor += 1;
288
346
  this.#reflow();
289
347
  if (!this.#pasting)
290
- this.#onRender();
348
+ this.#refreshMenu();
291
349
  }
292
350
  #backspace() {
293
351
  if (this.#cursor === 0)
@@ -296,7 +354,7 @@ export class Editor {
296
354
  this.#cursor -= 1;
297
355
  this.#reflow();
298
356
  if (!this.#pasting)
299
- this.#onRender();
357
+ this.#refreshMenu();
300
358
  }
301
359
  #delete() {
302
360
  if (this.#cursor >= this.#chars.length)
@@ -304,7 +362,7 @@ export class Editor {
304
362
  this.#chars.splice(this.#cursor, 1);
305
363
  this.#reflow();
306
364
  if (!this.#pasting)
307
- this.#onRender();
365
+ this.#refreshMenu();
308
366
  }
309
367
  #move(delta) {
310
368
  this.#cursor = Math.max(0, Math.min(this.#chars.length, this.#cursor + delta));
@@ -336,10 +394,18 @@ export class Editor {
336
394
  this.#reflow();
337
395
  }
338
396
  #submit() {
339
- const line = String.fromCodePoint(...this.#chars);
397
+ let line = String.fromCodePoint(...this.#chars);
398
+ if (this.#menuOpen) {
399
+ // v3 §04: Enter submits the SELECTED command.
400
+ const m = this.#menuFiltered()[this.#menuSel];
401
+ if (m !== undefined)
402
+ line = m.name;
403
+ }
340
404
  this.#chars = [];
341
405
  this.#cursor = 0;
342
406
  this.#scroll = 0;
407
+ this.#menuOpen = false;
408
+ this.#menuSel = 0;
343
409
  const cb = this.#questionCb;
344
410
  this.#questionCb = null;
345
411
  if (cb !== null) {
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
27
27
  import { createAgent, disposeExtensions, loadExtensions, loadProjectExtensions, projectArtifacts, recordTrust, SessionStore, trustFor, } from "@vincemakes/kiso-runtime";
28
28
  import { createFauxProvider } from "@vincemakes/kiso-evals";
29
29
  import { createCodingTools } from "@vincemakes/kiso-tools-node";
30
- import { escapeTerminal, foldResult, foldThinking, palette, renderEvent, renderSessionLine, renderStatusLine, renderTerminalGap, renderToolSummary, } from "./render.js";
30
+ import { escapeTerminal, foldResult, foldThinking, palette, renderEvent, renderSessionLine, renderStatusLine, renderTerminalGap, renderToolSummary, bannerLines, kUnit, renderRecap, truncateRow, } from "./render.js";
31
31
  import { Dock } from "./dock.js";
32
32
  /** 发现#11: KISO_HOME is the ONE root — every default path derives from
33
33
  * it (sessions, trust, extensions, mcp config, skills). The dedicated
@@ -67,17 +67,18 @@ catch {
67
67
  * merges into the third row on TTY and stays a standalone line off-TTY.
68
68
  * v2a: the logo rows stay dim; the TAGLINE (row 2) is the blue identity
69
69
  * accent. */
70
- const LOGO_TOP = "█ █ ▀█▀ █▀▀ █▀█\n█▀▄ █ ▀▀█ █ █ ";
71
- const TAGLINE = "the coding agent that survives kill -9";
72
- const LOGO_BOTTOM = "\n▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀";
73
70
  function startupBanner() {
74
- // The historical `[N extensions: names]` text merges VERBATIM into the
75
- // third row the existing e2e assertions keep matching (天然不破). E3:
76
- // project-level extensions are counted in N and listed after `project:`
77
- // `[3 extensions: safe-defaults · project: lint-rules, mcp]`.
71
+ // v3 §01: the banner is block-split three independent logo rows
72
+ // (TOP / tagline / BOTTOM), then TWO info rows (version,
73
+ // extensions), each truncated at the window width; < 40 columns
74
+ // skips the logo. The historical `[N extensions: names]` text rides
75
+ // the extensions row verbatim (the e2e assertions keep matching).
78
76
  const p = palette();
79
- const names = bannerExtensionText();
80
- return `${p.dim}${LOGO_TOP}${p.blue}${TAGLINE}${p.reset}${p.dim}${LOGO_BOTTOM} v${VERSION}${names}${p.reset}\n`;
77
+ // A pty without a winsize reports columns = 0 (not undefined) — treat
78
+ // it as the default width, never as a 0-column truncation.
79
+ const W = process.stdout.columns ?? 0;
80
+ const rows = bannerLines(W > 0 ? W : 80, VERSION, bannerExtensionText().replace(/^ · /, ""));
81
+ return `${rows.map((r) => `${p.dim}${r}${p.reset}`).join("\n")}\n`;
81
82
  }
82
83
  /** v2a: the interactive prompt — blue, the identity accent. readline owns
83
84
  * the echo of what the user types; we own the prompt's color. (v2c: the
@@ -194,6 +195,7 @@ function makeLineInput() {
194
195
  editor.enter();
195
196
  const p = palette();
196
197
  dock.bindInput(() => editor.dockState(), `${p.blue}${EDITOR_PROMPT}${p.reset}`);
198
+ dock.bindMenu(() => editor.menuState()); // v3 §04: the slash-command menu
197
199
  return editorInput(editor);
198
200
  }
199
201
  return readlineInput(createInterface({ input: process.stdin, output: process.stdout }));
@@ -214,27 +216,19 @@ function bodyLog(text) {
214
216
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
215
217
  * is gone) — docked only, 200ms rotation between the request and the
216
218
  * first event. */
217
- function startStatusSpinner() {
219
+ function startStatusSpinner(onTick) {
218
220
  if (!dock.active)
219
221
  return () => { };
220
- const GLYPHS = ["◐", "◓", "◑", "◒"];
222
+ // v3 §03/§05: the working glyph family ▖▘▝▗, 200ms rotation — the
223
+ // callback repaints the running status line with the new glyph.
224
+ const GLYPHS = ["▖", "▘", "▝", "▗"];
221
225
  let i = 0;
222
- const timer = setInterval(() => dock.setTail(GLYPHS[i++ % GLYPHS.length]), 200);
226
+ const timer = setInterval(() => onTick(GLYPHS[i++ % GLYPHS.length]), 200);
223
227
  timer.unref();
224
- return () => dock.setTail("");
225
- }
226
- /** v2b: "running <tool> Ns" in the status bar while a tool executes. */
227
- function startRunningTimer(name) {
228
- if (!dock.active)
229
- return () => { };
230
- const started = Date.now();
231
- const timer = setInterval(() => dock.setTail(`running ${name} ${Math.round((Date.now() - started) / 1000)}s`), 1000);
232
- timer.unref();
233
- return () => {
234
- clearInterval(timer);
235
- dock.setTail("");
236
- };
228
+ return () => clearInterval(timer);
237
229
  }
230
+ /** v3 §03: "running <tool> Ns" is gone — the running status line owns
231
+ * the wall clock; the per-tool timer was the old tail mechanism. */
238
232
  /** The model name for the status bar — set by makeAgent. */
239
233
  let agentModel = "faux";
240
234
  /** E3: the `[N extensions: ...]` text — user-level names, then project-level
@@ -251,21 +245,6 @@ function bannerExtensionText() {
251
245
  parts.push(`project: ${projectExtensions.map((e) => e.name).join(", ")}`);
252
246
  return ` · [${total} extension${total === 1 ? "" : "s"}: ${parts.join(" · ")}]`;
253
247
  }
254
- /** Modes: the status-bar indicator — the default tier shows nothing; the
255
- * others show their blue name, the dangerous ones (plan/bypass) with the
256
- * ⚠ prefix. */
257
- function modeStatusText() {
258
- if (getMode() === "default")
259
- return "";
260
- const p = palette();
261
- const danger = getMode() === "plan" || getMode() === "bypass" ? "⚠ " : "";
262
- return `${p.blue}${danger}${getMode()}${p.reset}`;
263
- }
264
- /** Modes: append the mode indicator to a composed status base. */
265
- function statusWithMode(base) {
266
- const mode = modeStatusText();
267
- return mode === "" ? base : `${base} · ${mode}`;
268
- }
269
248
  /** E1: the startup banner line(s) — TTY: logo + merged extensions; off-TTY:
270
249
  * the historical `[N extensions: ...]` standalone line (zero change). */
271
250
  function extensionsBanner() {
@@ -751,6 +730,11 @@ function approvalDiff(name, input) {
751
730
  async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb) {
752
731
  let last;
753
732
  let usage = { in: null, out: null, cache: null, known: false };
733
+ // v3 §02: the recap line derives ENTIRELY from the local event stream
734
+ // (zero tokens) — wall seconds, tool/edit counts, usage, ctx left.
735
+ const turnStart = Date.now();
736
+ let toolCount = 0;
737
+ let editCount = 0;
754
738
  try {
755
739
  for await (const ev of run) {
756
740
  last = ev;
@@ -776,6 +760,9 @@ async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb
776
760
  body.thinkingAppend(ev.text);
777
761
  break;
778
762
  case "tool_call_end":
763
+ toolCount += 1;
764
+ if (ev.name === "edit_file")
765
+ editCount += 1;
779
766
  body.toolStart(ev.name, ev.callId, ev.input ?? {});
780
767
  break;
781
768
  case "tool_execution_started":
@@ -828,14 +815,21 @@ async function consumeRun(session, run, input, turnNo, faux, liveInput, statusCb
828
815
  await session.approve(decisionId, answer.trim().toLowerCase().startsWith("y"));
829
816
  break;
830
817
  }
831
- case "terminal":
832
- statusCb?.(usage, estimateCtxRatio(session));
818
+ case "terminal": {
819
+ // v3 §02: the run's recap line REPLACES the old "done" label
820
+ // + status line — one local line, derived from this run's
821
+ // events (zero tokens). The dock's status bar still paints.
833
822
  statusCb?.(usage, estimateCtxRatio(session));
834
- // v2a rhythm: the honest label (\ndone\n — the completed
835
- // marker), the status line hugging it, then EXACTLY one blank
836
- // line before the next prompt.
837
- body.terminal(renderEvent(ev).text, renderStatusLine(turnNo, usage, estimateCtxRatio(session), faux) ?? "");
823
+ const ratio = estimateCtxRatio(session);
824
+ bodyLog(renderRecap({
825
+ seconds: Math.round((Date.now() - turnStart) / 1000),
826
+ tools: toolCount,
827
+ edits: editCount,
828
+ usage,
829
+ ctxLeftPct: Number.isFinite(ratio) ? (1 - ratio) * 100 : null,
830
+ }));
838
831
  break;
832
+ }
839
833
  default: {
840
834
  // Events without a cell (stop, …) — the generic render, byte-
841
835
  // preserved for the pipe path.
@@ -892,10 +886,20 @@ async function chat(session, faux, input) {
892
886
  currentRun = run;
893
887
  turnNo += 1;
894
888
  const myTurn = turnNo;
889
+ // v3 §03: the running state owns the status bar — the glyph
890
+ // rotates every 200ms; the idle state returns after the run.
891
+ runStart = Date.now();
892
+ runUsage = { in: null, out: null, cache: null, known: false };
893
+ const stopSpinner = startStatusSpinner((g) => {
894
+ runGlyph = g;
895
+ paintRunning();
896
+ });
895
897
  (async () => {
896
898
  let last;
897
899
  try {
898
900
  last = await consumeRun(session, run, input, myTurn, faux, liveInput, statusCb);
901
+ stopSpinner();
902
+ paintIdle();
899
903
  currentRun = null;
900
904
  // 八: a faux script that ran out of declared turns exits
901
905
  // loudly with a non-zero status — never a silent status 0.
@@ -980,18 +984,31 @@ async function chat(session, faux, input) {
980
984
  let queued = 0;
981
985
  // v2b: the live status bar (docked only). Modes: /mode switches repaint
982
986
  // it immediately through paintStatus (the last turn stats are kept).
983
- let statusSt = null;
984
- const statusCb = (u, ctx) => {
987
+ // v3 §03: the status bar has TWO states. Idle: the mode is ALWAYS
988
+ // shown (default included) with the /mode hint. Running: the working
989
+ // glyph (▖▘▝▗ — the spinner drives it) + wall seconds + ↓ out tokens
990
+ // + the interrupt hint. ctx left is the live estimate everywhere.
991
+ let runUsage = { in: null, out: null, cache: null, known: false };
992
+ let runGlyph = "▖";
993
+ let runStart = Date.now();
994
+ const paintRunning = () => {
985
995
  if (!dock.active)
986
996
  return;
987
- statusSt = renderStatusLine(turnNo, u, ctx, faux);
988
- paintStatus();
997
+ const ratio = estimateCtxRatio(session);
998
+ const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
999
+ const out = runUsage.out !== null ? ` ↓ ${kUnit(runUsage.out)} tokens` : "";
1000
+ dock.setStatus(`${runGlyph} working ${Math.max(1, Math.round((Date.now() - runStart) / 1000))}s${out} · esc to interrupt · ctx left ~${pct}%`);
989
1001
  };
990
- const paintStatus = () => {
1002
+ const paintIdle = () => {
991
1003
  if (!dock.active)
992
1004
  return;
993
- const base = statusSt === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${statusSt}`;
994
- dock.setStatus(`${statusWithMode(base)}${queued > 0 ? ` · +${queued} queued` : ""}`);
1005
+ const ratio = estimateCtxRatio(session);
1006
+ const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
1007
+ dock.setStatus(`▸ ${getMode()} · /mode to switch · ${agentModel} · ctx left ~${pct}%`);
1008
+ };
1009
+ const statusCb = (u, ctx) => {
1010
+ runUsage = u;
1011
+ paintRunning();
995
1012
  };
996
1013
  // The ONE dispatcher: slash commands, exit, and turns. The recovery
997
1014
  // replay routes through it too — a queued "/last" must never become a
@@ -1080,7 +1097,7 @@ async function chat(session, faux, input) {
1080
1097
  else {
1081
1098
  setMode(m);
1082
1099
  body.notice(`mode → ${m}`);
1083
- paintStatus();
1100
+ paintIdle();
1084
1101
  }
1085
1102
  input.prompt();
1086
1103
  });
@@ -1147,22 +1164,41 @@ async function resume(session, prompt, faux, input) {
1147
1164
  let currentRun = null;
1148
1165
  let cancelled = false;
1149
1166
  let turnNo = 0;
1150
- // v2b: the live status bar (docked only).
1167
+ // v3 §03: the two-state status bar (see chat — same shapes).
1168
+ let runUsage = { in: null, out: null, cache: null, known: false };
1169
+ let runGlyph = "▖";
1170
+ let runStart = Date.now();
1151
1171
  const statusCb = (u, ctx) => {
1172
+ runUsage = u;
1152
1173
  if (!dock.active)
1153
1174
  return;
1154
- const st = renderStatusLine(turnNo, u, ctx, faux);
1155
- const base = st === null ? `${session.id} · ${agentModel}` : `${session.id} · ${agentModel} · ${st}`;
1156
- dock.setStatus(statusWithMode(base));
1175
+ const pct = Number.isFinite(ctx) ? Math.round((1 - ctx) * 100) : null;
1176
+ const out = runUsage.out !== null ? ` ${kUnit(runUsage.out)} tokens` : "";
1177
+ dock.setStatus(`${runGlyph} working ${Math.max(1, Math.round((Date.now() - runStart) / 1000))}s${out} · esc to interrupt · ctx left ~${pct}%`);
1178
+ };
1179
+ const paintIdle = () => {
1180
+ if (!dock.active)
1181
+ return;
1182
+ const ratio = estimateCtxRatio(session);
1183
+ const pct = Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
1184
+ dock.setStatus(`▸ ${getMode()} · /mode to switch · ${agentModel} · ctx left ~${pct}%`);
1157
1185
  };
1158
1186
  const withRun = async (run) => {
1159
1187
  currentRun = run;
1188
+ runStart = Date.now();
1189
+ runUsage = { in: null, out: null, cache: null, known: false };
1190
+ const stopSpinner = startStatusSpinner((g) => {
1191
+ runGlyph = g;
1192
+ statusCb(runUsage, estimateCtxRatio(session));
1193
+ });
1160
1194
  try {
1161
1195
  turnNo += 1;
1162
1196
  const last = await consumeRun(session, run, input, turnNo, faux, null, statusCb);
1163
1197
  failOnFauxExhaustion(last, faux, input);
1164
1198
  }
1165
1199
  finally {
1200
+ stopSpinner();
1201
+ paintIdle();
1166
1202
  currentRun = null;
1167
1203
  }
1168
1204
  };
@@ -1287,7 +1323,7 @@ async function main() {
1287
1323
  }
1288
1324
  case "help": {
1289
1325
  const p = palette();
1290
- console.log(`${p.dim}${LOGO_TOP}${p.blue}${TAGLINE}${p.reset}${p.dim}${LOGO_BOTTOM}${p.reset}\n\n` +
1326
+ console.log(`${p.dim}${bannerLines(80, VERSION, "").join("\n")}${p.reset}\n\n` +
1291
1327
  "kiso — the coding agent that survives kill -9\n\n" +
1292
1328
  " kiso [sessionId] interactive session (default command)\n" +
1293
1329
  " kiso chat [sessionId] same as above\n" +
package/dist/render.d.ts CHANGED
@@ -11,12 +11,14 @@ import { canonicalTargetPath } from "@vincemakes/kiso-tools-node";
11
11
  * errors; dim for metadata. NO_COLOR set, or a non-TTY output → every
12
12
  * code is empty, so pipes and CI carry ZERO ANSI (the existing byte-level
13
13
  * e2e assertions guard it). Everything not listed here is plain.
14
+ * v3: `bg` — the user-message block background (SGR 48, dark gray 237).
14
15
  */
15
16
  export interface Palette {
16
17
  readonly blue: string;
17
18
  readonly dim: string;
18
19
  readonly red: string;
19
20
  readonly green: string;
21
+ readonly bg: string;
20
22
  readonly reset: string;
21
23
  }
22
24
  export declare const COLOR_ON: Palette;
@@ -73,6 +75,8 @@ export declare function renderToolSummary(name: string, input: Record<string, un
73
75
  content: string;
74
76
  isError: boolean;
75
77
  }): string;
78
+ /** k-units for the status line: 12345 → 12.3k, 800 → 800, null → ?. */
79
+ export declare function kUnit(value: number | null): string;
76
80
  /** B 区: usage data gathered from the run's usage events. */
77
81
  export interface RunUsage {
78
82
  readonly in: number | null;
@@ -96,6 +100,31 @@ export declare function renderStatusLine(turn: number, usage: RunUsage, ctxRatio
96
100
  * this verbatim; the render tests pin the sequence.
97
101
  */
98
102
  export declare function renderTerminalGap(statusLine: string | null): string;
103
+ /**
104
+ * v3 §01 — the banner, block-split. The logo is three INDEPENDENT rows
105
+ * (TOP / the tagline / BOTTOM), then TWO info rows (version,
106
+ * extensions). Every row truncates at the terminal width with a " (+N)"
107
+ * marker (N = the hidden display width); a window narrower than 40
108
+ * columns skips the logo entirely — only the info rows. Pure.
109
+ */
110
+ export declare const TAGLINE = "the coding agent that survives kill -9";
111
+ /** v3 §01: truncate a row at `width`, marking the hidden span " (+N)". */
112
+ export declare function truncateRow(row: string, width: number): string;
113
+ /** v3 §01: the banner lines for a width W — logo (skipped under 40
114
+ * columns) + version + extensions. */
115
+ export declare function bannerLines(W: number, version: string, extensionsText: string): string[];
116
+ /** v3 §02 — the recap line that ends a run, replacing the "done" label +
117
+ * the old status line. All fields derive LOCALLY from the event stream
118
+ * (zero tokens): wall seconds, tool counts, usage, cache hit %, ctx left.
119
+ */
120
+ export interface RecapStats {
121
+ readonly seconds: number;
122
+ readonly tools: number;
123
+ readonly edits: number;
124
+ readonly usage: RunUsage;
125
+ readonly ctxLeftPct: number | null;
126
+ }
127
+ export declare function renderRecap(s: RecapStats): string;
99
128
  /** One-line summary of a session, for `kiso sessions`. */
100
129
  export declare function renderSessionLine(meta: {
101
130
  id: string;
package/dist/render.js CHANGED
@@ -3,8 +3,8 @@
3
3
  * the lines a human sees. Colors are raw ANSI — no dependencies.
4
4
  */
5
5
  import { canonicalTargetPath } from "@vincemakes/kiso-tools-node";
6
- export const COLOR_ON = { blue: "\x1b[38;5;75m", dim: "\x1b[2m", red: "\x1b[31m", green: "\x1b[32m", reset: "\x1b[0m" };
7
- export const COLOR_OFF = { blue: "", dim: "", red: "", green: "", reset: "" };
6
+ 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" };
7
+ export const COLOR_OFF = { blue: "", dim: "", red: "", green: "", bg: "", reset: "" };
8
8
  export function palette() {
9
9
  return process.env.NO_COLOR === undefined && process.stdout.isTTY ? COLOR_ON : COLOR_OFF;
10
10
  }
@@ -50,7 +50,7 @@ export function foldThinking(block) {
50
50
  const p = palette();
51
51
  const trimmed = escapeTerminal(block.trim());
52
52
  const truncated = trimmed.length > 100;
53
- return `${p.dim}…${trimmed.slice(0, 100)}${truncated ? " ( /think shows full)" : ""}${p.reset}\n`;
53
+ return `${p.dim}…${trimmed.slice(0, 100)}${truncated ? ` (${block.length} chars · /think)` : ""}${p.reset}\n`;
54
54
  }
55
55
  /** v2b — the [result] echo truncates at 160 chars + a /last hint. */
56
56
  export function foldResult(content) {
@@ -215,7 +215,7 @@ function exitCodeOf(result) {
215
215
  return m !== null ? Number(m[1]) : 1;
216
216
  }
217
217
  /** k-units for the status line: 12345 → 12.3k, 800 → 800, null → ?. */
218
- function kUnit(value) {
218
+ export function kUnit(value) {
219
219
  if (value === null)
220
220
  return "?";
221
221
  if (value >= 1000)
@@ -257,6 +257,66 @@ export function renderStatusLine(turn, usage, ctxRatio, faux = false) {
257
257
  export function renderTerminalGap(statusLine) {
258
258
  return `${statusLine === null ? "" : `${statusLine}\n`}\n`;
259
259
  }
260
+ /**
261
+ * v3 §01 — the banner, block-split. The logo is three INDEPENDENT rows
262
+ * (TOP / the tagline / BOTTOM), then TWO info rows (version,
263
+ * extensions). Every row truncates at the terminal width with a " (+N)"
264
+ * marker (N = the hidden display width); a window narrower than 40
265
+ * columns skips the logo entirely — only the info rows. Pure.
266
+ */
267
+ export const TAGLINE = "the coding agent that survives kill -9";
268
+ const LOGO_ROWS = ["█ █ ▀█▀ █▀▀ █▀█", TAGLINE, "▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀"];
269
+ /** Display width of a row (1-cell ASCII; 2-cell CJK/wide). */
270
+ function displayW(row) {
271
+ let w = 0;
272
+ for (let i = 0; i < row.length; i += 1) {
273
+ const cp = row.codePointAt(i);
274
+ w += cp > 0xff ? 2 : 1;
275
+ }
276
+ return w;
277
+ }
278
+ /** v3 §01: truncate a row at `width`, marking the hidden span " (+N)". */
279
+ export function truncateRow(row, width) {
280
+ if (displayW(row) <= width)
281
+ return row;
282
+ const cut = Math.max(0, width - 4);
283
+ let w = 0;
284
+ let i = 0;
285
+ for (; i < row.length; i += 1) {
286
+ const cw = row.codePointAt(i) > 0xff ? 2 : 1;
287
+ if (w + cw > cut)
288
+ break;
289
+ w += cw;
290
+ }
291
+ return `${row.slice(0, i)} (+${displayW(row) - w})`;
292
+ }
293
+ /** v3 §01: the banner lines for a width W — logo (skipped under 40
294
+ * columns) + version + extensions. */
295
+ export function bannerLines(W, version, extensionsText) {
296
+ const rows = [];
297
+ if (W >= 40)
298
+ for (const r of LOGO_ROWS)
299
+ rows.push(truncateRow(r, W));
300
+ rows.push(truncateRow(`v${version}`, W));
301
+ if (extensionsText !== "")
302
+ rows.push(truncateRow(extensionsText, W));
303
+ return rows;
304
+ }
305
+ export function renderRecap(s) {
306
+ const p = palette();
307
+ const parts = [`${s.seconds}s`, `${s.tools} tool${s.tools === 1 ? "" : "s"}${s.edits > 0 ? ` (${s.edits} edit${s.edits === 1 ? "" : "s"})` : ""}`];
308
+ if (s.usage.known) {
309
+ 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)}` : ""}`;
310
+ if (seg !== "")
311
+ parts.push(seg);
312
+ if (s.usage.cache !== null && s.usage.in !== null && s.usage.in > 0) {
313
+ parts.push(`cache ${Math.round((s.usage.cache / s.usage.in) * 100)}%`);
314
+ }
315
+ }
316
+ if (s.ctxLeftPct !== null)
317
+ parts.push(`ctx left ~${Math.round(s.ctxLeftPct)}%`);
318
+ return `${p.blue}▞${p.reset} ${parts.join(" · ")}\n`;
319
+ }
260
320
  /** One-line summary of a session, for `kiso sessions`. */
261
321
  export function renderSessionLine(meta) {
262
322
  const when = meta.updatedAt ? new Date(meta.updatedAt).toISOString().slice(0, 16) : "—";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-code",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "kiso CLI — the coding-agent reference product: kiso chat / kiso resume / kiso sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,12 +18,12 @@
18
18
  "test": "vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vincemakes/kiso-core": "0.1.16",
22
- "@vincemakes/kiso-evals": "0.1.16",
23
- "@vincemakes/kiso-provider-anthropic": "0.1.16",
24
- "@vincemakes/kiso-provider-openai": "0.1.16",
25
- "@vincemakes/kiso-runtime": "0.1.16",
26
- "@vincemakes/kiso-tools-node": "0.1.16"
21
+ "@vincemakes/kiso-core": "0.1.18",
22
+ "@vincemakes/kiso-evals": "0.1.18",
23
+ "@vincemakes/kiso-provider-anthropic": "0.1.18",
24
+ "@vincemakes/kiso-provider-openai": "0.1.18",
25
+ "@vincemakes/kiso-runtime": "0.1.18",
26
+ "@vincemakes/kiso-tools-node": "0.1.18"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.1.2",