@prometheus-ai/tui 0.5.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +704 -0
  3. package/dist/types/autocomplete.d.ts +76 -0
  4. package/dist/types/bracketed-paste.d.ts +26 -0
  5. package/dist/types/components/box.d.ts +17 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +105 -0
  8. package/dist/types/components/image.d.ts +84 -0
  9. package/dist/types/components/input.d.ts +18 -0
  10. package/dist/types/components/loader.d.ts +13 -0
  11. package/dist/types/components/markdown.d.ts +61 -0
  12. package/dist/types/components/scroll-view.d.ts +40 -0
  13. package/dist/types/components/select-list.d.ts +48 -0
  14. package/dist/types/components/settings-list.d.ts +41 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +56 -0
  17. package/dist/types/components/text.d.ts +13 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/editor-component.d.ts +36 -0
  21. package/dist/types/fuzzy.d.ts +15 -0
  22. package/dist/types/index.d.ts +28 -0
  23. package/dist/types/keybindings.d.ts +189 -0
  24. package/dist/types/keys.d.ts +208 -0
  25. package/dist/types/kill-ring.d.ts +27 -0
  26. package/dist/types/kitty-graphics.d.ts +94 -0
  27. package/dist/types/stdin-buffer.d.ts +43 -0
  28. package/dist/types/symbols.d.ts +25 -0
  29. package/dist/types/terminal-capabilities.d.ts +196 -0
  30. package/dist/types/terminal.d.ts +103 -0
  31. package/dist/types/ttyid.d.ts +9 -0
  32. package/dist/types/tui.d.ts +275 -0
  33. package/dist/types/utils.d.ts +89 -0
  34. package/package.json +73 -0
  35. package/src/autocomplete.ts +871 -0
  36. package/src/bracketed-paste.ts +47 -0
  37. package/src/components/box.ts +156 -0
  38. package/src/components/cancellable-loader.ts +40 -0
  39. package/src/components/editor.ts +2695 -0
  40. package/src/components/image.ts +318 -0
  41. package/src/components/input.ts +459 -0
  42. package/src/components/loader.ts +86 -0
  43. package/src/components/markdown.ts +1189 -0
  44. package/src/components/scroll-view.ts +166 -0
  45. package/src/components/select-list.ts +331 -0
  46. package/src/components/settings-list.ts +212 -0
  47. package/src/components/spacer.ts +28 -0
  48. package/src/components/tab-bar.ts +175 -0
  49. package/src/components/text.ts +110 -0
  50. package/src/components/truncated-text.ts +61 -0
  51. package/src/deccara.ts +314 -0
  52. package/src/editor-component.ts +71 -0
  53. package/src/fuzzy.ts +143 -0
  54. package/src/index.ts +44 -0
  55. package/src/keybindings.ts +279 -0
  56. package/src/keys.ts +537 -0
  57. package/src/kill-ring.ts +46 -0
  58. package/src/kitty-graphics.ts +270 -0
  59. package/src/stdin-buffer.ts +423 -0
  60. package/src/symbols.ts +26 -0
  61. package/src/terminal-capabilities.ts +1009 -0
  62. package/src/terminal.ts +1114 -0
  63. package/src/ttyid.ts +70 -0
  64. package/src/tui.ts +2988 -0
  65. package/src/utils.ts +452 -0
@@ -0,0 +1,166 @@
1
+ import type { Component } from "../tui";
2
+ import { Ellipsis, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
3
+
4
+ const DEFAULT_TRACK = "│";
5
+ const DEFAULT_THUMB = "█";
6
+
7
+ type ScrollbarMode = "auto" | "always" | "never";
8
+
9
+ export interface ScrollViewTheme {
10
+ track?: (text: string) => string;
11
+ thumb?: (text: string) => string;
12
+ }
13
+
14
+ export interface ScrollViewOptions {
15
+ height: number;
16
+ /** Defaults to "auto". "auto" reserves a scrollbar column only when content overflows. */
17
+ scrollbar?: ScrollbarMode | boolean;
18
+ /** Logical row count for pre-windowed line slices. Defaults to lines.length. */
19
+ totalRows?: number;
20
+ theme?: ScrollViewTheme;
21
+ trackChar?: string;
22
+ thumbChar?: string;
23
+ }
24
+
25
+ function normalizeScrollbarMode(scrollbar: ScrollViewOptions["scrollbar"]): ScrollbarMode {
26
+ if (scrollbar === true) return "auto";
27
+ if (scrollbar === false) return "never";
28
+ return scrollbar ?? "auto";
29
+ }
30
+
31
+ function firstCellGlyph(value: string, fallback: string): string {
32
+ const glyph = Array.from(value)[0] ?? fallback;
33
+ return visibleWidth(glyph) === 1 ? glyph : fallback;
34
+ }
35
+
36
+ /**
37
+ * Fixed-height viewport over pre-rendered lines, with optional right-edge scrollbar.
38
+ *
39
+ * ScrollView owns only the row offset. Callers remain responsible for producing
40
+ * already-wrapped logical lines appropriate for the current render width.
41
+ */
42
+ export class ScrollView implements Component {
43
+ #lines: string[];
44
+ #height: number;
45
+ #scrollOffset = 0;
46
+ #totalRows: number | undefined;
47
+ #scrollbar: ScrollbarMode;
48
+ #theme: Required<ScrollViewTheme>;
49
+ #trackChar: string;
50
+ #thumbChar: string;
51
+
52
+ constructor(lines: readonly string[], options: ScrollViewOptions) {
53
+ this.#lines = [...lines];
54
+ this.#height = Number.isFinite(options.height) ? Math.max(0, Math.trunc(options.height)) : 0;
55
+ this.#totalRows = options.totalRows === undefined ? undefined : Math.max(0, Math.trunc(options.totalRows));
56
+ this.#scrollbar = normalizeScrollbarMode(options.scrollbar);
57
+ this.#theme = {
58
+ track: options.theme?.track ?? (text => text),
59
+ thumb: options.theme?.thumb ?? (text => text),
60
+ };
61
+ this.#trackChar = firstCellGlyph(options.trackChar ?? DEFAULT_TRACK, DEFAULT_TRACK);
62
+ this.#thumbChar = firstCellGlyph(options.thumbChar ?? DEFAULT_THUMB, DEFAULT_THUMB);
63
+ this.#clampScrollOffset();
64
+ }
65
+
66
+ setLines(lines: readonly string[]): void {
67
+ this.#lines = [...lines];
68
+ this.#clampScrollOffset();
69
+ }
70
+
71
+ setTotalRows(totalRows: number | undefined): void {
72
+ this.#totalRows = totalRows === undefined ? undefined : Math.max(0, Math.trunc(totalRows));
73
+ this.#clampScrollOffset();
74
+ }
75
+
76
+ setHeight(height: number): void {
77
+ this.#height = Number.isFinite(height) ? Math.max(0, Math.trunc(height)) : 0;
78
+ this.#clampScrollOffset();
79
+ }
80
+
81
+ setScrollbar(scrollbar: ScrollViewOptions["scrollbar"]): void {
82
+ this.#scrollbar = normalizeScrollbarMode(scrollbar);
83
+ }
84
+
85
+ getScrollOffset(): number {
86
+ return this.#scrollOffset;
87
+ }
88
+
89
+ getMaxScrollOffset(): number {
90
+ const rowCount = this.#totalRows ?? this.#lines.length;
91
+ return Math.max(0, rowCount - this.#height);
92
+ }
93
+
94
+ setScrollOffset(offset: number): void {
95
+ this.#scrollOffset = Number.isFinite(offset) ? Math.trunc(offset) : 0;
96
+ this.#clampScrollOffset();
97
+ }
98
+
99
+ scroll(delta: number): void {
100
+ this.setScrollOffset(this.#scrollOffset + (Number.isFinite(delta) ? Math.trunc(delta) : 0));
101
+ }
102
+
103
+ page(delta: number): void {
104
+ const step = Math.max(1, this.#height - 1);
105
+ this.scroll(step * (Number.isFinite(delta) ? Math.trunc(delta) : 0));
106
+ }
107
+
108
+ scrollToTop(): void {
109
+ this.#scrollOffset = 0;
110
+ }
111
+
112
+ scrollToBottom(): void {
113
+ this.#scrollOffset = this.getMaxScrollOffset();
114
+ }
115
+
116
+ invalidate(): void {
117
+ // No cached layout to invalidate.
118
+ }
119
+
120
+ render(width: number): string[] {
121
+ this.#clampScrollOffset();
122
+ const safeWidth = Number.isFinite(width) ? Math.max(0, Math.trunc(width)) : 0;
123
+ if (this.#height === 0) return [];
124
+ const showScrollbar = safeWidth > 0 && this.#shouldRenderScrollbar();
125
+ const contentWidth = Math.max(0, safeWidth - (showScrollbar ? 1 : 0));
126
+ const thumb = showScrollbar ? this.#thumbRange() : undefined;
127
+ const lines: string[] = [];
128
+ for (let row = 0; row < this.#height; row++) {
129
+ const sourceIndex = this.#totalRows === undefined ? this.#scrollOffset + row : row;
130
+ const source = this.#lines[sourceIndex] ?? "";
131
+ const truncated = truncateToWidth(replaceTabs(source), contentWidth, Ellipsis.Unicode);
132
+ if (!showScrollbar) {
133
+ lines.push(truncated);
134
+ continue;
135
+ }
136
+ const content = `${truncated}${" ".repeat(Math.max(0, contentWidth - visibleWidth(truncated)))}`;
137
+ const barGlyph = thumb && row >= thumb.start && row < thumb.end ? this.#thumbChar : this.#trackChar;
138
+ const styledBar =
139
+ thumb && row >= thumb.start && row < thumb.end ? this.#theme.thumb(barGlyph) : this.#theme.track(barGlyph);
140
+ lines.push(`${content}${styledBar}`);
141
+ }
142
+ return lines;
143
+ }
144
+
145
+ #clampScrollOffset(): void {
146
+ this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, this.getMaxScrollOffset()));
147
+ }
148
+
149
+ #shouldRenderScrollbar(): boolean {
150
+ if (this.#height <= 0) return false;
151
+ if (this.#scrollbar === "never") return false;
152
+ if (this.#scrollbar === "always") return true;
153
+ return (this.#totalRows ?? this.#lines.length) > this.#height;
154
+ }
155
+
156
+ #thumbRange(): { start: number; end: number } {
157
+ if (this.#height <= 0) return { start: 0, end: 0 };
158
+ const rowCount = this.#totalRows ?? this.#lines.length;
159
+ if (rowCount <= this.#height) return { start: 0, end: this.#height };
160
+ const thumbSize = Math.max(1, Math.min(Math.floor((this.#height * this.#height) / rowCount), this.#height));
161
+ const travel = this.#height - thumbSize;
162
+ const maxOffset = this.getMaxScrollOffset();
163
+ const start = maxOffset === 0 ? 0 : Math.round((this.#scrollOffset / maxOffset) * travel);
164
+ return { start, end: start + thumbSize };
165
+ }
166
+ }
@@ -0,0 +1,331 @@
1
+ import { fuzzyFilter } from "../fuzzy";
2
+ import { getKeybindings } from "../keybindings";
3
+ import { extractPrintableText } from "../keys";
4
+ import type { SymbolTheme } from "../symbols";
5
+ import type { Component } from "../tui";
6
+ import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
7
+ import { ScrollView } from "./scroll-view";
8
+
9
+ const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
10
+ const PRIMARY_COLUMN_GAP = 2;
11
+ const MIN_DESCRIPTION_WIDTH = 10;
12
+
13
+ function sanitizeSingleLine(text: string): string {
14
+ return replaceTabs(text)
15
+ .replace(/[\r\n]+/g, " ")
16
+ .replace(/\s+/g, " ")
17
+ .trim();
18
+ }
19
+
20
+ const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));
21
+
22
+ export interface SelectItem {
23
+ value: string;
24
+ label: string;
25
+ description?: string;
26
+ /** Dim hint text shown inline after cursor when this item is selected */
27
+ hint?: string;
28
+ }
29
+
30
+ export interface SelectListTheme {
31
+ selectedPrefix: (text: string) => string;
32
+ selectedText: (text: string) => string;
33
+ description: (text: string) => string;
34
+ scrollInfo: (text: string) => string;
35
+ noMatch: (text: string) => string;
36
+ symbols: SymbolTheme;
37
+ }
38
+
39
+ export interface SelectListTruncatePrimaryContext {
40
+ text: string;
41
+ maxWidth: number;
42
+ columnWidth: number;
43
+ item: SelectItem;
44
+ isSelected: boolean;
45
+ }
46
+
47
+ export interface SelectListLayoutOptions {
48
+ minPrimaryColumnWidth?: number;
49
+ maxPrimaryColumnWidth?: number;
50
+ truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
51
+ /** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
52
+ overflowSearch?: boolean;
53
+ }
54
+
55
+ export class SelectList implements Component {
56
+ #filteredItems: ReadonlyArray<SelectItem>;
57
+ #filterQuery = "";
58
+ #selectedIndex: number = 0;
59
+
60
+ onSelect?: (item: SelectItem) => void;
61
+ onCancel?: () => void;
62
+ onSelectionChange?: (item: SelectItem) => void;
63
+
64
+ constructor(
65
+ private readonly items: ReadonlyArray<SelectItem>,
66
+ private readonly maxVisible: number,
67
+ private readonly theme: SelectListTheme,
68
+ private readonly layout: SelectListLayoutOptions = {},
69
+ ) {
70
+ this.#filteredItems = items;
71
+ }
72
+
73
+ setFilter(filter: string): void {
74
+ this.#setFilter(filter, true);
75
+ }
76
+
77
+ setSelectedIndex(index: number): void {
78
+ this.#selectedIndex = Math.max(0, Math.min(index, this.#filteredItems.length - 1));
79
+ }
80
+
81
+ invalidate(): void {
82
+ // No cached state to invalidate currently
83
+ }
84
+
85
+ render(width: number): string[] {
86
+ const lines: string[] = [];
87
+ const showSearchStatus = this.#shouldRenderSearchStatus();
88
+
89
+ // If no items match filter, show message
90
+ if (this.#filteredItems.length === 0) {
91
+ if (showSearchStatus) {
92
+ lines.push(this.#renderStatusLine(width));
93
+ }
94
+ lines.push(this.theme.noMatch(" No matching items"));
95
+ return lines;
96
+ }
97
+
98
+ const primaryColumnWidth = this.#getPrimaryColumnWidth();
99
+
100
+ // Calculate visible range with scrolling
101
+ const startIndex = Math.max(
102
+ 0,
103
+ Math.min(this.#selectedIndex - Math.floor(this.maxVisible / 2), this.#filteredItems.length - this.maxVisible),
104
+ );
105
+ const endIndex = Math.min(startIndex + this.maxVisible, this.#filteredItems.length);
106
+
107
+ // Render visible items
108
+ const overflow = this.#filteredItems.length > this.maxVisible;
109
+ const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
110
+ const rows: string[] = [];
111
+ for (let i = startIndex; i < endIndex; i++) {
112
+ const item = this.#filteredItems[i];
113
+ if (!item) continue;
114
+
115
+ const isSelected = i === this.#selectedIndex;
116
+ const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
117
+ rows.push(this.#renderItem(item, isSelected, rowWidth, descriptionText, primaryColumnWidth));
118
+ }
119
+
120
+ const sv = new ScrollView(rows, {
121
+ height: rows.length,
122
+ scrollbar: "auto",
123
+ totalRows: this.#filteredItems.length,
124
+ theme: { track: t => this.theme.scrollInfo(t), thumb: t => this.theme.selectedPrefix(t) },
125
+ });
126
+ sv.setScrollOffset(startIndex);
127
+ lines.push(...sv.render(width));
128
+
129
+ // Add search status when relevant (scrollbar now indicates overflow)
130
+ if (showSearchStatus) {
131
+ lines.push(this.#renderStatusLine(width));
132
+ }
133
+
134
+ return lines;
135
+ }
136
+
137
+ handleInput(keyData: string): void {
138
+ const kb = getKeybindings();
139
+ // Escape or Ctrl+C
140
+ if (kb.matches(keyData, "tui.select.cancel")) {
141
+ if (this.onCancel) {
142
+ this.onCancel();
143
+ }
144
+ return;
145
+ }
146
+
147
+ if (this.#handleSearchInput(keyData)) {
148
+ return;
149
+ }
150
+
151
+ if (this.#filteredItems.length === 0) return;
152
+ // Up arrow - wrap to bottom when at top
153
+ if (kb.matches(keyData, "tui.select.up")) {
154
+ this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
155
+ this.#notifySelectionChange();
156
+ }
157
+ // Down arrow - wrap to top when at bottom
158
+ else if (kb.matches(keyData, "tui.select.down")) {
159
+ this.#selectedIndex = this.#selectedIndex === this.#filteredItems.length - 1 ? 0 : this.#selectedIndex + 1;
160
+ this.#notifySelectionChange();
161
+ }
162
+ // PageUp - jump up by one visible page
163
+ else if (kb.matches(keyData, "tui.select.pageUp")) {
164
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisible);
165
+ this.#notifySelectionChange();
166
+ }
167
+ // PageDown - jump down by one visible page
168
+ else if (kb.matches(keyData, "tui.select.pageDown")) {
169
+ this.#selectedIndex = Math.min(this.#filteredItems.length - 1, this.#selectedIndex + this.maxVisible);
170
+ this.#notifySelectionChange();
171
+ }
172
+ // Enter
173
+ else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
174
+ const selectedItem = this.#filteredItems[this.#selectedIndex];
175
+ if (selectedItem && this.onSelect) {
176
+ this.onSelect(selectedItem);
177
+ }
178
+ }
179
+ }
180
+
181
+ #renderItem(
182
+ item: SelectItem,
183
+ isSelected: boolean,
184
+ width: number,
185
+ descriptionSingleLine: string | undefined,
186
+ primaryColumnWidth: number,
187
+ ): string {
188
+ const prefix = isSelected
189
+ ? `${this.theme.symbols.cursor} `
190
+ : padding(visibleWidth(this.theme.symbols.cursor) + 1);
191
+ const prefixWidth = visibleWidth(prefix);
192
+
193
+ if (descriptionSingleLine && width > 40) {
194
+ const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
195
+ const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);
196
+ const truncatedValue = this.#truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);
197
+ const truncatedValueWidth = visibleWidth(truncatedValue);
198
+ const spacing = padding(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));
199
+ const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;
200
+ const remainingWidth = width - descriptionStart - 2; // -2 for safety
201
+
202
+ if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
203
+ const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
204
+ if (isSelected) {
205
+ return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
206
+ }
207
+
208
+ const descText = this.theme.description(spacing + truncatedDesc);
209
+ return prefix + truncatedValue + descText;
210
+ }
211
+ }
212
+
213
+ const maxWidth = width - prefixWidth - 2;
214
+ const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
215
+ if (isSelected) {
216
+ return this.theme.selectedText(`${prefix}${truncatedValue}`);
217
+ }
218
+
219
+ return prefix + truncatedValue;
220
+ }
221
+
222
+ #getPrimaryColumnWidth(): number {
223
+ const { min, max } = this.#getPrimaryColumnBounds();
224
+ const widestPrimary = this.#filteredItems.reduce((widest, item) => {
225
+ return Math.max(widest, visibleWidth(this.#getDisplayValue(item)) + PRIMARY_COLUMN_GAP);
226
+ }, 0);
227
+
228
+ return clamp(widestPrimary, min, max);
229
+ }
230
+
231
+ #getPrimaryColumnBounds(): { min: number; max: number } {
232
+ const rawMin =
233
+ this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
234
+ const rawMax =
235
+ this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
236
+
237
+ return {
238
+ min: Math.max(1, Math.min(rawMin, rawMax)),
239
+ max: Math.max(1, Math.max(rawMin, rawMax)),
240
+ };
241
+ }
242
+
243
+ #truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {
244
+ const displayValue = this.#getDisplayValue(item);
245
+ const truncatedValue = this.layout.truncatePrimary
246
+ ? this.layout.truncatePrimary({
247
+ text: displayValue,
248
+ maxWidth,
249
+ columnWidth,
250
+ item,
251
+ isSelected,
252
+ })
253
+ : truncateToWidth(displayValue, maxWidth, Ellipsis.Omit);
254
+
255
+ return truncateToWidth(truncatedValue, maxWidth, Ellipsis.Omit);
256
+ }
257
+
258
+ #getDisplayValue(item: SelectItem): string {
259
+ return sanitizeSingleLine(item.label || item.value);
260
+ }
261
+
262
+ #renderStatusLine(width: number): string {
263
+ const query = sanitizeSingleLine(this.#filterQuery);
264
+ const statusText = query ? ` Search: ${query}` : " Type to search";
265
+ return this.theme.scrollInfo(truncateToWidth(statusText, Math.max(1, width - 2), Ellipsis.Omit));
266
+ }
267
+
268
+ #shouldRenderSearchStatus(): boolean {
269
+ return (
270
+ this.layout.overflowSearch !== false && (this.items.length > this.maxVisible || this.#filterQuery.length > 0)
271
+ );
272
+ }
273
+
274
+ #canEditSearch(): boolean {
275
+ return this.layout.overflowSearch !== false && this.items.length > this.maxVisible;
276
+ }
277
+
278
+ #handleSearchInput(keyData: string): boolean {
279
+ if (!this.#canEditSearch()) return false;
280
+
281
+ const kb = getKeybindings();
282
+ if (kb.matches(keyData, "tui.editor.deleteCharBackward")) {
283
+ if (this.#filterQuery.length === 0) return false;
284
+ const chars = [...this.#filterQuery];
285
+ chars.pop();
286
+ this.#setFilter(chars.join(""), true);
287
+ return true;
288
+ }
289
+
290
+ const printableText = extractPrintableText(keyData);
291
+ if (printableText === undefined) return false;
292
+ if (this.#filterQuery.length === 0 && printableText.trim().length === 0) return false;
293
+
294
+ this.#setFilter(this.#filterQuery + printableText, true);
295
+ return true;
296
+ }
297
+
298
+ #setFilter(filter: string, notify: boolean): void {
299
+ this.#filterQuery = filter;
300
+ this.#filteredItems = filter.trim()
301
+ ? fuzzyFilter([...this.items], filter, item => this.#getFilterText(item))
302
+ : this.items;
303
+ this.#selectedIndex = 0;
304
+ if (notify) {
305
+ this.#notifySelectionChange();
306
+ }
307
+ }
308
+
309
+ #getFilterText(item: SelectItem): string {
310
+ let text = `${item.label} ${item.value}`;
311
+ if (item.description) {
312
+ text += ` ${item.description}`;
313
+ }
314
+ if (item.hint) {
315
+ text += ` ${item.hint}`;
316
+ }
317
+ return sanitizeSingleLine(text);
318
+ }
319
+
320
+ #notifySelectionChange(): void {
321
+ const selectedItem = this.#filteredItems[this.#selectedIndex];
322
+ if (selectedItem && this.onSelectionChange) {
323
+ this.onSelectionChange(selectedItem);
324
+ }
325
+ }
326
+
327
+ getSelectedItem(): SelectItem | null {
328
+ const item = this.#filteredItems[this.#selectedIndex];
329
+ return item || null;
330
+ }
331
+ }
@@ -0,0 +1,212 @@
1
+ import { getKeybindings } from "../keybindings";
2
+ import type { Component } from "../tui";
3
+ import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
4
+ import { ScrollView } from "./scroll-view";
5
+
6
+ export interface SettingItem {
7
+ /** Unique identifier for this setting */
8
+ id: string;
9
+ /** Display label (left side) */
10
+ label: string;
11
+ /** Optional description shown when selected */
12
+ description?: string;
13
+ /** Current value to display (right side) */
14
+ currentValue: string;
15
+ /** If provided, Enter/Space cycles through these values */
16
+ values?: string[];
17
+ /** If provided, Enter opens this submenu. Receives current value and done callback. */
18
+ submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
19
+ /** True when the displayed setting differs from its default value. */
20
+ changed?: boolean;
21
+ }
22
+
23
+ export interface SettingsListTheme {
24
+ label: (text: string, selected: boolean, changed: boolean) => string;
25
+ value: (text: string, selected: boolean, changed: boolean) => string;
26
+ description: (text: string) => string;
27
+ cursor: string;
28
+ hint: (text: string) => string;
29
+ }
30
+
31
+ export class SettingsList implements Component {
32
+ #items: SettingItem[];
33
+ #theme: SettingsListTheme;
34
+ #selectedIndex = 0;
35
+ #maxVisible: number;
36
+ #onChange: (id: string, newValue: string) => void;
37
+ #onCancel: () => void;
38
+
39
+ // Submenu state
40
+ #submenuComponent: Component | null = null;
41
+ #submenuItemIndex: number | null = null;
42
+
43
+ constructor(
44
+ items: SettingItem[],
45
+ maxVisible: number,
46
+ theme: SettingsListTheme,
47
+ onChange: (id: string, newValue: string) => void,
48
+ onCancel: () => void,
49
+ ) {
50
+ this.#items = items;
51
+ this.#maxVisible = maxVisible;
52
+ this.#theme = theme;
53
+ this.#onChange = onChange;
54
+ this.#onCancel = onCancel;
55
+ }
56
+
57
+ /** Update an item's currentValue */
58
+ updateValue(id: string, newValue: string): void {
59
+ const item = this.#items.find(i => i.id === id);
60
+ if (item) {
61
+ item.currentValue = newValue;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Replace the entire items array. Selection is preserved when the prior
67
+ * index is still valid, otherwise clamped to the last item (or 0 if the
68
+ * list is now empty). An open submenu is left untouched — its lifetime
69
+ * is bounded by its own done callback, and `#closeSubmenu` re-clamps the
70
+ * restored index against the new list on the way out.
71
+ */
72
+ setItems(items: SettingItem[]): void {
73
+ this.#items = items;
74
+ if (this.#items.length === 0) {
75
+ this.#selectedIndex = 0;
76
+ } else if (this.#selectedIndex >= this.#items.length) {
77
+ this.#selectedIndex = this.#items.length - 1;
78
+ }
79
+ }
80
+
81
+ invalidate(): void {
82
+ this.#submenuComponent?.invalidate?.();
83
+ }
84
+
85
+ render(width: number): string[] {
86
+ // If submenu is active, render it instead
87
+ if (this.#submenuComponent) {
88
+ return this.#submenuComponent.render(width);
89
+ }
90
+
91
+ return this.#renderMainList(width);
92
+ }
93
+
94
+ #renderItemRow(item: SettingItem, index: number, maxLabelWidth: number, rowWidth: number): string {
95
+ const isSelected = index === this.#selectedIndex;
96
+ const prefix = isSelected ? this.#theme.cursor : " ";
97
+ const prefixWidth = visibleWidth(prefix);
98
+ const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
99
+ const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
100
+ const separator = " ";
101
+ const valueMaxWidth = rowWidth - prefixWidth - maxLabelWidth - visibleWidth(separator) - 2;
102
+ const valueText = this.#theme.value(
103
+ truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit),
104
+ isSelected,
105
+ item.changed === true,
106
+ );
107
+ return truncateToWidth(prefix + labelText + separator + valueText, Math.max(0, rowWidth));
108
+ }
109
+
110
+ #renderMainList(width: number): string[] {
111
+ const lines: string[] = [];
112
+
113
+ if (this.#items.length === 0) {
114
+ lines.push(this.#theme.hint(" No settings available"));
115
+ return lines;
116
+ }
117
+
118
+ const viewportHeight = Math.min(this.#maxVisible, this.#items.length);
119
+ const startIndex = Math.max(
120
+ 0,
121
+ Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#items.length - viewportHeight),
122
+ );
123
+ const maxLabelWidth = Math.min(30, Math.max(...this.#items.map(item => visibleWidth(item.label))));
124
+ const itemRowsOverflow = this.#items.length > viewportHeight;
125
+ const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0));
126
+ const visibleItems = this.#items.slice(startIndex, startIndex + viewportHeight);
127
+ const itemRows = visibleItems.map((item, index) =>
128
+ this.#renderItemRow(item, startIndex + index, maxLabelWidth, itemRowWidth),
129
+ );
130
+ const scrollView = new ScrollView(itemRows, {
131
+ height: viewportHeight,
132
+ scrollbar: "auto",
133
+ totalRows: this.#items.length,
134
+ theme: {
135
+ track: text => this.#theme.hint(text),
136
+ thumb: text => this.#theme.label(text, true, false),
137
+ },
138
+ });
139
+ scrollView.setScrollOffset(startIndex);
140
+ lines.push(...scrollView.render(width));
141
+
142
+ // Add description for selected item
143
+ const selectedItem = this.#items[this.#selectedIndex];
144
+ if (selectedItem?.description) {
145
+ lines.push("");
146
+ const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
147
+ for (const line of wrappedDesc) {
148
+ lines.push(this.#theme.description(` ${line}`));
149
+ }
150
+ }
151
+
152
+ // Add hint
153
+ lines.push("");
154
+ lines.push(truncateToWidth(this.#theme.hint(" Enter/Space to change · Esc to cancel"), width));
155
+
156
+ return lines;
157
+ }
158
+
159
+ handleInput(data: string): void {
160
+ // If submenu is active, delegate all input to it
161
+ // The submenu's onCancel (triggered by escape) will call done() which closes it
162
+ if (this.#submenuComponent) {
163
+ this.#submenuComponent.handleInput?.(data);
164
+ return;
165
+ }
166
+
167
+ // Main list input handling
168
+ const kb = getKeybindings();
169
+ if (kb.matches(data, "tui.select.up")) {
170
+ this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
171
+ } else if (kb.matches(data, "tui.select.down")) {
172
+ this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1;
173
+ } else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") {
174
+ this.#activateItem();
175
+ } else if (kb.matches(data, "tui.select.cancel")) {
176
+ this.#onCancel();
177
+ }
178
+ }
179
+
180
+ #activateItem(): void {
181
+ const item = this.#items[this.#selectedIndex];
182
+ if (!item) return;
183
+
184
+ if (item.submenu) {
185
+ // Open submenu, passing current value so it can pre-select correctly
186
+ this.#submenuItemIndex = this.#selectedIndex;
187
+ this.#submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {
188
+ if (selectedValue !== undefined) {
189
+ item.currentValue = selectedValue;
190
+ this.#onChange(item.id, selectedValue);
191
+ }
192
+ this.#closeSubmenu();
193
+ });
194
+ } else if (item.values && item.values.length > 0) {
195
+ // Cycle through values
196
+ const currentIndex = item.values.indexOf(item.currentValue);
197
+ const nextIndex = (currentIndex + 1) % item.values.length;
198
+ const newValue = item.values[nextIndex];
199
+ item.currentValue = newValue;
200
+ this.#onChange(item.id, newValue);
201
+ }
202
+ }
203
+
204
+ #closeSubmenu(): void {
205
+ this.#submenuComponent = null;
206
+ // Restore selection to the item that opened the submenu
207
+ if (this.#submenuItemIndex !== null) {
208
+ this.#selectedIndex = this.#submenuItemIndex;
209
+ this.#submenuItemIndex = null;
210
+ }
211
+ }
212
+ }