@gajae-code/tui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +818 -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 +15 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +101 -0
  8. package/dist/types/components/image.d.ts +16 -0
  9. package/dist/types/components/input.d.ts +16 -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/select-list.d.ts +46 -0
  13. package/dist/types/components/settings-list.d.ts +39 -0
  14. package/dist/types/components/spacer.d.ts +11 -0
  15. package/dist/types/components/tab-bar.d.ts +56 -0
  16. package/dist/types/components/text.d.ts +13 -0
  17. package/dist/types/components/truncated-text.d.ts +10 -0
  18. package/dist/types/editor-component.d.ts +36 -0
  19. package/dist/types/fuzzy.d.ts +15 -0
  20. package/dist/types/index.d.ts +25 -0
  21. package/dist/types/keybindings.d.ts +189 -0
  22. package/dist/types/keys.d.ts +208 -0
  23. package/dist/types/kill-ring.d.ts +27 -0
  24. package/dist/types/stdin-buffer.d.ts +43 -0
  25. package/dist/types/symbols.d.ts +23 -0
  26. package/dist/types/terminal-capabilities.d.ts +75 -0
  27. package/dist/types/terminal.d.ts +61 -0
  28. package/dist/types/ttyid.d.ts +9 -0
  29. package/dist/types/tui.d.ts +161 -0
  30. package/dist/types/utils.d.ts +74 -0
  31. package/package.json +73 -0
  32. package/src/autocomplete.ts +836 -0
  33. package/src/bracketed-paste.ts +47 -0
  34. package/src/components/box.ts +144 -0
  35. package/src/components/cancellable-loader.ts +40 -0
  36. package/src/components/editor.ts +2664 -0
  37. package/src/components/image.ts +90 -0
  38. package/src/components/input.ts +465 -0
  39. package/src/components/loader.ts +86 -0
  40. package/src/components/markdown.ts +1009 -0
  41. package/src/components/select-list.ts +249 -0
  42. package/src/components/settings-list.ts +211 -0
  43. package/src/components/spacer.ts +28 -0
  44. package/src/components/tab-bar.ts +175 -0
  45. package/src/components/text.ts +110 -0
  46. package/src/components/truncated-text.ts +61 -0
  47. package/src/editor-component.ts +71 -0
  48. package/src/fuzzy.ts +143 -0
  49. package/src/index.ts +39 -0
  50. package/src/keybindings.ts +279 -0
  51. package/src/keys.ts +537 -0
  52. package/src/kill-ring.ts +46 -0
  53. package/src/stdin-buffer.ts +410 -0
  54. package/src/symbols.ts +24 -0
  55. package/src/terminal-capabilities.ts +537 -0
  56. package/src/terminal.ts +716 -0
  57. package/src/ttyid.ts +66 -0
  58. package/src/tui.ts +1481 -0
  59. package/src/utils.ts +359 -0
@@ -0,0 +1,249 @@
1
+ import { getKeybindings } from "../keybindings";
2
+ import type { SymbolTheme } from "../symbols";
3
+ import type { Component } from "../tui";
4
+ import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
5
+
6
+ const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
7
+ const PRIMARY_COLUMN_GAP = 2;
8
+ const MIN_DESCRIPTION_WIDTH = 10;
9
+
10
+ function sanitizeSingleLine(text: string): string {
11
+ return replaceTabs(text)
12
+ .replace(/[\r\n]+/g, " ")
13
+ .replace(/\s+/g, " ")
14
+ .trim();
15
+ }
16
+
17
+ const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));
18
+
19
+ export interface SelectItem {
20
+ value: string;
21
+ label: string;
22
+ description?: string;
23
+ /** Dim hint text shown inline after cursor when this item is selected */
24
+ hint?: string;
25
+ }
26
+
27
+ export interface SelectListTheme {
28
+ selectedPrefix: (text: string) => string;
29
+ selectedText: (text: string) => string;
30
+ description: (text: string) => string;
31
+ scrollInfo: (text: string) => string;
32
+ noMatch: (text: string) => string;
33
+ symbols: SymbolTheme;
34
+ }
35
+
36
+ export interface SelectListTruncatePrimaryContext {
37
+ text: string;
38
+ maxWidth: number;
39
+ columnWidth: number;
40
+ item: SelectItem;
41
+ isSelected: boolean;
42
+ }
43
+
44
+ export interface SelectListLayoutOptions {
45
+ minPrimaryColumnWidth?: number;
46
+ maxPrimaryColumnWidth?: number;
47
+ truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
48
+ }
49
+
50
+ export class SelectList implements Component {
51
+ #filteredItems: ReadonlyArray<SelectItem>;
52
+ #selectedIndex: number = 0;
53
+
54
+ onSelect?: (item: SelectItem) => void;
55
+ onCancel?: () => void;
56
+ onSelectionChange?: (item: SelectItem) => void;
57
+
58
+ constructor(
59
+ private readonly items: ReadonlyArray<SelectItem>,
60
+ private readonly maxVisible: number,
61
+ private readonly theme: SelectListTheme,
62
+ private readonly layout: SelectListLayoutOptions = {},
63
+ ) {
64
+ this.#filteredItems = items;
65
+ }
66
+
67
+ setFilter(filter: string): void {
68
+ this.#filteredItems = this.items.filter(item => item.value.toLowerCase().startsWith(filter.toLowerCase()));
69
+ // Reset selection when filter changes
70
+ this.#selectedIndex = 0;
71
+ }
72
+
73
+ setSelectedIndex(index: number): void {
74
+ this.#selectedIndex = Math.max(0, Math.min(index, this.#filteredItems.length - 1));
75
+ }
76
+
77
+ invalidate(): void {
78
+ // No cached state to invalidate currently
79
+ }
80
+
81
+ render(width: number): string[] {
82
+ const lines: string[] = [];
83
+
84
+ // If no items match filter, show message
85
+ if (this.#filteredItems.length === 0) {
86
+ lines.push(this.theme.noMatch(" No matching commands"));
87
+ return lines;
88
+ }
89
+
90
+ const primaryColumnWidth = this.#getPrimaryColumnWidth();
91
+
92
+ // Calculate visible range with scrolling
93
+ const startIndex = Math.max(
94
+ 0,
95
+ Math.min(this.#selectedIndex - Math.floor(this.maxVisible / 2), this.#filteredItems.length - this.maxVisible),
96
+ );
97
+ const endIndex = Math.min(startIndex + this.maxVisible, this.#filteredItems.length);
98
+
99
+ // Render visible items
100
+ for (let i = startIndex; i < endIndex; i++) {
101
+ const item = this.#filteredItems[i];
102
+ if (!item) continue;
103
+
104
+ const isSelected = i === this.#selectedIndex;
105
+ const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
106
+ lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth));
107
+ }
108
+
109
+ // Add scroll indicators if needed
110
+ if (startIndex > 0 || endIndex < this.#filteredItems.length) {
111
+ const scrollText = ` (${this.#selectedIndex + 1}/${this.#filteredItems.length})`;
112
+ // Truncate if too long for terminal
113
+ lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
114
+ }
115
+
116
+ return lines;
117
+ }
118
+
119
+ handleInput(keyData: string): void {
120
+ if (this.#filteredItems.length === 0) return;
121
+ const kb = getKeybindings();
122
+ // Up arrow - wrap to bottom when at top
123
+ if (kb.matches(keyData, "tui.select.up")) {
124
+ this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
125
+ this.#notifySelectionChange();
126
+ }
127
+ // Down arrow - wrap to top when at bottom
128
+ else if (kb.matches(keyData, "tui.select.down")) {
129
+ this.#selectedIndex = this.#selectedIndex === this.#filteredItems.length - 1 ? 0 : this.#selectedIndex + 1;
130
+ this.#notifySelectionChange();
131
+ }
132
+ // PageUp - jump up by one visible page
133
+ else if (kb.matches(keyData, "tui.select.pageUp")) {
134
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisible);
135
+ this.#notifySelectionChange();
136
+ }
137
+ // PageDown - jump down by one visible page
138
+ else if (kb.matches(keyData, "tui.select.pageDown")) {
139
+ this.#selectedIndex = Math.min(this.#filteredItems.length - 1, this.#selectedIndex + this.maxVisible);
140
+ this.#notifySelectionChange();
141
+ }
142
+ // Enter
143
+ else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
144
+ const selectedItem = this.#filteredItems[this.#selectedIndex];
145
+ if (selectedItem && this.onSelect) {
146
+ this.onSelect(selectedItem);
147
+ }
148
+ }
149
+ // Escape or Ctrl+C
150
+ else if (kb.matches(keyData, "tui.select.cancel")) {
151
+ if (this.onCancel) {
152
+ this.onCancel();
153
+ }
154
+ }
155
+ }
156
+
157
+ #renderItem(
158
+ item: SelectItem,
159
+ isSelected: boolean,
160
+ width: number,
161
+ descriptionSingleLine: string | undefined,
162
+ primaryColumnWidth: number,
163
+ ): string {
164
+ const prefix = isSelected
165
+ ? `${this.theme.symbols.cursor} `
166
+ : padding(visibleWidth(this.theme.symbols.cursor) + 1);
167
+ const prefixWidth = visibleWidth(prefix);
168
+
169
+ if (descriptionSingleLine && width > 40) {
170
+ const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
171
+ const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);
172
+ const truncatedValue = this.#truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);
173
+ const truncatedValueWidth = visibleWidth(truncatedValue);
174
+ const spacing = padding(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));
175
+ const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;
176
+ const remainingWidth = width - descriptionStart - 2; // -2 for safety
177
+
178
+ if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
179
+ const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
180
+ if (isSelected) {
181
+ return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
182
+ }
183
+
184
+ const descText = this.theme.description(spacing + truncatedDesc);
185
+ return prefix + truncatedValue + descText;
186
+ }
187
+ }
188
+
189
+ const maxWidth = width - prefixWidth - 2;
190
+ const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
191
+ if (isSelected) {
192
+ return this.theme.selectedText(`${prefix}${truncatedValue}`);
193
+ }
194
+
195
+ return prefix + truncatedValue;
196
+ }
197
+
198
+ #getPrimaryColumnWidth(): number {
199
+ const { min, max } = this.#getPrimaryColumnBounds();
200
+ const widestPrimary = this.#filteredItems.reduce((widest, item) => {
201
+ return Math.max(widest, visibleWidth(this.#getDisplayValue(item)) + PRIMARY_COLUMN_GAP);
202
+ }, 0);
203
+
204
+ return clamp(widestPrimary, min, max);
205
+ }
206
+
207
+ #getPrimaryColumnBounds(): { min: number; max: number } {
208
+ const rawMin =
209
+ this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
210
+ const rawMax =
211
+ this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
212
+
213
+ return {
214
+ min: Math.max(1, Math.min(rawMin, rawMax)),
215
+ max: Math.max(1, Math.max(rawMin, rawMax)),
216
+ };
217
+ }
218
+
219
+ #truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {
220
+ const displayValue = this.#getDisplayValue(item);
221
+ const truncatedValue = this.layout.truncatePrimary
222
+ ? this.layout.truncatePrimary({
223
+ text: displayValue,
224
+ maxWidth,
225
+ columnWidth,
226
+ item,
227
+ isSelected,
228
+ })
229
+ : truncateToWidth(displayValue, maxWidth, Ellipsis.Omit);
230
+
231
+ return truncateToWidth(truncatedValue, maxWidth, Ellipsis.Omit);
232
+ }
233
+
234
+ #getDisplayValue(item: SelectItem): string {
235
+ return sanitizeSingleLine(item.label || item.value);
236
+ }
237
+
238
+ #notifySelectionChange(): void {
239
+ const selectedItem = this.#filteredItems[this.#selectedIndex];
240
+ if (selectedItem && this.onSelectionChange) {
241
+ this.onSelectionChange(selectedItem);
242
+ }
243
+ }
244
+
245
+ getSelectedItem(): SelectItem | null {
246
+ const item = this.#filteredItems[this.#selectedIndex];
247
+ return item || null;
248
+ }
249
+ }
@@ -0,0 +1,211 @@
1
+ import { getKeybindings } from "../keybindings";
2
+ import type { Component } from "../tui";
3
+ import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
4
+
5
+ export interface SettingItem {
6
+ /** Unique identifier for this setting */
7
+ id: string;
8
+ /** Display label (left side) */
9
+ label: string;
10
+ /** Optional description shown when selected */
11
+ description?: string;
12
+ /** Current value to display (right side) */
13
+ currentValue: string;
14
+ /** If provided, Enter/Space cycles through these values */
15
+ values?: string[];
16
+ /** If provided, Enter opens this submenu. Receives current value and done callback. */
17
+ submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
18
+ }
19
+
20
+ export interface SettingsListTheme {
21
+ label: (text: string, selected: boolean) => string;
22
+ value: (text: string, selected: boolean) => string;
23
+ description: (text: string) => string;
24
+ cursor: string;
25
+ hint: (text: string) => string;
26
+ }
27
+
28
+ export class SettingsList implements Component {
29
+ #items: SettingItem[];
30
+ #theme: SettingsListTheme;
31
+ #selectedIndex = 0;
32
+ #maxVisible: number;
33
+ #onChange: (id: string, newValue: string) => void;
34
+ #onCancel: () => void;
35
+
36
+ // Submenu state
37
+ #submenuComponent: Component | null = null;
38
+ #submenuItemIndex: number | null = null;
39
+
40
+ constructor(
41
+ items: SettingItem[],
42
+ maxVisible: number,
43
+ theme: SettingsListTheme,
44
+ onChange: (id: string, newValue: string) => void,
45
+ onCancel: () => void,
46
+ ) {
47
+ this.#items = items;
48
+ this.#maxVisible = maxVisible;
49
+ this.#theme = theme;
50
+ this.#onChange = onChange;
51
+ this.#onCancel = onCancel;
52
+ }
53
+
54
+ /** Update an item's currentValue */
55
+ updateValue(id: string, newValue: string): void {
56
+ const item = this.#items.find(i => i.id === id);
57
+ if (item) {
58
+ item.currentValue = newValue;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Replace the entire items array. Selection is preserved when the prior
64
+ * index is still valid, otherwise clamped to the last item (or 0 if the
65
+ * list is now empty). An open submenu is left untouched — its lifetime
66
+ * is bounded by its own done callback, and `#closeSubmenu` re-clamps the
67
+ * restored index against the new list on the way out.
68
+ */
69
+ setItems(items: SettingItem[]): void {
70
+ this.#items = items;
71
+ if (this.#items.length === 0) {
72
+ this.#selectedIndex = 0;
73
+ } else if (this.#selectedIndex >= this.#items.length) {
74
+ this.#selectedIndex = this.#items.length - 1;
75
+ }
76
+ }
77
+
78
+ invalidate(): void {
79
+ this.#submenuComponent?.invalidate?.();
80
+ }
81
+
82
+ render(width: number): string[] {
83
+ // If submenu is active, render it instead
84
+ if (this.#submenuComponent) {
85
+ return this.#submenuComponent.render(width);
86
+ }
87
+
88
+ return this.#renderMainList(width);
89
+ }
90
+
91
+ #renderMainList(width: number): string[] {
92
+ const lines: string[] = [];
93
+
94
+ if (this.#items.length === 0) {
95
+ lines.push(this.#theme.hint(" No settings available"));
96
+ return lines;
97
+ }
98
+
99
+ // Calculate visible range with scrolling
100
+ const startIndex = Math.max(
101
+ 0,
102
+ Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.#items.length - this.#maxVisible),
103
+ );
104
+ const endIndex = Math.min(startIndex + this.#maxVisible, this.#items.length);
105
+
106
+ // Calculate max label width for alignment
107
+ const maxLabelWidth = Math.min(30, Math.max(...this.#items.map(item => visibleWidth(item.label))));
108
+
109
+ // Render visible items
110
+ for (let i = startIndex; i < endIndex; i++) {
111
+ const item = this.#items[i];
112
+ if (!item) continue;
113
+
114
+ const isSelected = i === this.#selectedIndex;
115
+ const prefix = isSelected ? this.#theme.cursor : " ";
116
+ const prefixWidth = visibleWidth(prefix);
117
+
118
+ // Pad label to align values
119
+ const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
120
+ const labelText = this.#theme.label(labelPadded, isSelected);
121
+
122
+ // Calculate space for value
123
+ const separator = " ";
124
+ const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
125
+ const valueMaxWidth = width - usedWidth - 2;
126
+
127
+ const valueText = this.#theme.value(
128
+ truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit),
129
+ isSelected,
130
+ );
131
+
132
+ lines.push(truncateToWidth(prefix + labelText + separator + valueText, width));
133
+ }
134
+
135
+ // Add scroll indicator if needed
136
+ if (startIndex > 0 || endIndex < this.#items.length) {
137
+ const scrollText = ` (${this.#selectedIndex + 1}/${this.#items.length})`;
138
+ lines.push(this.#theme.hint(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
139
+ }
140
+
141
+ // Add description for selected item
142
+ const selectedItem = this.#items[this.#selectedIndex];
143
+ if (selectedItem?.description) {
144
+ lines.push("");
145
+ const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
146
+ for (const line of wrappedDesc) {
147
+ lines.push(this.#theme.description(` ${line}`));
148
+ }
149
+ }
150
+
151
+ // Add hint
152
+ lines.push("");
153
+ lines.push(truncateToWidth(this.#theme.hint(" Enter/Space to change · Esc to cancel"), width));
154
+
155
+ return lines;
156
+ }
157
+
158
+ handleInput(data: string): void {
159
+ // If submenu is active, delegate all input to it
160
+ // The submenu's onCancel (triggered by escape) will call done() which closes it
161
+ if (this.#submenuComponent) {
162
+ this.#submenuComponent.handleInput?.(data);
163
+ return;
164
+ }
165
+
166
+ // Main list input handling
167
+ const kb = getKeybindings();
168
+ if (kb.matches(data, "tui.select.up")) {
169
+ this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
170
+ } else if (kb.matches(data, "tui.select.down")) {
171
+ this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1;
172
+ } else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") {
173
+ this.#activateItem();
174
+ } else if (kb.matches(data, "tui.select.cancel")) {
175
+ this.#onCancel();
176
+ }
177
+ }
178
+
179
+ #activateItem(): void {
180
+ const item = this.#items[this.#selectedIndex];
181
+ if (!item) return;
182
+
183
+ if (item.submenu) {
184
+ // Open submenu, passing current value so it can pre-select correctly
185
+ this.#submenuItemIndex = this.#selectedIndex;
186
+ this.#submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {
187
+ if (selectedValue !== undefined) {
188
+ item.currentValue = selectedValue;
189
+ this.#onChange(item.id, selectedValue);
190
+ }
191
+ this.#closeSubmenu();
192
+ });
193
+ } else if (item.values && item.values.length > 0) {
194
+ // Cycle through values
195
+ const currentIndex = item.values.indexOf(item.currentValue);
196
+ const nextIndex = (currentIndex + 1) % item.values.length;
197
+ const newValue = item.values[nextIndex];
198
+ item.currentValue = newValue;
199
+ this.#onChange(item.id, newValue);
200
+ }
201
+ }
202
+
203
+ #closeSubmenu(): void {
204
+ this.#submenuComponent = null;
205
+ // Restore selection to the item that opened the submenu
206
+ if (this.#submenuItemIndex !== null) {
207
+ this.#selectedIndex = this.#submenuItemIndex;
208
+ this.#submenuItemIndex = null;
209
+ }
210
+ }
211
+ }
@@ -0,0 +1,28 @@
1
+ import type { Component } from "../tui";
2
+
3
+ /**
4
+ * Spacer component that renders empty lines
5
+ */
6
+ export class Spacer implements Component {
7
+ #lines: number;
8
+
9
+ constructor(lines: number = 1) {
10
+ this.#lines = lines;
11
+ }
12
+
13
+ setLines(lines: number): void {
14
+ this.#lines = lines;
15
+ }
16
+
17
+ invalidate(): void {
18
+ // No cached state to invalidate currently
19
+ }
20
+
21
+ render(_width: number): string[] {
22
+ const result: string[] = [];
23
+ for (let i = 0; i < this.#lines; i++) {
24
+ result.push("");
25
+ }
26
+ return result;
27
+ }
28
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Tab Bar Component
3
+ *
4
+ * A horizontal tab bar for switching between views/panels.
5
+ * Renders as: "Label: Tab1 Tab2 Tab3 (tab to cycle)"
6
+ *
7
+ * Navigation:
8
+ * - Tab / Arrow Right: Next tab (wraps around)
9
+ * - Shift+Tab / Arrow Left: Previous tab (wraps around)
10
+ */
11
+ import { matchesKey } from "../keys";
12
+ import type { Component } from "../tui";
13
+ import { truncateToWidth, visibleWidth } from "../utils";
14
+
15
+ /** Tab definition */
16
+ export interface Tab {
17
+ /** Unique identifier for the tab */
18
+ id: string;
19
+ /** Display label shown in the tab bar */
20
+ label: string;
21
+ }
22
+
23
+ /** Theme for styling the tab bar */
24
+ export interface TabBarTheme {
25
+ /** Style for the label prefix (e.g., "Settings:") */
26
+ label: (text: string) => string;
27
+ /** Style for the currently active tab */
28
+ activeTab: (text: string) => string;
29
+ /** Style for inactive tabs */
30
+ inactiveTab: (text: string) => string;
31
+ /** Style for the hint text (e.g., "(tab to cycle)") */
32
+ hint: (text: string) => string;
33
+ }
34
+
35
+ /**
36
+ * Horizontal tab bar component.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const tabs = [
41
+ * { id: "config", label: "Config" },
42
+ * { id: "tools", label: "Tools" },
43
+ * ];
44
+ * const tabBar = new TabBar("Settings", tabs, theme);
45
+ * tabBar.onTabChange = (tab) => console.log(`Switched to ${tab.id}`);
46
+ * ```
47
+ */
48
+ export class TabBar implements Component {
49
+ #tabs: Tab[];
50
+ #activeIndex: number = 0;
51
+ #theme: TabBarTheme;
52
+ #label: string;
53
+
54
+ /** Callback fired when the active tab changes */
55
+ onTabChange?: (tab: Tab, index: number) => void;
56
+
57
+ constructor(label: string, tabs: Tab[], theme: TabBarTheme, initialIndex: number = 0) {
58
+ this.#label = label;
59
+ this.#tabs = tabs;
60
+ this.#theme = theme;
61
+ this.#activeIndex = initialIndex;
62
+ }
63
+
64
+ /** Get the currently active tab */
65
+ getActiveTab(): Tab {
66
+ return this.#tabs[this.#activeIndex];
67
+ }
68
+
69
+ /** Get the index of the currently active tab */
70
+ getActiveIndex(): number {
71
+ return this.#activeIndex;
72
+ }
73
+
74
+ /** Set the active tab by index (clamped to valid range) */
75
+ setActiveIndex(index: number): void {
76
+ const newIndex = Math.max(0, Math.min(index, this.#tabs.length - 1));
77
+ if (newIndex !== this.#activeIndex) {
78
+ this.#activeIndex = newIndex;
79
+ this.onTabChange?.(this.#tabs[this.#activeIndex], this.#activeIndex);
80
+ }
81
+ }
82
+
83
+ /** Move to the next tab (wraps to first tab after last) */
84
+ nextTab(): void {
85
+ this.setActiveIndex((this.#activeIndex + 1) % this.#tabs.length);
86
+ }
87
+
88
+ /** Move to the previous tab (wraps to last tab before first) */
89
+ prevTab(): void {
90
+ this.setActiveIndex((this.#activeIndex - 1 + this.#tabs.length) % this.#tabs.length);
91
+ }
92
+
93
+ invalidate(): void {
94
+ // No cached state to invalidate
95
+ }
96
+
97
+ /**
98
+ * Handle keyboard input for tab navigation.
99
+ * @returns true if the input was handled, false otherwise
100
+ */
101
+ handleInput(data: string): boolean {
102
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
103
+ this.nextTab();
104
+ return true;
105
+ }
106
+ if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
107
+ this.prevTab();
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+
113
+ /** Render the tab bar, wrapping to multiple lines if needed */
114
+ render(width: number): string[] {
115
+ const maxWidth = Math.max(1, width);
116
+ const chunks: string[] = [];
117
+
118
+ // Label prefix
119
+ chunks.push(this.#theme.label(`${this.#label}:`));
120
+ chunks.push(" ");
121
+
122
+ // Tab buttons
123
+ for (let i = 0; i < this.#tabs.length; i++) {
124
+ const tab = this.#tabs[i];
125
+ if (i === this.#activeIndex) {
126
+ chunks.push(this.#theme.activeTab(` ${tab.label} `));
127
+ } else {
128
+ chunks.push(this.#theme.inactiveTab(` ${tab.label} `));
129
+ }
130
+ if (i < this.#tabs.length - 1) {
131
+ chunks.push(" ");
132
+ }
133
+ }
134
+
135
+ // Navigation hint
136
+ chunks.push(" ");
137
+ chunks.push(this.#theme.hint("(tab to cycle)"));
138
+
139
+ const lines: string[] = [];
140
+ let currentLine = "";
141
+ let currentWidth = 0;
142
+
143
+ for (const chunk of chunks) {
144
+ const chunkWidth = visibleWidth(chunk);
145
+ if (chunkWidth <= 0) {
146
+ continue;
147
+ }
148
+
149
+ if (chunkWidth > maxWidth) {
150
+ if (currentLine) {
151
+ lines.push(currentLine);
152
+ currentLine = "";
153
+ currentWidth = 0;
154
+ }
155
+ lines.push(truncateToWidth(chunk, maxWidth));
156
+ continue;
157
+ }
158
+
159
+ if (currentWidth > 0 && currentWidth + chunkWidth > maxWidth) {
160
+ lines.push(currentLine);
161
+ currentLine = "";
162
+ currentWidth = 0;
163
+ }
164
+
165
+ currentLine += chunk;
166
+ currentWidth += chunkWidth;
167
+ }
168
+
169
+ if (currentLine) {
170
+ lines.push(currentLine);
171
+ }
172
+
173
+ return lines.length > 0 ? lines : [""];
174
+ }
175
+ }