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