@prometheus-ai/tui 0.5.4 → 0.5.8

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 (55) hide show
  1. package/dist/types/autocomplete.d.ts +3 -1
  2. package/dist/types/components/box.d.ts +1 -1
  3. package/dist/types/components/editor.d.ts +35 -2
  4. package/dist/types/components/image.d.ts +22 -3
  5. package/dist/types/components/input.d.ts +6 -1
  6. package/dist/types/components/loader.d.ts +9 -2
  7. package/dist/types/components/markdown.d.ts +3 -1
  8. package/dist/types/components/scroll-view.d.ts +23 -1
  9. package/dist/types/components/select-list.d.ts +19 -1
  10. package/dist/types/components/settings-list.d.ts +87 -7
  11. package/dist/types/components/spacer.d.ts +1 -1
  12. package/dist/types/components/tab-bar.d.ts +37 -4
  13. package/dist/types/components/text.d.ts +2 -2
  14. package/dist/types/components/truncated-text.d.ts +1 -1
  15. package/dist/types/fuzzy.d.ts +10 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/keybindings.d.ts +5 -3
  18. package/dist/types/keys.d.ts +1 -1
  19. package/dist/types/kill-ring.d.ts +0 -7
  20. package/dist/types/kitty-graphics.d.ts +16 -31
  21. package/dist/types/loop-watchdog.d.ts +39 -0
  22. package/dist/types/mouse.d.ts +41 -0
  23. package/dist/types/stdin-buffer.d.ts +17 -0
  24. package/dist/types/terminal-capabilities.d.ts +74 -18
  25. package/dist/types/terminal.d.ts +34 -36
  26. package/dist/types/tui.d.ts +191 -79
  27. package/dist/types/utils.d.ts +5 -2
  28. package/package.json +4 -4
  29. package/src/autocomplete.ts +79 -65
  30. package/src/components/box.ts +43 -63
  31. package/src/components/editor.ts +471 -136
  32. package/src/components/image.ts +85 -9
  33. package/src/components/input.ts +12 -3
  34. package/src/components/loader.ts +35 -21
  35. package/src/components/markdown.ts +174 -53
  36. package/src/components/scroll-view.ts +63 -2
  37. package/src/components/select-list.ts +233 -38
  38. package/src/components/settings-list.ts +626 -64
  39. package/src/components/spacer.ts +9 -5
  40. package/src/components/tab-bar.ts +153 -28
  41. package/src/components/text.ts +6 -2
  42. package/src/components/truncated-text.ts +10 -2
  43. package/src/fuzzy.ts +214 -59
  44. package/src/index.ts +3 -1
  45. package/src/keybindings.ts +72 -14
  46. package/src/keys.ts +1 -1
  47. package/src/kill-ring.ts +5 -0
  48. package/src/kitty-graphics.ts +2 -101
  49. package/src/loop-watchdog.ts +106 -0
  50. package/src/mouse.ts +55 -0
  51. package/src/stdin-buffer.ts +291 -81
  52. package/src/terminal-capabilities.ts +206 -168
  53. package/src/terminal.ts +367 -110
  54. package/src/tui.ts +2102 -1729
  55. package/src/utils.ts +92 -60
@@ -1,8 +1,18 @@
1
+ import { fuzzyFilter } from "../fuzzy";
1
2
  import { getKeybindings } from "../keybindings";
3
+ import { extractPrintableText } from "../keys";
4
+ import type { MouseRoutable, SgrMouseEvent } from "../mouse";
2
5
  import type { Component } from "../tui";
3
- import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
6
+ import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
4
7
  import { ScrollView } from "./scroll-view";
5
8
 
9
+ function sanitizeSingleLine(text: string): string {
10
+ return replaceTabs(text)
11
+ .replace(/[\r\n]+/g, " ")
12
+ .replace(/\s+/g, " ")
13
+ .trim();
14
+ }
15
+
6
16
  export interface SettingItem {
7
17
  /** Unique identifier for this setting */
8
18
  id: string;
@@ -18,6 +28,8 @@ export interface SettingItem {
18
28
  submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
19
29
  /** True when the displayed setting differs from its default value. */
20
30
  changed?: boolean;
31
+ /** Render as a non-interactive section heading. Skipped by navigation and search. */
32
+ heading?: boolean;
21
33
  }
22
34
 
23
35
  export interface SettingsListTheme {
@@ -26,133 +38,650 @@ export interface SettingsListTheme {
26
38
  description: (text: string) => string;
27
39
  cursor: string;
28
40
  hint: (text: string) => string;
41
+ /** Style for section heading rows (dimmed when outside the active section). Falls back to `hint` when omitted. */
42
+ heading?: (text: string, dimmed: boolean) => string;
43
+ /** Style for sidebar section names in the split layout. Falls back to label/hint. */
44
+ section?: (text: string, active: boolean) => string;
45
+ /** Hover band applied to the full row under the mouse pointer. */
46
+ hovered?: (text: string) => string;
47
+ }
48
+
49
+ /** A contiguous run of items under one heading, derived from the item list. */
50
+ interface SettingSection {
51
+ name: string;
52
+ firstItemIndex: number;
53
+ lastItemIndex: number;
54
+ }
55
+
56
+ /** Optional behavior overrides for {@link SettingsList}. */
57
+ export interface SettingsListOptions {
58
+ /**
59
+ * "auto" (default) renders the section sidebar layout when headings exist
60
+ * and the width allows; "flat" always renders inline heading rows.
61
+ */
62
+ layout?: "auto" | "flat";
63
+ /**
64
+ * When false, printable input is ignored (no internal type-to-filter) and
65
+ * the search status line is never rendered. Use when a parent component
66
+ * owns the query. Default true.
67
+ */
68
+ typeToSearch?: boolean;
69
+ /** Text shown when the list has no items at all. */
70
+ emptyText?: string;
71
+ /**
72
+ * Footer hint line (hint-styled, replaces the default navigation hint).
73
+ * An empty string removes the hint row and its leading blank entirely —
74
+ * use when the host renders its own footer.
75
+ */
76
+ hint?: string;
77
+ /** Fixed split-sidebar width (columns incl. indent+gap); default derives from section names. */
78
+ sidebarWidth?: number;
79
+ }
80
+
81
+ /** Searchable text for a setting item: label, id, value, description, and cycle values. */
82
+ export function getSettingItemFilterText(item: SettingItem): string {
83
+ let text = `${item.label} ${item.id} ${item.currentValue}`;
84
+ if (item.description) {
85
+ text += ` ${item.description}`;
86
+ }
87
+ if (item.values) {
88
+ text += ` ${item.values.join(" ")}`;
89
+ }
90
+ return sanitizeSingleLine(text);
29
91
  }
30
92
 
31
93
  export class SettingsList implements Component {
32
94
  #items: SettingItem[];
95
+ #filteredItems: SettingItem[];
33
96
  #theme: SettingsListTheme;
34
97
  #selectedIndex = 0;
35
98
  #maxVisible: number;
36
99
  #onChange: (id: string, newValue: string) => void;
37
100
  #onCancel: () => void;
101
+ #options: SettingsListOptions;
102
+ #filterQuery = "";
103
+ #sectionFocus = false;
104
+ #lastNotifiedSelectionId: string | undefined;
105
+
106
+ /** Fired when the selected item changes (navigation, filtering, or setItems). */
107
+ onSelectionChange?: (item: SettingItem | undefined) => void;
38
108
 
39
109
  // Submenu state
40
110
  #submenuComponent: Component | null = null;
41
- #submenuItemIndex: number | null = null;
42
-
111
+ #submenuItemId: string | null = null;
112
+ // Mouse support: hover highlight and per-render hit maps (content-line
113
+ // index → item id), rebuilt by every main-list render.
114
+ #hoveredItemId: string | null = null;
115
+ #hitRows: (string | undefined)[] = [];
116
+ #sidebarHitRows: (string | undefined)[] = [];
117
+ #sidebarHitCol = 0;
43
118
  constructor(
44
119
  items: SettingItem[],
45
120
  maxVisible: number,
46
121
  theme: SettingsListTheme,
47
122
  onChange: (id: string, newValue: string) => void,
48
123
  onCancel: () => void,
124
+ options: SettingsListOptions = {},
49
125
  ) {
50
126
  this.#items = items;
127
+ this.#filteredItems = items;
51
128
  this.#maxVisible = maxVisible;
52
129
  this.#theme = theme;
53
130
  this.#onChange = onChange;
54
131
  this.#onCancel = onCancel;
132
+ this.#options = options;
133
+ this.#selectedIndex = this.#firstSelectableIndex();
134
+ this.#lastNotifiedSelectionId = this.getSelectedItem()?.id;
135
+ }
136
+
137
+ /** The currently selected item, or undefined when empty or on a heading. */
138
+ getSelectedItem(): SettingItem | undefined {
139
+ const item = this.#filteredItems[this.#selectedIndex];
140
+ return item && !item.heading ? item : undefined;
141
+ }
142
+
143
+ /** Move selection to the item with `id`. Returns false when it is not visible. */
144
+ selectItem(id: string): boolean {
145
+ const index = this.#filteredItems.findIndex(item => !item.heading && item.id === id);
146
+ if (index === -1) return false;
147
+ this.#sectionFocus = false;
148
+ this.#selectedIndex = index;
149
+ this.#notifySelection();
150
+ return true;
151
+ }
152
+
153
+ /** True while keyboard focus is on the section headings instead of the setting rows. */
154
+ get sectionFocused(): boolean {
155
+ return this.#sectionFocus;
156
+ }
157
+
158
+ /** Whether section focus has anywhere to go: 2+ derived sections in the current view. */
159
+ hasSectionFocusTargets(): boolean {
160
+ return this.#sections().length >= 2;
161
+ }
162
+
163
+ /**
164
+ * Toggle keyboard focus between section headings and setting rows. While
165
+ * focused, Up/Down jump whole sections and Enter/Esc return to the rows.
166
+ * Engages only when {@link hasSectionFocusTargets}; returns the new state.
167
+ */
168
+ toggleSectionFocus(): boolean {
169
+ this.#sectionFocus = !this.#sectionFocus && this.hasSectionFocusTargets();
170
+ return this.#sectionFocus;
171
+ }
172
+
173
+ /** True while an item submenu owns input. */
174
+ hasOpenSubmenu(): boolean {
175
+ return this.#submenuComponent !== null;
176
+ }
177
+
178
+ #notifySelection(): void {
179
+ const item = this.getSelectedItem();
180
+ if (item?.id === this.#lastNotifiedSelectionId) return;
181
+ this.#lastNotifiedSelectionId = item?.id;
182
+ this.onSelectionChange?.(item);
183
+ }
184
+
185
+ /** Resize the visible viewport (fullscreen hosts call this every render). */
186
+ setMaxVisible(rows: number): void {
187
+ const next = Math.max(3, Math.floor(rows));
188
+ if (next === this.#maxVisible) return;
189
+ this.#maxVisible = next;
190
+ this.#clampSelectedIndex();
191
+ }
192
+
193
+ /** Move the selection one step for a wheel notch. */
194
+ handleWheel(delta: -1 | 1): void {
195
+ if (this.#submenuComponent) return;
196
+ // Wheel is row-level interaction: it returns focus to the rows.
197
+ this.#sectionFocus = false;
198
+ this.#moveSelection(delta);
199
+ }
200
+
201
+ /** Highlight the item under the pointer (null clears). */
202
+ setHoverItem(id: string | null): void {
203
+ this.#hoveredItemId = id;
204
+ }
205
+
206
+ /**
207
+ * Resolve a pointer position against the last rendered frame. `line` is the
208
+ * 0-based content-line index within this component's render output, `col`
209
+ * the 0-based column. Sidebar rows resolve to the section's first item.
210
+ */
211
+ hitTest(line: number, col: number): string | undefined {
212
+ if (this.#submenuComponent) return undefined;
213
+ if (this.#sidebarHitCol > 0 && col < this.#sidebarHitCol) {
214
+ return this.#sidebarHitRows[line];
215
+ }
216
+ return this.#hitRows[line];
217
+ }
218
+
219
+ /**
220
+ * Like {@link hitTest}, but only rows the pointer is visually on: sidebar
221
+ * jump targets are excluded so hovering section names does not light up
222
+ * pane rows.
223
+ */
224
+ hoverTest(line: number, col: number): string | undefined {
225
+ if (this.#submenuComponent) return undefined;
226
+ if (this.#sidebarHitCol > 0 && col < this.#sidebarHitCol) return undefined;
227
+ return this.#hitRows[line];
228
+ }
229
+
230
+ /**
231
+ * Route a mouse event into an open submenu (coordinates are local to this
232
+ * list's rendered lines). Returns false when no submenu is open; submenus
233
+ * that do not implement {@link MouseRoutable} consume the event silently.
234
+ */
235
+ routeSubmenuMouse(event: SgrMouseEvent, line: number, col: number): boolean {
236
+ if (!this.#submenuComponent) return false;
237
+ (this.#submenuComponent as Component & Partial<MouseRoutable>).routeMouse?.(event, line, col);
238
+ return true;
239
+ }
240
+
241
+ getSearchQuery(): string {
242
+ return this.#filterQuery;
243
+ }
244
+
245
+ hasSearchQuery(): boolean {
246
+ return this.#filterQuery.length > 0;
247
+ }
248
+
249
+ clearSearch(): void {
250
+ if (this.#filterQuery.length === 0) return;
251
+ this.#setFilter("");
55
252
  }
56
253
 
57
254
  /** Update an item's currentValue */
58
255
  updateValue(id: string, newValue: string): void {
59
256
  const item = this.#items.find(i => i.id === id);
60
- if (item) {
61
- item.currentValue = newValue;
257
+ if (!item) return;
258
+
259
+ item.currentValue = newValue;
260
+ if (this.#filterQuery.trim()) {
261
+ this.#applyFilter();
262
+ this.#clampSelectedIndex();
62
263
  }
63
264
  }
64
265
 
65
266
  /**
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.
267
+ * Replace the entire items array. Selection is preserved by item id when
268
+ * the previous selection still survives the active filter, otherwise
269
+ * clamped to the last filtered item (or 0 if there are no matches).
270
+ * An open submenu is left untouched its lifetime is bounded by its own
271
+ * done callback, and `#closeSubmenu` re-resolves the restored item on exit.
71
272
  */
72
273
  setItems(items: SettingItem[]): void {
274
+ const selectedId = this.#filteredItems[this.#selectedIndex]?.id;
73
275
  this.#items = items;
74
- if (this.#items.length === 0) {
276
+ this.#applyFilter();
277
+ if (this.#sectionFocus && !this.hasSectionFocusTargets()) this.#sectionFocus = false;
278
+
279
+ const nextIndex = selectedId ? this.#filteredItems.findIndex(item => item.id === selectedId) : -1;
280
+ if (nextIndex >= 0) {
281
+ this.#selectedIndex = nextIndex;
282
+ } else {
283
+ this.#clampSelectedIndex();
284
+ }
285
+ this.#notifySelection();
286
+ }
287
+
288
+ #setFilter(filter: string): void {
289
+ this.#filterQuery = filter;
290
+ if (filter.trim()) this.#sectionFocus = false;
291
+ this.#applyFilter();
292
+ this.#selectedIndex = this.#firstSelectableIndex();
293
+ this.#notifySelection();
294
+ }
295
+
296
+ #applyFilter(): void {
297
+ this.#filteredItems = this.#filterQuery.trim()
298
+ ? fuzzyFilter(
299
+ this.#items.filter(item => !item.heading),
300
+ this.#filterQuery,
301
+ getSettingItemFilterText,
302
+ )
303
+ : this.#items;
304
+ }
305
+
306
+ #firstSelectableIndex(): number {
307
+ const index = this.#filteredItems.findIndex(item => !item.heading);
308
+ return index >= 0 ? index : 0;
309
+ }
310
+
311
+ /** Move selection by one selectable item, wrapping and skipping headings. */
312
+ #moveSelection(delta: -1 | 1): void {
313
+ const len = this.#filteredItems.length;
314
+ if (len === 0) return;
315
+ let index = this.#selectedIndex;
316
+ for (let step = 0; step < len; step++) {
317
+ index = (index + delta + len) % len;
318
+ if (!this.#filteredItems[index]?.heading) {
319
+ this.#selectedIndex = index;
320
+ this.#notifySelection();
321
+ return;
322
+ }
323
+ }
324
+ }
325
+
326
+ /** Sections derived from heading rows in the filtered list. */
327
+ #sections(): SettingSection[] {
328
+ const sections: SettingSection[] = [];
329
+ let current: SettingSection | null = null;
330
+ for (let i = 0; i < this.#filteredItems.length; i++) {
331
+ const item = this.#filteredItems[i];
332
+ if (item.heading) {
333
+ current = { name: item.label, firstItemIndex: -1, lastItemIndex: -1 };
334
+ sections.push(current);
335
+ continue;
336
+ }
337
+ if (!current) {
338
+ current = { name: "", firstItemIndex: i, lastItemIndex: i };
339
+ sections.push(current);
340
+ }
341
+ if (current.firstItemIndex < 0) current.firstItemIndex = i;
342
+ current.lastItemIndex = i;
343
+ }
344
+ return sections.filter(section => section.firstItemIndex >= 0);
345
+ }
346
+
347
+ #activeSectionIndex(sections: SettingSection[]): number {
348
+ for (let i = sections.length - 1; i >= 0; i--) {
349
+ if (sections[i].firstItemIndex <= this.#selectedIndex) return i;
350
+ }
351
+ return 0;
352
+ }
353
+
354
+ /** Jump to the next/previous section; page through items when there are no sections. */
355
+ #jumpSection(delta: -1 | 1): void {
356
+ const sections = this.#sections();
357
+ if (sections.length < 2) {
358
+ const len = this.#filteredItems.length;
359
+ if (len === 0) return;
360
+ this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex + delta * this.#maxVisible, len - 1));
361
+ this.#clampSelectedIndex();
362
+ } else {
363
+ const next = (this.#activeSectionIndex(sections) + delta + sections.length) % sections.length;
364
+ this.#selectedIndex = sections[next].firstItemIndex;
365
+ }
366
+ this.#notifySelection();
367
+ }
368
+
369
+ #clampSelectedIndex(): void {
370
+ if (this.#filteredItems.length === 0) {
75
371
  this.#selectedIndex = 0;
76
- } else if (this.#selectedIndex >= this.#items.length) {
77
- this.#selectedIndex = this.#items.length - 1;
372
+ return;
373
+ }
374
+ this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, this.#filteredItems.length - 1));
375
+ if (!this.#filteredItems[this.#selectedIndex]?.heading) return;
376
+ // Landed on a heading: prefer the next selectable item, else the previous one.
377
+ for (let i = this.#selectedIndex + 1; i < this.#filteredItems.length; i++) {
378
+ if (!this.#filteredItems[i].heading) {
379
+ this.#selectedIndex = i;
380
+ return;
381
+ }
382
+ }
383
+ for (let i = this.#selectedIndex - 1; i >= 0; i--) {
384
+ if (!this.#filteredItems[i].heading) {
385
+ this.#selectedIndex = i;
386
+ return;
387
+ }
78
388
  }
79
389
  }
80
390
 
391
+ #renderSearchStatus(width: number): string {
392
+ const query = sanitizeSingleLine(this.#filterQuery);
393
+ const statusText = query ? ` Search: ${query}` : " Type to search";
394
+ return this.#theme.hint(truncateToWidth(statusText, width, Ellipsis.Omit));
395
+ }
396
+
397
+ #shouldRenderSearchStatus(): boolean {
398
+ if (this.#options.typeToSearch === false) return false;
399
+ return this.#items.length > this.#maxVisible || this.#filterQuery.length > 0;
400
+ }
401
+
402
+ #handleSearchInput(data: string): boolean {
403
+ if (this.#options.typeToSearch === false) return false;
404
+ if (this.#items.length === 0) return false;
405
+
406
+ const kb = getKeybindings();
407
+ if (kb.matches(data, "tui.editor.deleteCharBackward")) {
408
+ if (this.#filterQuery.length === 0) return false;
409
+ const chars = [...this.#filterQuery];
410
+ chars.pop();
411
+ this.#setFilter(chars.join(""));
412
+ return true;
413
+ }
414
+
415
+ const printableText = extractPrintableText(data);
416
+ if (printableText === undefined) return false;
417
+ if (this.#filterQuery.length === 0 && printableText.trim().length === 0) return false;
418
+
419
+ this.#setFilter(this.#filterQuery + printableText);
420
+ return true;
421
+ }
422
+
81
423
  invalidate(): void {
82
424
  this.#submenuComponent?.invalidate?.();
83
425
  }
84
426
 
85
- render(width: number): string[] {
86
- // If submenu is active, render it instead
427
+ /**
428
+ * Every render path is padded to the same stable height so interacting with
429
+ * the list (navigating sections, opening submenus, filtering, condition-gated
430
+ * rows appearing) never resizes the panel. A live region that thrashes its
431
+ * height forces the terminal to re-anchor and can strand scrollback rows.
432
+ */
433
+ #stableHeight(): number {
434
+ // viewport + blank + 3 description rows, plus the optional search status
435
+ // row and the optional blank+hint footer.
436
+ let height = this.#maxVisible + 4;
437
+ if (this.#options.typeToSearch !== false) height += 1;
438
+ if (this.#options.hint !== "") height += 2;
439
+ return height;
440
+ }
441
+
442
+ #padLines(lines: string[]): string[] {
443
+ while (lines.length < this.#stableHeight()) lines.push("");
444
+ return lines;
445
+ }
446
+
447
+ render(width: number): readonly string[] {
448
+ // Hit maps describe exactly the frame being produced now.
449
+ this.#hitRows = [];
450
+ this.#sidebarHitRows = [];
451
+ this.#sidebarHitCol = 0;
452
+ // If submenu is active, render it instead (padded to the list's stable
453
+ // height so opening/closing a submenu does not resize the panel).
87
454
  if (this.#submenuComponent) {
88
- return this.#submenuComponent.render(width);
455
+ return this.#padLines([...this.#submenuComponent.render(width)]);
89
456
  }
90
457
 
91
- return this.#renderMainList(width);
458
+ return this.#padLines(this.#renderMainList(width));
92
459
  }
93
460
 
94
- #renderItemRow(item: SettingItem, index: number, maxLabelWidth: number, rowWidth: number): string {
95
- const isSelected = index === this.#selectedIndex;
461
+ #renderItemRow(
462
+ item: SettingItem,
463
+ index: number,
464
+ maxLabelWidth: number,
465
+ rowWidth: number,
466
+ dimmed = false,
467
+ headingCursor = false,
468
+ ): string {
469
+ if (item.heading) {
470
+ const headingStyle = this.#theme.heading ?? ((text: string) => this.#theme.hint(text));
471
+ const prefix = headingCursor ? this.#theme.cursor : " ";
472
+ return truncateToWidth(`${prefix}${headingStyle(item.label, dimmed)}`, Math.max(0, rowWidth));
473
+ }
474
+ // While section focus owns the keyboard, the row cursor hides so the
475
+ // section cursor is the single focus indicator.
476
+ const isSelected = index === this.#selectedIndex && !this.#sectionFocus;
96
477
  const prefix = isSelected ? this.#theme.cursor : " ";
97
478
  const prefixWidth = visibleWidth(prefix);
98
479
  const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
99
- const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
100
480
  const separator = " ";
101
481
  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));
482
+ const valuePlain = truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit);
483
+ const hovered = !isSelected && this.#theme.hovered !== undefined && item.id === this.#hoveredItemId;
484
+ // De-emphasized rows (outside the active section) render as plain text
485
+ // under one dim wash so inner label/value colors don't fight it.
486
+ if (dimmed && !isSelected) {
487
+ const text = this.#theme.hint(
488
+ truncateToWidth(` ${labelPadded}${separator}${valuePlain}`, Math.max(0, rowWidth)),
489
+ );
490
+ return hovered && this.#theme.hovered ? this.#theme.hovered(text) : text;
491
+ }
492
+ const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
493
+ const valueText = this.#theme.value(valuePlain, isSelected, item.changed === true);
494
+ const text = truncateToWidth(prefix + labelText + separator + valueText, Math.max(0, rowWidth));
495
+ // Pointer hover paints a band behind the whole row, distinct from the
496
+ // keyboard selection (cursor glyph + accent) which stays where it is.
497
+ if (hovered && this.#theme.hovered) {
498
+ return this.#theme.hovered(text);
499
+ }
500
+ return text;
108
501
  }
109
502
 
110
503
  #renderMainList(width: number): string[] {
111
504
  const lines: string[] = [];
112
505
 
113
506
  if (this.#items.length === 0) {
114
- lines.push(this.#theme.hint(" No settings available"));
507
+ lines.push(this.#theme.hint(` ${this.#options.emptyText ?? "No settings available"}`));
115
508
  return lines;
116
509
  }
117
510
 
118
- const viewportHeight = Math.min(this.#maxVisible, this.#items.length);
119
- const startIndex = Math.max(
511
+ if (this.#filteredItems.length === 0) {
512
+ if (this.#shouldRenderSearchStatus()) {
513
+ lines.push(this.#renderSearchStatus(width));
514
+ }
515
+ lines.push(this.#theme.hint(" No matching settings"));
516
+ lines.push("");
517
+ lines.push(truncateToWidth(this.#theme.hint(" Backspace to edit search · Esc to cancel"), width));
518
+ return lines;
519
+ }
520
+
521
+ const sections = this.#sections();
522
+ const splitLines =
523
+ this.#options.layout !== "flat" && !this.#filterQuery.trim() && sections.length >= 2
524
+ ? this.#renderSplitList(width, sections)
525
+ : null;
526
+ if (splitLines) {
527
+ lines.push(...splitLines);
528
+ } else {
529
+ const viewportHeight = Math.min(this.#maxVisible, this.#filteredItems.length);
530
+ const startIndex = Math.max(
531
+ 0,
532
+ Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
533
+ );
534
+ const labelWidths = this.#filteredItems.filter(item => !item.heading).map(item => visibleWidth(item.label));
535
+ const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
536
+ const itemRowsOverflow = this.#filteredItems.length > viewportHeight;
537
+ const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0));
538
+ const visibleItems = this.#filteredItems.slice(startIndex, startIndex + viewportHeight);
539
+ // In the flat layout the active section's heading row carries the
540
+ // section-focus cursor (the split layout shows it in the sidebar).
541
+ const active = sections[this.#activeSectionIndex(sections)];
542
+ const focusedHeadingIndex = this.#sectionFocus && active?.name ? active.firstItemIndex - 1 : -1;
543
+ const itemRows = visibleItems.map((item, index) =>
544
+ this.#renderItemRow(
545
+ item,
546
+ startIndex + index,
547
+ maxLabelWidth,
548
+ itemRowWidth,
549
+ false,
550
+ startIndex + index === focusedHeadingIndex,
551
+ ),
552
+ );
553
+ visibleItems.forEach((item, index) => {
554
+ this.#hitRows[index] = item.heading ? undefined : item.id;
555
+ });
556
+ const scrollView = new ScrollView(itemRows, {
557
+ height: viewportHeight,
558
+ scrollbar: "auto",
559
+ totalRows: this.#filteredItems.length,
560
+ theme: {
561
+ track: text => this.#theme.hint(text),
562
+ thumb: text => this.#theme.label(text, true, false),
563
+ },
564
+ });
565
+ scrollView.setScrollOffset(startIndex);
566
+ lines.push(...scrollView.render(width));
567
+ // Pad short lists to the full viewport so the panel height is constant.
568
+ while (lines.length < this.#maxVisible) lines.push("");
569
+ }
570
+
571
+ // Description area: 1 blank + exactly 3 rows, clamped with an ellipsis,
572
+ // so moving between items with/without descriptions never shifts rows.
573
+ lines.push("");
574
+ const selectedItem = this.#filteredItems[this.#selectedIndex];
575
+ const descLines: string[] = [];
576
+ if (selectedItem?.description && !selectedItem.heading) {
577
+ const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
578
+ for (const line of wrappedDesc.slice(0, 3)) {
579
+ descLines.push(this.#theme.description(` ${line}`));
580
+ }
581
+ if (wrappedDesc.length > 3) {
582
+ descLines[2] = truncateToWidth(`${descLines[2]}…`, width);
583
+ }
584
+ }
585
+ while (descLines.length < 3) descLines.push("");
586
+ lines.push(...descLines);
587
+
588
+ // External-search mode: the host renders the query; skip the status row.
589
+ if (this.#options.typeToSearch !== false) {
590
+ lines.push(this.#renderSearchStatus(width));
591
+ }
592
+
593
+ // Add hint (suppressed entirely when the host owns the footer)
594
+ if (this.#options.hint !== "") {
595
+ lines.push("");
596
+ const jumpHint = sections.length >= 2 ? "PgUp/PgDn to jump sections · " : "";
597
+ const hintText = this.#options.hint ?? `Enter/Space to change · ${jumpHint}Type to search · Esc to cancel`;
598
+ lines.push(truncateToWidth(this.#theme.hint(` ${hintText}`), width));
599
+ }
600
+
601
+ return lines;
602
+ }
603
+
604
+ /**
605
+ * Split layout: section sidebar on the left, every item on the right with
606
+ * rows outside the active section dimmed so the section under the cursor
607
+ * pops. Up/Down navigation flows across section boundaries; the sidebar
608
+ * highlight follows the selection. Returns null when the width cannot fit
609
+ * both panes, falling back to the flat single-column layout.
610
+ */
611
+ #renderSplitList(width: number, sections: SettingSection[]): string[] | null {
612
+ const sectionNames = sections.map(section => section.name || "Other");
613
+ let nameWidth = 0;
614
+ for (const name of sectionNames) nameWidth = Math.max(nameWidth, visibleWidth(name));
615
+ const sidebarWidth = this.#options.sidebarWidth ?? Math.min(22, nameWidth) + 4; // 2-space indent + 2-space gap
616
+ const paneWidth = width - sidebarWidth - 2; // "│ " separator
617
+ // Below this the value column starves (2 prefix + 30 label + 2 gap + ~25 value).
618
+ if (paneWidth < 60) return null;
619
+
620
+ const activeIndex = this.#activeSectionIndex(sections);
621
+ const active = sections[activeIndex];
622
+
623
+ const sectionStyle =
624
+ this.#theme.section ??
625
+ ((text: string, isActive: boolean) =>
626
+ isActive ? this.#theme.label(text, true, false) : this.#theme.hint(text));
627
+ const sidebarRows = sectionNames.map((name, i) => {
628
+ const label = truncateToWidth(name, sidebarWidth - 4, Ellipsis.Omit);
629
+ // Section focus parks the cursor glyph on the active sidebar entry.
630
+ const prefix = this.#sectionFocus && i === activeIndex ? this.#theme.cursor : " ";
631
+ return `${prefix}${sectionStyle(label, i === activeIndex)}${padding(sidebarWidth - visibleWidth(prefix) - visibleWidth(label))}`;
632
+ });
633
+
634
+ // Right pane: the whole list, continuously scrollable. The active
635
+ // section's heading row belongs to its dim-exempt range.
636
+ const activeStart = active.name ? active.firstItemIndex - 1 : active.firstItemIndex;
637
+ const viewportHeight = Math.min(this.#maxVisible, this.#filteredItems.length);
638
+ const startRow = Math.max(
120
639
  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),
640
+ Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#filteredItems.length - viewportHeight),
129
641
  );
642
+ // Label column width spans all items so the layout stays stable across sections.
643
+ const labelWidths = this.#filteredItems.filter(item => !item.heading).map(item => visibleWidth(item.label));
644
+ const maxLabelWidth = Math.min(30, labelWidths.length > 0 ? Math.max(...labelWidths) : 0);
645
+ const overflow = this.#filteredItems.length > viewportHeight;
646
+ const rowWidth = Math.max(0, paneWidth - (overflow ? 1 : 0));
647
+ const itemRows: string[] = [];
648
+ for (let r = 0; r < viewportHeight; r++) {
649
+ const index = startRow + r;
650
+ const item = this.#filteredItems[index];
651
+ if (!item) break;
652
+ const dimmed = index < activeStart || index > active.lastItemIndex;
653
+ itemRows.push(this.#renderItemRow(item, index, maxLabelWidth, rowWidth, dimmed));
654
+ }
130
655
  const scrollView = new ScrollView(itemRows, {
131
656
  height: viewportHeight,
132
657
  scrollbar: "auto",
133
- totalRows: this.#items.length,
658
+ totalRows: this.#filteredItems.length,
134
659
  theme: {
135
660
  track: text => this.#theme.hint(text),
136
661
  thumb: text => this.#theme.label(text, true, false),
137
662
  },
138
663
  });
139
- scrollView.setScrollOffset(startIndex);
140
- lines.push(...scrollView.render(width));
664
+ scrollView.setScrollOffset(startRow);
665
+ const paneRows = scrollView.render(paneWidth);
141
666
 
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
- }
667
+ // Hit maps: sidebar rows resolve to each section's first item; pane rows
668
+ // to the item they render.
669
+ this.#sidebarHitCol = sidebarWidth;
670
+ for (let i = 0; i < sectionNames.length; i++) {
671
+ this.#sidebarHitRows[i] = this.#filteredItems[sections[i].firstItemIndex]?.id;
672
+ }
673
+ for (let r = 0; r < viewportHeight; r++) {
674
+ const item = this.#filteredItems[startRow + r];
675
+ if (item && !item.heading) this.#hitRows[r] = item.id;
150
676
  }
151
677
 
152
- // Add hint
153
- lines.push("");
154
- lines.push(truncateToWidth(this.#theme.hint(" Enter/Space to change · Esc to cancel"), width));
155
-
678
+ const separator = this.#theme.hint("│ ");
679
+ const lines: string[] = [];
680
+ const height = Math.max(this.#maxVisible, sidebarRows.length);
681
+ for (let i = 0; i < height; i++) {
682
+ const left = sidebarRows[i] ?? padding(sidebarWidth);
683
+ lines.push(truncateToWidth(left + separator + (paneRows[i] ?? ""), width));
684
+ }
156
685
  return lines;
157
686
  }
158
687
 
@@ -166,24 +695,49 @@ export class SettingsList implements Component {
166
695
 
167
696
  // Main list input handling
168
697
  const kb = getKeybindings();
698
+ if (kb.matches(data, "tui.select.cancel")) {
699
+ if (this.#filterQuery.length > 0) {
700
+ this.clearSearch();
701
+ return;
702
+ }
703
+ if (this.#sectionFocus) {
704
+ this.#sectionFocus = false;
705
+ return;
706
+ }
707
+ this.#onCancel();
708
+ return;
709
+ }
710
+
711
+ if (this.#handleSearchInput(data)) {
712
+ return;
713
+ }
714
+
715
+ if (this.#filteredItems.length === 0) return;
716
+
169
717
  if (kb.matches(data, "tui.select.up")) {
170
- this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
718
+ if (this.#sectionFocus) this.#jumpSection(-1);
719
+ else this.#moveSelection(-1);
171
720
  } else if (kb.matches(data, "tui.select.down")) {
172
- this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1;
721
+ if (this.#sectionFocus) this.#jumpSection(1);
722
+ else this.#moveSelection(1);
723
+ } else if (kb.matches(data, "tui.select.pageDown")) {
724
+ this.#jumpSection(1);
725
+ } else if (kb.matches(data, "tui.select.pageUp")) {
726
+ this.#jumpSection(-1);
173
727
  } 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();
728
+ // Confirm on a focused heading drops into its first setting.
729
+ if (this.#sectionFocus) this.#sectionFocus = false;
730
+ else this.#activateItem();
177
731
  }
178
732
  }
179
733
 
180
734
  #activateItem(): void {
181
- const item = this.#items[this.#selectedIndex];
182
- if (!item) return;
735
+ const item = this.#filteredItems[this.#selectedIndex];
736
+ if (!item || item.heading) return;
183
737
 
184
738
  if (item.submenu) {
185
739
  // Open submenu, passing current value so it can pre-select correctly
186
- this.#submenuItemIndex = this.#selectedIndex;
740
+ this.#submenuItemId = item.id;
187
741
  this.#submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {
188
742
  if (selectedValue !== undefined) {
189
743
  item.currentValue = selectedValue;
@@ -203,10 +757,18 @@ export class SettingsList implements Component {
203
757
 
204
758
  #closeSubmenu(): void {
205
759
  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;
760
+ // Restore selection to the item that opened the submenu. Resolve by id:
761
+ // onChange handlers may have called setItems while the submenu was open,
762
+ // so a captured index could point at a different (or vanished) row.
763
+ if (this.#submenuItemId !== null) {
764
+ const index = this.#filteredItems.findIndex(item => !item.heading && item.id === this.#submenuItemId);
765
+ this.#submenuItemId = null;
766
+ if (index >= 0) {
767
+ this.#selectedIndex = index;
768
+ } else {
769
+ this.#clampSelectedIndex();
770
+ }
771
+ this.#notifySelection();
210
772
  }
211
773
  }
212
774
  }