@vincemakes/kiso-tui 0.39.1 → 0.40.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.
@@ -18,12 +18,16 @@
18
18
  * through the host.
19
19
  */
20
20
  import { AT_VISIBLE } from "./at-picker.js";
21
- import { sessionFilter } from "./session-picker.js";
21
+ import { scopeSessions, sessionFilter } from "./session-picker.js";
22
22
  export class PickInput {
23
23
  host;
24
24
  #cards = null;
25
25
  #commit = null;
26
26
  #sel = 0;
27
+ /** 0.40.0: the running workspace (undefined = an unscoped picker), and
28
+ * whether the person flipped to ALL. */
29
+ #here = undefined;
30
+ #all = false;
27
31
  constructor(host) {
28
32
  this.host = host;
29
33
  }
@@ -34,10 +38,12 @@ export class PickInput {
34
38
  * (the buffer becomes the filter query) and `onPick` receives the
35
39
  * chosen id — or null when the human leaves without picking, which
36
40
  * is a first-class outcome and not an error. */
37
- begin(cards, onPick) {
41
+ begin(cards, onPick, here) {
38
42
  this.#cards = cards;
39
43
  this.#commit = onPick;
40
44
  this.#sel = 0;
45
+ this.#here = here;
46
+ this.#all = false;
41
47
  this.host.syncMouse();
42
48
  this.host.clear();
43
49
  this.host.reflow();
@@ -51,15 +57,35 @@ export class PickInput {
51
57
  state() {
52
58
  if (this.#cards === null)
53
59
  return null;
54
- const cards = this.#cards();
60
+ // 0.40.0: the filter runs INSIDE the scope — typing narrows what the
61
+ // title says is showing, never reaches past it
62
+ const scoped = this.#here === undefined ? null : scopeSessions(this.#cards(), this.#here, this.#all);
63
+ const cards = scoped === null ? this.#cards() : scoped.cards;
55
64
  const matches = sessionFilter(cards, this.host.line());
56
- return { cards, matches, selected: Math.max(0, Math.min(this.#sel, matches.length - 1)) };
65
+ return { cards, matches, selected: Math.max(0, Math.min(this.#sel, matches.length - 1)), scope: scoped?.scope ?? null };
66
+ }
67
+ /** 0.40.0 — tab flips CURRENT ↔ ALL. The filter owns every printable
68
+ * key (the buffer IS the query), so the toggle cannot be one. True when
69
+ * the picker owned the key. */
70
+ toggleScope() {
71
+ const view = this.state();
72
+ if (view === null || view.scope === null || view.scope === undefined)
73
+ return false;
74
+ this.#all = !this.#all;
75
+ this.#sel = 0;
76
+ this.host.reflow();
77
+ this.host.render();
78
+ return true;
57
79
  }
58
80
  /** The band's height estimate: the header + the windowed rows (or
59
81
  * the one "no match" row) + the counter. */
60
82
  rows() {
61
83
  const view = this.state();
62
- return view === null ? 0 : Math.min(Math.max(view.matches.length, 1), AT_VISIBLE) + 2;
84
+ if (view === null)
85
+ return 0;
86
+ // 0.40.1: + the unknown-workspace row, when CURRENT shows it
87
+ const unknownRow = view.scope != null && !view.scope.all && view.scope.unknown > 0 ? 1 : 0;
88
+ return Math.min(Math.max(view.matches.length, 1), AT_VISIBLE) + 2 + unknownRow;
63
89
  }
64
90
  /** ↑↓: the selection walks the matches and stops at both ends. True
65
91
  * when the picker owned the key (the caller renders). */
@@ -78,6 +104,8 @@ export class PickInput {
78
104
  this.#cards = null;
79
105
  this.#commit = null;
80
106
  this.#sel = 0;
107
+ this.#here = undefined;
108
+ this.#all = false;
81
109
  this.host.syncMouse();
82
110
  this.host.clear();
83
111
  this.host.reflow();
@@ -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