@sigrun/tui 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +9 -2
- package/dist/actions.d.ts +119 -0
- package/dist/actions.js +12 -0
- package/dist/api.d.ts +152 -0
- package/dist/api.js +189 -0
- package/dist/blocks/braille.d.ts +41 -0
- package/dist/blocks/braille.js +131 -0
- package/dist/blocks/chart.d.ts +20 -0
- package/dist/blocks/chart.js +397 -0
- package/dist/blocks/collection/calendar.d.ts +28 -0
- package/dist/blocks/collection/calendar.js +232 -0
- package/dist/blocks/collection/card.d.ts +36 -0
- package/dist/blocks/collection/card.js +128 -0
- package/dist/blocks/collection/collection.d.ts +9 -0
- package/dist/blocks/collection/collection.js +26 -0
- package/dist/blocks/collection/grid.d.ts +18 -0
- package/dist/blocks/collection/grid.js +72 -0
- package/dist/blocks/collection/lanes.d.ts +32 -0
- package/dist/blocks/collection/lanes.js +320 -0
- package/dist/blocks/collection/list.d.ts +30 -0
- package/dist/blocks/collection/list.js +273 -0
- package/dist/blocks/collection/move.d.ts +67 -0
- package/dist/blocks/collection/move.js +96 -0
- package/dist/blocks/collection/shared.d.ts +87 -0
- package/dist/blocks/collection/shared.js +177 -0
- package/dist/blocks/collection/table.d.ts +26 -0
- package/dist/blocks/collection/table.js +221 -0
- package/dist/blocks/content.d.ts +48 -0
- package/dist/blocks/content.js +253 -0
- package/dist/blocks/context.d.ts +96 -0
- package/dist/blocks/context.js +65 -0
- package/dist/blocks/diff.d.ts +21 -0
- package/dist/blocks/diff.js +25 -0
- package/dist/blocks/ext-transport.d.ts +70 -0
- package/dist/blocks/ext-transport.js +165 -0
- package/dist/blocks/form/choice.d.ts +54 -0
- package/dist/blocks/form/choice.js +175 -0
- package/dist/blocks/form/fields.d.ts +56 -0
- package/dist/blocks/form/fields.js +136 -0
- package/dist/blocks/form/file.d.ts +61 -0
- package/dist/blocks/form/file.js +121 -0
- package/dist/blocks/form/form.d.ts +53 -0
- package/dist/blocks/form/form.js +703 -0
- package/dist/blocks/form/list.d.ts +37 -0
- package/dist/blocks/form/list.js +70 -0
- package/dist/blocks/form/values.d.ts +20 -0
- package/dist/blocks/form/values.js +104 -0
- package/dist/blocks/icons.d.ts +14 -0
- package/dist/blocks/icons.js +74 -0
- package/dist/blocks/image.d.ts +39 -0
- package/dist/blocks/image.js +82 -0
- package/dist/blocks/layout.d.ts +31 -0
- package/dist/blocks/layout.js +155 -0
- package/dist/blocks/log.d.ts +24 -0
- package/dist/blocks/log.js +111 -0
- package/dist/blocks/meter.d.ts +16 -0
- package/dist/blocks/meter.js +50 -0
- package/dist/blocks/page.d.ts +54 -0
- package/dist/blocks/page.js +355 -0
- package/dist/blocks/render.d.ts +31 -0
- package/dist/blocks/render.js +145 -0
- package/dist/blocks/surface-view.d.ts +33 -0
- package/dist/blocks/surface-view.js +86 -0
- package/dist/blocks/surface.d.ts +75 -0
- package/dist/blocks/surface.js +468 -0
- package/dist/blocks/tones.d.ts +23 -0
- package/dist/blocks/tones.js +63 -0
- package/dist/blocks/treeview.d.ts +22 -0
- package/dist/blocks/treeview.js +121 -0
- package/dist/blocks/width.d.ts +32 -0
- package/dist/blocks/width.js +70 -0
- package/dist/clipboard.d.ts +20 -0
- package/dist/clipboard.js +19 -0
- package/dist/editor-launch.d.ts +34 -0
- package/dist/editor-launch.js +70 -0
- package/dist/keymaps/emacs.d.ts +18 -0
- package/dist/keymaps/emacs.js +172 -0
- package/dist/keymaps/standard.d.ts +17 -0
- package/dist/keymaps/standard.js +175 -0
- package/dist/keymaps/types.d.ts +43 -0
- package/dist/keymaps/types.js +28 -0
- package/dist/keymaps/vim.d.ts +48 -0
- package/dist/keymaps/vim.js +291 -0
- package/dist/launch.d.ts +149 -0
- package/dist/launch.js +446 -0
- package/dist/sse.d.ts +109 -0
- package/dist/sse.js +401 -0
- package/dist/theme.d.ts +51 -0
- package/dist/theme.js +119 -0
- package/dist/ui/ambient.d.ts +81 -0
- package/dist/ui/ambient.js +301 -0
- package/dist/ui/app.d.ts +245 -0
- package/dist/ui/app.js +1325 -0
- package/dist/ui/composer.d.ts +66 -0
- package/dist/ui/composer.js +125 -0
- package/dist/ui/conversation-pane.d.ts +123 -0
- package/dist/ui/conversation-pane.js +463 -0
- package/dist/ui/help.d.ts +15 -0
- package/dist/ui/help.js +80 -0
- package/dist/ui/interactions.d.ts +90 -0
- package/dist/ui/interactions.js +330 -0
- package/dist/ui/picker.d.ts +26 -0
- package/dist/ui/picker.js +130 -0
- package/dist/ui/sidebar.d.ts +99 -0
- package/dist/ui/sidebar.js +269 -0
- package/dist/ui/status-bar.d.ts +77 -0
- package/dist/ui/status-bar.js +146 -0
- package/package.json +49 -5
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interaction seam's TUI renderer (Task 8 — the parity rule): the
|
|
3
|
+
* pending-interaction stack rendered as an answerable prompt strip
|
|
4
|
+
* between the conversation pane and the composer (spec §3.2: "a visible
|
|
5
|
+
* prompt in the conversation pane" — the TUI's medium-appropriate form is
|
|
6
|
+
* a strip directly above the composer, where the answer keys live).
|
|
7
|
+
*
|
|
8
|
+
* Genericity (the seam's whole point): this component renders the closed
|
|
9
|
+
* primitives (text / options / fields / diff) and the envelope, and knows
|
|
10
|
+
* NOTHING about kinds — there is no gate-specific concept here. `details` renders as
|
|
11
|
+
* one collapsed JSON line, never interpreted (the single read is the
|
|
12
|
+
* webui-parity `details.multi === true` multi-select flag).
|
|
13
|
+
*
|
|
14
|
+
* Answering: ctrl+o enters answer mode (the app routes it via
|
|
15
|
+
* {@link TuiApp.handleKeybinding}; ctrl+a is the editor's cursorLineStart,
|
|
16
|
+
* so the prompt uses ctrl+o). Digits 1-9 pick options — single-choice
|
|
17
|
+
* answers immediately; multi (`details.multi`) toggles and enter submits
|
|
18
|
+
* the accumulated set; esc exits. Answer mode PINS the record it was
|
|
19
|
+
* armed on: if a poll replaces the top of the stack before the user keys
|
|
20
|
+
* an answer, the mode exits instead of answering a record the user never
|
|
21
|
+
* saw. The answer POSTs through the ONE generic route
|
|
22
|
+
* (`answerInteraction`); the record clears when the next list poll
|
|
23
|
+
* reflects the settle (the server drops answered records from pending).
|
|
24
|
+
*
|
|
25
|
+
* Medium limits (the parity rule's documented exceptions):
|
|
26
|
+
* - Form `fields` render read-only with a "answer in the webui" hint —
|
|
27
|
+
* the TUI's composer is the conversation's text input; capturing it for
|
|
28
|
+
* form values would collide with submission routing. v1 answers
|
|
29
|
+
* option-based prompts only.
|
|
30
|
+
* - Option `tone` is a color/severity hint; the shell pins a no-color
|
|
31
|
+
* theme (styling is a later task), so tone is not rendered.
|
|
32
|
+
*
|
|
33
|
+
* Refetch triggers (W3 Task 4 — the 2s poll interval is RETIRED): kicks
|
|
34
|
+
* arrive on each stream frame (a revision may change the pending set) and
|
|
35
|
+
* on `interaction.*` host events (the app wires `watchEvents`'s
|
|
36
|
+
* `interaction.pending`/`interaction.settled` to {@link kick}). Kicks
|
|
37
|
+
* coalesce (one fetch in flight, one queued). Poll failures are
|
|
38
|
+
* QUIET — the prompt keeps its last state; stream transport health is
|
|
39
|
+
* reported separately by the watcher's error/open transition notices
|
|
40
|
+
* (the launcher's stream-health wiring). Answer failures (user-initiated)
|
|
41
|
+
* are LOUD via {@link onError} (the app routes them to the pane's
|
|
42
|
+
* `[error]` line).
|
|
43
|
+
*/
|
|
44
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
45
|
+
import { diffLines } from "../blocks/diff.js";
|
|
46
|
+
import { describeCommandError } from "./composer.js";
|
|
47
|
+
/** The answer-mode option pick keys: digits 1-9. */
|
|
48
|
+
const DIGIT = /^[1-9]$/;
|
|
49
|
+
export class InteractionPrompter {
|
|
50
|
+
#client;
|
|
51
|
+
#conversationId;
|
|
52
|
+
/** Monotonic per-conversation generation: stale fetches never land. */
|
|
53
|
+
#gen = 0;
|
|
54
|
+
#pending = [];
|
|
55
|
+
#answerMode = false;
|
|
56
|
+
/**
|
|
57
|
+
* The record answer mode is ARMED ON. Pinned at beginAnswer: a poll can
|
|
58
|
+
* replace the top of the stack at any time (the record settled via the
|
|
59
|
+
* webui, the extension resolved it, it expired), and a digit keyed for
|
|
60
|
+
* the prompt the user READ must never answer the prompt that replaced
|
|
61
|
+
* it. Any top-identity change exits answer mode instead.
|
|
62
|
+
*/
|
|
63
|
+
#answerCallId;
|
|
64
|
+
/** The multi-select working set (option ids), pre-seeded recommended. */
|
|
65
|
+
#selected = new Set();
|
|
66
|
+
/** Coalescing poll state: one fetch in flight, at most one queued. */
|
|
67
|
+
#inFlight = false;
|
|
68
|
+
#queued = false;
|
|
69
|
+
/** Answer failures (user-initiated — loud). The app routes to the pane. */
|
|
70
|
+
onError;
|
|
71
|
+
/** Rendered-state changes (the app requests a repaint). */
|
|
72
|
+
onChanged;
|
|
73
|
+
constructor(client) {
|
|
74
|
+
this.#client = client;
|
|
75
|
+
}
|
|
76
|
+
/** The latest pending list (top of stack first). */
|
|
77
|
+
get pending() {
|
|
78
|
+
return this.#pending;
|
|
79
|
+
}
|
|
80
|
+
/** Whether digit/enter/esc keys are captured for answering. */
|
|
81
|
+
get answerMode() {
|
|
82
|
+
return this.#answerMode;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Retarget the prompter: reset state and fetch immediately.
|
|
86
|
+
* `undefined` clears the prompt. (W3 Task 4: no poll interval —
|
|
87
|
+
* stream frames and `interaction.*` host events own the refreshes.)
|
|
88
|
+
*/
|
|
89
|
+
setConversation(id) {
|
|
90
|
+
this.#gen += 1;
|
|
91
|
+
this.#conversationId = id;
|
|
92
|
+
this.#pending = [];
|
|
93
|
+
this.#exitAnswer();
|
|
94
|
+
// A fetch from the OLD conversation may still be in flight (or stuck);
|
|
95
|
+
// its result is fenced by the generation guard, so its promise is
|
|
96
|
+
// orphaned — the new conversation must not queue behind it.
|
|
97
|
+
this.#inFlight = false;
|
|
98
|
+
this.#queued = false;
|
|
99
|
+
if (id === undefined) {
|
|
100
|
+
this.#changed();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
void this.#refresh();
|
|
104
|
+
}
|
|
105
|
+
/** Coalesced poll (the app's stream frames call this). */
|
|
106
|
+
kick() {
|
|
107
|
+
void this.#refresh();
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* kick(), awaited. Production polls are fire-and-forget; tests need to
|
|
111
|
+
* await the poll's settle to assert the state after it.
|
|
112
|
+
*/
|
|
113
|
+
async kickAndWait() {
|
|
114
|
+
await this.#refresh();
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Enter answer mode — only when the top pending item is option-
|
|
118
|
+
* answerable. Returns whether the mode was entered (the caller consumes
|
|
119
|
+
* the key only then).
|
|
120
|
+
*/
|
|
121
|
+
beginAnswer() {
|
|
122
|
+
const top = this.#pending[0];
|
|
123
|
+
const options = top?.body?.options ?? [];
|
|
124
|
+
if (top === undefined || options.length === 0)
|
|
125
|
+
return false;
|
|
126
|
+
this.#answerMode = true;
|
|
127
|
+
// Pin the target: keys answer THIS record or nothing.
|
|
128
|
+
this.#answerCallId = top.callId;
|
|
129
|
+
// The webui's pre-selection rule: recommended options start selected.
|
|
130
|
+
this.#selected = new Set(options.filter((o) => o.recommended === true).map((o) => o.id));
|
|
131
|
+
this.#changed();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Route one key while in answer mode. Returns true when consumed.
|
|
136
|
+
* Unrelated keys pass through (the seam never blocks — the user can
|
|
137
|
+
* keep typing while a prompt waits).
|
|
138
|
+
*/
|
|
139
|
+
handleKey(data) {
|
|
140
|
+
const top = this.#pending[0];
|
|
141
|
+
const options = top?.body?.options ?? [];
|
|
142
|
+
if (!this.#answerMode || top === undefined || options.length === 0) {
|
|
143
|
+
this.#exitAnswer();
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
// Target-pin check: if a poll replaced the armed record, the keys
|
|
147
|
+
// the user is pressing belong to a prompt they never saw — exit
|
|
148
|
+
// answer mode and let the key pass through unanswered.
|
|
149
|
+
if (this.#answerCallId !== top.callId) {
|
|
150
|
+
this.#exitAnswer();
|
|
151
|
+
this.#changed();
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (matchesKey(data, Key.escape)) {
|
|
155
|
+
this.#exitAnswer();
|
|
156
|
+
this.#changed();
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
const multi = isMulti(top);
|
|
160
|
+
if (matchesKey(data, Key.enter)) {
|
|
161
|
+
if (multi && this.#selected.size > 0) {
|
|
162
|
+
this.#submit(top, [...this.#selected]);
|
|
163
|
+
}
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (DIGIT.test(data)) {
|
|
167
|
+
const index = Number(data) - 1;
|
|
168
|
+
const option = options[index];
|
|
169
|
+
if (option !== undefined) {
|
|
170
|
+
if (multi) {
|
|
171
|
+
const next = new Set(this.#selected);
|
|
172
|
+
if (next.has(option.id)) {
|
|
173
|
+
next.delete(option.id);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
next.add(option.id);
|
|
177
|
+
}
|
|
178
|
+
this.#selected = next;
|
|
179
|
+
this.#changed();
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
this.#submit(top, [option.id]);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// In-range or not, digits belong to the prompt in answer mode.
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
/** Fire-and-forget answer; the next list poll reflects the settle. */
|
|
191
|
+
#submit(record, ids) {
|
|
192
|
+
const id = this.#conversationId;
|
|
193
|
+
if (id === undefined)
|
|
194
|
+
return;
|
|
195
|
+
// Belt to the handleKey pin check: never POST for a record the
|
|
196
|
+
// answer keys were not armed on.
|
|
197
|
+
if (this.#answerCallId !== record.callId)
|
|
198
|
+
return;
|
|
199
|
+
this.#exitAnswer();
|
|
200
|
+
this.#changed();
|
|
201
|
+
const answer = { ids };
|
|
202
|
+
this.#client.answerInteraction(id, record.callId, answer).then(() => {
|
|
203
|
+
void this.#refresh();
|
|
204
|
+
}, (error) => {
|
|
205
|
+
this.onError?.(describeCommandError("answer", error));
|
|
206
|
+
void this.#refresh();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
#exitAnswer() {
|
|
210
|
+
this.#answerMode = false;
|
|
211
|
+
this.#answerCallId = undefined;
|
|
212
|
+
this.#selected = new Set();
|
|
213
|
+
}
|
|
214
|
+
/** Generation-guarded, coalesced fetch of the pending list. */
|
|
215
|
+
async #refresh() {
|
|
216
|
+
const id = this.#conversationId;
|
|
217
|
+
const gen = this.#gen;
|
|
218
|
+
if (id === undefined)
|
|
219
|
+
return;
|
|
220
|
+
if (this.#inFlight) {
|
|
221
|
+
this.#queued = true;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
this.#inFlight = true;
|
|
225
|
+
try {
|
|
226
|
+
do {
|
|
227
|
+
this.#queued = false;
|
|
228
|
+
let list;
|
|
229
|
+
try {
|
|
230
|
+
list = await this.#client.listInteractions(id);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// Quiet poll failure: keep the last pending list.
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (gen !== this.#gen)
|
|
237
|
+
return; // stale — a switch happened
|
|
238
|
+
// The route serves pending-only; the filter is belt for races.
|
|
239
|
+
this.#pending = list.interactions.filter((record) => record.status === "pending");
|
|
240
|
+
// The armed record left the top of the stack (settled via
|
|
241
|
+
// another channel, resolved by its extension, expired — or
|
|
242
|
+
// simply reordered): the user's armed keys belong to a prompt
|
|
243
|
+
// they are no longer looking at. Exit answer mode rather than
|
|
244
|
+
// risk answering a record they never saw.
|
|
245
|
+
if (this.#answerMode &&
|
|
246
|
+
this.#pending[0]?.callId !== this.#answerCallId) {
|
|
247
|
+
this.#exitAnswer();
|
|
248
|
+
}
|
|
249
|
+
this.#changed();
|
|
250
|
+
} while (this.#queued && gen === this.#gen);
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
if (gen === this.#gen)
|
|
254
|
+
this.#inFlight = false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
#changed() {
|
|
258
|
+
this.onChanged?.();
|
|
259
|
+
}
|
|
260
|
+
/** Detach (the app calls this on dispose): stale fetches never land. */
|
|
261
|
+
dispose() {
|
|
262
|
+
this.#gen += 1;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The prompt strip: nothing when no item is pending; otherwise the top
|
|
266
|
+
* item's header, body primitives, answer affordance, and a stack count.
|
|
267
|
+
*/
|
|
268
|
+
render(_width) {
|
|
269
|
+
const top = this.#pending[0];
|
|
270
|
+
if (top === undefined)
|
|
271
|
+
return [];
|
|
272
|
+
const body = top.body;
|
|
273
|
+
const options = body?.options ?? [];
|
|
274
|
+
const fields = body?.fields ?? [];
|
|
275
|
+
const multi = isMulti(top);
|
|
276
|
+
const lines = [];
|
|
277
|
+
lines.push(this.#answerMode
|
|
278
|
+
? `[interaction] ${top.kind} — answering`
|
|
279
|
+
: `[interaction] ${top.kind}`);
|
|
280
|
+
if (body?.text !== undefined) {
|
|
281
|
+
for (const line of body.text.split("\n"))
|
|
282
|
+
lines.push(line);
|
|
283
|
+
}
|
|
284
|
+
if (body?.diff !== undefined) {
|
|
285
|
+
lines.push(...diffLines(body.diff));
|
|
286
|
+
}
|
|
287
|
+
for (const [index, option] of options.entries()) {
|
|
288
|
+
const marker = this.#answerMode && multi
|
|
289
|
+
? this.#selected.has(option.id)
|
|
290
|
+
? "[x] "
|
|
291
|
+
: "[ ] "
|
|
292
|
+
: "";
|
|
293
|
+
const recommended = option.recommended === true ? " (recommended)" : "";
|
|
294
|
+
const description = option.description === undefined ? "" : ` — ${option.description}`;
|
|
295
|
+
lines.push(` ${marker}${index + 1}. ${option.label}${recommended}${description}`);
|
|
296
|
+
}
|
|
297
|
+
for (const field of fields) {
|
|
298
|
+
const label = field.label ?? field.name;
|
|
299
|
+
const required = field.required === true ? " required" : "";
|
|
300
|
+
lines.push(` - ${label} (${field.type})${required}`);
|
|
301
|
+
}
|
|
302
|
+
if (top.details !== undefined) {
|
|
303
|
+
lines.push(`[details] ${JSON.stringify(top.details)}`);
|
|
304
|
+
}
|
|
305
|
+
if (this.#pending.length > 1) {
|
|
306
|
+
lines.push(`+${this.#pending.length - 1} more pending`);
|
|
307
|
+
}
|
|
308
|
+
if (options.length > 0) {
|
|
309
|
+
if (this.#answerMode) {
|
|
310
|
+
lines.push(multi
|
|
311
|
+
? "1-9 toggle · enter submit · esc cancel"
|
|
312
|
+
: "1-9 answer · esc cancel");
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
lines.push("ctrl+o to answer");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else if (fields.length > 0) {
|
|
319
|
+
lines.push("needs form input — answer in the webui");
|
|
320
|
+
}
|
|
321
|
+
return lines;
|
|
322
|
+
}
|
|
323
|
+
invalidate() {
|
|
324
|
+
// Stateless render: lines assemble from the current pending list.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** The webui's multi-select reading: `details.multi === true`. */
|
|
328
|
+
function isMulti(record) {
|
|
329
|
+
return (record.details?.multi === true);
|
|
330
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The picker overlay (Task C3, UI refresh): a centred `showOverlay` box —
|
|
3
|
+
* a title line, a one-line fuzzy filter, and a `SelectList`. Typing
|
|
4
|
+
* re-filters (pi-tui's `fuzzyFilter` over label + detail), Enter picks,
|
|
5
|
+
* Esc closes, and a mouse click picks (SelectList's own mouse handling).
|
|
6
|
+
*
|
|
7
|
+
* Conversation/model/command pickers are built by the app from the
|
|
8
|
+
* sidebar tree, `listModels()`, and the slash catalog respectively.
|
|
9
|
+
*/
|
|
10
|
+
import { type TUI } from "@earendil-works/pi-tui";
|
|
11
|
+
import type { TuiTheme } from "../theme.js";
|
|
12
|
+
export interface PickerItem<T> {
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly detail?: string;
|
|
15
|
+
readonly value: T;
|
|
16
|
+
}
|
|
17
|
+
export interface PickerOptions<T> {
|
|
18
|
+
readonly title: string;
|
|
19
|
+
readonly onPick: (value: T) => void;
|
|
20
|
+
/** Fires on close (pick OR cancel) so the app can clear overlayOpen. */
|
|
21
|
+
readonly onClose?: () => void;
|
|
22
|
+
/** The token theme; defaults to plain identity styling. */
|
|
23
|
+
readonly theme?: TuiTheme;
|
|
24
|
+
}
|
|
25
|
+
/** Open a centred picker overlay; Enter/click picks, Esc closes. */
|
|
26
|
+
export declare function openPicker<T>(tui: TUI, items: readonly PickerItem<T>[], options: PickerOptions<T>): void;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The picker overlay (Task C3, UI refresh): a centred `showOverlay` box —
|
|
3
|
+
* a title line, a one-line fuzzy filter, and a `SelectList`. Typing
|
|
4
|
+
* re-filters (pi-tui's `fuzzyFilter` over label + detail), Enter picks,
|
|
5
|
+
* Esc closes, and a mouse click picks (SelectList's own mouse handling).
|
|
6
|
+
*
|
|
7
|
+
* Conversation/model/command pickers are built by the app from the
|
|
8
|
+
* sidebar tree, `listModels()`, and the slash catalog respectively.
|
|
9
|
+
*/
|
|
10
|
+
import { fuzzyFilter, Key, matchesKey, SelectList, } from "@earendil-works/pi-tui";
|
|
11
|
+
const MAX_VISIBLE = 10;
|
|
12
|
+
function plainSelectListTheme() {
|
|
13
|
+
const identity = (text) => text;
|
|
14
|
+
return {
|
|
15
|
+
selectedPrefix: identity,
|
|
16
|
+
selectedText: identity,
|
|
17
|
+
description: identity,
|
|
18
|
+
scrollInfo: identity,
|
|
19
|
+
noMatch: identity,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
class Picker {
|
|
23
|
+
#items;
|
|
24
|
+
#options;
|
|
25
|
+
#selectTheme;
|
|
26
|
+
#muted;
|
|
27
|
+
#query = "";
|
|
28
|
+
#filtered;
|
|
29
|
+
#list;
|
|
30
|
+
#overlay;
|
|
31
|
+
constructor(items, options) {
|
|
32
|
+
this.#items = items;
|
|
33
|
+
this.#options = options;
|
|
34
|
+
const theme = options.theme;
|
|
35
|
+
this.#selectTheme =
|
|
36
|
+
theme === undefined
|
|
37
|
+
? plainSelectListTheme()
|
|
38
|
+
: {
|
|
39
|
+
selectedPrefix: (text) => theme.fg("accent")(text),
|
|
40
|
+
selectedText: (text) => theme.bold(text),
|
|
41
|
+
description: (text) => theme.fg("muted")(text),
|
|
42
|
+
scrollInfo: (text) => theme.fg("muted")(text),
|
|
43
|
+
noMatch: (text) => theme.fg("muted")(text),
|
|
44
|
+
};
|
|
45
|
+
this.#muted =
|
|
46
|
+
theme === undefined ? (text) => text : (text) => theme.fg("muted")(text);
|
|
47
|
+
this.#filtered = items;
|
|
48
|
+
this.#list = this.#buildList();
|
|
49
|
+
}
|
|
50
|
+
/** The overlay handle is installed by {@link openPicker} after show. */
|
|
51
|
+
setOverlay(overlay) {
|
|
52
|
+
this.#overlay = overlay;
|
|
53
|
+
}
|
|
54
|
+
#buildList() {
|
|
55
|
+
const list = new SelectList(this.#filtered.map((item, index) => ({
|
|
56
|
+
value: String(index),
|
|
57
|
+
label: item.label,
|
|
58
|
+
...(item.detail === undefined ? {} : { description: item.detail }),
|
|
59
|
+
})), MAX_VISIBLE, this.#selectTheme);
|
|
60
|
+
list.onSelect = (item) => {
|
|
61
|
+
const picked = this.#filtered[Number(item.value)];
|
|
62
|
+
if (picked === undefined)
|
|
63
|
+
return;
|
|
64
|
+
this.#close();
|
|
65
|
+
this.#options.onPick(picked.value);
|
|
66
|
+
};
|
|
67
|
+
list.onCancel = () => this.#close();
|
|
68
|
+
return list;
|
|
69
|
+
}
|
|
70
|
+
#close() {
|
|
71
|
+
this.#overlay?.hide();
|
|
72
|
+
this.#options.onClose?.();
|
|
73
|
+
}
|
|
74
|
+
#refilter() {
|
|
75
|
+
const filtered = this.#query === ""
|
|
76
|
+
? this.#items
|
|
77
|
+
: fuzzyFilter([...this.#items], this.#query, (item) => item.detail === undefined ? item.label : `${item.label} ${item.detail}`);
|
|
78
|
+
this.#filtered = filtered;
|
|
79
|
+
this.#list = this.#buildList();
|
|
80
|
+
}
|
|
81
|
+
handleInput(data) {
|
|
82
|
+
// C-g is the emacs cancel: close like Esc (the emacs keymap passes it
|
|
83
|
+
// through while an overlay owns the keys).
|
|
84
|
+
if (data === "\x07") {
|
|
85
|
+
this.#close();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (matchesKey(data, Key.backspace) || matchesKey(data, "backspace")) {
|
|
89
|
+
if (this.#query !== "") {
|
|
90
|
+
this.#query = this.#query.slice(0, -1);
|
|
91
|
+
this.#refilter();
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// One printable character extends the filter; everything else
|
|
96
|
+
// (arrows, Enter, Esc, mouse) is the list's own input.
|
|
97
|
+
if (data.length === 1 && data >= " ") {
|
|
98
|
+
this.#query += data;
|
|
99
|
+
this.#refilter();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.#list.handleInput(data);
|
|
103
|
+
}
|
|
104
|
+
render(width) {
|
|
105
|
+
return [
|
|
106
|
+
this.#muted(this.#options.title),
|
|
107
|
+
this.#muted(`filter: ${this.#query}`),
|
|
108
|
+
...this.#list.render(width),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
/** The click is additive: delegate to the list under the filter rows. */
|
|
112
|
+
handleMouse(event) {
|
|
113
|
+
if (event.y < 2)
|
|
114
|
+
return { handled: true };
|
|
115
|
+
return this.#list.handleMouse({ ...event, y: event.y - 2 });
|
|
116
|
+
}
|
|
117
|
+
invalidate() {
|
|
118
|
+
this.#list.invalidate();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Open a centred picker overlay; Enter/click picks, Esc closes. */
|
|
122
|
+
export function openPicker(tui, items, options) {
|
|
123
|
+
const picker = new Picker(items, options);
|
|
124
|
+
const overlay = tui.showOverlay(picker, {
|
|
125
|
+
anchor: "center",
|
|
126
|
+
width: "60%",
|
|
127
|
+
maxHeight: "60%",
|
|
128
|
+
});
|
|
129
|
+
picker.setOverlay(overlay);
|
|
130
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar (Task 6; restyled in C2, UI refresh): the conversation
|
|
3
|
+
* TREE — rows come from B1's `buildConversationTree` (fork children
|
|
4
|
+
* indent two spaces + `↳ `), names are single-line via B1's
|
|
5
|
+
* `displayName` + pi-tui's `truncateToWidth`, ages right-align via B1's
|
|
6
|
+
* `formatAge`, and the active row highlights accent-fg + bold. Archived
|
|
7
|
+
* conversations stay hidden (the tree's `includeArchived: false`; the
|
|
8
|
+
* server has no delete, archive is the hide). `+ New conversation` is
|
|
9
|
+
* always the last row.
|
|
10
|
+
*
|
|
11
|
+
* The listing itself carries every field the rows need (`name`,
|
|
12
|
+
* `archived`, `pinned`, `parent`, `lastActivityAt` ride
|
|
13
|
+
* `ConversationSummary` — A1), so {@link refresh} makes ONE request: no
|
|
14
|
+
* per-row meta fetch. The app re-refreshes on a 10 s poll (an unref'd
|
|
15
|
+
* `setInterval` the launcher clears on dispose).
|
|
16
|
+
*
|
|
17
|
+
* Keyboard-first (mouse is additive): `cursor` / `moveCursor` /
|
|
18
|
+
* `confirm()` are the C3 action layer's seam; a click on a row selects
|
|
19
|
+
* it (`MouseRegion` per row). Selection reports to {@link onSelect}: a
|
|
20
|
+
* conversation id, or `"new"` for the `+ New conversation` row
|
|
21
|
+
* (creation is the app's job — the sidebar never calls
|
|
22
|
+
* createConversation itself).
|
|
23
|
+
*/
|
|
24
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
25
|
+
import { type TuiTheme } from "../theme.js";
|
|
26
|
+
import type { SigrunClient } from "../api.js";
|
|
27
|
+
/** The sidebar selection: a conversation id, the `+ new` action, or an
|
|
28
|
+
* extension VIEW (UI-blocks U3 Task 5 — the app opens it in the main
|
|
29
|
+
* pane). */
|
|
30
|
+
export type SidebarSelection = number | "new" | {
|
|
31
|
+
readonly view: {
|
|
32
|
+
readonly slug: string;
|
|
33
|
+
readonly id: string;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
/** One display row: a conversation, or the trailing `+ New` action. */
|
|
37
|
+
interface SidebarRow {
|
|
38
|
+
/** The conversation id; -1 for the `+ New conversation` action row. */
|
|
39
|
+
readonly id: number;
|
|
40
|
+
/** The display name (displayName — unnamed → "New conversation"). */
|
|
41
|
+
readonly name: string;
|
|
42
|
+
/** The tree depth (fork children render indented). */
|
|
43
|
+
readonly depth: number;
|
|
44
|
+
/** The right-aligned age column ("" when never used). */
|
|
45
|
+
readonly age: string;
|
|
46
|
+
/** Whether this row is the active conversation. */
|
|
47
|
+
readonly active: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface SidebarOptions {
|
|
50
|
+
/** The token theme; defaults to the plain (identity) fallback. */
|
|
51
|
+
readonly theme?: TuiTheme;
|
|
52
|
+
/** The clock seam for age rendering (tests pin it). */
|
|
53
|
+
readonly now?: () => Date;
|
|
54
|
+
}
|
|
55
|
+
export declare class Sidebar implements Component {
|
|
56
|
+
#private;
|
|
57
|
+
/** Selection callback: the conversation id, or `"new"` for `+ new`. */
|
|
58
|
+
onSelect?: (selection: SidebarSelection) => void;
|
|
59
|
+
constructor(client: SigrunClient, options?: SidebarOptions);
|
|
60
|
+
/** The conversation rows, in display order (the app's boot default
|
|
61
|
+
* reads `rows[0]`); the trailing `+ New conversation` action row is
|
|
62
|
+
* NOT part of this list — it renders last via {@link #newRow}. */
|
|
63
|
+
get rows(): readonly SidebarRow[];
|
|
64
|
+
/** The cursor's row index (0-based over {@link rows}). */
|
|
65
|
+
get cursor(): number;
|
|
66
|
+
/**
|
|
67
|
+
* Re-fetch the listing and rebuild the rows from the tree (summary
|
|
68
|
+
* fields only — no per-row meta fetch). The cursor follows its
|
|
69
|
+
* conversation when it still exists.
|
|
70
|
+
*/
|
|
71
|
+
refresh(): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* The extension views (U3 Task 5): refetch `GET /v1/extensions` and
|
|
74
|
+
* rebuild the VIEWS group (one row per declared view surface, in
|
|
75
|
+
* declaration order, status "ok" only). Panels live in the side pane,
|
|
76
|
+
* not here. Failures keep the last good list (a flaky server must
|
|
77
|
+
* never blank the sidebar).
|
|
78
|
+
*/
|
|
79
|
+
refreshViews(): Promise<void>;
|
|
80
|
+
/** The extension view rows (the app's openExtView reads them). */
|
|
81
|
+
get views(): readonly {
|
|
82
|
+
slug: string;
|
|
83
|
+
id: string;
|
|
84
|
+
title: string;
|
|
85
|
+
}[];
|
|
86
|
+
/** Select the Nth extension view (the action layer's seam). */
|
|
87
|
+
confirmView(index: number): void;
|
|
88
|
+
/** Mark the active conversation (accent fg + bold); pass `undefined` to clear. */
|
|
89
|
+
setActive(id: number | undefined): void;
|
|
90
|
+
/** Move the cursor by `delta` rows, clamped to the list. */
|
|
91
|
+
moveCursor(delta: number): void;
|
|
92
|
+
/** Select the cursor's row (Enter — the action layer's seam). */
|
|
93
|
+
confirm(): void;
|
|
94
|
+
/** The keyboard seam: arrows move, Enter confirms (pre-C3 minimal). */
|
|
95
|
+
handleInput(data: string): void;
|
|
96
|
+
render(width: number): string[];
|
|
97
|
+
invalidate(): void;
|
|
98
|
+
}
|
|
99
|
+
export {};
|