@vincemakes/kiso-tui 0.32.1 → 0.33.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/editor.js CHANGED
@@ -1107,6 +1107,37 @@ export class Editor {
1107
1107
  continue;
1108
1108
  }
1109
1109
  }
1110
+ // DC-62 — ONE RULE, at the top of the chain: inside a paste a
1111
+ // control byte is CONTENT OR NOTHING, never a gesture.
1112
+ //
1113
+ // DC-58 guarded three branches (0x07, 0x14, 0x0f) and stated the
1114
+ // rule generally; the rule was right and the placement was not.
1115
+ // Every other claimed control byte still ran: a pasted backspace
1116
+ // ate the text typed BEFORE the paste, a pasted 0x15 emptied the
1117
+ // buffer, a pasted 0x1a rewound it — and a pasted 0x03 or 0x04
1118
+ // fired the exit callbacks, so a paste could end the session.
1119
+ // `#insert` only COLLECTS what reaches it, so none of these ever
1120
+ // reached the collection; they acted instead.
1121
+ //
1122
+ // Nothing rather than content, because that is what the chain
1123
+ // already does with an UNCLAIMED control byte (`c < " "` discards
1124
+ // it), and a paste should not be the one place where 0x15 becomes
1125
+ // visible text. Three bytes are let through to branches that
1126
+ // already know about `#pasting` and treat them as content: CR and
1127
+ // LF become a newline, TAB stays a tab (DC-55). ESC is let
1128
+ // through because the parser must still see `ESC[201~` to END the
1129
+ // paste — swallow that and the editor never leaves paste mode.
1130
+ //
1131
+ // The three DC-58 guards are retired into this: one rule, not
1132
+ // twelve, and the next control byte someone claims is covered
1133
+ // without anyone remembering to guard it.
1134
+ if (this.#pasting && c !== undefined && c !== "\x1b" && c !== "\x0d" && c !== "\x0a" && c !== "\t") {
1135
+ const code = c.codePointAt(0);
1136
+ if (code < 0x20 || code === 0x7f) {
1137
+ i += 1;
1138
+ continue;
1139
+ }
1140
+ }
1110
1141
  if (this.#panelInput.up()) {
1111
1142
  // S5: the panel answers PARSED keys — a bare Esc, an Enter, a
1112
1143
  // Tab, a character — and says how many bytes it took, or null
@@ -1172,6 +1203,19 @@ export class Editor {
1172
1203
  i += seqLen; // intermediates: a report kiso did not ask for — skipped whole
1173
1204
  continue;
1174
1205
  }
1206
+ // DC-62b: inside a paste the ONLY ESC-led gesture is the paste
1207
+ // end. DC-62's rule passes ESC through so the parser can still
1208
+ // see `ESC[201~`, and everything else ESC-led rode through with
1209
+ // it: a pasted `ESC[3~` forward-deleted the text after the
1210
+ // cursor. A complete CSI that is not the paste end is skipped
1211
+ // WHOLE — neither content nor gesture, the same answer the C0
1212
+ // rule gives. The incomplete case above is untouched: a CSI
1213
+ // split across two feeds still parks, or a paste arriving in
1214
+ // pieces would lose its end.
1215
+ if (this.#pasting && !(m[1] === "201" && m[3] === "~")) {
1216
+ i += seqLen;
1217
+ continue;
1218
+ }
1175
1219
  // TMUX-F1 ②: a BURST of identical arrows in ONE read is a wheel,
1176
1220
  // not a hand. Apple Terminal turns wheel and trackpad scrolling
1177
1221
  // into arrow keys for an alternate-screen app (tmux's client is
@@ -1211,9 +1255,74 @@ export class Editor {
1211
1255
  this.#pending = tail; // incomplete OSC — wait for more
1212
1256
  break;
1213
1257
  }
1214
- this.#oscCb?.(rest.slice(1, end.index));
1258
+ // DC-62b: a pasted OSC is not a terminal report. A shell's
1259
+ // PROMPT_COMMAND writes `ESC]0;title BEL` on every prompt, so
1260
+ // it is in any captured log a human might paste — and it was
1261
+ // reaching the ground-probe reply handler, which is DC-7's
1262
+ // rule inverted: kiso must not read the human's data as the
1263
+ // terminal's answer either. Skipped to its terminator.
1264
+ if (!this.#pasting)
1265
+ this.#oscCb?.(rest.slice(1, end.index));
1215
1266
  i += 1 + end.index + end[0].length;
1216
1267
  }
1268
+ else if (this.#pasting && rest === "") {
1269
+ // DC-62c: a lone trailing ESC inside a paste is HELD, not
1270
+ // consumed — parked in `#pending` the way an incomplete CSI
1271
+ // parks, so the next feed resolves it.
1272
+ //
1273
+ // Measured: with the paste-end marker split immediately after
1274
+ // its ESC byte (the chunk ends on the bare ESC, the next
1275
+ // begins `[201~`), the marker was missed, `#pasting` never
1276
+ // cleared, and THE COMPOSER WENT DEAF — every later keystroke
1277
+ // collected, nothing on screen, until some intact `ESC[201~`
1278
+ // arrived and flushed the lot. The TMUX-F1 shape, and it
1279
+ // predates DC-62b: byte-identical on the editor before it.
1280
+ //
1281
+ // The hold is free HERE and nowhere else. CA-4 rules that a
1282
+ // bare Esc fires at once rather than waiting for a possible
1283
+ // CSI, because its immediacy is what a hold would spend — esc
1284
+ // interrupts a run. Inside a paste, after DC-62b, a bare ESC
1285
+ // already does nothing at all, so there is no promptness to
1286
+ // spend and nothing to protect. Outside a paste CA-4 is
1287
+ // untouched, and a gate pins that so this is not widened.
1288
+ //
1289
+ // KNOWN RESIDUAL, the mirror at the paste START: a lone
1290
+ // trailing ESC OUTSIDE a paste followed by `[200~body` in the
1291
+ // next chunk fires the bare esc, and the body arrives as
1292
+ // keystrokes, control bytes and all. That one cannot be fixed
1293
+ // without holding the ESC exactly where CA-4 says not to, so
1294
+ // it stays — same rarity argument, a terminal writes each
1295
+ // marker in one write. A real log showing it makes it a
1296
+ // finding of its own.
1297
+ this.#pending = text.slice(i);
1298
+ break;
1299
+ }
1300
+ else if (this.#pasting) {
1301
+ // DC-62b: inside a paste, the CSI arm above has already let
1302
+ // `ESC[201~` through and skipped every other complete CSI, and
1303
+ // the OSC arm has skipped its report. Everything ESC-led that
1304
+ // reaches here is a gesture the human did not make: a bare ESC
1305
+ // fires the escape callbacks (interrupting a running turn), a
1306
+ // double ESC is the redirect, and `ESC(B` — a charset reset,
1307
+ // in any captured terminal log — took the same road.
1308
+ //
1309
+ // ONE BYTE: the ESC is nothing and what follows is content.
1310
+ //
1311
+ // Not because the rest cannot be parsed — an ECMA-48 nF
1312
+ // escape is deterministic (ESC, intermediates 0x20–0x2F, one
1313
+ // final 0x30–0x7E), so `ESC(B` and `ESC=` COULD be consumed
1314
+ // whole. The reason is smaller and still enough: beside CSI
1315
+ // and OSC these forms are rare in pasted logs, the C0 rule
1316
+ // already gives the shape "nothing, not content", and the two
1317
+ // failure modes are not equal — `(B` left visible is
1318
+ // recoverable by the human, text eaten by a misread is not.
1319
+ //
1320
+ // KNOWN RESIDUAL: a pasted nF escape leaves its intermediates
1321
+ // and its final as text. Named here rather than implied,
1322
+ // because the next reader deserves the cost and not only the
1323
+ // choice.
1324
+ i += 1;
1325
+ }
1217
1326
  else if (rest.startsWith("O")) {
1218
1327
  i += 3; // SS3 (function keys) — ignored
1219
1328
  }
@@ -1411,6 +1520,11 @@ export class Editor {
1411
1520
  if (file !== null && file !== "") {
1412
1521
  // REL-0152-D16: the capsule goes in the buffer, the file
1413
1522
  // goes beside it. See #attachments.
1523
+ //
1524
+ // DC-61: and it is an archive point, for the same reason
1525
+ // the bracketed-paste path is — this inserts a token the
1526
+ // human did not type, so one ctrl+z has to take it back.
1527
+ this.#checkpoint();
1414
1528
  this.#attachSeq += 1;
1415
1529
  this.#attachments.set(this.#attachSeq, file);
1416
1530
  for (const ch of `[Image #${this.#attachSeq}]`)
@@ -1419,15 +1533,21 @@ export class Editor {
1419
1533
  }
1420
1534
  i += 1;
1421
1535
  }
1422
- else if (c === "\x0f" && !this.#pasting) {
1423
- // DC-58 (0.32.1): this branch and the two below take `!#pasting`,
1424
- // the guard tab and CR already had — inside a paste a control byte
1425
- // is content or nothing, never a gesture. Unguarded, a pasted BEL
1426
- // opened $VISUAL mid-paste and the rest of the body went to that
1427
- // child's stdin: DC-7's rule from the third side (a byte from a
1428
- // paste is data the human handed over, as a reply's byte is the
1429
- // terminal's). Unclaimed control bytes were discarded already;
1430
- // now the claimed ones fall to the same discard while pasting.
1536
+ else if (c === "\x0f") {
1537
+ // DC-58 (0.32.1) guarded THIS branch and the two below with
1538
+ // `!#pasting`, the guard tab and CR already had: unguarded, a
1539
+ // pasted BEL opened $VISUAL mid-paste and the rest of the body
1540
+ // went to that child's stdin — DC-7's rule from the third side (a
1541
+ // byte from a paste is data the human handed over, as a reply's
1542
+ // byte is the terminal's).
1543
+ //
1544
+ // DC-62 (0.32.2) RETIRED those three guards into one rule at the
1545
+ // top of the byte loop, because the same question asked of every
1546
+ // other branch found the same answer: a pasted backspace ate the
1547
+ // text typed before the paste, and a pasted 0x03 fired the exit
1548
+ // callback. There is no per-branch guard here any more — look at
1549
+ // the top of the loop, not at this line, for why a control byte
1550
+ // inside a paste reaches nothing.
1431
1551
  //
1432
1552
  // W15: the expand key — rides the chain like a command, the
1433
1553
  // editor just forwards it.
@@ -1445,7 +1565,7 @@ export class Editor {
1445
1565
  cb();
1446
1566
  i += 1;
1447
1567
  }
1448
- else if (c === "\x14" && !this.#pasting) {
1568
+ else if (c === "\x14") {
1449
1569
  // §2.3 — ctrl+t folds the committed thinking blocks, and
1450
1570
  // folds them back. `\x14` was unbound across the tree
1451
1571
  // (checked before the round), and it is the key the
@@ -1458,7 +1578,7 @@ export class Editor {
1458
1578
  cb();
1459
1579
  i += 1;
1460
1580
  }
1461
- else if (c === "\x07" && !this.#pasting) {
1581
+ else if (c === "\x07") {
1462
1582
  // §2.4 — ctrl+g opens $VISUAL / $EDITOR on the composer.
1463
1583
  //
1464
1584
  // 0x07 is BEL, which is also the terminator a terminal puts
@@ -2146,6 +2266,19 @@ export class Editor {
2146
2266
  if (this.#historyIdx !== null)
2147
2267
  this.#historyIdx = null;
2148
2268
  this.#queuePopMode = false;
2269
+ // DC-61: a paste is an ARCHIVE POINT. UD-1's invariant was written
2270
+ // around destructive gestures — no gesture may discard more than one
2271
+ // code point without a checkpoint — and a paste discards nothing, so
2272
+ // it never took one. The loss arrived from the other side: ctrl+z
2273
+ // after a paste did nothing, or reached an OLDER checkpoint and threw
2274
+ // away the paste plus everything typed since it in one press.
2275
+ //
2276
+ // It sits here, after the image branch has either returned or filled
2277
+ // `run`, so both shapes are covered by one call and the
2278
+ // nothing-happened case (an empty paste with no clipboard image)
2279
+ // still takes no checkpoint — a phantom entry would make ctrl+z a
2280
+ // press the human has to repeat.
2281
+ this.#checkpoint();
2149
2282
  const pasted = _a.#textOf(run);
2150
2283
  const lines = pasted.split("\n").length;
2151
2284
  const small = lines < _a.#PASTE_LINES && run.length < _a.#PASTE_CHARS;
package/dist/index.d.ts CHANGED
@@ -12,7 +12,7 @@ export { Container, foldLine, foldWords, visibleWidth, SPINNER, type Component,
12
12
  export { Editor, MENU_ITEMS, PROMPT, PROMPT_WIDTH, displayWidth, charWidth, widthOf, type MenuItem, } from "./editor.js";
13
13
  export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, escapeTerminal, foldResult, foldThinking, kUnit, palette, renderEvent, renderRecap, renderResumeList, renderSessionLine, renderStatusLine, relativeTime, renderTerminalGap, renderToolSummary, TAGLINE, toolTarget, truncateRow, type Palette, type PathResolver, type RecapStats, type ResumeMeta, type RenderInput, type RenderResult, type RunUsage, } from "./lines.js";
14
14
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
15
- export { STATUS_GLYPHS, cacheHitPct, idleStatus, runningStatus, type StatusMeter } from "./status.js";
15
+ export { STATUS_GLYPHS, cacheHitPct, decodeRate, idleStatus, runningStatus, type StatusMeter } from "./status.js";
16
16
  export { contextRows, contextUnavailableRows, type ContextLedger } from "./context-ledger.js";
17
17
  export { interactivePrompt, projectTrustRows, projectTrustView, projectUntrustedNote, uncertainView, verifyOfferView, type TrustArtifact } from "./strings.js";
18
18
  export { AT_CAP, AT_SKIP, AT_VISIBLE, atEmbed, atFilter, atPanelRows, atWindow, bandHeader, longestRun, type AtItem, type AtMatch } from "./at-picker.js";
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ export { bannerLines, COLOR_OFF, COLOR_ON, currentGround, setGround, escapeTermi
19
19
  export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
20
20
  // KC2 §5: the status rows' formatters — the CLI keeps the state and the
21
21
  // repaint, the terminal layer owns what the row says.
22
- export { STATUS_GLYPHS, cacheHitPct, idleStatus, runningStatus } from "./status.js";
22
+ export { STATUS_GLYPHS, cacheHitPct, decodeRate, idleStatus, runningStatus } from "./status.js";
23
23
  // TUI2-R1 (E): /context's attribution rows — a pure function of the
24
24
  // counts the trace sidecar already records (the CLI reads, this renders).
25
25
  export { contextRows, contextUnavailableRows } from "./context-ledger.js";
package/dist/lines.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * the event renderers (renderEvent, the status line, the recap, the
14
14
  * session line).
15
15
  */
16
- import { escapeTerminal, foldResult, foldThinking, kUnit, palette } from "@vincemakes/kiso-tui-cells/render";
16
+ import { echoText, escapeTerminal, foldResult, foldThinking, kUnit, palette } from "@vincemakes/kiso-tui-cells/render";
17
17
  import { foldTerms, widthCut } from "@vincemakes/kiso-tui-cells/components";
18
18
  import { visibleWidth } from "@vincemakes/kiso-tui-cells/width";
19
19
  export * from "@vincemakes/kiso-tui-cells/render";
@@ -60,7 +60,7 @@ export function renderEvent(ev, prevThinking = false, resolvePath = (p) => p) {
60
60
  case "tool_execution_failed":
61
61
  return { text: `${p.red} failed: ${escapeTerminal(ev.error.slice(0, 160))}${p.reset}\n`, newline: true, prompt: false };
62
62
  case "tool_result": {
63
- const content = typeof ev.content === "string" ? ev.content : ev.content.map((b) => (b.type === "text" ? b.text : "(image)")).join("");
63
+ const content = echoText(ev.content); // DC-60: one projection, shared with the live echo
64
64
  return {
65
65
  // v2b: the echo truncates at 160 chars + a /last hint — the
66
66
  // full content stays in the event stream.
package/dist/status.d.ts CHANGED
@@ -36,6 +36,25 @@
36
36
  * Glyphs only, no colour: intact under NO_COLOR and on any ground.
37
37
  */
38
38
  export declare const STATUS_GLYPHS: readonly ["✧", "✦", "✶", "✸", "✺", "✸", "✦"];
39
+ /**
40
+ * TPS-1 — the settled DECODE rate of one model call: its output tokens
41
+ * over the seconds from the call's first streamed event to its usage
42
+ * event. TTFT is excluded on purpose; this is the speed of the text
43
+ * arriving, which is what "tokens per second" means to the person
44
+ * watching it.
45
+ *
46
+ * The null rule, and every branch of it is the same principle: a number
47
+ * on this row is a MEASUREMENT or it is absent. No output count (the
48
+ * provider reported no usage) is not a zero. Under half a second of
49
+ * decoding is a sample too short to divide by. A non-positive elapsed is
50
+ * a clock, not a rate. And a call that decoded NOTHING has no rate to
51
+ * report: `0 tok/s` would read as a measured speed and it is not one — it
52
+ * is the absence of output, which the transcript already shows. The
53
+ * condition is on the RENDERED INTEGER rather than on the token count, so
54
+ * a slow trickle that rounds to the same `0` reaches the same absence for
55
+ * the same reason.
56
+ */
57
+ export declare function decodeRate(outputTokens: number | null, elapsedMs: number): number | null;
39
58
  /**
40
59
  * The RUNNING row: the rotating glyph, the wall seconds since `since`
41
60
  * (never below 1 — a run that just started still reads "1s", so the row
@@ -46,7 +65,7 @@ export declare const STATUS_GLYPHS: readonly ["✧", "✦", "✶", "✸", "✺",
46
65
  * — stop, and do THIS instead. The row is where the gesture is taught,
47
66
  * because it is on screen exactly when the gesture is useful.
48
67
  */
49
- export declare function runningStatus(glyph: string, since: number, outTokens: number | null, ctxRatio: number): string;
68
+ export declare function runningStatus(glyph: string, since: number, outTokens: number | null, ctxRatio: number, tokPerSec?: number | null): string;
50
69
  /**
51
70
  * TUI2-R1 (E) — the idle row's meter: what the session has SPENT, next
52
71
  * to what it has left.
@@ -71,12 +90,42 @@ export interface StatusMeter {
71
90
  * (trace ledger, /context); the field is kept so callers need not
72
91
  * change shape, and it renders NOTHING. */
73
92
  readonly costUsd: number | null;
93
+ /** TPS-1 — the decode rate of the LAST settled call, carried into the
94
+ * idle row so the figure a person watched during the turn is still
95
+ * there when the turn ends. Null renders nothing (see `decodeRate`);
96
+ * a new model binding starts with none, exactly as the cache figure
97
+ * does (DF-0311-F1: an unmeasured binding has no measurement). */
98
+ readonly tokPerSec: number | null;
74
99
  }
75
- /** The IDLE row: the approval tier as the CALLER names it, the /mode
76
- * hint, the model driving the session, the TUI2-R1 meter when there is
77
- * one, and the ctx estimate. Called without a meter — or with one that
78
- * knows nothing — the row is byte-identical to the pre-round row. */
79
- export declare function idleStatus(tier: string, model: string, ctxRatio: number, meter?: StatusMeter): string;
100
+ /**
101
+ * The IDLE row: the approval tier as the CALLER names it, the /mode hint,
102
+ * the model driving the session, the TUI2-R1 meter when there is one, and
103
+ * the ctx estimate. Called without a meter — or with one that knows
104
+ * nothing — the row is byte-identical to the pre-round row.
105
+ *
106
+ * DF-0330-F1 — THE DROP ORDER. `W` is the row's budget; given one, the row
107
+ * gives ground in a fixed order rather than letting invariant ① cut its
108
+ * end off. Found the hard way: at 100 columns the ` · N tok/s` segment
109
+ * never appeared and at 140 it did, because the row was 102 columns wide
110
+ * and the segment TPS-1 added sat last.
111
+ *
112
+ * 1. the MODEL ID is elided in its middle. It is the only segment that
113
+ * varies, it is the one that grew (the owner's is 35 columns of a
114
+ * 90-column row), and eliding it gives the row back a budget instead
115
+ * of re-allocating a deficit;
116
+ * 2. `/mode to switch` is dropped. It teaches a gesture; `/mode` and `?`
117
+ * still exist and the row is not the only place they are taught;
118
+ * 3. the FACTS are never dropped and never cut — the tier, CH, the ctx
119
+ * estimate and the rate. A row that silently drops a measurement is
120
+ * the defect this rule exists to prevent.
121
+ *
122
+ * The elision is ON THE ROW only. `/model`, the session log and the trace
123
+ * ledger all keep the id whole — the row is a view, never the record.
124
+ *
125
+ * No `W` means no dropping, which is what the callers that do not know
126
+ * their width should get: today's row, unchanged.
127
+ */
128
+ export declare function idleStatus(tier: string, model: string, ctxRatio: number, meter?: StatusMeter, W?: number): string;
80
129
  /** TUI2-R1 (E) — the cache hit rate the status row shows, from the usage
81
130
  * the CLI already tracks. The denominator is the TOTAL the model was
82
131
  * given (fresh + cacheRead), which is the E2 ruling's own: cacheRead
package/dist/status.js CHANGED
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { kUnit } from "./lines.js";
25
25
  import { TWINKLE } from "@vincemakes/kiso-tui-cells/render";
26
+ import { displayWidth } from "@vincemakes/kiso-tui-cells/width";
26
27
  /**
27
28
  * R3 (design §5.2) — the working glyph family is the TWINKLE, and the
28
29
  * CLI's 200ms spinner walks it exactly as it walked the four quadrant
@@ -45,6 +46,32 @@ export const STATUS_GLYPHS = TWINKLE;
45
46
  function ctxLeft(ratio) {
46
47
  return Number.isFinite(ratio) ? Math.round((1 - ratio) * 100) : null;
47
48
  }
49
+ /**
50
+ * TPS-1 — the settled DECODE rate of one model call: its output tokens
51
+ * over the seconds from the call's first streamed event to its usage
52
+ * event. TTFT is excluded on purpose; this is the speed of the text
53
+ * arriving, which is what "tokens per second" means to the person
54
+ * watching it.
55
+ *
56
+ * The null rule, and every branch of it is the same principle: a number
57
+ * on this row is a MEASUREMENT or it is absent. No output count (the
58
+ * provider reported no usage) is not a zero. Under half a second of
59
+ * decoding is a sample too short to divide by. A non-positive elapsed is
60
+ * a clock, not a rate. And a call that decoded NOTHING has no rate to
61
+ * report: `0 tok/s` would read as a measured speed and it is not one — it
62
+ * is the absence of output, which the transcript already shows. The
63
+ * condition is on the RENDERED INTEGER rather than on the token count, so
64
+ * a slow trickle that rounds to the same `0` reaches the same absence for
65
+ * the same reason.
66
+ */
67
+ export function decodeRate(outputTokens, elapsedMs) {
68
+ if (outputTokens === null)
69
+ return null;
70
+ if (!Number.isFinite(elapsedMs) || elapsedMs < 500)
71
+ return null;
72
+ const rate = Math.round(outputTokens / (elapsedMs / 1000));
73
+ return rate > 0 ? rate : null;
74
+ }
48
75
  /**
49
76
  * The RUNNING row: the rotating glyph, the wall seconds since `since`
50
77
  * (never below 1 — a run that just started still reads "1s", so the row
@@ -55,22 +82,101 @@ function ctxLeft(ratio) {
55
82
  * — stop, and do THIS instead. The row is where the gesture is taught,
56
83
  * because it is on screen exactly when the gesture is useful.
57
84
  */
58
- export function runningStatus(glyph, since, outTokens, ctxRatio) {
85
+ export function runningStatus(glyph, since, outTokens, ctxRatio, tokPerSec = null) {
59
86
  const out = outTokens !== null ? ` ↓ ${kUnit(outTokens)} tokens` : "";
87
+ // TPS-1: after each call SETTLES within the turn, between the tokens
88
+ // segment and the stop hint. The default is null and that is the honest
89
+ // rule spelled as a default — the recovery flow has no per-call timing
90
+ // state, so its row says nothing rather than guessing.
91
+ const rate = tokPerSec !== null ? ` · ${tokPerSec} tok/s` : "";
60
92
  const seconds = Math.max(1, Math.round((Date.now() - since) / 1000));
61
- return `${glyph} working ${seconds}s${out} · esc stop · alt+⏎ redirect · ctx left ~${ctxLeft(ctxRatio)}%`;
93
+ return `${glyph} working ${seconds}s${out}${rate} · esc stop · alt+⏎ redirect · ctx left ~${ctxLeft(ctxRatio)}%`;
94
+ }
95
+ /** DF-0330-F1 — how far the model id may be squeezed on the ROW. Twenty
96
+ * visible columns keeps a head and a tail: `deepseek-v…s-on-0910` still
97
+ * says which binding is driving, and the tail is where the parts that
98
+ * distinguish one id from its neighbours live (`-flash`, `-0910`). */
99
+ const MODEL_ON_ROW = 20;
100
+ /** Elide in the MIDDLE, keeping the head and the tail. A string already
101
+ * within budget is returned untouched, so this is a no-op for every
102
+ * ordinary model name.
103
+ *
104
+ * Budgeted in DISPLAY COLUMNS, not code points. A first version sliced
105
+ * code points and a wide-character id came out at 28 columns while the
106
+ * function claimed 20 — which would have put the row straight back over
107
+ * its budget and handed it to invariant ①'s cut, the exact defect this
108
+ * whole change exists to prevent. No such model id exists today; the
109
+ * guarantee should not depend on that staying true. */
110
+ function elideMiddle(text, max) {
111
+ if (displayWidth(text) <= max)
112
+ return text;
113
+ const chars = [...text];
114
+ const room = max - 1; // the ellipsis costs one column
115
+ const headBudget = Math.ceil(room / 2);
116
+ let head = "";
117
+ for (const c of chars) {
118
+ if (displayWidth(head + c) > headBudget)
119
+ break;
120
+ head += c;
121
+ }
122
+ let tail = "";
123
+ const tailBudget = room - displayWidth(head);
124
+ for (let i = chars.length - 1; i >= 0; i -= 1) {
125
+ if (displayWidth(chars[i] + tail) > tailBudget)
126
+ break;
127
+ tail = chars[i] + tail;
128
+ }
129
+ return `${head}…${tail}`;
62
130
  }
63
- /** The IDLE row: the approval tier as the CALLER names it, the /mode
64
- * hint, the model driving the session, the TUI2-R1 meter when there is
65
- * one, and the ctx estimate. Called without a meter — or with one that
66
- * knows nothing — the row is byte-identical to the pre-round row. */
67
- export function idleStatus(tier, model, ctxRatio, meter) {
68
- const parts = [`▸ ${tier}`, "/mode to switch", model];
69
- if (meter?.cacheHitPct != null)
70
- parts.push(`CH ${Math.round(meter.cacheHitPct)}%`);
71
- // costUsd deliberately NOT rendered — see StatusMeter.costUsd.
72
- parts.push(`ctx left ~${ctxLeft(ctxRatio)}%`);
73
- return parts.join(" · ");
131
+ /**
132
+ * The IDLE row: the approval tier as the CALLER names it, the /mode hint,
133
+ * the model driving the session, the TUI2-R1 meter when there is one, and
134
+ * the ctx estimate. Called without a meter — or with one that knows
135
+ * nothing — the row is byte-identical to the pre-round row.
136
+ *
137
+ * DF-0330-F1 — THE DROP ORDER. `W` is the row's budget; given one, the row
138
+ * gives ground in a fixed order rather than letting invariant ① cut its
139
+ * end off. Found the hard way: at 100 columns the ` · N tok/s` segment
140
+ * never appeared and at 140 it did, because the row was 102 columns wide
141
+ * and the segment TPS-1 added sat last.
142
+ *
143
+ * 1. the MODEL ID is elided in its middle. It is the only segment that
144
+ * varies, it is the one that grew (the owner's is 35 columns of a
145
+ * 90-column row), and eliding it gives the row back a budget instead
146
+ * of re-allocating a deficit;
147
+ * 2. `/mode to switch` is dropped. It teaches a gesture; `/mode` and `?`
148
+ * still exist and the row is not the only place they are taught;
149
+ * 3. the FACTS are never dropped and never cut — the tier, CH, the ctx
150
+ * estimate and the rate. A row that silently drops a measurement is
151
+ * the defect this rule exists to prevent.
152
+ *
153
+ * The elision is ON THE ROW only. `/model`, the session log and the trace
154
+ * ledger all keep the id whole — the row is a view, never the record.
155
+ *
156
+ * No `W` means no dropping, which is what the callers that do not know
157
+ * their width should get: today's row, unchanged.
158
+ */
159
+ export function idleStatus(tier, model, ctxRatio, meter, W) {
160
+ const compose = (label, hint) => {
161
+ const parts = [`▸ ${tier}`];
162
+ if (hint)
163
+ parts.push("/mode to switch");
164
+ parts.push(label);
165
+ if (meter?.cacheHitPct != null)
166
+ parts.push(`CH ${Math.round(meter.cacheHitPct)}%`);
167
+ // costUsd deliberately NOT rendered — see StatusMeter.costUsd.
168
+ parts.push(`ctx left ~${ctxLeft(ctxRatio)}%`);
169
+ if (meter?.tokPerSec != null)
170
+ parts.push(`${meter.tokPerSec} tok/s`); // TPS-1: last, after the ctx estimate
171
+ return parts.join(" · ");
172
+ };
173
+ const full = compose(model, true);
174
+ if (W === undefined || displayWidth(full) <= W)
175
+ return full;
176
+ const squeezed = compose(elideMiddle(model, MODEL_ON_ROW), true);
177
+ if (displayWidth(squeezed) <= W)
178
+ return squeezed;
179
+ return compose(elideMiddle(model, MODEL_ON_ROW), false);
74
180
  }
75
181
  /** TUI2-R1 (E) — the cache hit rate the status row shows, from the usage
76
182
  * the CLI already tracks. The denominator is the TOTAL the model was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
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",
@@ -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.32.1"
38
+ "@vincemakes/kiso-tui-cells": "0.33.0"
39
39
  }
40
40
  }