@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.
- package/CHANGELOG.md +7 -0
- package/README.md +704 -0
- package/dist/types/autocomplete.d.ts +76 -0
- package/dist/types/bracketed-paste.d.ts +26 -0
- package/dist/types/components/box.d.ts +17 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +105 -0
- package/dist/types/components/image.d.ts +84 -0
- package/dist/types/components/input.d.ts +18 -0
- package/dist/types/components/loader.d.ts +13 -0
- package/dist/types/components/markdown.d.ts +61 -0
- package/dist/types/components/scroll-view.d.ts +40 -0
- package/dist/types/components/select-list.d.ts +48 -0
- package/dist/types/components/settings-list.d.ts +41 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +56 -0
- package/dist/types/components/text.d.ts +13 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/deccara.d.ts +49 -0
- package/dist/types/editor-component.d.ts +36 -0
- package/dist/types/fuzzy.d.ts +15 -0
- package/dist/types/index.d.ts +28 -0
- package/dist/types/keybindings.d.ts +189 -0
- package/dist/types/keys.d.ts +208 -0
- package/dist/types/kill-ring.d.ts +27 -0
- package/dist/types/kitty-graphics.d.ts +94 -0
- package/dist/types/stdin-buffer.d.ts +43 -0
- package/dist/types/symbols.d.ts +25 -0
- package/dist/types/terminal-capabilities.d.ts +196 -0
- package/dist/types/terminal.d.ts +103 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +275 -0
- package/dist/types/utils.d.ts +89 -0
- package/package.json +73 -0
- package/src/autocomplete.ts +871 -0
- package/src/bracketed-paste.ts +47 -0
- package/src/components/box.ts +156 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +2695 -0
- package/src/components/image.ts +318 -0
- package/src/components/input.ts +459 -0
- package/src/components/loader.ts +86 -0
- package/src/components/markdown.ts +1189 -0
- package/src/components/scroll-view.ts +166 -0
- package/src/components/select-list.ts +331 -0
- package/src/components/settings-list.ts +212 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +175 -0
- package/src/components/text.ts +110 -0
- package/src/components/truncated-text.ts +61 -0
- package/src/deccara.ts +314 -0
- package/src/editor-component.ts +71 -0
- package/src/fuzzy.ts +143 -0
- package/src/index.ts +44 -0
- package/src/keybindings.ts +279 -0
- package/src/keys.ts +537 -0
- package/src/kill-ring.ts +46 -0
- package/src/kitty-graphics.ts +270 -0
- package/src/stdin-buffer.ts +423 -0
- package/src/symbols.ts +26 -0
- package/src/terminal-capabilities.ts +1009 -0
- package/src/terminal.ts +1114 -0
- package/src/ttyid.ts +70 -0
- package/src/tui.ts +2988 -0
- 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
|
+
}
|