@vincemakes/kiso-tui 0.9.0 → 0.11.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.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * TUI2-R2 slices ①–③ — the session picker's PURE half: the durability
3
+ * badge, the row, the band, and the filter.
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
19
+ *
20
+ * Purity, as everywhere in this package: the cards are DATA the CLI
21
+ * projects (session-cards.ts) and this module turns them into bytes. It
22
+ * never reads a session, never asks the runtime anything, and holds no
23
+ * state — which is what lets the picker band and the `kiso sessions`
24
+ * listing render from ONE definition instead of two that drift.
25
+ */
26
+ import { escapeTerminal, palette } from "./render.js";
27
+ import { visibleWidth, widthCut } from "./components.js";
28
+ 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}`;
54
+ }
55
+ /**
56
+ * What the row SAYS about the state. The interrupted note is the
57
+ * product's promise stated in the place the promise matters: the run
58
+ * continues from its durable prefix, so picking this row costs nothing
59
+ * that was already paid for.
60
+ *
61
+ * The ✗ note names the OUTCOME rather than flattening six endings into
62
+ * one word — "aborted" and "max turns" are different things to have
63
+ * happened, and a picker that calls both "failed" teaches the user
64
+ * nothing.
65
+ */
66
+ export function sessionNote(card) {
67
+ switch (card.badge) {
68
+ case "uncertain":
69
+ return `${card.uncertain} uncertain — needs your verdict`;
70
+ case "ask":
71
+ return `${card.asks} ask${card.asks === 1 ? "" : "s"} pending`;
72
+ case "interrupted":
73
+ return "interrupted mid-run — resumes exactly";
74
+ case "completed":
75
+ return "completed clean";
76
+ default:
77
+ return card.outcome === null || card.outcome === "error" ? "failed" : card.outcome.replaceAll("_", " ");
78
+ }
79
+ }
80
+ /** The compact age — the picker's column, not the banner's sentence.
81
+ * `relativeTime` says "3d ago"; a column of ages does not need the
82
+ * word repeated on every row. */
83
+ export function sessionAge(updatedAt, now) {
84
+ const s = Math.max(0, now - updatedAt) / 1000;
85
+ if (s < 60)
86
+ return "now";
87
+ const m = Math.floor(s / 60);
88
+ if (m < 60)
89
+ return `${m}m`;
90
+ const h = Math.floor(m / 60);
91
+ if (h < 24)
92
+ return `${h}h`;
93
+ const d = Math.floor(h / 24);
94
+ if (d < 7)
95
+ return `${d}d`;
96
+ return `${Math.floor(d / 7)}w`;
97
+ }
98
+ /** The id column's width — computed over EVERY card, never over the
99
+ * filtered subset, so the columns do not jump while the user types
100
+ * (the whole reason a filter-as-you-type picker is usable). */
101
+ export function idColumn(cards) {
102
+ let w = 0;
103
+ for (const c of cards)
104
+ w = Math.max(w, visibleWidth(escapeTerminal(c.id)));
105
+ return Math.min(Math.max(w, 1), 24);
106
+ }
107
+ /**
108
+ * The filter — the @ picker's muscle, aimed at the session id: a
109
+ * case-insensitive SUBSEQUENCE, ranked by the longest contiguous run,
110
+ * then by id length, then lexically. Identical determinism, identical
111
+ * feel; a row under the cursor never moves because two ids tied.
112
+ *
113
+ * An empty query matches everything and keeps the caller's order (the
114
+ * listing's newest-first), because "no query" is not a search — it is
115
+ * the list.
116
+ */
117
+ export function sessionFilter(cards, query) {
118
+ if (query === "")
119
+ return [...cards];
120
+ const lower = query.toLowerCase();
121
+ const scored = [];
122
+ for (const card of cards) {
123
+ const hit = atEmbed(card.id.toLowerCase(), lower);
124
+ if (hit === null)
125
+ continue;
126
+ scored.push({ card, run: longestRun(hit) });
127
+ }
128
+ scored.sort((a, b) => {
129
+ if (a.run !== b.run)
130
+ return b.run - a.run;
131
+ if (a.card.id.length !== b.card.id.length)
132
+ return a.card.id.length - b.card.id.length;
133
+ return a.card.id < b.card.id ? -1 : a.card.id > b.card.id ? 1 : 0;
134
+ });
135
+ return scored.map((s) => s.card);
136
+ }
137
+ /**
138
+ * The row's spans, built against a HARD budget — the badge, the id
139
+ * column, the metadata, the note.
140
+ *
141
+ * The spans are appended in order of what the row is FOR and each one
142
+ * is dropped whole rather than half-drawn when the budget runs out:
143
+ * the badge and the id are the row's identity, the age/turns say
144
+ * whether it is the one, and the note is the sentence that explains
145
+ * the badge. A narrow terminal loses them from the right.
146
+ *
147
+ * The running width is the authority — never a formula computed up
148
+ * front. That is what the invariant-① sweep across five widths in the
149
+ * gate is for: a row that overflows does not truncate quietly here, it
150
+ * CRASHES the compositor, so the arithmetic has to be provably right at
151
+ * every width rather than right at eighty.
152
+ */
153
+ function rowSpans(card, budget, now, idCol) {
154
+ const p = palette();
155
+ let text = "";
156
+ let w = 0;
157
+ /** append a styled span iff its VISIBLE cells still fit */
158
+ const put = (plain, styled) => {
159
+ const cells = visibleWidth(plain);
160
+ if (w + cells > budget)
161
+ return;
162
+ text += styled;
163
+ w += cells;
164
+ };
165
+ // the badge: one glyph + one space, styled as a unit (the glyph's own
166
+ // SGR spans make it unmeasurable by `put`'s plain/styled pair)
167
+ if (w + 2 <= budget) {
168
+ text += `${sessionBadge(card.badge)} `;
169
+ w += 2;
170
+ }
171
+ const id = widthCut(escapeTerminal(card.id), Math.max(1, Math.min(idCol, budget - w)));
172
+ put(id, id);
173
+ // the column pad only survives while there is room for what follows
174
+ const pad = Math.max(0, Math.min(idCol - visibleWidth(id), budget - w));
175
+ put(" ".repeat(pad), " ".repeat(pad));
176
+ const meta = ` ${sessionAge(card.updatedAt, now)} · ${card.turns} turn${card.turns === 1 ? "" : "s"}`;
177
+ put(meta, `${p.dim}${meta}${p.reset}`);
178
+ const note = widthCut(sessionNote(card), Math.max(0, budget - w - 3));
179
+ if (note !== "") {
180
+ // the ? note carries the warn tint — the row's own words are what
181
+ // the user acts on, and the one that demands an action says so
182
+ put(" · ", `${p.dim} · ${p.reset}`);
183
+ put(note, card.badge === "uncertain" ? `${p.warn}${note}${p.reset}` : `${p.dim}${note}${p.reset}`);
184
+ }
185
+ return { text, width: w };
186
+ }
187
+ /**
188
+ * ONE picker row. The selection is a FULL-ROW reverse bar — the R1.5
189
+ * ⑧ ruling's shape, shared with the @ picker and the user chip: a
190
+ * two-cell marker in an eighty-column row is a selection you have to
191
+ * hunt for.
192
+ *
193
+ * The inner spans close with rvEnd inside the bar (never SGR 0, which
194
+ * would punch a hole in it) — the same composition atRow uses.
195
+ */
196
+ export function sessionRow(card, selected, W, now, idCol) {
197
+ const p = palette();
198
+ // both forms spend two cells of the width on their frame — the
199
+ // unselected row's indent, the bar's own leading/trailing cell — so
200
+ // the spans are built against the same budget either way and the
201
+ // selection cannot change the columns
202
+ const { text, width } = rowSpans(card, Math.max(0, W - 2), now, idCol);
203
+ if (!selected)
204
+ return ` ${text}`;
205
+ const inner = text.replaceAll(p.reset, `${p.reset}${p.rv}`);
206
+ return `${p.rv} ${inner}${" ".repeat(Math.max(0, W - width - 2))} ${p.rvEnd}`;
207
+ }
208
+ /** The counter row — the SELECTION's 1-based place in the whole
209
+ * filtered list, which the visible window cannot tell the user. */
210
+ export function sessionCounterRow(selected, total, W) {
211
+ const p = palette();
212
+ return `${p.dim}${widthCut(total === 0 ? " (0/0)" : ` (${selected + 1}/${total})`, W)}${p.reset}`;
213
+ }
214
+ /**
215
+ * The whole band: the `sessions` header (R1.5 ⑦(b) — a band names
216
+ * itself or it reads as more scrollback), at most AT_VISIBLE windowed
217
+ * rows, then the counter. Returned as plain strings for the menu-rows
218
+ * channel, which already accounts them in chromeRows — the picker needs
219
+ * no geometry of its own, which is the entire reason it rides that
220
+ * channel.
221
+ */
222
+ export function sessionPickerRows(state, W, now) {
223
+ const rows = [bandHeader("sessions", W)];
224
+ const col = idColumn(state.cards);
225
+ if (state.matches.length === 0) {
226
+ const p = palette();
227
+ rows.push(`${p.dim}${widthCut(" no session matches", W)}${p.reset}`);
228
+ rows.push(sessionCounterRow(0, 0, W));
229
+ return rows;
230
+ }
231
+ const { first, count } = atWindow(state.matches.length, state.selected, AT_VISIBLE);
232
+ for (let i = first; i < first + count; i += 1)
233
+ rows.push(sessionRow(state.matches[i], i === state.selected, W, now, col));
234
+ rows.push(sessionCounterRow(state.selected, state.matches.length, W));
235
+ return rows;
236
+ }
237
+ /** Slice ③ — the `kiso sessions` TTY row: the SAME projection, printed
238
+ * rather than picked. No selection bar (nothing is selected on a
239
+ * listing) and no leading indent: this row starts at column 1 like
240
+ * every other line a shell command prints. */
241
+ export function sessionListRow(card, W, now, idCol) {
242
+ return rowSpans(card, W, now, idCol).text;
243
+ }
244
+ /** Slice ③ — the listing's last line: the count, and the one thing the
245
+ * user can do next. */
246
+ export function sessionListFooter(count, W) {
247
+ const p = palette();
248
+ return `${p.dim}${widthCut(`${count} session${count === 1 ? "" : "s"} · kiso resume picks interactively`, W)}${p.reset}`;
249
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "kiso tui — the pure terminal layer (cell renderer, dock, raw editor, diff, palette). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,6 +35,6 @@
35
35
  },
36
36
  "homepage": "https://github.com/vincemakes/kiso/tree/main/packages/tui#readme",
37
37
  "dependencies": {
38
- "@vincemakes/kiso-tui-cells": "0.9.0"
38
+ "@vincemakes/kiso-tui-cells": "0.11.0"
39
39
  }
40
40
  }