@vincemakes/kiso-tui 0.39.2 → 0.40.1

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.
@@ -2,20 +2,12 @@
2
2
  * TUI2-R2 slices ①–③ — the session picker's PURE half: the durability
3
3
  * badge, the row, the band, and the filter.
4
4
  *
5
- * The badge is the round's whole argument. kiso's claim is that a
6
- * session survives kill -9 and resumes from its durable prefix; until
7
- * now that claim was a sentence in a README. A badge per row makes it a
8
- * thing you can SEE before you pick: this one completed, this one was
9
- * cut mid-run and will resume exactly, this one is holding a question
10
- * for you.
11
- *
12
- * The vocabulary (the palette's functional set — no new colour):
13
- *
14
- * ✓ green the run's terminal event says completed
15
- * ✗ red the terminal says anything else
16
- * ▌ bold no terminal event — interrupted mid-run
17
- * ? warn the uncertain ledger is not empty (overrides ▌)
18
- * ◌ dim a permission request nobody has answered
5
+ * The row's STATE is the round's whole argument. kiso's claim is that a
6
+ * session survives kill -9 and resumes from its durable prefix; the note
7
+ * column makes it a thing you can READ before you pick: this one completed,
8
+ * this one was cut mid-run and will resume exactly, this one is holding a
9
+ * question for you. (0.40.1, owner's ruling: words, never glyphs — the
10
+ * ✓ ✗ ▌ ? ◌ column that stood here said nothing the words did not.)
19
11
  *
20
12
  * Purity, as everywhere in this package: the cards are DATA the CLI
21
13
  * projects (session-cards.ts) and this module turns them into bytes. It
@@ -32,21 +24,46 @@ export interface SessionCardView {
32
24
  * a caller that has not got one still renders — the row simply
33
25
  * carries no title, which is where this picker started. */
34
26
  readonly title?: string;
35
- readonly badge: "uncertain" | "ask" | "interrupted" | "completed" | "failed";
36
- readonly turns: number;
27
+ readonly badge: "uncertain" | "ask" | "interrupted" | "completed" | "failed" | "unknown";
28
+ /** null when unknown — the row then says nothing about turns */
29
+ readonly turns: number | null;
37
30
  readonly updatedAt: number;
38
31
  readonly uncertain: number;
39
32
  readonly asks: number;
40
33
  readonly outcome: string | null;
34
+ /** 0.40.0: the realpath the session STARTED in, from its profile; null
35
+ * when unknown (a legacy session). Absent = the caller did not say. */
36
+ readonly workspace?: string | null;
37
+ /** 0.40.0: the config profile its latest revision names, for a dim tag. */
38
+ readonly profileName?: string | null;
39
+ /** 0.40.0: its project was inferred by the one-time migration, never
40
+ * recorded — the row says so. */
41
+ readonly inferred?: boolean;
41
42
  }
42
- /** The glyph per state — one cell each, so the badge column never
43
- * shifts the id column (a column that moves per row reads as damage). */
44
- export declare const BADGE_GLYPH: Readonly<Record<SessionCardView["badge"], string>>;
45
- /** The badge, styled. The colour IS the meaning here (the mono
46
- * discipline's three functional exceptions), so NO_COLOR degrades to
47
- * the glyph alone — which is why the glyphs are distinct shapes and
48
- * not three coloured dots. */
49
- export declare function sessionBadge(badge: SessionCardView["badge"]): string;
43
+ /** 0.40.0 — which sessions the picker shows. `here` is the running
44
+ * workspace's realpath; `all` is the person's choice; `unknown` counts
45
+ * the sessions with no recorded workspace (0.40.1), which the default view
46
+ * hides behind one header row.
47
+ *
48
+ * 0.40.1 (owner's ruling): the default view NEVER falls back to all. The
49
+ * fallback made every directory list every older session — 118 of them —
50
+ * which is the view the scope exists to prevent. */
51
+ export interface PickScopeState {
52
+ readonly here: string;
53
+ readonly all: boolean;
54
+ readonly inHere: number;
55
+ readonly total: number;
56
+ readonly unknown: number;
57
+ }
58
+ /** The scope, as a pure function of the cards: CURRENT is the sessions
59
+ * whose recorded workspace IS the running one; a session with no recorded
60
+ * workspace is never "here" — unknown history is shown under ALL only. */
61
+ export declare function scopeSessions(cards: readonly SessionCardView[], here: string, wantAll: boolean): {
62
+ readonly cards: readonly SessionCardView[];
63
+ readonly scope: PickScopeState;
64
+ };
65
+ /** The band's title — the scope and both counts, and the key that flips it. */
66
+ export declare function scopeTitle(scope: PickScopeState | null): string;
50
67
  /**
51
68
  * What the row SAYS about the state. The interrupted note is the
52
69
  * product's promise stated in the place the promise matters: the run
@@ -100,7 +117,7 @@ export declare function sessionFilter(cards: readonly SessionCardView[], query:
100
117
  * The inner spans close with rvEnd inside the bar (never SGR 0, which
101
118
  * would punch a hole in it) — the same composition atRow uses.
102
119
  */
103
- export declare function sessionRow(card: SessionCardView, selected: boolean, W: number, now: number, idCol: number): string;
120
+ export declare function sessionRow(card: SessionCardView, selected: boolean, W: number, now: number, idCol: number, here?: string | null): string;
104
121
  /** The counter row — the SELECTION's 1-based place in the whole
105
122
  * filtered list, which the visible window cannot tell the user. */
106
123
  export declare function sessionCounterRow(selected: number, total: number, W: number): string;
@@ -110,6 +127,8 @@ export interface SessionPickState {
110
127
  readonly cards: readonly SessionCardView[];
111
128
  readonly matches: readonly SessionCardView[];
112
129
  readonly selected: number;
130
+ /** 0.40.0: null when the picker was opened without a workspace. */
131
+ readonly scope?: PickScopeState | null;
113
132
  }
114
133
  /**
115
134
  * The whole band: the `sessions` header (R1.5 ⑦(b) — a band names
@@ -137,7 +156,16 @@ export declare function sessionPickerRows(state: SessionPickState, W: number, no
137
156
  * id goes LAST and dim — present for the hand that needs it, out of the
138
157
  * way of the eye that does not.
139
158
  */
140
- export declare function sessionListRow(card: SessionCardView, W: number, now: number, idCol: number): string;
159
+ export declare function sessionListRow(card: SessionCardView, W: number, now: number, idCol: number, here?: string | null): string;
141
160
  /** Slice ③ — the listing's last line: the count, and the one thing the
142
161
  * user can do next. */
143
162
  export declare function sessionListFooter(count: number, W: number): string;
163
+ /** 0.40.1 — the `kiso sessions` TTY listing's line for the sessions with no
164
+ * recorded workspace: counted, never listed, and the flag that lists them.
165
+ * Empty when there are none. */
166
+ export declare function sessionListUnknownLine(unknown: number, W: number): string;
167
+ /** 0.40.0 — the `kiso sessions` TTY listing's FIRST line: which sessions
168
+ * follow, and both counts. The listing never falls back the way the
169
+ * picker does: a listing that says "0 of 5 from this workspace" and how
170
+ * to see the rest is already the honest answer. */
171
+ export declare function sessionListHeader(inHere: number, total: number, all: boolean, W: number): string;
@@ -2,20 +2,12 @@
2
2
  * TUI2-R2 slices ①–③ — the session picker's PURE half: the durability
3
3
  * badge, the row, the band, and the filter.
4
4
  *
5
- * The badge is the round's whole argument. kiso's claim is that a
6
- * session survives kill -9 and resumes from its durable prefix; until
7
- * now that claim was a sentence in a README. A badge per row makes it a
8
- * thing you can SEE before you pick: this one completed, this one was
9
- * cut mid-run and will resume exactly, this one is holding a question
10
- * for you.
11
- *
12
- * The vocabulary (the palette's functional set — no new colour):
13
- *
14
- * ✓ green the run's terminal event says completed
15
- * ✗ red the terminal says anything else
16
- * ▌ bold no terminal event — interrupted mid-run
17
- * ? warn the uncertain ledger is not empty (overrides ▌)
18
- * ◌ dim a permission request nobody has answered
5
+ * The row's STATE is the round's whole argument. kiso's claim is that a
6
+ * session survives kill -9 and resumes from its durable prefix; the note
7
+ * column makes it a thing you can READ before you pick: this one completed,
8
+ * this one was cut mid-run and will resume exactly, this one is holding a
9
+ * question for you. (0.40.1, owner's ruling: words, never glyphs — the
10
+ * ✓ ✗ ▌ ? ◌ column that stood here said nothing the words did not.)
19
11
  *
20
12
  * Purity, as everywhere in this package: the cards are DATA the CLI
21
13
  * projects (session-cards.ts) and this module turns them into bytes. It
@@ -26,31 +18,53 @@
26
18
  import { escapeTerminal, palette } from "./lines.js";
27
19
  import { selectionBar, visibleWidth, widthCut } from "./components.js";
28
20
  import { atEmbed, bandHeader, longestRun, AT_VISIBLE, atWindow } from "./at-picker.js";
29
- /** The glyph per state — one cell each, so the badge column never
30
- * shifts the id column (a column that moves per row reads as damage). */
31
- export const BADGE_GLYPH = {
32
- completed: "✓", // ✓
33
- failed: "✗", // ✗
34
- interrupted: "▌", // ▌ — the input brick: this session is mid-sentence
35
- uncertain: "?",
36
- ask: "◌", // ◌ — the dotted circle: a question with no answer in it yet
37
- };
38
- /** The badge, styled. The colour IS the meaning here (the mono
39
- * discipline's three functional exceptions), so NO_COLOR degrades to
40
- * the glyph alone — which is why the glyphs are distinct shapes and
41
- * not three coloured dots. */
42
- export function sessionBadge(badge) {
43
- const p = palette();
44
- const g = BADGE_GLYPH[badge];
45
- if (badge === "completed")
46
- return `${p.green}${g}${p.reset}`;
47
- if (badge === "failed")
48
- return `${p.red}${g}${p.reset}`;
49
- if (badge === "interrupted")
50
- return `${p.bold}${g}${p.reset}`;
51
- if (badge === "uncertain")
52
- return `${p.warn}${g}${p.reset}`;
53
- return `${p.dim}${g}${p.reset}`;
21
+ /** The scope, as a pure function of the cards: CURRENT is the sessions
22
+ * whose recorded workspace IS the running one; a session with no recorded
23
+ * workspace is never "here" — unknown history is shown under ALL only. */
24
+ export function scopeSessions(cards, here, wantAll) {
25
+ const inHere = cards.filter((c) => c.workspace === here);
26
+ const unknown = cards.filter((c) => c.workspace === null || c.workspace === undefined).length;
27
+ return { cards: wantAll ? cards : inHere, scope: { here, all: wantAll, inHere: inHere.length, total: cards.length, unknown } };
28
+ }
29
+ /** The band's title — the scope and both counts, and the key that flips it. */
30
+ export function scopeTitle(scope) {
31
+ 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
+ }
37
+ /** A workspace path as a person reads it: the home directory as `~`. */
38
+ function tildePath(path) {
39
+ const home = process.env.HOME;
40
+ return home !== undefined && home !== "" && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path;
41
+ }
42
+ /** 0.40.0 — the row's dim tags: the profile the session last ran under,
43
+ * and — only when the row is from ANOTHER workspace than `here` — where
44
+ * it came from. `here === null` means the listing is not scoped at all,
45
+ * so no row is foreign.
46
+ *
47
+ * Fitted to `room`, degrading by the DC-2 rule (drop or shorten a whole
48
+ * part, never cut one mid-word): the full path, then `…/<last dir>`, then
49
+ * the path alone without the profile, then nothing. An inferred row keeps
50
+ * its "inferred" mark longest. */
51
+ function fitTags(card, here, room) {
52
+ const profile = typeof card.profileName === "string" && card.profileName !== "" ? card.profileName : null;
53
+ const foreign = here !== null && card.workspace !== undefined && card.workspace !== here;
54
+ const where = !foreign ? null : card.workspace === null || card.workspace === undefined ? "workspace unknown" : tildePath(card.workspace);
55
+ const short = where === null || card.workspace === null || card.workspace === undefined ? where : `\u2026/${card.workspace.split("/").filter((x) => x !== "").at(-1) ?? ""}`;
56
+ const join = (parts) => {
57
+ const kept = parts.filter((x) => x !== null);
58
+ return kept.length === 0 ? "" : ` \u00b7 ${kept.join(" \u00b7 ")}`;
59
+ };
60
+ // 0.40.0: an inferred project is a guess, and the row never presents a
61
+ // guess as a record; the mark outlives the profile when room is short
62
+ const mark = card.inferred === true ? "inferred" : null;
63
+ for (const candidate of [join([profile, where, mark]), join([profile, short, mark]), join([short, mark]), join([profile, mark]), join([mark]), join([profile])]) {
64
+ if (candidate !== "" && visibleWidth(candidate) <= room)
65
+ return candidate;
66
+ }
67
+ return "";
54
68
  }
55
69
  /**
56
70
  * What the row SAYS about the state. The interrupted note is the
@@ -73,6 +87,10 @@ export function sessionNote(card) {
73
87
  return "interrupted mid-run — resumes exactly";
74
88
  case "completed":
75
89
  return "completed clean";
90
+ case "unknown":
91
+ // 0.40.0 dogfood: no summary, or a log that could not be read —
92
+ // said, never guessed
93
+ return card.outcome ?? "no summary";
76
94
  default:
77
95
  return card.outcome === null || card.outcome === "error" ? "failed" : card.outcome.replaceAll("_", " ");
78
96
  }
@@ -173,7 +191,7 @@ export function sessionFilter(cards, query) {
173
191
  * executions"), so an actionable row keeps saying so. */
174
192
  const TITLE_MAX = 44;
175
193
  const NOTE_RESERVE = 22;
176
- function rowSpans(card, budget, now, idCol) {
194
+ function rowSpans(card, budget, now, idCol, here = null) {
177
195
  const p = palette();
178
196
  let text = "";
179
197
  let w = 0;
@@ -185,12 +203,9 @@ function rowSpans(card, budget, now, idCol) {
185
203
  text += styled;
186
204
  w += cells;
187
205
  };
188
- // the badge: one glyph + one space, styled as a unit (the glyph's own
189
- // SGR spans make it unmeasurable by `put`'s plain/styled pair)
190
- if (w + 2 <= budget) {
191
- text += `${sessionBadge(card.badge)} `;
192
- w += 2;
193
- }
206
+ // 0.40.1 (owner's ruling): NO status glyph. The ✓ ✗ ▌ ? ◌ column is
207
+ // gone; the state is a WORD, in the note column (sessionNote), where it
208
+ // already said everything the glyph did.
194
209
  // R2 (owner, 2026-08-27) — the TITLE LEADS and the id is gone.
195
210
  //
196
211
  // The id was four characters of machine identity sitting in the column
@@ -212,8 +227,17 @@ function rowSpans(card, budget, now, idCol) {
212
227
  if (cut !== "")
213
228
  put(cut, `${p.bold}${cut}${p.reset}`);
214
229
  }
215
- const meta = ` ${sessionAge(card.updatedAt, now)} · ${card.turns} turn${card.turns === 1 ? "" : "s"}`;
230
+ const meta = ` ${sessionAge(card.updatedAt, now)}${card.turns === null ? "" : ` · ${card.turns} turn${card.turns === 1 ? "" : "s"}`}`;
216
231
  put(meta, `${p.dim}${meta}${p.reset}`);
232
+ // 0.40.0: the tags are their OWN span, after the meta and before the
233
+ // note, and they give way first — a long workspace path must never take
234
+ // the age and the turn count down with it, nor the note's reserve.
235
+ // the note is what the person acts on ("needs your verdict"), so it keeps
236
+ // its WHOLE width — a tag that cut it would trade an action for a label
237
+ const noteCells = sessionNote(card) === "" ? 0 : visibleWidth(sessionNote(card)) + 3;
238
+ const tags = fitTags(card, here, Math.max(0, budget - w - noteCells));
239
+ if (tags !== "")
240
+ put(tags, `${p.dim}${tags}${p.reset}`);
217
241
  const note = widthCut(sessionNote(card), Math.max(0, budget - w - 3));
218
242
  if (note !== "") {
219
243
  // the ? note carries the warn tint — the row's own words are what
@@ -232,13 +256,13 @@ function rowSpans(card, budget, now, idCol) {
232
256
  * The inner spans close with rvEnd inside the bar (never SGR 0, which
233
257
  * would punch a hole in it) — the same composition atRow uses.
234
258
  */
235
- export function sessionRow(card, selected, W, now, idCol) {
259
+ export function sessionRow(card, selected, W, now, idCol, here = null) {
236
260
  const p = palette();
237
261
  // both forms spend two cells of the width on their frame — the
238
262
  // unselected row's indent, the bar's own leading/trailing cell — so
239
263
  // the spans are built against the same budget either way and the
240
264
  // selection cannot change the columns
241
- const { text, width } = rowSpans(card, Math.max(0, W - 2), now, idCol);
265
+ const { text, width } = rowSpans(card, Math.max(0, W - 2), now, idCol, here);
242
266
  if (!selected)
243
267
  return ` ${text}`;
244
268
  // R2: one bar, in one place — and with it §2.1's rule that dim never
@@ -261,17 +285,29 @@ export function sessionCounterRow(selected, total, W) {
261
285
  * channel.
262
286
  */
263
287
  export function sessionPickerRows(state, W, now) {
264
- const rows = [bandHeader("sessions", W)];
288
+ 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;
265
299
  const col = idColumn(state.cards);
266
300
  if (state.matches.length === 0) {
267
301
  const p = palette();
268
- rows.push(`${p.dim}${widthCut(" no session matches", W)}${p.reset}`);
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}`);
269
305
  rows.push(sessionCounterRow(0, 0, W));
270
306
  return rows;
271
307
  }
272
308
  const { first, count } = atWindow(state.matches.length, state.selected, AT_VISIBLE);
273
309
  for (let i = first; i < first + count; i += 1)
274
- rows.push(sessionRow(state.matches[i], i === state.selected, W, now, col));
310
+ rows.push(sessionRow(state.matches[i], i === state.selected, W, now, col, here));
275
311
  rows.push(sessionCounterRow(state.selected, state.matches.length, W));
276
312
  return rows;
277
313
  }
@@ -292,10 +328,10 @@ export function sessionPickerRows(state, W, now) {
292
328
  * id goes LAST and dim — present for the hand that needs it, out of the
293
329
  * way of the eye that does not.
294
330
  */
295
- export function sessionListRow(card, W, now, idCol) {
331
+ export function sessionListRow(card, W, now, idCol, here = null) {
296
332
  const p = palette();
297
333
  const tail = ` ${card.id}`;
298
- const { text, width } = rowSpans(card, Math.max(1, W - visibleWidth(tail)), now, idCol);
334
+ const { text, width } = rowSpans(card, Math.max(1, W - visibleWidth(tail)), now, idCol, here);
299
335
  if (width + visibleWidth(tail) > W)
300
336
  return text; // a terminal too narrow for both keeps the words
301
337
  return `${text}${p.dim}${tail}${p.reset}`;
@@ -306,3 +342,22 @@ export function sessionListFooter(count, W) {
306
342
  const p = palette();
307
343
  return `${p.dim}${widthCut(`${count} session${count === 1 ? "" : "s"} · kiso resume picks interactively`, W)}${p.reset}`;
308
344
  }
345
+ /** 0.40.1 — the `kiso sessions` TTY listing's line for the sessions with no
346
+ * recorded workspace: counted, never listed, and the flag that lists them.
347
+ * Empty when there are none. */
348
+ export function sessionListUnknownLine(unknown, W) {
349
+ if (unknown === 0)
350
+ return "";
351
+ const p = palette();
352
+ return `${p.dim}${widthCut(`${unknown} older session${unknown === 1 ? "" : "s"} without a workspace \u00b7 --all`, W)}${p.reset}`;
353
+ }
354
+ /** 0.40.0 — the `kiso sessions` TTY listing's FIRST line: which sessions
355
+ * follow, and both counts. The listing never falls back the way the
356
+ * picker does: a listing that says "0 of 5 from this workspace" and how
357
+ * to see the rest is already the honest answer. */
358
+ export function sessionListHeader(inHere, total, all, W) {
359
+ const p = palette();
360
+ const plural = (n) => `${n} session${n === 1 ? "" : "s"}`;
361
+ const text = all ? `all ${plural(total)}` : `${inHere} of ${plural(total)} from this workspace \u00b7 --all lists every one`;
362
+ return `${p.dim}${widthCut(text, W)}${p.reset}`;
363
+ }
package/dist/status.d.ts CHANGED
@@ -21,6 +21,14 @@
21
21
  * deliberate exception: the running row's interrupt hint, which KC2 §2
22
22
  * widens to name the new gesture.
23
23
  */
24
+ /** 0.40.0 — the compacting row's bar: output produced so far against the
25
+ * summary call's output budget (text AND reasoning — see the runtime's
26
+ * SummaryProgress), and whether reasoning was billed without streaming. */
27
+ export interface CompactingProgress {
28
+ readonly produced: number;
29
+ readonly budget: number | null;
30
+ readonly reasoningUnseen: boolean;
31
+ }
24
32
  /**
25
33
  * R3 (design §5.2) — the working glyph family is the TWINKLE, and the
26
34
  * CLI's 200ms spinner walks it exactly as it walked the four quadrant
@@ -55,6 +63,37 @@ export declare const STATUS_GLYPHS: readonly ["✧", "✦", "✶", "✸", "✺",
55
63
  * the same reason.
56
64
  */
57
65
  export declare function decodeRate(outputTokens: number | null, elapsedMs: number): number | null;
66
+ /**
67
+ * THE ROW SEAM (0.40.0). One composer for every status row.
68
+ *
69
+ * Three rows grew their own string-building — the running row, the idle
70
+ * row, and the compacting row inline in dispatch — and three features of
71
+ * the launch build want to add to them at once (a retry state, a mode
72
+ * and floor indicator, a compaction progress bar). Each splicing its own
73
+ * segment into its own template is how a row ends up with two of its
74
+ * facts cut by invariant ① at 80 columns, because nobody decided what
75
+ * gives way first. So the decision is made HERE, once, and every row is
76
+ * a list of typed segments:
77
+ *
78
+ * - `fact` — a measurement or a state (the tier, ctx, a rate, a retry
79
+ * count). NEVER dropped and never cut; a row that silently
80
+ * drops a measurement is the defect DF-0330-F1 fixed;
81
+ * - `hint` — teaches a gesture (`esc stop`, `/mode to switch`). Dropped
82
+ * first, from the END, because the row is not the only
83
+ * place a gesture is taught;
84
+ * - `label` — a name that may be ELIDED in its middle (the model id),
85
+ * tried before any hint is dropped, because eliding gives
86
+ * the row its budget back instead of re-allocating a deficit.
87
+ *
88
+ * The first entry is the row's HEAD and is never touched. Everything is
89
+ * joined with ` · `. With no `W` the row is the full composition — every
90
+ * caller that does not know its width gets exactly the row it had.
91
+ */
92
+ export interface RowSegment {
93
+ readonly text: string;
94
+ readonly kind: "fact" | "hint" | "label";
95
+ }
96
+ export declare function composeRow(head: string, segments: readonly (RowSegment | null | undefined)[], W?: number): string;
58
97
  /**
59
98
  * The RUNNING row: the rotating glyph, the wall seconds since `since`
60
99
  * (never below 1 — a run that just started still reads "1s", so the row
@@ -65,7 +104,28 @@ export declare function decodeRate(outputTokens: number | null, elapsedMs: numbe
65
104
  * — stop, and do THIS instead. The row is where the gesture is taught,
66
105
  * because it is on screen exactly when the gesture is useful.
67
106
  */
68
- export declare function runningStatus(glyph: string, since: number, outTokens: number | null, ctxRatio: number, tokPerSec?: number | null): string;
107
+ /** ADR-0005 Amendment 2 — a retry the kernel is waiting on, as the row
108
+ * shows it. `remainingMs` is how much of the wait is left; at or below
109
+ * zero the attempt is in flight and the countdown is gone. */
110
+ export interface RetryOnRow {
111
+ readonly attempt: number;
112
+ readonly maxRetries: number;
113
+ readonly code: string;
114
+ readonly remainingMs: number;
115
+ }
116
+ /** `retrying 3/10 · network · 4s` — ONE fact: the attempt, the budget it
117
+ * counts against, what failed, and how long until it is tried. Whole
118
+ * seconds, rounded UP, so the row never says 0s while still waiting. */
119
+ export declare function retrySegment(r: RetryOnRow): string;
120
+ export declare function runningStatus(glyph: string, since: number, outTokens: number | null, ctxRatio: number, tokPerSec?: number | null, W?: number, retry?: RetryOnRow | null): string;
121
+ /**
122
+ * The COMPACTING row (W18): the covered rounds, the pre-call token
123
+ * estimate, and the elapsed seconds — all knowable before the one summary
124
+ * call returns, which has no fraction of its own. Moved here from an
125
+ * inline template in dispatch (0.40.0) so it composes like every other
126
+ * row and has a place for what the launch build adds to it.
127
+ */
128
+ export declare function compactingStatus(glyph: string, rounds: number, tokens: number, elapsedSeconds: number, W?: number, retry?: RetryOnRow | null, progress?: CompactingProgress | null): string;
69
129
  /**
70
130
  * TUI2-R1 (E) — the idle row's meter: what the session has SPENT, next
71
131
  * to what it has left.
@@ -125,7 +185,7 @@ export interface StatusMeter {
125
185
  * No `W` means no dropping, which is what the callers that do not know
126
186
  * their width should get: today's row, unchanged.
127
187
  */
128
- export declare function idleStatus(tier: string, model: string, ctxRatio: number, meter?: StatusMeter, W?: number): string;
188
+ export declare function idleStatus(tier: string, model: string, ctxRatio: number, meter?: StatusMeter, W?: number, floorOff?: boolean): string;
129
189
  /** TUI2-R1 (E) — the cache hit rate the status row shows, from the usage
130
190
  * the CLI already tracks. The denominator is the TOTAL the model was
131
191
  * given (fresh + cacheRead), which is the E2 ruling's own: cacheRead
package/dist/status.js CHANGED
@@ -22,6 +22,9 @@
22
22
  * widens to name the new gesture.
23
23
  */
24
24
  import { kUnit } from "./lines.js";
25
+ import { meterGlyphs } from "./context-ledger.js";
26
+ /** The compacting row's bar width — short: it shares a row. */
27
+ const BAR_ON_ROW = 6;
25
28
  import { elapsedLabel } from "@vincemakes/kiso-tui-cells";
26
29
  import { TWINKLE } from "@vincemakes/kiso-tui-cells/render";
27
30
  import { displayWidth } from "@vincemakes/kiso-tui-cells/width";
@@ -85,31 +88,88 @@ export function decodeRate(outputTokens, elapsedMs) {
85
88
  const rate = Math.round(outputTokens / (elapsedMs / 1000));
86
89
  return rate > 0 ? rate : null;
87
90
  }
88
- /**
89
- * The RUNNING row: the rotating glyph, the wall seconds since `since`
90
- * (never below 1 — a run that just started still reads "1s", so the row
91
- * never claims a turn took no time), the streamed output tokens once the
92
- * count is known, the interrupt hints, and the live ctx estimate.
93
- *
94
- * KC2 §2: the hint names BOTH gestures. Esc still stops; alt+⏎ redirects
95
- * — stop, and do THIS instead. The row is where the gesture is taught,
96
- * because it is on screen exactly when the gesture is useful.
97
- */
98
- export function runningStatus(glyph, since, outTokens, ctxRatio, tokPerSec = null) {
91
+ export function composeRow(head, segments, W) {
92
+ const present = segments.filter((x) => x != null && x.text !== "");
93
+ const join = (xs) => [head, ...xs.map((x) => x.text)].join(" · ");
94
+ const full = join(present);
95
+ if (W === undefined || displayWidth(full) <= W)
96
+ return full;
97
+ // 1. elide every label in its middle
98
+ let row = present.map((x) => (x.kind === "label" ? { ...x, text: elideMiddle(x.text, LABEL_ON_ROW) } : x));
99
+ if (displayWidth(join(row)) <= W)
100
+ return join(row);
101
+ // 2. drop hints from the end, one at a time
102
+ for (let i = row.length - 1; i >= 0; i -= 1) {
103
+ if (row[i].kind !== "hint")
104
+ continue;
105
+ row = [...row.slice(0, i), ...row.slice(i + 1)];
106
+ if (displayWidth(join(row)) <= W)
107
+ return join(row);
108
+ }
109
+ // 3. facts are never dropped: past this point the row is over budget,
110
+ // and it is invariant ①'s to cut — which it will do to the LAST
111
+ // segment, so the order of facts is the order of their importance.
112
+ return join(row);
113
+ }
114
+ /** `retrying 3/10 · network · 4s` — ONE fact: the attempt, the budget it
115
+ * counts against, what failed, and how long until it is tried. Whole
116
+ * seconds, rounded UP, so the row never says 0s while still waiting. */
117
+ export function retrySegment(r) {
118
+ const head = `retrying ${r.attempt}/${r.maxRetries} · ${r.code}`;
119
+ return r.remainingMs > 0 ? `${head} · ${Math.ceil(r.remainingMs / 1000)}s` : head;
120
+ }
121
+ export function runningStatus(glyph, since, outTokens, ctxRatio, tokPerSec = null, W, retry) {
99
122
  const out = outTokens !== null ? ` ↓ ${kUnit(outTokens)} tokens` : "";
100
- // TPS-1: after each call SETTLES within the turn, between the tokens
101
- // segment and the stop hint. The default is null and that is the honest
102
- // rule spelled as a default — the recovery flow has no per-call timing
103
- // state, so its row says nothing rather than guessing.
104
- const rate = tokPerSec !== null ? ` · ${tokPerSec} tok/s` : "";
105
123
  const seconds = Math.max(1, Math.round((Date.now() - since) / 1000));
106
- return `${glyph} working ${elapsedLabel(seconds)}${out}${rate} · esc stop · alt+⏎ redirect · ${ctxSegment(ctxRatio)}`;
124
+ return composeRow(`${glyph} working ${elapsedLabel(seconds)}${out}`, [
125
+ // ADR-0005 Amendment 2: a pending retry is a FACT and sits first — it
126
+ // is the one thing on the row that explains why nothing is arriving,
127
+ // and a retry budget of minutes with nothing on screen reads as a
128
+ // hung session.
129
+ retry != null ? { kind: "fact", text: retrySegment(retry) } : null,
130
+ // TPS-1: after each call SETTLES within the turn, between the tokens
131
+ // segment and the stop hint. The default is null and that is the
132
+ // honest rule spelled as a default — the recovery flow has no
133
+ // per-call timing state, so its row says nothing rather than guessing.
134
+ tokPerSec !== null ? { kind: "fact", text: `${tokPerSec} tok/s` } : null,
135
+ { kind: "hint", text: "esc stop" },
136
+ { kind: "hint", text: "alt+⏎ redirect" },
137
+ { kind: "fact", text: ctxSegment(ctxRatio) },
138
+ ], W);
139
+ }
140
+ /**
141
+ * The COMPACTING row (W18): the covered rounds, the pre-call token
142
+ * estimate, and the elapsed seconds — all knowable before the one summary
143
+ * call returns, which has no fraction of its own. Moved here from an
144
+ * inline template in dispatch (0.40.0) so it composes like every other
145
+ * row and has a place for what the launch build adds to it.
146
+ */
147
+ export function compactingStatus(glyph, rounds, tokens, elapsedSeconds, W, retry, progress) {
148
+ // 0.40.0: with a budget to measure against, the covered size and the bar
149
+ // are ONE fact — what went in, and how much of the output budget has
150
+ // come out. Without a budget the row keeps the covered size alone: the
151
+ // bar never invents a denominator.
152
+ const covered = progress != null && progress.budget !== null && progress.budget > 0
153
+ ? `~${kUnit(tokens)} \u2192 ${meterGlyphs(progress.produced / progress.budget, BAR_ON_ROW)} ${kUnit(progress.produced)}/${kUnit(progress.budget)}`
154
+ : `~${kUnit(tokens)} tokens`;
155
+ return composeRow(`${glyph} compacting`, [
156
+ { kind: "fact", text: `${rounds} rounds` },
157
+ { kind: "fact", text: covered },
158
+ // why the figure jumped when the usage landed — a HINT, so a narrow
159
+ // row gives it up before the bar, the seconds or the retry
160
+ progress?.reasoningUnseen === true && progress.budget !== null ? { kind: "hint", text: "incl. unstreamed reasoning" } : null,
161
+ { kind: "fact", text: `${Math.max(0, elapsedSeconds)}s` },
162
+ // ADR-0005 Amendment 2: the summary call retries under the kernel's
163
+ // policy, and a retry here is the same fact it is on the running row.
164
+ retry != null ? { kind: "fact", text: retrySegment(retry) } : null,
165
+ ], W);
107
166
  }
108
- /** DF-0330-F1 — how far the model id may be squeezed on the ROW. Twenty
109
- * visible columns keeps a head and a tail: `deepseek-v…s-on-0910` still
110
- * says which binding is driving, and the tail is where the parts that
111
- * distinguish one id from its neighbours live (`-flash`, `-0910`). */
112
- const MODEL_ON_ROW = 20;
167
+ /** DF-0330-F1 — how far a LABEL may be squeezed on the ROW; the model id
168
+ * is the one there is. Twenty visible columns keeps a head and a tail:
169
+ * `deepseek-v…s-on-0910` still says which binding is driving, and the
170
+ * tail is where the parts that distinguish one id from its neighbours
171
+ * live (`-flash`, `-0910`). Read by `composeRow`. */
172
+ const LABEL_ON_ROW = 20;
113
173
  /** Elide in the MIDDLE, keeping the head and the tail. A string already
114
174
  * within budget is returned untouched, so this is a no-op for every
115
175
  * ordinary model name.
@@ -169,27 +229,19 @@ function elideMiddle(text, max) {
169
229
  * No `W` means no dropping, which is what the callers that do not know
170
230
  * their width should get: today's row, unchanged.
171
231
  */
172
- export function idleStatus(tier, model, ctxRatio, meter, W) {
173
- const compose = (label, hint) => {
174
- const parts = [`▸ ${tier}`];
175
- if (hint)
176
- parts.push("/mode to switch");
177
- parts.push(label);
178
- if (meter?.cacheHitPct != null)
179
- parts.push(`CH ${Math.round(meter.cacheHitPct)}%`);
232
+ export function idleStatus(tier, model, ctxRatio, meter, W, floorOff = false) {
233
+ return composeRow(`▸ ${tier}`, [
234
+ // 0.40.0: the catastrophe floor is on by default and says nothing;
235
+ // OFF is the state worth seeing, and a fact beside the tier it
236
+ // changes the meaning of.
237
+ floorOff ? { kind: "fact", text: "floor off" } : null,
238
+ { kind: "hint", text: "/mode to switch" },
239
+ { kind: "label", text: model },
240
+ meter?.cacheHitPct != null ? { kind: "fact", text: `CH ${Math.round(meter.cacheHitPct)}%` } : null,
180
241
  // costUsd deliberately NOT rendered — see StatusMeter.costUsd.
181
- parts.push(ctxSegment(ctxRatio));
182
- if (meter?.tokPerSec != null)
183
- parts.push(`${meter.tokPerSec} tok/s`); // TPS-1: last, after the ctx estimate
184
- return parts.join(" · ");
185
- };
186
- const full = compose(model, true);
187
- if (W === undefined || displayWidth(full) <= W)
188
- return full;
189
- const squeezed = compose(elideMiddle(model, MODEL_ON_ROW), true);
190
- if (displayWidth(squeezed) <= W)
191
- return squeezed;
192
- return compose(elideMiddle(model, MODEL_ON_ROW), false);
242
+ { kind: "fact", text: ctxSegment(ctxRatio) },
243
+ meter?.tokPerSec != null ? { kind: "fact", text: `${meter.tokPerSec} tok/s` } : null, // TPS-1: last, after the ctx estimate
244
+ ], W);
193
245
  }
194
246
  /** TUI2-R1 (E) — the cache hit rate the status row shows, from the usage
195
247
  * 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.39.2",
3
+ "version": "0.40.1",
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.39.2"
38
+ "@vincemakes/kiso-tui-cells": "0.40.1"
39
39
  }
40
40
  }