@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.
- package/dist/ask-panel.d.ts +8 -7
- package/dist/ask-panel.js +22 -11
- package/dist/compositor.d.ts +8 -9
- package/dist/compositor.js +274 -44
- package/dist/editor.d.ts +8 -1
- package/dist/editor.js +266 -17
- package/dist/index.d.ts +4 -3
- package/dist/index.js +9 -3
- package/dist/session-picker.d.ts +113 -0
- package/dist/session-picker.js +249 -0
- package/package.json +2 -2
|
@@ -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.
|
|
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.
|
|
38
|
+
"@vincemakes/kiso-tui-cells": "0.11.0"
|
|
39
39
|
}
|
|
40
40
|
}
|