@vincemakes/kiso-tui 0.46.2 → 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.
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { escapeTerminal, palette } from "./lines.js";
19
19
  import { selectionBar, visibleWidth, widthCut } from "./components.js";
20
- import { atEmbed, bandHeader, longestRun, AT_VISIBLE, atWindow } from "./at-picker.js";
20
+ import { atEmbed, bandHeader, bandVisible, bandWindow, longestRun } from "./at-picker.js";
21
21
  /** The scope, as a pure function of the cards: CURRENT is the sessions
22
22
  * whose recorded workspace IS the running one; a session with no recorded
23
23
  * workspace is never "here" — unknown history is shown under ALL only. */
@@ -26,13 +26,17 @@ export function scopeSessions(cards, here, wantAll) {
26
26
  const unknown = cards.filter((c) => c.workspace === null || c.workspace === undefined).length;
27
27
  return { cards: wantAll ? cards : inHere, scope: { here, all: wantAll, inHere: inHere.length, total: cards.length, unknown } };
28
28
  }
29
- /** The band's title — the scope and both counts, and the key that flips it. */
30
- export function scopeTitle(scope) {
29
+ /** The band's title — named after the command that opened it, then the
30
+ * scope and its counts. Graphite P1 (owner, 2026-09-30): the key that
31
+ * flips the scope moved to the key row, and a filter says how many of
32
+ * the scope match. */
33
+ export function scopeTitle(scope, filter = null) {
34
+ const match = filter === null ? null : `${filter.matches} of ${filter.of} match`;
31
35
  if (scope === null)
32
- return "sessions";
33
- return scope.all
34
- ? `sessions \u00b7 all ${scope.total} \u00b7 tab this workspace (${scope.inHere})`
35
- : `sessions \u00b7 this workspace ${scope.inHere} of ${scope.total} \u00b7 tab all`;
36
+ return match === null ? "resume" : `resume · ${match}`;
37
+ if (scope.all)
38
+ return `resume · every workspace · ${match ?? scope.total}`;
39
+ return `resume · this workspace · ${match ?? `${scope.inHere} of ${scope.total}`}`;
36
40
  }
37
41
  /** A workspace path as a person reads it: the home directory as `~`. */
38
42
  function tildePath(path) {
@@ -247,68 +251,277 @@ function rowSpans(card, budget, now, idCol, here = null) {
247
251
  }
248
252
  return { text, width: w };
249
253
  }
250
- /**
251
- * ONE picker row. The selection is a FULL-ROW reverse bar — the R1.5
252
- * ⑧ ruling's shape, shared with the @ picker and the user chip: a
253
- * two-cell marker in an eighty-column row is a selection you have to
254
- * hunt for.
254
+ /*
255
+ * Graphite P1 — the /resume picker as a table (owner, 2026-09-30: option
256
+ * B, revision 2, after comparing it with the reference picker).
255
257
  *
256
- * The inner spans close with rvEnd inside the bar (never SGR 0, which
257
- * would punch a hole in it) — the same composition atRow uses.
258
+ * DECLARED CHANGES, each against the ruling it touches:
259
+ * - The row is a TABLE: the title, then a state word, the age and the
260
+ * turns in columns measured over every card in the scope (never the
261
+ * filtered subset — the idColumn rule, kept), so the eye reads down a
262
+ * column and nothing moves while the person types. The facts used to
263
+ * trail the title and start at a different cell on every row.
264
+ * - The state is a word only when it asks for attention. A finished
265
+ * session says nothing; `interrupted` is blue (unfinished work, the
266
+ * running card's colour), an ask or an uncertain side effect gold (it
267
+ * waits for the person), a failed ending red. 0.40.1's "words, never
268
+ * glyphs" stands — the quiet default is an empty cell, not a glyph.
269
+ * - The row under the cursor OPENS into a second row that says the
270
+ * whole note (sessionNote — one definition with `kiso sessions`), when
271
+ * the session started, its profile, and its id where they fit. The
272
+ * note used to be on every row; R2's "the id left the row" stands —
273
+ * it is on the opened card only.
274
+ * - 0.40.1's row counting the sessions with no workspace, above the
275
+ * list, is folded into the key row's `tab N more elsewhere` (N counts
276
+ * them with the rest). The default view still never falls back to
277
+ * every workspace; under tab they are listed, marked `unknown`.
278
+ * - The counter row joins the key row; the band shows eight sessions on
279
+ * a terminal 30 rows or taller, five below (it showed five).
258
280
  */
259
- export function sessionRow(card, selected, W, now, idCol, here = null) {
281
+ /** How many sessions the band shows: eight on a terminal 30 rows or
282
+ * taller, five below (owner, 2026-09-30). */
283
+ export function resumeVisible(height) {
284
+ return bandVisible(height); // Graphite P2: one window rule for every band
285
+ }
286
+ /** The window over the matches, with a scroll-off of one: while more lies
287
+ * past an edge, the cursor stays a row inside it, so the edge row that
288
+ * carries a more-mark is never the selected one. Stateless, like
289
+ * bandWindow: the same (total, selected) always draws the same window. */
290
+ export function resumeWindow(total, selected, visible) {
291
+ return bandWindow(total, selected, visible);
292
+ }
293
+ /** The row's state word — empty for the quiet default, a finished session. */
294
+ export function sessionStateWord(card) {
295
+ switch (card.badge) {
296
+ case "interrupted":
297
+ return "interrupted";
298
+ case "ask":
299
+ return `${card.asks} ask${card.asks === 1 ? "" : "s"}`;
300
+ case "uncertain":
301
+ return `${card.uncertain} uncertain`;
302
+ case "completed":
303
+ return "";
304
+ default:
305
+ // failed (the outcome's own words) and unknown ("no summary")
306
+ return sessionNote(card);
307
+ }
308
+ }
309
+ /** The state's colour: blue for unfinished work, gold for what waits for
310
+ * the person, red for a failed ending, dim for what is not known. Off a
311
+ * known ground gold falls back to the warn tint and blue to none. */
312
+ function stateTone(card) {
260
313
  const p = palette();
261
- // both forms spend two cells of the width on their frame — the
262
- // unselected row's indent, the bar's own leading/trailing cell — so
263
- // the spans are built against the same budget either way and the
264
- // selection cannot change the columns
265
- const { text, width } = rowSpans(card, Math.max(0, W - 2), now, idCol, here);
266
- if (!selected)
267
- return ` ${text}`;
268
- // R2: one bar, in one place — and with it §2.1's rule that dim never
269
- // sits on the wash. The age/turns/note spans were dim INSIDE the bar,
270
- // grey on grey, on the row the cursor was pointing at.
271
- return selectionBar(text, width, W);
314
+ switch (card.badge) {
315
+ case "interrupted":
316
+ return p.blue;
317
+ case "ask":
318
+ case "uncertain":
319
+ return p.gold !== "" ? p.gold : p.warn;
320
+ case "failed":
321
+ return p.red;
322
+ case "completed":
323
+ return "";
324
+ default:
325
+ return p.dim;
326
+ }
327
+ }
328
+ /** Where a row is from, for the workspace column (every-workspace view
329
+ * only): blank for this workspace, `unknown` for a session from before
330
+ * workspaces were recorded, the last directory — or on a wide terminal
331
+ * the path — for another. */
332
+ function whereOf(card, here, wide) {
333
+ if (card.workspace === here)
334
+ return "";
335
+ if (card.workspace === null || card.workspace === undefined)
336
+ return "unknown";
337
+ return wide ? tildePath(card.workspace) : `…/${card.workspace.split("/").filter((x) => x !== "").at(-1) ?? ""}`;
338
+ }
339
+ /** A title fitted to its column: whole when it fits; otherwise cut by
340
+ * CELLS (a CJK character is two) with an ellipsis — on a space when one
341
+ * lies in the last two fifths, so a word is not halved needlessly. */
342
+ function fitTitle(text, room) {
343
+ if (visibleWidth(text) <= room)
344
+ return text;
345
+ if (room <= 1)
346
+ return widthCut(text, Math.max(0, room));
347
+ let cut = widthCut(text, room - 1);
348
+ const sp = cut.lastIndexOf(" ");
349
+ if (sp > cut.length * 0.6)
350
+ cut = cut.slice(0, sp);
351
+ return `${cut.trimEnd()}…`;
272
352
  }
273
- /** The counter row — the SELECTION's 1-based place in the whole
274
- * filtered list, which the visible window cannot tell the user. */
275
- export function sessionCounterRow(selected, total, W) {
353
+ const TITLE_CAP = 60;
354
+ const TITLE_FLOOR = 12;
355
+ function resumeColumns(cards, W, now, where) {
356
+ const wide = W >= 110;
357
+ const width = (xs) => xs.reduce((m, x) => Math.max(m, visibleWidth(x)), 0);
358
+ const longest = Math.max(1, width(cards.map((c) => escapeTerminal(c.title ?? c.id))));
359
+ let parts = {
360
+ state: width(cards.map(sessionStateWord)),
361
+ where: where === null ? 0 : Math.min(wide ? 24 : 12, width(cards.map((c) => whereOf(c, where, wide)))),
362
+ age: width(cards.map((c) => sessionAge(c.updatedAt, now))),
363
+ turns: width(cards.map((c) => (c.turns === null ? "" : turnsOf(c.turns)))),
364
+ };
365
+ const avail = Math.max(0, W - 3); // the two-cell indent and one cell of right margin
366
+ const used = (x) => Object.values(x).reduce((s, v) => s + (v > 0 ? v + 2 : 0), 0);
367
+ const floor = Math.min(longest, TITLE_FLOOR);
368
+ for (const drop of ["turns", "where", "state", "age"]) {
369
+ if (floor + used(parts) <= avail)
370
+ break;
371
+ parts = { ...parts, [drop]: 0 };
372
+ }
373
+ const title = Math.max(0, Math.min(longest, TITLE_CAP, avail - used(parts)));
374
+ return { title, ...parts, table: 2 + title + used(parts) + 1 };
375
+ }
376
+ const turnsOf = (n) => `${n} turn${n === 1 ? "" : "s"}`;
377
+ /** The title with the letters the filter matched in gold — what the
378
+ * person typed, in the colour of what the person says. */
379
+ function markMatches(title, query, base) {
380
+ const p = palette();
381
+ const hit = query === "" ? null : atEmbed(title.toLowerCase(), query.toLowerCase());
382
+ if (hit === null || hit.length === 0)
383
+ return base === "" ? title : `${base}${title}${p.reset}`;
384
+ const on = new Set(hit);
385
+ const gold = p.gold !== "" ? p.gold : p.warn;
386
+ let out = "";
387
+ let i = 0;
388
+ for (const ch of title) {
389
+ out += on.has(i) ? `${p.reset}${p.bold}${gold}${ch}${p.reset}${base}` : ch;
390
+ i += ch.length;
391
+ }
392
+ return `${base}${out}${p.reset}`;
393
+ }
394
+ /** ONE table row. Unselected it is indented two cells, or carries a dim
395
+ * more-mark in column 0 (column 1 stays empty, so the mark never reads
396
+ * as the title's first letter); selected it is the Graphite selection
397
+ * bar with the title in bold, its text still starting in column 2. */
398
+ function resumeRow(card, selected, cols, W, now, query, where, mark) {
399
+ const p = palette();
400
+ const title = fitTitle(escapeTerminal(card.title ?? card.id), cols.title);
401
+ let text = `${markMatches(title, query, selected ? p.bold : "")}${" ".repeat(cols.title - visibleWidth(title))}`;
402
+ if (cols.state > 0) {
403
+ const word = sessionStateWord(card);
404
+ const tone = stateTone(card);
405
+ text += ` ${word === "" || tone === "" ? word : `${tone}${word}${p.reset}`}${" ".repeat(cols.state - visibleWidth(word))}`;
406
+ }
407
+ if (cols.where > 0 && where !== null) {
408
+ const at = fitTitle(whereOf(card, where, W >= 110), cols.where);
409
+ text += ` ${p.dim}${at}${p.reset}${" ".repeat(cols.where - visibleWidth(at))}`;
410
+ }
411
+ if (cols.age > 0)
412
+ text += ` ${p.dim}${sessionAge(card.updatedAt, now).padStart(cols.age)}${p.reset}`;
413
+ if (cols.turns > 0)
414
+ text += ` ${p.dim}${(card.turns === null ? "" : turnsOf(card.turns)).padStart(cols.turns)}${p.reset}`;
415
+ const w = cols.table - 3;
416
+ if (selected)
417
+ return selectionBar(` ${text}`, w + 1, W);
418
+ return mark === null ? ` ${text}` : `${p.dim}${mark}${p.reset} ${text}`;
419
+ }
420
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
421
+ /** When the session started, read from its id — the id IS the UTC stamp
422
+ * it was made at (apps/cli session-id.ts) — in local time. Null for an
423
+ * id that is not one. */
424
+ export function sessionStarted(id) {
425
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/.exec(id);
426
+ if (m === null)
427
+ return null;
428
+ const t = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])));
429
+ if (Number.isNaN(t.getTime()))
430
+ return null;
431
+ const two = (n) => String(n).padStart(2, "0");
432
+ return `${MONTHS[t.getMonth()]} ${t.getDate()} ${two(t.getHours())}:${two(t.getMinutes())}`;
433
+ }
434
+ /** The opened card's second row: the whole note (its state word in the
435
+ * state's colour, the rest dim), then — while they fit — when it
436
+ * started, its profile, where it is from (another workspace only), and
437
+ * its id. On the selection bar, like the row above it. */
438
+ function openedRow(card, W, where) {
439
+ const p = palette();
440
+ const room = Math.max(0, W - 3);
441
+ const note = sessionNote(card);
442
+ const dash = note.indexOf(" — ");
443
+ const head = widthCut(dash < 0 ? note : note.slice(0, dash), room);
444
+ const tone = stateTone(card);
445
+ let text = tone === "" ? head : `${tone}${head}${p.reset}`;
446
+ let w = visibleWidth(head);
447
+ const facts = [];
448
+ if (dash >= 0)
449
+ facts.push(note.slice(dash));
450
+ const started = sessionStarted(card.id);
451
+ if (started !== null)
452
+ facts.push(` · started ${started}`);
453
+ if (typeof card.profileName === "string" && card.profileName !== "")
454
+ facts.push(` · profile ${card.profileName}`);
455
+ if (where !== null && typeof card.workspace === "string" && card.workspace !== where)
456
+ facts.push(` · ${tildePath(card.workspace)}`);
457
+ if (card.title !== undefined && card.title !== card.id)
458
+ facts.push(` · ${card.id}`);
459
+ for (const f of facts) {
460
+ const cells = visibleWidth(escapeTerminal(f));
461
+ if (w + cells > room)
462
+ break;
463
+ text += `${p.dim}${escapeTerminal(f)}${p.reset}`;
464
+ w += cells;
465
+ }
466
+ return selectionBar(` ${text}`, w + 1, W);
467
+ }
468
+ /** The key row: what the keys do, then the selection's place in the whole
469
+ * filtered list, aligned to the table's right edge. The keys give way
470
+ * from the least needed — the scope's, then the arrows' — and enter and
471
+ * esc stay while anything does. */
472
+ function resumeKeyRow(state, W, table) {
276
473
  const p = palette();
277
- return `${p.dim}${widthCut(total === 0 ? " (0/0)" : ` (${selected + 1}/${total})`, W)}${p.reset}`;
474
+ const total = state.matches.length;
475
+ const count = total === 0 ? "0/0" : `${state.selected + 1}/${total}`;
476
+ const scope = state.scope ?? null;
477
+ const elsewhere = scope === null ? 0 : scope.total - scope.inHere;
478
+ const tab = scope === null ? null : scope.all ? "tab this workspace" : elsewhere > 0 ? `tab ${elsewhere} more elsewhere` : null;
479
+ const keys = ["↑↓ move", "⏎ resumes", tab, "esc"].filter((k) => k !== null);
480
+ const edge = Math.min(W, Math.max(table, visibleWidth(keys.join(" · ")) + count.length + 5));
481
+ const fits = (ks) => 2 + visibleWidth(ks.join(" · ")) + 2 + count.length + 1 <= edge;
482
+ let kept = keys;
483
+ for (const drop of [tab, "↑↓ move", "⏎ resumes", "esc"]) {
484
+ if (fits(kept))
485
+ break;
486
+ kept = kept.filter((k) => k !== drop);
487
+ }
488
+ const text = kept.join(" · ");
489
+ const gap = Math.max(1, edge - 1 - 2 - visibleWidth(text) - count.length);
490
+ return `${p.dim}${widthCut(` ${text}${" ".repeat(gap)}${count}`, W)}${p.reset}`;
278
491
  }
492
+ /** What the empty input says while the picker is up: typing there filters. */
493
+ export const RESUME_FILTER_HINT = "filter by title or id";
279
494
  /**
280
- * The whole band: the `sessions` header (R1.5 ⑦(b) — a band names
281
- * itself or it reads as more scrollback), at most AT_VISIBLE windowed
282
- * rows, then the counter. Returned as plain strings for the menu-rows
283
- * channel, which already accounts them in chromeRows — the picker needs
284
- * no geometry of its own, which is the entire reason it rides that
285
- * channel.
495
+ * The whole band: the named hairline, the windowed table with the
496
+ * selected row opened, and the key row. Returned as plain strings for the
497
+ * menu-rows channel, which already accounts them in chromeRows — the
498
+ * picker needs no geometry of its own, which is the entire reason it
499
+ * rides that channel. `height` is the terminal's: it sets how many
500
+ * sessions show (resumeVisible).
286
501
  */
287
- export function sessionPickerRows(state, W, now) {
502
+ export function sessionPickerRows(state, W, now, height = 24) {
503
+ const p = palette();
288
504
  const scope = state.scope ?? null;
289
- const rows = [bandHeader(scopeTitle(scope), W)];
290
- // 0.40.1: the sessions without a workspace, as ONE row under CURRENT —
291
- // counted, never listed (tab shows them, labelled)
292
- if (scope !== null && !scope.all && scope.unknown > 0) {
293
- const p = palette();
294
- rows.push(`${p.dim}${widthCut(` ${scope.unknown} older session${scope.unknown === 1 ? "" : "s"} without a workspace \u00b7 tab all`, W)}${p.reset}`);
295
- }
296
- // a row is tagged with its workspace only when ALL is showing — under
297
- // CURRENT every row is from here, and saying so eight times is noise
298
- const here = scope !== null && scope.all ? scope.here : null;
299
- const col = idColumn(state.cards);
505
+ const query = state.query ?? "";
506
+ const rows = [bandHeader(scopeTitle(scope, query === "" ? null : { matches: state.matches.length, of: state.cards.length }), W)];
507
+ // the workspace column shows only when every workspace is listed
508
+ const where = scope !== null && scope.all ? scope.here : null;
509
+ const cols = resumeColumns(state.cards, W, now, where);
300
510
  if (state.matches.length === 0) {
301
- const p = palette();
302
- // an empty CURRENT view says why, rather than an empty band
303
- const empty = scope !== null && !scope.all && scope.inHere === 0 ? " no session from this workspace yet" : " no session matches";
304
- rows.push(`${p.dim}${widthCut(empty, W)}${p.reset}`);
305
- rows.push(sessionCounterRow(0, 0, W));
511
+ // an empty view says why, rather than an empty band
512
+ const empty = query !== "" ? `nothing matches “${escapeTerminal(query)}”` : scope !== null && !scope.all && scope.inHere === 0 ? "no session from this workspace yet" : "nothing to resume yet";
513
+ rows.push(`${p.dim}${widthCut(` ${empty}`, W)}${p.reset}`);
514
+ rows.push(resumeKeyRow(state, W, cols.table));
306
515
  return rows;
307
516
  }
308
- const { first, count } = atWindow(state.matches.length, state.selected, AT_VISIBLE);
309
- for (let i = first; i < first + count; i += 1)
310
- rows.push(sessionRow(state.matches[i], i === state.selected, W, now, col, here));
311
- rows.push(sessionCounterRow(state.selected, state.matches.length, W));
517
+ const { first, count } = resumeWindow(state.matches.length, state.selected, resumeVisible(height));
518
+ for (let i = first; i < first + count; i += 1) {
519
+ const mark = i === first && first > 0 ? "↑" : i === first + count - 1 && first + count < state.matches.length ? "↓" : null;
520
+ rows.push(resumeRow(state.matches[i], i === state.selected, cols, W, now, query, where, mark));
521
+ if (i === state.selected)
522
+ rows.push(openedRow(state.matches[i], W, where));
523
+ }
524
+ rows.push(resumeKeyRow(state, W, cols.table));
312
525
  return rows;
313
526
  }
314
527
  /**
package/dist/status.d.ts CHANGED
@@ -127,75 +127,7 @@ export interface TaskCountsOnRow {
127
127
  * second count here: it is an event, said once in the transcript
128
128
  * (Amendment 8). */
129
129
  export declare function tasksSegment(c: TaskCountsOnRow | undefined): string;
130
- export declare function runningStatus(glyph: string, since: number, outTokens: number | null, ctxRatio: number, tokPerSec?: number | null, W?: number, retry?: RetryOnRow | null, tasks?: TaskCountsOnRow, detachable?: boolean): string;
131
- /**
132
- * The COMPACTING row (W18): the covered rounds, the pre-call token
133
- * estimate, and the elapsed seconds — all knowable before the one summary
134
- * call returns, which has no fraction of its own. Moved here from an
135
- * inline template in dispatch (0.40.0) so it composes like every other
136
- * row and has a place for what the launch build adds to it.
137
- */
138
- export declare function compactingStatus(glyph: string, rounds: number, tokens: number, elapsedSeconds: number, W?: number, retry?: RetryOnRow | null, progress?: CompactingProgress | null): string;
139
- /**
140
- * TUI2-R1 (E) — the idle row's meter: what the session has SPENT, next
141
- * to what it has left.
142
- *
143
- * Both fields are optional and both are omitted when unknown, because
144
- * the row's job is to be true rather than complete:
145
- *
146
- * - `cacheHitPct` is cacheRead / (fresh + cacheRead) — the E2
147
- * denominator (the pinned sentence: it cannot exceed 100%). A
148
- * session with no usage yet has no cache hit rate, and an
149
- * unmeasured cache is NOT a 0% cache, so it renders nothing.
150
- * - `costUsd` is the CANONICAL cost, which is null whenever the
151
- * pricing table has no rate for the route. Null renders nothing.
152
- * No rate table, no number — kiso does not invent a price.
153
- */
154
- export interface StatusMeter {
155
- readonly cacheHitPct: number | null;
156
- /** RETIRED from the row (the owner's 2026-08-23 directive): live
157
- * prices fluctuate and the canonical table is "an approximation,
158
- * not a bill" — a four-decimal figure on the status bar claimed a
159
- * precision the data never had. The canonical cost STAYS recorded
160
- * (trace ledger, /context); the field is kept so callers need not
161
- * change shape, and it renders NOTHING. */
162
- readonly costUsd: number | null;
163
- /** TPS-1 — the decode rate of the LAST settled call, carried into the
164
- * idle row so the figure a person watched during the turn is still
165
- * there when the turn ends. Null renders nothing (see `decodeRate`);
166
- * a new model binding starts with none, exactly as the cache figure
167
- * does (DF-0311-F1: an unmeasured binding has no measurement). */
168
- readonly tokPerSec: number | null;
169
- }
170
- /**
171
- * The IDLE row: the approval tier as the CALLER names it, the /mode hint,
172
- * the model driving the session, the TUI2-R1 meter when there is one, and
173
- * the ctx estimate. Called without a meter — or with one that knows
174
- * nothing — the row is byte-identical to the pre-round row.
175
- *
176
- * DF-0330-F1 — THE DROP ORDER. `W` is the row's budget; given one, the row
177
- * gives ground in a fixed order rather than letting invariant ① cut its
178
- * end off. Found the hard way: at 100 columns the ` · N tok/s` segment
179
- * never appeared and at 140 it did, because the row was 102 columns wide
180
- * and the segment TPS-1 added sat last.
181
- *
182
- * 1. the MODEL ID is elided in its middle. It is the only segment that
183
- * varies, it is the one that grew (the owner's is 35 columns of a
184
- * 90-column row), and eliding it gives the row back a budget instead
185
- * of re-allocating a deficit;
186
- * 2. `/mode to switch` is dropped. It teaches a gesture; `/mode` and `?`
187
- * still exist and the row is not the only place they are taught;
188
- * 3. the FACTS are never dropped and never cut — the tier, CH, the ctx
189
- * estimate and the rate. A row that silently drops a measurement is
190
- * the defect this rule exists to prevent.
191
- *
192
- * The elision is ON THE ROW only. `/model`, the session log and the trace
193
- * ledger all keep the id whole — the row is a view, never the record.
194
- *
195
- * No `W` means no dropping, which is what the callers that do not know
196
- * their width should get: today's row, unchanged.
197
- */
198
- export declare function idleStatus(tier: string, model: string, ctxRatio: number, meter?: StatusMeter, W?: number, floorOff?: boolean, tasks?: TaskCountsOnRow): string;
130
+ export declare function compactingStatus(glyph: string, rounds: number, tokens: number, elapsedSeconds: number, W?: number, retry?: RetryOnRow | null, progress?: CompactingProgress | null, why?: string): string;
199
131
  /** TUI2-R1 (E) — the cache hit rate the status row shows, from the usage
200
132
  * the CLI already tracks. The denominator is the TOTAL the model was
201
133
  * given (fresh + cacheRead), which is the E2 ruling's own: cacheRead
@@ -205,3 +137,75 @@ export declare function cacheHitPct(usage: {
205
137
  in: number | null;
206
138
  cache: number | null;
207
139
  }): number | null;
140
+ /**
141
+ * Graphite §8.9 — what the status bar says: the session and its health.
142
+ * The CLI keeps the state and hands this over; the compositor composes the
143
+ * row at paint time, because the drop order (§8.5) runs across both sides
144
+ * of the row and only the paint knows the width and the `ctrl+o` state.
145
+ */
146
+ export interface BarInput {
147
+ /** The mode as the chip says it: `default`, `plan · read-only`, … */
148
+ readonly mode: string;
149
+ /** Full access (the old bypass) wears the failure colour (§8.9). */
150
+ readonly modeAlert?: boolean;
151
+ /** The main-sync round (owner, 2026-09-30): the don't-ask switch is on —
152
+ * a second chip, gold (the person's standing choice), beside the tier's. */
153
+ readonly dontAsk?: boolean;
154
+ readonly floorOff: boolean;
155
+ readonly model: string;
156
+ /** The share of the window USED (0..1), and the compaction tiers as
157
+ * shares of the same window; null when the window is not known. */
158
+ readonly ctx: {
159
+ readonly used: number;
160
+ readonly soft: number;
161
+ readonly hard: number;
162
+ } | null;
163
+ readonly tokPerSec: number | null;
164
+ /** The main-sync round (ADR-0058 3e): the session's tasks — running, and
165
+ * unknown not yet looked at in `/tasks`. Absent or all zero, nothing. */
166
+ readonly tasks?: TaskCountsOnRow;
167
+ readonly branch: string | null;
168
+ readonly folder: string | null;
169
+ }
170
+ /** The ctx meter's width in cells (§8.9). */
171
+ /**
172
+ * §8.9 — the ctx meter: ten `▆` cells with the used share filled, then the
173
+ * percentage used. Filled cells take `ink2` below the soft compaction tier,
174
+ * gold from the soft tier to the hard one, the failure colour past the hard
175
+ * tier; the empty cells are `track`. There is no marker inside the bar —
176
+ * the tier shows as colour only (owner, 2026-09-28). Off a known ground
177
+ * the colours carry nothing, so the meter is the percentage alone (§1.2:
178
+ * the fact survives without the colour). `ctx ?` when the window is not
179
+ * known.
180
+ */
181
+ export declare function ctxMeter(ctx: BarInput["ctx"]): string;
182
+ /**
183
+ * §8.9 — the status bar, composed for `W` cells. Left: the mode chip,
184
+ * `floor off`, `/mode to switch`, the model and its effort, the ctx meter,
185
+ * the decode rate. The cache share is the seal's (owner, 2026-09-29: every
186
+ * run's closing row already says it), not the bar's. Right: the branch, the folder, and the
187
+ * `ctrl+o` switch while a card has rows behind the key.
188
+ *
189
+ * §8.5 — what gives way, in order: the `ctrl+o` hint, the folder (the
190
+ * terminal title names it too), the branch, the model's middle, and last
191
+ * `/mode to switch` — the one place a newcomer meets modes. The facts
192
+ * never drop; past that the row is invariant ①'s to cut.
193
+ *
194
+ * On a known ground the chip is a surface and the segments are spaced; off
195
+ * one the head is today's `▸ <mode>` and the segments are joined with ` · `
196
+ * — the same facts, in words that survive without colour.
197
+ */
198
+ export declare function statusBar(b: BarInput, W: number, expand: "expand all" | "collapse all" | null): string;
199
+ /**
200
+ * §8.7 — the LIVE ROW's words for a running turn: `working` for the whole
201
+ * turn, whatever the model is doing (it never switches to "thinking" —
202
+ * what the model thinks is in the stream), the elapsed, the output tokens,
203
+ * the decode rate. A pending retry replaces it. The keys ride the row's
204
+ * right end.
205
+ */
206
+ export declare function workingRow(glyph: string, since: number, outTokens: number | null, tokPerSec: number | null, W: number, retry?: RetryOnRow | null, detachable?: boolean): string;
207
+ /** §8.7 — a live row: its mark in the mark column (column 0), its facts
208
+ * from the content edge, its keys right-aligned; the keys give way from
209
+ * the right when the row is short, the facts never (past them the row is
210
+ * cut). */
211
+ export declare function liveRow(facts: string, keys: readonly string[], W: number): string;