@f5xc-salesdemos/pi-tui 14.0.2

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.
@@ -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,195 @@
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
+ invalidate(): void {
63
+ this.#submenuComponent?.invalidate?.();
64
+ }
65
+
66
+ render(width: number): string[] {
67
+ // If submenu is active, render it instead
68
+ if (this.#submenuComponent) {
69
+ return this.#submenuComponent.render(width);
70
+ }
71
+
72
+ return this.#renderMainList(width);
73
+ }
74
+
75
+ #renderMainList(width: number): string[] {
76
+ const lines: string[] = [];
77
+
78
+ if (this.#items.length === 0) {
79
+ lines.push(this.#theme.hint(" No settings available"));
80
+ return lines;
81
+ }
82
+
83
+ // Calculate visible range with scrolling
84
+ const startIndex = Math.max(
85
+ 0,
86
+ Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.#items.length - this.#maxVisible),
87
+ );
88
+ const endIndex = Math.min(startIndex + this.#maxVisible, this.#items.length);
89
+
90
+ // Calculate max label width for alignment
91
+ const maxLabelWidth = Math.min(30, Math.max(...this.#items.map(item => visibleWidth(item.label))));
92
+
93
+ // Render visible items
94
+ for (let i = startIndex; i < endIndex; i++) {
95
+ const item = this.#items[i];
96
+ if (!item) continue;
97
+
98
+ const isSelected = i === this.#selectedIndex;
99
+ const prefix = isSelected ? this.#theme.cursor : " ";
100
+ const prefixWidth = visibleWidth(prefix);
101
+
102
+ // Pad label to align values
103
+ const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
104
+ const labelText = this.#theme.label(labelPadded, isSelected);
105
+
106
+ // Calculate space for value
107
+ const separator = " ";
108
+ const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
109
+ const valueMaxWidth = width - usedWidth - 2;
110
+
111
+ const valueText = this.#theme.value(
112
+ truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit),
113
+ isSelected,
114
+ );
115
+
116
+ lines.push(truncateToWidth(prefix + labelText + separator + valueText, width));
117
+ }
118
+
119
+ // Add scroll indicator if needed
120
+ if (startIndex > 0 || endIndex < this.#items.length) {
121
+ const scrollText = ` (${this.#selectedIndex + 1}/${this.#items.length})`;
122
+ lines.push(this.#theme.hint(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
123
+ }
124
+
125
+ // Add description for selected item
126
+ const selectedItem = this.#items[this.#selectedIndex];
127
+ if (selectedItem?.description) {
128
+ lines.push("");
129
+ const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
130
+ for (const line of wrappedDesc) {
131
+ lines.push(this.#theme.description(` ${line}`));
132
+ }
133
+ }
134
+
135
+ // Add hint
136
+ lines.push("");
137
+ lines.push(truncateToWidth(this.#theme.hint(" Enter/Space to change · Esc to cancel"), width));
138
+
139
+ return lines;
140
+ }
141
+
142
+ handleInput(data: string): void {
143
+ // If submenu is active, delegate all input to it
144
+ // The submenu's onCancel (triggered by escape) will call done() which closes it
145
+ if (this.#submenuComponent) {
146
+ this.#submenuComponent.handleInput?.(data);
147
+ return;
148
+ }
149
+
150
+ // Main list input handling
151
+ const kb = getKeybindings();
152
+ if (kb.matches(data, "tui.select.up")) {
153
+ this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
154
+ } else if (kb.matches(data, "tui.select.down")) {
155
+ this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1;
156
+ } else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") {
157
+ this.#activateItem();
158
+ } else if (kb.matches(data, "tui.select.cancel")) {
159
+ this.#onCancel();
160
+ }
161
+ }
162
+
163
+ #activateItem(): void {
164
+ const item = this.#items[this.#selectedIndex];
165
+ if (!item) return;
166
+
167
+ if (item.submenu) {
168
+ // Open submenu, passing current value so it can pre-select correctly
169
+ this.#submenuItemIndex = this.#selectedIndex;
170
+ this.#submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {
171
+ if (selectedValue !== undefined) {
172
+ item.currentValue = selectedValue;
173
+ this.#onChange(item.id, selectedValue);
174
+ }
175
+ this.#closeSubmenu();
176
+ });
177
+ } else if (item.values && item.values.length > 0) {
178
+ // Cycle through values
179
+ const currentIndex = item.values.indexOf(item.currentValue);
180
+ const nextIndex = (currentIndex + 1) % item.values.length;
181
+ const newValue = item.values[nextIndex];
182
+ item.currentValue = newValue;
183
+ this.#onChange(item.id, newValue);
184
+ }
185
+ }
186
+
187
+ #closeSubmenu(): void {
188
+ this.#submenuComponent = null;
189
+ // Restore selection to the item that opened the submenu
190
+ if (this.#submenuItemIndex !== null) {
191
+ this.#selectedIndex = this.#submenuItemIndex;
192
+ this.#submenuItemIndex = null;
193
+ }
194
+ }
195
+ }
@@ -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
+ }