@adeildo/pi-kit 4.0.0 → 5.0.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,771 @@
1
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ decodeKittyPrintable,
5
+ Input,
6
+ matchesKey,
7
+ type TUI,
8
+ truncateToWidth,
9
+ visibleWidth,
10
+ wrapTextWithAnsi,
11
+ } from "@earendil-works/pi-tui";
12
+
13
+ import type { RowView } from "../contracts/screen.ts";
14
+ import {
15
+ type Control,
16
+ type ControlOption,
17
+ formatValue,
18
+ type Json,
19
+ optionText,
20
+ sameJson,
21
+ } from "../control.ts";
22
+ import type { Layer } from "../settings/store.ts";
23
+ import { applyRow, listTabs, runRow } from "./client.ts";
24
+ import { keyOf, type Line, type ScreenModel } from "./model.ts";
25
+
26
+ export type ScreenResult = { kind: "close" } | { kind: "edit"; row: RowView };
27
+
28
+ export interface ScreenViewOptions {
29
+ tui: TUI;
30
+ theme: Theme;
31
+ events: ExtensionAPI["events"];
32
+ model: ScreenModel;
33
+ done: (result: ScreenResult) => void;
34
+ }
35
+
36
+ interface PickItem {
37
+ value: Json;
38
+ label: string;
39
+ description?: string;
40
+ /** Opens an input or the editor instead of picking a value. */
41
+ opens?: "input" | "edit";
42
+ }
43
+
44
+ interface PickMode {
45
+ kind: "pick";
46
+ row: RowView;
47
+ items: PickItem[];
48
+ index: number;
49
+ current: Json | undefined;
50
+ }
51
+
52
+ interface CheckMode {
53
+ kind: "check";
54
+ row: RowView;
55
+ items: PickItem[];
56
+ checked: Json[];
57
+ index: number;
58
+ }
59
+
60
+ interface InputMode {
61
+ kind: "input";
62
+ row: RowView;
63
+ input: Input;
64
+ error?: string;
65
+ }
66
+
67
+ interface TextMode {
68
+ kind: "text";
69
+ title: string;
70
+ text: string;
71
+ scroll: number;
72
+ }
73
+
74
+ type Mode = { kind: "browse" } | PickMode | CheckMode | InputMode | TextMode;
75
+
76
+ type Tone = "success" | "error" | "warning" | "muted";
77
+
78
+ const PANEL = 4;
79
+ const CHROME = 7 + PANEL;
80
+ const MIN_BODY = 6;
81
+
82
+ export class ScreenView implements Component {
83
+ #options: ScreenViewOptions;
84
+ #mode: Mode = { kind: "browse" };
85
+ #status: { text: string; tone: Tone } | undefined;
86
+ #running = new Set<string>();
87
+ #confirming: string | undefined;
88
+ #scroll = 0;
89
+ #bodyHeight = MIN_BODY;
90
+
91
+ constructor(options: ScreenViewOptions) {
92
+ this.#options = options;
93
+ }
94
+
95
+ invalidate(): void {}
96
+
97
+ handleInput(data: string): void {
98
+ this.#status = undefined;
99
+ if (this.#confirmed(data)) {
100
+ this.#options.tui.requestRender();
101
+ return;
102
+ }
103
+ switch (this.#mode.kind) {
104
+ case "browse":
105
+ this.#browseKey(data);
106
+ break;
107
+ case "pick":
108
+ this.#pickKey(this.#mode, data);
109
+ break;
110
+ case "check":
111
+ this.#checkKey(this.#mode, data);
112
+ break;
113
+ case "input":
114
+ this.#mode.input.handleInput(data);
115
+ break;
116
+ case "text":
117
+ this.#textKey(this.#mode, data);
118
+ break;
119
+ }
120
+ this.#options.tui.requestRender();
121
+ }
122
+
123
+ /** A second Enter on the same row runs it. Any other key cancels. */
124
+ #confirmed(data: string): boolean {
125
+ const pending = this.#confirming;
126
+ this.#confirming = undefined;
127
+ if (pending === undefined || !matchesKey(data, "enter")) return false;
128
+
129
+ const row = this.#options.model.selected()?.row;
130
+ if (row === undefined || keyOf(row) !== pending) return false;
131
+ this.#run(row);
132
+ return true;
133
+ }
134
+
135
+ #browseKey(data: string): void {
136
+ const model = this.#options.model;
137
+ const row = model.selected()?.row;
138
+
139
+ if (matchesKey(data, "escape")) {
140
+ if (model.searching) model.setQuery("");
141
+ else this.#options.done({ kind: "close" });
142
+ } else if (matchesKey(data, "ctrl+c")) this.#options.done({ kind: "close" });
143
+ else if (matchesKey(data, "up")) model.move(-1);
144
+ else if (matchesKey(data, "down")) model.move(1);
145
+ else if (matchesKey(data, "pageUp")) model.move(-this.#bodyHeight);
146
+ else if (matchesKey(data, "pageDown")) model.move(this.#bodyHeight);
147
+ else if (matchesKey(data, "home")) model.moveToEdge("first");
148
+ else if (matchesKey(data, "end")) model.moveToEdge("last");
149
+ else if (matchesKey(data, "left")) model.switchTab(-1);
150
+ else if (matchesKey(data, "right")) model.switchTab(1);
151
+ else if (matchesKey(data, "tab")) this.#jumpSection(1);
152
+ else if (matchesKey(data, "shift+tab")) this.#jumpSection(-1);
153
+ else if (matchesKey(data, "enter")) {
154
+ if (row !== undefined) this.#activate(row);
155
+ } else if (matchesKey(data, "delete")) {
156
+ if (row !== undefined) this.#unset(row);
157
+ } else if (matchesKey(data, "backspace")) {
158
+ if (model.searching) model.setQuery(model.query.slice(0, -1));
159
+ } else if (matchesKey(data, "space") && !model.searching) {
160
+ if (row !== undefined) this.#cycle(row);
161
+ } else {
162
+ const typed = printable(data);
163
+ if (typed !== undefined) model.setQuery(model.query + typed);
164
+ }
165
+ }
166
+
167
+ /** The section starts at the top, heading and all. */
168
+ #jumpSection(direction: 1 | -1): void {
169
+ const model = this.#options.model;
170
+ model.jumpSection(direction);
171
+ this.#scroll = Math.max(0, model.cursorLine() - 1);
172
+ }
173
+
174
+ #pickKey(mode: PickMode, data: string): void {
175
+ if (matchesKey(data, "escape")) this.#mode = { kind: "browse" };
176
+ else if (matchesKey(data, "up")) step(mode, -1);
177
+ else if (matchesKey(data, "down")) step(mode, 1);
178
+ else if (matchesKey(data, "enter")) {
179
+ const item = mode.items[mode.index];
180
+ if (item === undefined) return;
181
+ this.#mode = { kind: "browse" };
182
+ if (item.opens === "input") this.#openInput(mode.row);
183
+ else if (item.opens === "edit") this.#options.done({ kind: "edit", row: mode.row });
184
+ else this.#set(mode.row, item.value);
185
+ }
186
+ }
187
+
188
+ #checkKey(mode: CheckMode, data: string): void {
189
+ const item = mode.items[mode.index];
190
+ const at =
191
+ item === undefined ? -1 : mode.checked.findIndex((value) => sameJson(value, item.value));
192
+
193
+ if (matchesKey(data, "escape")) this.#mode = { kind: "browse" };
194
+ else if (matchesKey(data, "alt+up") || matchesKey(data, "shift+up")) {
195
+ if (at > 0) this.#swap(mode, at, at - 1);
196
+ } else if (matchesKey(data, "alt+down") || matchesKey(data, "shift+down")) {
197
+ if (at !== -1 && at < mode.checked.length - 1) this.#swap(mode, at, at + 1);
198
+ } else if (matchesKey(data, "up")) step(mode, -1);
199
+ else if (matchesKey(data, "down")) step(mode, 1);
200
+ else if (matchesKey(data, "space") && item !== undefined) {
201
+ if (at === -1) mode.checked.push(item.value);
202
+ else mode.checked.splice(at, 1);
203
+ this.#sortChecks(mode);
204
+ } else if (matchesKey(data, "enter")) {
205
+ this.#mode = { kind: "browse" };
206
+ this.#set(mode.row, mode.checked);
207
+ }
208
+ }
209
+
210
+ #textKey(mode: TextMode, data: string): void {
211
+ if (matchesKey(data, "escape") || matchesKey(data, "enter")) this.#mode = { kind: "browse" };
212
+ else if (matchesKey(data, "up")) mode.scroll = Math.max(0, mode.scroll - 1);
213
+ else if (matchesKey(data, "down")) mode.scroll++;
214
+ else if (matchesKey(data, "pageUp")) mode.scroll = Math.max(0, mode.scroll - this.#bodyHeight);
215
+ else if (matchesKey(data, "pageDown")) mode.scroll += this.#bodyHeight;
216
+ }
217
+
218
+ #activate(row: RowView): void {
219
+ if (row.kind === "action") {
220
+ if (row.confirm === undefined) this.#run(row);
221
+ else {
222
+ this.#confirming = keyOf(row);
223
+ this.#say(`${row.confirm} \u21b5 again to confirm, any other key to keep`, "warning");
224
+ }
225
+ return;
226
+ }
227
+ const control = row.control;
228
+ if (control === undefined) {
229
+ this.#say("read only", "muted");
230
+ return;
231
+ }
232
+ switch (control.type) {
233
+ case "toggle":
234
+ this.#set(row, row.value !== true);
235
+ return;
236
+ case "choice":
237
+ this.#openPick(row, control.options.map(pickItem), control.custom === true);
238
+ return;
239
+ case "number":
240
+ this.#openInput(row);
241
+ return;
242
+ case "text":
243
+ if (control.presets !== undefined) {
244
+ const items = control.presets.map(pickItem);
245
+ items.push({
246
+ value: null,
247
+ label: "Edit\u2026",
248
+ opens: control.multiline ? "edit" : "input",
249
+ });
250
+ this.#openPick(row, items, false);
251
+ } else if (control.multiline) this.#options.done({ kind: "edit", row });
252
+ else this.#openInput(row);
253
+ return;
254
+ case "list":
255
+ if (control.options === undefined) this.#openInput(row);
256
+ else this.#openCheck(row, control.options);
257
+ return;
258
+ }
259
+ }
260
+
261
+ #cycle(row: RowView): void {
262
+ const control = row.control;
263
+ if (control?.type === "toggle") {
264
+ this.#set(row, row.value !== true);
265
+ return;
266
+ }
267
+ if (control?.type === "choice" && control.options.length > 0) {
268
+ const at = control.options.findIndex((option) => sameJson(option.value, row.value));
269
+ const next = control.options[(at + 1) % control.options.length];
270
+ if (next !== undefined) this.#set(row, next.value);
271
+ return;
272
+ }
273
+ this.#activate(row);
274
+ }
275
+
276
+ #openPick(row: RowView, items: PickItem[], custom: boolean): void {
277
+ if (custom) items.push({ value: null, label: "Other\u2026", opens: "input" });
278
+ const found = items.findIndex(
279
+ (item) => item.opens === undefined && sameJson(item.value, row.value),
280
+ );
281
+ this.#mode = { kind: "pick", row, items, index: Math.max(0, found), current: row.value };
282
+ }
283
+
284
+ #openCheck(row: RowView, options: readonly ControlOption[]): void {
285
+ const checked = Array.isArray(row.value) ? [...row.value] : [];
286
+ const mode: CheckMode = {
287
+ kind: "check",
288
+ row,
289
+ items: options.map(pickItem),
290
+ checked,
291
+ index: 0,
292
+ };
293
+ this.#sortChecks(mode);
294
+ this.#mode = mode;
295
+ }
296
+
297
+ #openInput(row: RowView): void {
298
+ const input = new Input();
299
+ input.focused = true;
300
+ input.setValue(inputText(row));
301
+ // setValue leaves the cursor at the start.
302
+ input.handleInput("\u001b[F");
303
+ const mode: InputMode = { kind: "input", row, input };
304
+ input.onEscape = () => {
305
+ this.#mode = { kind: "browse" };
306
+ };
307
+ input.onSubmit = (text) => {
308
+ const parsed = parseInput(row.control, text);
309
+ if ("error" in parsed) {
310
+ mode.error = parsed.error;
311
+ return;
312
+ }
313
+ const error = this.#set(row, parsed.value);
314
+ if (error === undefined) this.#mode = { kind: "browse" };
315
+ else mode.error = error;
316
+ };
317
+ this.#mode = mode;
318
+ }
319
+
320
+ #set(row: RowView, value: Json): string | undefined {
321
+ const error = applyRow(this.#options.events, row, "set", value);
322
+ this.#refresh();
323
+ if (error !== undefined) this.#say(error, "error");
324
+ else
325
+ this.#say(`${row.label}: ${row.control ? formatValue(row.control, value) : ""}`, "success");
326
+ return error;
327
+ }
328
+
329
+ #unset(row: RowView): void {
330
+ if (row.kind !== "setting") {
331
+ this.#say("nothing to reset", "muted");
332
+ return;
333
+ }
334
+ if (row.layer === "default") {
335
+ this.#say("already the default", "muted");
336
+ return;
337
+ }
338
+ const error = applyRow(this.#options.events, row, "unset");
339
+ this.#refresh();
340
+ if (error !== undefined) this.#say(error, "error");
341
+ else
342
+ this.#say(
343
+ row.layer === "project" ? "dropped the project value" : "back to the default",
344
+ "success",
345
+ );
346
+ }
347
+
348
+ #run(row: RowView): void {
349
+ const key = keyOf(row);
350
+ if (this.#running.has(key)) return;
351
+ this.#running.add(key);
352
+ void runRow(this.#options.events, row).then((done) => {
353
+ this.#running.delete(key);
354
+ this.#refresh();
355
+ if (done.error !== undefined) this.#say(done.error, "error");
356
+ else if (done.text !== undefined)
357
+ this.#mode = { kind: "text", title: row.label, text: done.text, scroll: 0 };
358
+ else this.#say(`${row.label}: done`, "success");
359
+ this.#options.tui.requestRender();
360
+ });
361
+ }
362
+
363
+ #refresh(): void {
364
+ this.#options.model.refresh(listTabs(this.#options.events));
365
+ }
366
+
367
+ #say(text: string, tone: Tone): void {
368
+ this.#status = { text, tone };
369
+ }
370
+
371
+ #swap(mode: CheckMode, from: number, to: number): void {
372
+ const moved = mode.checked[from];
373
+ const other = mode.checked[to];
374
+ if (moved === undefined || other === undefined) return;
375
+ mode.checked[from] = other;
376
+ mode.checked[to] = moved;
377
+ this.#sortChecks(mode);
378
+ mode.index = to;
379
+ }
380
+
381
+ /** Checked values first, in their order, then the rest as the control lists them. */
382
+ #sortChecks(mode: CheckMode): void {
383
+ const highlighted = mode.items[mode.index]?.value;
384
+ const rank = (item: PickItem): number => {
385
+ const at = mode.checked.findIndex((value) => sameJson(value, item.value));
386
+ return at === -1 ? mode.checked.length + mode.items.indexOf(item) : at;
387
+ };
388
+ mode.items.sort((left, right) => rank(left) - rank(right));
389
+ const found = mode.items.findIndex((item) => sameJson(item.value, highlighted));
390
+ mode.index = Math.max(0, found);
391
+ }
392
+
393
+ render(width: number): string[] {
394
+ const { theme, tui } = this.#options;
395
+ const inner = Math.max(20, width - 2);
396
+ const height = Math.max(CHROME + MIN_BODY, tui.terminal.rows);
397
+ this.#bodyHeight = height - CHROME;
398
+
399
+ const border = (text: string): string => theme.fg("borderMuted", text);
400
+ const frame = (text: string): string =>
401
+ border("\u2502") + truncateToWidth(text, inner, "\u2026", true) + border("\u2502");
402
+
403
+ const left = this.#mode.kind === "text" || inner < 50 ? 0 : this.#leftWidth(inner);
404
+ const title = ` ${theme.fg("accent", theme.bold("Settings"))} `;
405
+ const lines = [
406
+ border("\u256d\u2500") +
407
+ title +
408
+ border(`${"\u2500".repeat(Math.max(0, inner - 1 - visibleWidth(title)))}\u256e`),
409
+ frame(this.#tabsLine(inner)),
410
+ border(divider("\u251c", "\u2524", inner, left, "\u252c")),
411
+ ];
412
+
413
+ const body = this.#body(inner, left);
414
+ for (const line of body) lines.push(frame(line));
415
+
416
+ lines.push(border(divider("\u251c", "\u2524", inner, left, "\u2534")));
417
+ for (const line of this.#panel(inner - 2)) lines.push(frame(` ${line}`));
418
+ lines.push(border(divider("\u251c", "\u2524", inner, 0, "")));
419
+ lines.push(frame(` ${theme.fg("dim", this.#hints())}`));
420
+ lines.push(border(`\u2570${"\u2500".repeat(inner)}\u256f`));
421
+ return lines;
422
+ }
423
+
424
+ #tabsLine(inner: number): string {
425
+ const { theme, model } = this.#options;
426
+ if (model.searching) {
427
+ const count = model.lines().filter((line) => line.kind === "row").length;
428
+ const found = count === 1 ? "1 match" : `${count} matches`;
429
+ return ` ${theme.fg("accent", "\u2315")} ${model.query}${theme.inverse(" ")} ${theme.fg("dim", found)}`;
430
+ }
431
+ const tabs = model.tabs.map((tab, index) =>
432
+ index === model.tab
433
+ ? theme.bg("selectedBg", theme.fg("accent", theme.bold(` ${tab.title} `)))
434
+ : theme.fg("muted", ` ${tab.title} `),
435
+ );
436
+ const hint = theme.fg("dim", "type to search ");
437
+ const used = visibleWidth(tabs.join(" ")) + 1;
438
+ const gap = Math.max(1, inner - used - visibleWidth(hint));
439
+ return ` ${tabs.join(" ")}${" ".repeat(gap)}${hint}`;
440
+ }
441
+
442
+ #leftWidth(inner: number): number {
443
+ const widest = Math.max(
444
+ 0,
445
+ ...this.#options.model.sections().map((entry) => visibleWidth(entry.title)),
446
+ );
447
+ return Math.min(Math.floor(inner / 3), Math.max(14, widest + 4));
448
+ }
449
+
450
+ #body(inner: number, left: number): string[] {
451
+ const mode = this.#mode;
452
+ if (mode.kind === "text") return this.#textBody(mode, inner);
453
+
454
+ const right = inner - (left === 0 ? 0 : left + 1);
455
+ const content =
456
+ mode.kind === "pick" || mode.kind === "check"
457
+ ? this.#pickLines(mode, right)
458
+ : this.#rowLines(right);
459
+ const sections = left === 0 ? [] : this.#sectionLines(left);
460
+
461
+ const out: string[] = [];
462
+ for (let index = 0; index < this.#bodyHeight; index++) {
463
+ const main = content[index] ?? "";
464
+ if (left === 0) {
465
+ out.push(main);
466
+ continue;
467
+ }
468
+ const side = truncateToWidth(sections[index] ?? "", left, "\u2026", true);
469
+ out.push(side + this.#options.theme.fg("borderMuted", "\u2502") + main);
470
+ }
471
+ return out;
472
+ }
473
+
474
+ #sectionLines(width: number): string[] {
475
+ const { theme, model } = this.#options;
476
+ return model.sections().map((entry) => {
477
+ const text = truncateToWidth(entry.title, width - 3, "\u2026");
478
+ return entry.active
479
+ ? ` ${theme.fg("accent", `\u25b8 ${theme.bold(text)}`)}`
480
+ : ` ${theme.fg("dim", text)}`;
481
+ });
482
+ }
483
+
484
+ #rowLines(width: number): string[] {
485
+ const { theme, model } = this.#options;
486
+ const lines = model.lines();
487
+ if (lines.length === 0) {
488
+ const empty = model.searching ? "nothing matches" : "no settings yet";
489
+ return ["", ` ${theme.fg("dim", empty)}`];
490
+ }
491
+
492
+ const height = this.#bodyHeight;
493
+ const cursor = model.cursorLine();
494
+ // Show the heading when the cursor is on a section's first row.
495
+ const top = lines[cursor - 1]?.kind === "heading" ? cursor - 1 : cursor;
496
+ if (top < this.#scroll) this.#scroll = Math.max(0, top);
497
+ if (cursor >= this.#scroll + height) this.#scroll = cursor - height + 1;
498
+ this.#scroll = Math.min(this.#scroll, Math.max(0, lines.length - height));
499
+
500
+ const labelWidth = Math.min(
501
+ Math.floor((width - 4) / 2),
502
+ Math.max(
503
+ 12,
504
+ ...lines.map((line) =>
505
+ line.kind === "row" ? visibleWidth(line.row.label) + (line.row.indent ?? 0) * 2 : 0,
506
+ ),
507
+ ) + 2,
508
+ );
509
+ const bar = scrollbar(lines.length, height, this.#scroll);
510
+
511
+ const out: string[] = [];
512
+ for (let index = 0; index < height; index++) {
513
+ const line = lines[this.#scroll + index];
514
+ const text =
515
+ line === undefined ? "" : this.#line(line, this.#scroll + index === cursor, labelWidth);
516
+ const mark = bar[index];
517
+ const edge = mark === true ? theme.fg("scrollbarThumb", "\u2503") : " ";
518
+ out.push(truncateToWidth(text, width - 1, "\u2026", true) + edge);
519
+ }
520
+ return out;
521
+ }
522
+
523
+ #line(line: Line, selected: boolean, labelWidth: number): string {
524
+ const { theme } = this.#options;
525
+ if (line.kind === "heading") return ` ${theme.underline(theme.fg("muted", line.title))}`;
526
+
527
+ const row = line.row;
528
+ const indent = " ".repeat(row.indent ?? 0);
529
+ const cursor = selected ? theme.fg("accent", "\u276f ") : " ";
530
+ const room = Math.max(1, labelWidth - 1 - visibleWidth(indent));
531
+ const label = truncateToWidth(row.label, room, "\u2026");
532
+ const pad = " ".repeat(Math.max(1, labelWidth - visibleWidth(indent) - visibleWidth(label)));
533
+ const labelText = selected
534
+ ? theme.fg("accent", theme.bold(label))
535
+ : theme.fg(row.kind === "info" ? "muted" : "text", label);
536
+ return ` ${cursor}${indent}${labelText}${pad}${this.#value(row, selected)}`;
537
+ }
538
+
539
+ #value(row: RowView, selected: boolean): string {
540
+ const { theme } = this.#options;
541
+ if (row.kind === "action") {
542
+ if (this.#running.has(keyOf(row))) return theme.fg("accent", "running\u2026");
543
+ if (row.text === undefined) return theme.fg(selected ? "accent" : "dim", "\u21b5 run");
544
+ return theme.fg("muted", row.text) + (selected ? theme.fg("dim", " \u21b5 run") : "");
545
+ }
546
+ if (row.kind === "info" || row.control === undefined) return theme.fg("dim", row.text ?? "");
547
+
548
+ const shown = formatValue(row.control, row.value) || theme.fg("dim", "(empty)");
549
+ const color = valueColor(row.layer);
550
+ const value = selected
551
+ ? theme.bold(theme.fg(color === "muted" ? "text" : color, shown))
552
+ : theme.fg(color, shown);
553
+ return value + this.#tags(row);
554
+ }
555
+
556
+ #tags(row: RowView): string {
557
+ const { theme } = this.#options;
558
+
559
+ let tags = "";
560
+ if (row.layer === "project") tags += theme.fg("warning", " \u25c6 project");
561
+ if (row.restart === true) tags += theme.fg("dim", " \u21bb");
562
+ return tags;
563
+ }
564
+
565
+ #pickLines(mode: PickMode | CheckMode, width: number): string[] {
566
+ const { theme } = this.#options;
567
+ const out = [` ${theme.underline(theme.fg("muted", mode.row.label))}`];
568
+ const height = this.#bodyHeight - 1;
569
+ const top = Math.max(0, Math.min(mode.index - height + 1, mode.items.length - height));
570
+ const labelWidth = Math.min(
571
+ 34,
572
+ Math.floor(width / 2),
573
+ Math.max(...mode.items.map((item) => visibleWidth(item.label))) + 6,
574
+ );
575
+
576
+ mode.items.slice(top, top + height).forEach((item, offset) => {
577
+ const index = top + offset;
578
+ const selected = index === mode.index;
579
+ const cursor = selected ? theme.fg("accent", "\u276f ") : " ";
580
+ let mark: string;
581
+ if (mode.kind === "check") {
582
+ const at = mode.checked.findIndex((value) => sameJson(value, item.value));
583
+ mark = at === -1 ? theme.fg("dim", "[ ] ") : theme.fg("accent", `[${at + 1}] `);
584
+ } else {
585
+ const current = item.opens === undefined && sameJson(item.value, mode.current);
586
+ mark = current ? theme.fg("accent", "\u25cf ") : " ";
587
+ }
588
+ const label = selected
589
+ ? theme.fg("accent", theme.bold(item.label))
590
+ : theme.fg("text", item.label);
591
+ const pad = " ".repeat(
592
+ Math.max(1, labelWidth - visibleWidth(mark) - visibleWidth(item.label)),
593
+ );
594
+ const description = item.description === undefined ? "" : theme.fg("dim", item.description);
595
+ out.push(` ${cursor}${mark}${label}${pad}${description}`);
596
+ });
597
+ return out;
598
+ }
599
+
600
+ #textBody(mode: TextMode, inner: number): string[] {
601
+ const { theme } = this.#options;
602
+ const wrapped = mode.text
603
+ .split("\n")
604
+ .flatMap((line) => (line === "" ? [""] : wrapTextWithAnsi(line, inner - 4)));
605
+ const height = this.#bodyHeight - 2;
606
+ mode.scroll = Math.min(mode.scroll, Math.max(0, wrapped.length - height));
607
+ const out = [` ${theme.underline(theme.fg("muted", mode.title))}`, ""];
608
+ for (const line of wrapped.slice(mode.scroll, mode.scroll + height)) out.push(` ${line}`);
609
+ while (out.length < this.#bodyHeight) out.push("");
610
+ return out;
611
+ }
612
+
613
+ #panel(width: number): string[] {
614
+ const { theme, model } = this.#options;
615
+ const mode = this.#mode;
616
+ const status = this.#status === undefined ? "" : theme.fg(this.#status.tone, this.#status.text);
617
+
618
+ if (mode.kind === "input") {
619
+ const error = mode.error === undefined ? "" : theme.fg("error", mode.error);
620
+ return [
621
+ theme.fg("muted", `${mode.row.label}, new value:`),
622
+ ...mode.input.render(width),
623
+ error || theme.fg("dim", inputHint(mode.row.control)),
624
+ "",
625
+ ].slice(0, PANEL);
626
+ }
627
+
628
+ let description = "";
629
+ let meta = "";
630
+ if (mode.kind === "pick" || mode.kind === "check") {
631
+ description = mode.items[mode.index]?.description ?? mode.row.description;
632
+ meta = mode.kind === "check" ? "[1] shows first" : "";
633
+ } else if (mode.kind === "browse") {
634
+ const row = model.selected()?.row;
635
+ description = row?.description ?? "";
636
+ meta = row === undefined ? "" : rowMeta(row);
637
+ }
638
+
639
+ const wrapped = wrapTextWithAnsi(description, width).slice(0, 2);
640
+ while (wrapped.length < 2) wrapped.push("");
641
+ return [...wrapped.map((line) => theme.fg("text", line)), theme.fg("dim", meta), status];
642
+ }
643
+
644
+ #hints(): string {
645
+ const mode = this.#mode;
646
+ switch (mode.kind) {
647
+ case "pick":
648
+ return "\u2191\u2193 move \u21b5 pick esc back";
649
+ case "check":
650
+ return "\u2191\u2193 move space check alt+\u2191\u2193 reorder \u21b5 save esc back";
651
+ case "input":
652
+ return "\u21b5 save esc cancel";
653
+ case "text":
654
+ return "\u2191\u2193 scroll esc back";
655
+ case "browse":
656
+ break;
657
+ }
658
+
659
+ const model = this.#options.model;
660
+ const row = model.selected()?.row;
661
+ const parts = ["\u2191\u2193 move"];
662
+ if (row?.kind === "action") parts.push("\u21b5 run");
663
+ else if (row?.control?.type === "toggle") parts.push("\u21b5 flip");
664
+ else if (row?.control !== undefined) parts.push("\u21b5 change");
665
+ if (row?.control?.type === "choice" && !model.searching) parts.push("space next");
666
+ if (row?.kind === "setting" && row.layer === "project") parts.push("del drop project value");
667
+ else if (row?.kind === "setting" && row.layer === "global") parts.push("del reset");
668
+ if (!model.searching) parts.push("\u2190\u2192 tabs", "\u21e5 section");
669
+ parts.push(model.searching ? "esc clear search" : "esc close");
670
+ return parts.join(" ");
671
+ }
672
+ }
673
+
674
+ function divider(start: string, end: string, inner: number, left: number, joint: string): string {
675
+ if (left === 0 || joint === "") return `${start}${"\u2500".repeat(inner)}${end}`;
676
+ return `${start}${"\u2500".repeat(left)}${joint}${"\u2500".repeat(inner - left - 1)}${end}`;
677
+ }
678
+
679
+ /** True where the thumb is. Empty when everything fits. */
680
+ function scrollbar(total: number, height: number, scroll: number): (boolean | undefined)[] {
681
+ if (total <= height) return [];
682
+ const size = Math.max(1, Math.round((height * height) / total));
683
+ const start = Math.round((scroll / (total - height)) * (height - size));
684
+ return Array.from({ length: height }, (_, index) => index >= start && index < start + size);
685
+ }
686
+
687
+ function rowMeta(row: RowView): string {
688
+ const parts: string[] = [];
689
+ if (row.kind === "setting" && row.control !== undefined) {
690
+ parts.push(row.id);
691
+ const fallback = formatValue(row.control, row.fallback) || "(empty)";
692
+ if (row.layer === "project") {
693
+ const global = formatValue(row.control, row.hidden) || "(empty)";
694
+ parts.push(`this project sets it, global is ${global}`);
695
+ }
696
+ parts.push(`default ${fallback}`);
697
+ } else if (row.kind === "value") parts.push(row.meta ?? "this session only");
698
+ if (row.restart === true) parts.push("applies after /reload");
699
+ return parts.join(", ");
700
+ }
701
+
702
+ function valueColor(layer: Layer | undefined): "accent" | "warning" | "muted" | "text" {
703
+ switch (layer) {
704
+ case "global":
705
+ return "accent";
706
+ case "project":
707
+ return "warning";
708
+ case "default":
709
+ return "muted";
710
+ case undefined:
711
+ return "text";
712
+ }
713
+ }
714
+
715
+ function step(mode: PickMode | CheckMode, delta: number): void {
716
+ mode.index = Math.min(mode.items.length - 1, Math.max(0, mode.index + delta));
717
+ }
718
+
719
+ function pickItem(option: ControlOption): PickItem {
720
+ const item: PickItem = { value: option.value, label: optionText(option) };
721
+ if (option.description !== undefined) item.description = option.description;
722
+ return item;
723
+ }
724
+
725
+ function inputText(row: RowView): string {
726
+ const value = row.value;
727
+ if (value === undefined || value === null) return "";
728
+ if (Array.isArray(value)) return value.join(", ");
729
+ return typeof value === "string" ? value : String(value);
730
+ }
731
+
732
+ function inputHint(control: Control | undefined): string {
733
+ if (control?.type === "number") {
734
+ let range = "a number";
735
+ if (control.min !== undefined && control.max !== undefined)
736
+ range = `from ${control.min} to ${control.max}`;
737
+ else if (control.min !== undefined) range = `${control.min} or more`;
738
+ return control.nullable ? `${range}, or empty for none` : range;
739
+ }
740
+ if (control?.type === "list") return "comma separated";
741
+ return "";
742
+ }
743
+
744
+ function parseInput(
745
+ control: Control | undefined,
746
+ text: string,
747
+ ): { value: Json } | { error: string } {
748
+ const trimmed = text.trim();
749
+ if (control?.type === "number") {
750
+ if (trimmed === "" && control.nullable) return { value: null };
751
+ const value = Number(trimmed);
752
+ if (trimmed === "" || !Number.isFinite(value)) return { error: "not a number" };
753
+ return { value };
754
+ }
755
+ if (control?.type === "list") {
756
+ return {
757
+ value: trimmed
758
+ .split(",")
759
+ .map((entry) => entry.trim())
760
+ .filter((entry) => entry !== ""),
761
+ };
762
+ }
763
+ return { value: text };
764
+ }
765
+
766
+ function printable(data: string): string | undefined {
767
+ const kitty = decodeKittyPrintable(data);
768
+ if (kitty !== undefined) return kitty;
769
+ if (data.length === 1 && data >= " " && data !== "\u007f") return data;
770
+ return undefined;
771
+ }