@vincemakes/kiso-tui 0.46.1 → 0.47.0

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/status.js CHANGED
@@ -22,12 +22,12 @@
22
22
  * widens to name the new gesture.
23
23
  */
24
24
  import { kUnit } from "./lines.js";
25
- import { meterGlyphs } from "./context-ledger.js";
25
+ import { meterCells, meterGlyphs } from "./context-ledger.js";
26
26
  /** The compacting row's bar width — short: it shares a row. */
27
27
  const BAR_ON_ROW = 6;
28
28
  import { elapsedLabel } from "@vincemakes/kiso-tui-cells";
29
- import { TWINKLE } from "@vincemakes/kiso-tui-cells/render";
30
- import { displayWidth } from "@vincemakes/kiso-tui-cells/width";
29
+ import { TWINKLE, cutLine, palette } from "@vincemakes/kiso-tui-cells/render";
30
+ import { displayWidth, visibleWidth } from "@vincemakes/kiso-tui-cells/width";
31
31
  /**
32
32
  * R3 (design §5.2) — the working glyph family is the TWINKLE, and the
33
33
  * CLI's 200ms spinner walks it exactly as it walked the four quadrant
@@ -43,25 +43,6 @@ import { displayWidth } from "@vincemakes/kiso-tui-cells/width";
43
43
  * Glyphs only, no colour: intact under NO_COLOR and on any ground.
44
44
  */
45
45
  export const STATUS_GLYPHS = TWINKLE;
46
- /**
47
- * The ~ctx estimate as the whole-percent LEFT, or `ctx ?` when there is no
48
- * window to divide by.
49
- *
50
- * A percentage needs a denominator. When the model's context window is not
51
- * KNOWN — the registry records null for it, nobody set one in the profile,
52
- * no env — there is no denominator, and this row's rule is the same as
53
- * every other number on it: a measurement, or nothing. It used to print
54
- * `ctx left ~null%`, which at least did not invent a figure; `ctx ?` says
55
- * the same thing to a reader.
56
- *
57
- * The failure this replaces is worse than either: the window fell back to
58
- * a hardcoded 200,000 and the row printed a confident `ctx left ~82%`
59
- * against a number nobody had measured. A reader had no way to tell that
60
- * percentage from one computed against a real window.
61
- */
62
- function ctxSegment(ratio) {
63
- return Number.isFinite(ratio) ? `ctx left ~${Math.round((1 - ratio) * 100)}%` : "ctx ?";
64
- }
65
46
  /**
66
47
  * TPS-1 — the settled DECODE rate of one model call: its output tokens
67
48
  * over the seconds from the call's first streamed event to its usage
@@ -91,19 +72,22 @@ export function decodeRate(outputTokens, elapsedMs) {
91
72
  export function composeRow(head, segments, W) {
92
73
  const present = segments.filter((x) => x != null && x.text !== "");
93
74
  const join = (xs) => [head, ...xs.map((x) => x.text)].join(" · ");
75
+ // the compaction round: a fact may carry colour (the progress cells), and
76
+ // its codes take no cells — measured without them, a plain row as before
77
+ const width = (t) => displayWidth(t.replace(/\x1b\[[0-9;]*m/g, ""));
94
78
  const full = join(present);
95
- if (W === undefined || displayWidth(full) <= W)
79
+ if (W === undefined || width(full) <= W)
96
80
  return full;
97
81
  // 1. elide every label in its middle
98
82
  let row = present.map((x) => (x.kind === "label" ? { ...x, text: elideMiddle(x.text, LABEL_ON_ROW) } : x));
99
- if (displayWidth(join(row)) <= W)
83
+ if (width(join(row)) <= W)
100
84
  return join(row);
101
85
  // 2. drop hints from the end, one at a time
102
86
  for (let i = row.length - 1; i >= 0; i -= 1) {
103
87
  if (row[i].kind !== "hint")
104
88
  continue;
105
89
  row = [...row.slice(0, i), ...row.slice(i + 1)];
106
- if (displayWidth(join(row)) <= W)
90
+ if (width(join(row)) <= W)
107
91
  return join(row);
108
92
  }
109
93
  // 3. facts are never dropped: past this point the row is over budget,
@@ -118,44 +102,13 @@ export function retrySegment(r) {
118
102
  const head = `retrying ${r.attempt}/${r.maxRetries} · ${r.code}`;
119
103
  return r.remainingMs > 0 ? `${head} · ${Math.ceil(r.remainingMs / 1000)}s` : head;
120
104
  }
121
- /** `● 2 tasks running · ◌ 1 unknown`, `◌ 1 task unknown` — or nothing.
122
- * An unknown task is never hidden behind the panel: it is the one that
123
- * needs the person most. */
105
+ /** `● 2 tasks running` — or nothing. A task kiso lost track of is not a
106
+ * second count here: it is an event, said once in the transcript
107
+ * (Amendment 8). */
124
108
  export function tasksSegment(c) {
125
- if (c === undefined)
109
+ if (c === undefined || c.running <= 0)
126
110
  return "";
127
- const n = (k, word) => `${k} ${word}${k === 1 ? "" : "s"}`;
128
- if (c.running > 0 && c.unknown > 0)
129
- return `● ${n(c.running, "task")} running · ◌ ${c.unknown} unknown`;
130
- if (c.running > 0)
131
- return `● ${n(c.running, "task")} running`;
132
- if (c.unknown > 0)
133
- return `◌ ${n(c.unknown, "task")} unknown`;
134
- return "";
135
- }
136
- export function runningStatus(glyph, since, outTokens, ctxRatio, tokPerSec = null, W, retry,
137
- // 3e: the session's tasks, and whether a running command can be moved
138
- // to the background now (the row teaches ctrl+b exactly then)
139
- tasks, detachable = false) {
140
- const out = outTokens !== null ? ` ↓ ${kUnit(outTokens)} tokens` : "";
141
- const seconds = Math.max(1, Math.round((Date.now() - since) / 1000));
142
- return composeRow(`${glyph} working ${elapsedLabel(seconds)}${out}`, [
143
- // ADR-0005 Amendment 2: a pending retry is a FACT and sits first — it
144
- // is the one thing on the row that explains why nothing is arriving,
145
- // and a retry budget of minutes with nothing on screen reads as a
146
- // hung session.
147
- retry != null ? { kind: "fact", text: retrySegment(retry) } : null,
148
- // TPS-1: after each call SETTLES within the turn, between the tokens
149
- // segment and the stop hint. The default is null and that is the
150
- // honest rule spelled as a default — the recovery flow has no
151
- // per-call timing state, so its row says nothing rather than guessing.
152
- tokPerSec !== null ? { kind: "fact", text: `${tokPerSec} tok/s` } : null,
153
- { kind: "fact", text: tasksSegment(tasks) },
154
- { kind: "hint", text: "esc stop" },
155
- detachable ? { kind: "hint", text: "ctrl+b background" } : null,
156
- { kind: "hint", text: "alt+⏎ redirect" },
157
- { kind: "fact", text: ctxSegment(ctxRatio) },
158
- ], W);
111
+ return `● ${c.running} task${c.running === 1 ? "" : "s"} running`;
159
112
  }
160
113
  /**
161
114
  * The COMPACTING row (W18): the covered rounds, the pre-call token
@@ -164,16 +117,32 @@ tasks, detachable = false) {
164
117
  * inline template in dispatch (0.40.0) so it composes like every other
165
118
  * row and has a place for what the launch build adds to it.
166
119
  */
167
- export function compactingStatus(glyph, rounds, tokens, elapsedSeconds, W, retry, progress) {
120
+ /** The compaction round (owner, 2026-10-06) — the summary's progress in
121
+ * the bar's own cells (§8.9's ctx meter, `meterCells`): `▆` filled in
122
+ * `ink2`, the rest in the track colour. `▰▱` drew tiny in Menlo and
123
+ * retired from the bar; off a known ground there are no colours to tell
124
+ * filled from empty, so the glyphs carry it there. */
125
+ function progressCells(ratio, cells) {
126
+ const p = palette();
127
+ if (p.track === "")
128
+ return meterGlyphs(ratio, cells);
129
+ const filled = Math.max(0, Math.min(cells, Math.round((Number.isFinite(ratio) ? ratio : 0) * cells)));
130
+ return `${p.ink2}${"\u2586".repeat(filled)}${p.track}${"\u2586".repeat(cells - filled)}${p.fgEnd}`;
131
+ }
132
+ export function compactingStatus(glyph, rounds, tokens, elapsedSeconds, W, retry, progress, why) {
168
133
  // 0.40.0: with a budget to measure against, the covered size and the bar
169
134
  // are ONE fact — what went in, and how much of the output budget has
170
135
  // come out. Without a budget the row keeps the covered size alone: the
171
136
  // bar never invents a denominator.
172
137
  const covered = progress != null && progress.budget !== null && progress.budget > 0
173
- ? `~${kUnit(tokens)} \u2192 ${meterGlyphs(progress.produced / progress.budget, BAR_ON_ROW)} ${kUnit(progress.produced)}/${kUnit(progress.budget)}`
138
+ ? `~${kUnit(tokens)} \u2192 ${progressCells(progress.produced / progress.budget, BAR_ON_ROW)} ${kUnit(progress.produced)}/${kUnit(progress.budget)}`
174
139
  : `~${kUnit(tokens)} tokens`;
175
140
  return composeRow(`${glyph} compacting`, [
176
- { kind: "fact", text: `${rounds} rounds` },
141
+ // Graphite §8.7 (R3b, G7): why it is compacting — `manual` for
142
+ // /compact, `auto` past the configured threshold — first, a fact
143
+ why !== undefined && why !== "" ? { kind: "fact", text: why } : null,
144
+ // the compaction round: one round is a round
145
+ { kind: "fact", text: `${rounds} round${rounds === 1 ? "" : "s"}` },
177
146
  { kind: "fact", text: covered },
178
147
  // why the figure jumped when the usage landed — a HINT, so a narrow
179
148
  // row gives it up before the bar, the seconds or the retry
@@ -221,50 +190,6 @@ function elideMiddle(text, max) {
221
190
  }
222
191
  return `${head}…${tail}`;
223
192
  }
224
- /**
225
- * The IDLE row: the approval tier as the CALLER names it, the /mode hint,
226
- * the model driving the session, the TUI2-R1 meter when there is one, and
227
- * the ctx estimate. Called without a meter — or with one that knows
228
- * nothing — the row is byte-identical to the pre-round row.
229
- *
230
- * DF-0330-F1 — THE DROP ORDER. `W` is the row's budget; given one, the row
231
- * gives ground in a fixed order rather than letting invariant ① cut its
232
- * end off. Found the hard way: at 100 columns the ` · N tok/s` segment
233
- * never appeared and at 140 it did, because the row was 102 columns wide
234
- * and the segment TPS-1 added sat last.
235
- *
236
- * 1. the MODEL ID is elided in its middle. It is the only segment that
237
- * varies, it is the one that grew (the owner's is 35 columns of a
238
- * 90-column row), and eliding it gives the row back a budget instead
239
- * of re-allocating a deficit;
240
- * 2. `/mode to switch` is dropped. It teaches a gesture; `/mode` and `?`
241
- * still exist and the row is not the only place they are taught;
242
- * 3. the FACTS are never dropped and never cut — the tier, CH, the ctx
243
- * estimate and the rate. A row that silently drops a measurement is
244
- * the defect this rule exists to prevent.
245
- *
246
- * The elision is ON THE ROW only. `/model`, the session log and the trace
247
- * ledger all keep the id whole — the row is a view, never the record.
248
- *
249
- * No `W` means no dropping, which is what the callers that do not know
250
- * their width should get: today's row, unchanged.
251
- */
252
- export function idleStatus(tier, model, ctxRatio, meter, W, floorOff = false, tasks) {
253
- return composeRow(`▸ ${tier}`, [
254
- // 0.40.0: the catastrophe floor is on by default and says nothing;
255
- // OFF is the state worth seeing, and a fact beside the tier it
256
- // changes the meaning of.
257
- floorOff ? { kind: "fact", text: "floor off" } : null,
258
- // 3e: work still going on (or gone unknown) after the turn ended
259
- { kind: "fact", text: tasksSegment(tasks) },
260
- { kind: "hint", text: "/mode to switch" },
261
- { kind: "label", text: model },
262
- meter?.cacheHitPct != null ? { kind: "fact", text: `CH ${Math.round(meter.cacheHitPct)}%` } : null,
263
- // costUsd deliberately NOT rendered — see StatusMeter.costUsd.
264
- { kind: "fact", text: ctxSegment(ctxRatio) },
265
- meter?.tokPerSec != null ? { kind: "fact", text: `${meter.tokPerSec} tok/s` } : null, // TPS-1: last, after the ctx estimate
266
- ], W);
267
- }
268
193
  /** TUI2-R1 (E) — the cache hit rate the status row shows, from the usage
269
194
  * the CLI already tracks. The denominator is the TOTAL the model was
270
195
  * given (fresh + cacheRead), which is the E2 ruling's own: cacheRead
@@ -278,3 +203,152 @@ export function cacheHitPct(usage) {
278
203
  const total = fresh + cached;
279
204
  return total > 0 ? (cached / total) * 100 : null;
280
205
  }
206
+ /** The ctx meter's width in cells (§8.9). */
207
+ /**
208
+ * §8.9 — the ctx meter: ten `▆` cells with the used share filled, then the
209
+ * percentage used. Filled cells take `ink2` below the soft compaction tier,
210
+ * gold from the soft tier to the hard one, the failure colour past the hard
211
+ * tier; the empty cells are `track`. There is no marker inside the bar —
212
+ * the tier shows as colour only (owner, 2026-09-28). Off a known ground
213
+ * the colours carry nothing, so the meter is the percentage alone (§1.2:
214
+ * the fact survives without the colour). `ctx ?` when the window is not
215
+ * known.
216
+ */
217
+ export function ctxMeter(ctx) {
218
+ if (ctx === null || !Number.isFinite(ctx.used))
219
+ return "ctx ?";
220
+ const used = Math.max(0, ctx.used);
221
+ const pct = `${Math.round(used * 100)}%`;
222
+ const p = palette();
223
+ if (p.track === "")
224
+ return `ctx ${pct}`;
225
+ // the cells follow the percentage SHOWN: `ctx 0%` is an empty meter,
226
+ // and from 1% at least one cell is filled (owner, 2026-09-29 — a lit
227
+ // cell beside `0%` read as a contradiction); one rule with /context's
228
+ return `${p.dim}ctx${p.reset} ${meterCells(used, ctx.soft, ctx.hard)} ${p.dim}${pct}${p.reset}`;
229
+ }
230
+ /**
231
+ * §8.9 — the status bar, composed for `W` cells. Left: the mode chip,
232
+ * `floor off`, `/mode to switch`, the model and its effort, the ctx meter,
233
+ * the decode rate. The cache share is the seal's (owner, 2026-09-29: every
234
+ * run's closing row already says it), not the bar's. Right: the branch, the folder, and the
235
+ * `ctrl+o` switch while a card has rows behind the key.
236
+ *
237
+ * §8.5 — what gives way, in order: the `ctrl+o` hint, the folder (the
238
+ * terminal title names it too), the branch, the model's middle, and last
239
+ * `/mode to switch` — the one place a newcomer meets modes. The facts
240
+ * never drop; past that the row is invariant ①'s to cut.
241
+ *
242
+ * On a known ground the chip is a surface and the segments are spaced; off
243
+ * one the head is today's `▸ <mode>` and the segments are joined with ` · `
244
+ * — the same facts, in words that survive without colour.
245
+ */
246
+ export function statusBar(b, W, expand) {
247
+ const p = palette();
248
+ const painted = p.washDone !== "";
249
+ const sep = painted ? " " : " · ";
250
+ const tierChip = painted ? `${b.modeAlert === true ? p.fail : ""}${p.washDone} ${b.mode} ${p.washEnd}${b.modeAlert === true ? p.fgEnd : ""}` : `▸ ${b.mode}`;
251
+ // the switch is not a tier, so it is not in the tier's chip: a chip of its
252
+ // own, never dropped — it changes what kiso does as much as the tier does
253
+ const chip = b.dontAsk !== true ? tierChip : painted ? `${tierChip} ${p.gold}${p.washDone} don't ask ${p.washEnd}${p.fgEnd}` : `${tierChip} · don't ask`;
254
+ const fail = (s) => (painted ? `${p.fail}${s}${p.fgEnd}` : s);
255
+ const dim = (s) => `${p.dim}${s}${p.reset}`;
256
+ // the bar's words are quiet (§1.2: grey chrome); off a known ground each
257
+ // side is ONE dim span — the shape the status row always had
258
+ const quiet = (s) => (painted ? dim(s) : s);
259
+ // drop: 0 = never; otherwise the order it gives way in (1 first)
260
+ const left = [
261
+ b.floorOff ? { text: fail("floor off"), drop: 0 } : null,
262
+ // the main-sync round (ADR-0058 3e, §8.9): the session's tasks, right
263
+ // after the mode as 0.46.0's status row has them (owner, 2026-10-06): a
264
+ // FACT, which never gives way — `●` in the machine's blue (running), `◌`
265
+ // in gold (an outcome nobody can know is the one that needs the
266
+ // person, §4); the words quiet like the rest of the bar
267
+ tasksSegment(b.tasks) === "" ? null : { text: painted ? tasksOnBar(b.tasks) : tasksSegment(b.tasks), drop: 0 },
268
+ { text: quiet("/mode to switch"), drop: 4 },
269
+ { text: b.model, drop: 0 },
270
+ { text: ctxMeter(b.ctx), drop: 0 },
271
+ b.tokPerSec !== null ? { text: quiet(`${b.tokPerSec} tok/s`), drop: 0 } : null,
272
+ ];
273
+ const right = [
274
+ b.branch !== null ? { text: painted ? `${p.blue}${b.branch}${p.fgEnd}` : b.branch, drop: 3 } : null,
275
+ b.folder !== null ? { text: quiet(b.folder), drop: 2 } : null,
276
+ expand !== null ? { text: quiet(`ctrl+o ${expand}`), drop: 1 } : null,
277
+ ];
278
+ let model = b.model;
279
+ const compose = (dropped) => {
280
+ const l = left.filter((x) => x !== null && (x.drop === 0 || x.drop > dropped)).map((x) => (x.text === b.model ? quiet(model) : x.text));
281
+ const r = right.filter((x) => x !== null && (x.drop === 0 || x.drop > dropped)).map((x) => x.text);
282
+ const lt = painted ? [chip, ...l].join(sep) : dim([chip, ...l].join(sep));
283
+ if (r.length === 0)
284
+ return lt;
285
+ const rt = painted ? r.join(" ") : dim(r.join(" "));
286
+ const gap = W - visibleWidth(lt) - visibleWidth(rt);
287
+ return gap >= 2 ? `${lt}${" ".repeat(gap)}${rt}` : `${lt} ${rt}`;
288
+ };
289
+ const fits = (row) => visibleWidth(row) <= W;
290
+ let row = "";
291
+ for (const level of [0, 1, 2, 3]) {
292
+ row = compose(level);
293
+ if (fits(row))
294
+ return row;
295
+ }
296
+ // the model's middle gives way before `/mode to switch` does (DF-0330-F1)
297
+ model = elideMiddle(b.model, LABEL_ON_ROW);
298
+ for (const level of [3, 4]) {
299
+ row = compose(level);
300
+ if (fits(row))
301
+ return row;
302
+ }
303
+ return cutLine(row, W);
304
+ }
305
+ /** The bar's task segment, painted: the mark coloured, the words dim.
306
+ * The main sync (0.46.2, Amendment 8): the gold `◌ N unknown` beside it
307
+ * retired — a task kiso lost track of is said once in the transcript, a
308
+ * TASK row, not counted on the bar ("running" beside "may be running"
309
+ * read as a contradiction to the owner). */
310
+ function tasksOnBar(c) {
311
+ const p = palette();
312
+ return c.running > 0 ? `${p.blue}\u25cf${p.fgEnd} ${p.dim}${c.running} task${c.running === 1 ? "" : "s"} running${p.reset}` : "";
313
+ }
314
+ /**
315
+ * §8.7 — the LIVE ROW's words for a running turn: `working` for the whole
316
+ * turn, whatever the model is doing (it never switches to "thinking" —
317
+ * what the model thinks is in the stream), the elapsed, the output tokens,
318
+ * the decode rate. A pending retry replaces it. The keys ride the row's
319
+ * right end.
320
+ */
321
+ export function workingRow(glyph, since, outTokens, tokPerSec, W, retry, detachable = false) {
322
+ // a pending retry REPLACES `working` while it lasts (§8.7): it is the one
323
+ // thing that explains why nothing is arriving (ADR-0005 Amendment 2),
324
+ // and its countdown is the row's pulse — whole seconds, rounded up, so
325
+ // it never says 0s while still waiting
326
+ if (retry != null) {
327
+ const wait = retry.remainingMs > 0 ? ` · next try in ${Math.ceil(retry.remainingMs / 1000)}s` : "";
328
+ return liveRow(`↻ retrying ${retry.attempt}/${retry.maxRetries} · ${retry.code}${wait}`, ["esc gives up"], W);
329
+ }
330
+ const out = outTokens !== null ? ` · ↓ ${kUnit(outTokens)}` : "";
331
+ const seconds = Math.max(1, Math.round((Date.now() - since) / 1000));
332
+ const facts = [`${glyph} working ${elapsedLabel(seconds)}${out}`, ...(tokPerSec !== null ? [`${tokPerSec} tok/s`] : [])].join(" · ");
333
+ // the main-sync round (ADR-0058 3e): ctrl+b exactly while a running
334
+ // command can be moved to the background — it gives way after esc
335
+ if (detachable)
336
+ return liveRow(facts, ["esc stop \u00b7 ctrl+b background \u00b7 \u23ce steer \u00b7 alt+\u23ce redirect", "esc stop \u00b7 ctrl+b background \u00b7 \u23ce steer", "esc stop \u00b7 ctrl+b background", "esc stop"], W);
337
+ return liveRow(facts, ["esc stop · ⏎ steer · alt+⏎ redirect", "esc stop · ⏎ steer", "esc stop"], W);
338
+ }
339
+ /** §8.7 — a live row: its mark in the mark column (column 0), its facts
340
+ * from the content edge, its keys right-aligned; the keys give way from
341
+ * the right when the row is short, the facts never (past them the row is
342
+ * cut). */
343
+ export function liveRow(facts, keys, W) {
344
+ const p = palette();
345
+ const lead = facts;
346
+ for (const k of [...keys, ""]) {
347
+ if (k === "")
348
+ return visibleWidth(lead) <= W ? lead : cutLine(lead, W);
349
+ const gap = W - visibleWidth(lead) - visibleWidth(k);
350
+ if (gap >= 2)
351
+ return `${lead}${" ".repeat(gap)}${p.dim}${k}${p.reset}`;
352
+ }
353
+ return cutLine(lead, W);
354
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.46.1",
3
+ "version": "0.47.0",
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",
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
37
37
  "dependencies": {
38
- "@vincemakes/kiso-tui-cells": "0.46.1"
38
+ "@vincemakes/kiso-tui-cells": "0.47.0"
39
39
  }
40
40
  }