@linxiraos/pi-tui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +2219 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +116 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +162 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +25 -0
  10. package/dist/types/components/loader.d.ts +25 -0
  11. package/dist/types/components/markdown.d.ts +88 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +69 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +27 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/desktop-notify.d.ts +52 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +48 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +197 -0
  25. package/dist/types/keys.d.ts +210 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +76 -0
  28. package/dist/types/latex-block.d.ts +8 -0
  29. package/dist/types/latex-to-unicode.d.ts +50 -0
  30. package/dist/types/loop-watchdog.d.ts +44 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +285 -0
  35. package/dist/types/terminal.d.ts +175 -0
  36. package/dist/types/tmux.d.ts +6 -0
  37. package/dist/types/ttyid.d.ts +9 -0
  38. package/dist/types/tui.d.ts +457 -0
  39. package/dist/types/utils.d.ts +100 -0
  40. package/package.json +70 -0
  41. package/src/autocomplete.ts +1079 -0
  42. package/src/bracketed-paste.ts +123 -0
  43. package/src/components/box.ts +236 -0
  44. package/src/components/cancellable-loader.ts +40 -0
  45. package/src/components/editor.ts +3301 -0
  46. package/src/components/image.ts +460 -0
  47. package/src/components/input.ts +482 -0
  48. package/src/components/loader.ts +174 -0
  49. package/src/components/markdown.ts +3119 -0
  50. package/src/components/scroll-view.ts +227 -0
  51. package/src/components/select-list.ts +539 -0
  52. package/src/components/settings-list.ts +793 -0
  53. package/src/components/spacer.ts +32 -0
  54. package/src/components/tab-bar.ts +300 -0
  55. package/src/components/text.ts +173 -0
  56. package/src/components/truncated-text.ts +69 -0
  57. package/src/deccara.ts +314 -0
  58. package/src/desktop-notify.ts +192 -0
  59. package/src/editor-component.ts +74 -0
  60. package/src/fuzzy.ts +384 -0
  61. package/src/index.ts +51 -0
  62. package/src/keybindings.ts +346 -0
  63. package/src/keys.ts +566 -0
  64. package/src/kill-ring.ts +51 -0
  65. package/src/kitty-graphics.ts +171 -0
  66. package/src/latex-block.ts +1338 -0
  67. package/src/latex-to-unicode.ts +2017 -0
  68. package/src/loop-watchdog.ts +115 -0
  69. package/src/mouse.ts +105 -0
  70. package/src/stdin-buffer.ts +781 -0
  71. package/src/symbols.ts +26 -0
  72. package/src/terminal-capabilities.ts +1211 -0
  73. package/src/terminal.ts +1854 -0
  74. package/src/tmux.ts +14 -0
  75. package/src/ttyid.ts +84 -0
  76. package/src/tui.ts +4275 -0
  77. package/src/utils.ts +619 -0
@@ -0,0 +1,32 @@
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
+ #cached: string[] | undefined;
9
+
10
+ constructor(lines: number = 1) {
11
+ this.#lines = lines;
12
+ }
13
+
14
+ setLines(lines: number): void {
15
+ if (lines === this.#lines) return;
16
+ this.#lines = lines;
17
+ this.#cached = undefined;
18
+ }
19
+
20
+ invalidate(): void {
21
+ // No cached state to invalidate currently
22
+ }
23
+
24
+ render(_width: number): readonly string[] {
25
+ let cached = this.#cached;
26
+ if (cached === undefined) {
27
+ cached = new Array(this.#lines).fill("");
28
+ this.#cached = cached;
29
+ }
30
+ return cached;
31
+ }
32
+ }
@@ -0,0 +1,300 @@
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
+ /** Compact form (e.g. just the icon) used when the bar must shrink to fit one line. */
22
+ short?: string;
23
+ /** Render with the muted style and skip during keyboard navigation. */
24
+ muted?: boolean;
25
+ }
26
+
27
+ /** Theme for styling the tab bar */
28
+ export interface TabBarTheme {
29
+ /** Style for the label prefix (e.g., "Settings:") */
30
+ label: (text: string) => string;
31
+ /** Style for the currently active tab */
32
+ activeTab: (text: string) => string;
33
+ /** Style for inactive tabs */
34
+ inactiveTab: (text: string) => string;
35
+ /** Style for the hint text (e.g., "(tab to cycle)") */
36
+ hint: (text: string) => string;
37
+ /** Style for muted tabs. Falls back to `inactiveTab` when omitted. */
38
+ mutedTab?: (text: string) => string;
39
+ /** Style for the tab under the mouse pointer. Falls back to `inactiveTab` when omitted. */
40
+ hoverTab?: (text: string) => string;
41
+ }
42
+
43
+ /**
44
+ * Horizontal tab bar component.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * const tabs = [
49
+ * { id: "config", label: "Config" },
50
+ * { id: "tools", label: "Tools" },
51
+ * ];
52
+ * const tabBar = new TabBar("Settings", tabs, theme);
53
+ * tabBar.onTabChange = (tab) => console.log(`Switched to ${tab.id}`);
54
+ * ```
55
+ */
56
+ export class TabBar implements Component {
57
+ #tabs: Tab[];
58
+ #activeIndex: number = 0;
59
+ #theme: TabBarTheme;
60
+ #label: string;
61
+ #hoverTabId: string | null = null;
62
+ /** Per-render tab hit zones: 0-based line + [start, end) columns. */
63
+ #hitZones: { line: number; start: number; end: number; index: number }[] = [];
64
+
65
+ /** Callback fired when the active tab changes */
66
+ onTabChange?: (tab: Tab, index: number) => void;
67
+
68
+ /** Render the trailing "(tab to cycle)" hint. Disable when the host folds the hint into its own footer. */
69
+ showHint = true;
70
+
71
+ constructor(label: string, tabs: Tab[], theme: TabBarTheme, initialIndex: number = 0) {
72
+ this.#label = label;
73
+ this.#tabs = tabs;
74
+ this.#theme = theme;
75
+ this.#activeIndex = initialIndex;
76
+ }
77
+
78
+ /** Get the currently active tab */
79
+ getActiveTab(): Tab {
80
+ return this.#tabs[this.#activeIndex];
81
+ }
82
+
83
+ /** Get the index of the currently active tab */
84
+ getActiveIndex(): number {
85
+ return this.#activeIndex;
86
+ }
87
+
88
+ /** Set the active tab by index (clamped to valid range) */
89
+ setActiveIndex(index: number): void {
90
+ const newIndex = Math.max(0, Math.min(index, this.#tabs.length - 1));
91
+ if (newIndex !== this.#activeIndex) {
92
+ this.#activeIndex = newIndex;
93
+ this.onTabChange?.(this.#tabs[this.#activeIndex], this.#activeIndex);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Replace the tab set without firing onTabChange. The active tab is
99
+ * preserved by id when it survives the swap (or forced via `activeId`);
100
+ * otherwise the index is clamped.
101
+ */
102
+ setTabs(tabs: Tab[], activeId?: string): void {
103
+ const targetId = activeId ?? this.#tabs[this.#activeIndex]?.id;
104
+ this.#tabs = tabs;
105
+ const index = tabs.findIndex(tab => tab.id === targetId);
106
+ this.#activeIndex = index >= 0 ? index : Math.max(0, Math.min(this.#activeIndex, tabs.length - 1));
107
+ }
108
+
109
+ /** Set the active tab by id without firing onTabChange. Returns false when the id is unknown. */
110
+ setActiveById(id: string): boolean {
111
+ const index = this.#tabs.findIndex(tab => tab.id === id);
112
+ if (index === -1) return false;
113
+ this.#activeIndex = index;
114
+ return true;
115
+ }
116
+
117
+ /** Activate the tab with `id`, firing onTabChange when it changes. Muted tabs are ignored. */
118
+ selectTab(id: string): boolean {
119
+ const index = this.#tabs.findIndex(tab => tab.id === id);
120
+ if (index === -1 || this.#tabs[index]?.muted) return false;
121
+ this.setActiveIndex(index);
122
+ return true;
123
+ }
124
+
125
+ /** Move to the next non-muted tab (wraps to first tab after last) */
126
+ nextTab(): void {
127
+ this.#stepTab(1);
128
+ }
129
+
130
+ /** Move to the previous non-muted tab (wraps to last tab before first) */
131
+ prevTab(): void {
132
+ this.#stepTab(-1);
133
+ }
134
+
135
+ /** Step to the nearest non-muted tab in `delta` direction; no-op when none exists. */
136
+ #stepTab(delta: -1 | 1): void {
137
+ const len = this.#tabs.length;
138
+ if (len === 0) return;
139
+ for (let step = 1; step <= len; step++) {
140
+ const index = (((this.#activeIndex + delta * step) % len) + len) % len;
141
+ if (!this.#tabs[index]?.muted) {
142
+ this.setActiveIndex(index);
143
+ return;
144
+ }
145
+ }
146
+ }
147
+
148
+ invalidate(): void {
149
+ // No cached state to invalidate
150
+ }
151
+
152
+ /**
153
+ * Handle keyboard input for tab navigation.
154
+ * @returns true if the input was handled, false otherwise
155
+ */
156
+ handleInput(data: string): boolean {
157
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
158
+ this.nextTab();
159
+ return true;
160
+ }
161
+ if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
162
+ this.prevTab();
163
+ return true;
164
+ }
165
+ return false;
166
+ }
167
+
168
+ /**
169
+ * Render the tab bar. When the full labels overflow the width, tabs are
170
+ * collapsed to their `short` form one by one — starting with the tabs
171
+ * farthest from the active one — until the bar fits on a single line.
172
+ * Wrapping to multiple lines is the last resort.
173
+ */
174
+ render(width: number): readonly string[] {
175
+ const maxWidth = Math.max(1, width);
176
+
177
+ interface TabChunk {
178
+ text: string;
179
+ /** Index into #tabs when this chunk is a clickable tab button. */
180
+ tabIndex?: number;
181
+ }
182
+
183
+ const buildChunks = (labels: readonly string[]): TabChunk[] => {
184
+ const chunks: TabChunk[] = [];
185
+ // Label prefix (omitted when the label is empty)
186
+ if (this.#label) {
187
+ chunks.push({ text: this.#theme.label(`${this.#label}:`) });
188
+ chunks.push({ text: " " });
189
+ }
190
+ for (let i = 0; i < this.#tabs.length; i++) {
191
+ const tab = this.#tabs[i];
192
+ // Muted tabs never take the active highlight: they are skipped by
193
+ // navigation and only become "active" transiently via setTabs swaps.
194
+ // A hovered (non-active) tab lights up so mouse users see the target.
195
+ const hovered = tab.id === this.#hoverTabId && !tab.muted && i !== this.#activeIndex;
196
+ const style = tab.muted
197
+ ? (this.#theme.mutedTab ?? this.#theme.inactiveTab)
198
+ : i === this.#activeIndex
199
+ ? this.#theme.activeTab
200
+ : hovered
201
+ ? (this.#theme.hoverTab ?? this.#theme.inactiveTab)
202
+ : this.#theme.inactiveTab;
203
+ chunks.push({ text: style(` ${labels[i]} `), tabIndex: i });
204
+ if (i < this.#tabs.length - 1) {
205
+ chunks.push({ text: " " });
206
+ }
207
+ }
208
+ // Navigation hint
209
+ if (this.showHint) {
210
+ chunks.push({ text: " " });
211
+ chunks.push({ text: this.#theme.hint("(tab to cycle)") });
212
+ }
213
+ return chunks;
214
+ };
215
+ const totalWidth = (chunks: TabChunk[]): number =>
216
+ chunks.reduce((sum, chunk) => sum + visibleWidth(chunk.text), 0);
217
+
218
+ const labels = this.#tabs.map(tab => tab.label);
219
+ let chunks = buildChunks(labels);
220
+
221
+ if (totalWidth(chunks) > maxWidth) {
222
+ const collapseOrder = this.#tabs
223
+ .map((_, index) => index)
224
+ .filter(index => index !== this.#activeIndex && this.#tabs[index].short !== undefined)
225
+ .sort((a, b) => Math.abs(b - this.#activeIndex) - Math.abs(a - this.#activeIndex));
226
+ for (const index of collapseOrder) {
227
+ labels[index] = this.#tabs[index].short ?? this.#tabs[index].label;
228
+ chunks = buildChunks(labels);
229
+ if (totalWidth(chunks) <= maxWidth) break;
230
+ }
231
+ }
232
+
233
+ this.#hitZones = [];
234
+ const lines: string[] = [];
235
+ let currentLine = "";
236
+ let currentWidth = 0;
237
+
238
+ for (const chunk of chunks) {
239
+ const chunkWidth = visibleWidth(chunk.text);
240
+ if (chunkWidth <= 0) {
241
+ continue;
242
+ }
243
+
244
+ if (chunkWidth > maxWidth) {
245
+ if (currentLine) {
246
+ lines.push(currentLine);
247
+ currentLine = "";
248
+ currentWidth = 0;
249
+ }
250
+ if (chunk.tabIndex !== undefined) {
251
+ this.#hitZones.push({ line: lines.length, start: 0, end: maxWidth, index: chunk.tabIndex });
252
+ }
253
+ lines.push(truncateToWidth(chunk.text, maxWidth));
254
+ continue;
255
+ }
256
+
257
+ if (currentWidth > 0 && currentWidth + chunkWidth > maxWidth) {
258
+ lines.push(currentLine);
259
+ currentLine = "";
260
+ currentWidth = 0;
261
+ }
262
+
263
+ if (chunk.tabIndex !== undefined) {
264
+ this.#hitZones.push({
265
+ line: lines.length,
266
+ start: currentWidth,
267
+ end: currentWidth + chunkWidth,
268
+ index: chunk.tabIndex,
269
+ });
270
+ }
271
+ currentLine += chunk.text;
272
+ currentWidth += chunkWidth;
273
+ }
274
+
275
+ if (currentLine) {
276
+ lines.push(currentLine);
277
+ }
278
+
279
+ return lines.length > 0 ? lines : [""];
280
+ }
281
+
282
+ /**
283
+ * Resolve a pointer position against the last rendered frame. `line` is the
284
+ * 0-based line index within this component's render output, `col` the
285
+ * 0-based column.
286
+ */
287
+ tabAt(line: number, col: number): Tab | undefined {
288
+ for (const zone of this.#hitZones) {
289
+ if (zone.line === line && col >= zone.start && col < zone.end) {
290
+ return this.#tabs[zone.index];
291
+ }
292
+ }
293
+ return undefined;
294
+ }
295
+
296
+ /** Highlight the tab under the pointer (null clears). */
297
+ setHoverTab(id: string | null): void {
298
+ this.#hoverTabId = id;
299
+ }
300
+ }
@@ -0,0 +1,173 @@
1
+ import type { Component } from "../tui";
2
+ import {
3
+ applyBackgroundToLine,
4
+ getPaddingX,
5
+ getWidthConfigEpoch,
6
+ padding,
7
+ publishLineWidths,
8
+ replaceTabs,
9
+ visibleWidth,
10
+ wrapTextWithAnsi,
11
+ } from "../utils";
12
+
13
+ /**
14
+ * Text component - displays multi-line text with word wrapping.
15
+ *
16
+ * Foreground colors may be supplied lazily via {@link setStyleFn} instead of
17
+ * baked into `text`: the styler runs at render time, so a caller that
18
+ * invalidates the component on a theme change (see the coding-agent's
19
+ * `onThemeChange` handler) re-resolves the color against the now-active theme
20
+ * rather than replaying the palette active when the component was constructed.
21
+ */
22
+ export class Text implements Component {
23
+ #text: string;
24
+ #paddingX: number; // Left/right padding
25
+ #paddingY: number; // Top/bottom padding
26
+ #customBgFn?: (text: string) => string;
27
+ #styleFn?: (text: string) => string;
28
+
29
+ #ignoreTight = false;
30
+
31
+ setIgnoreTight(ignore: boolean): this {
32
+ this.#ignoreTight = ignore;
33
+ this.invalidate();
34
+ return this;
35
+ }
36
+
37
+ // Cache for rendered output
38
+ #cachedText?: string;
39
+ #cachedWidth?: number;
40
+ #cachedWidthEpoch?: number;
41
+ #cachedLines?: string[];
42
+
43
+ constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
44
+ this.#text = text;
45
+ this.#paddingX = paddingX;
46
+ this.#paddingY = paddingY;
47
+ this.#customBgFn = customBgFn;
48
+ }
49
+
50
+ getText(): string {
51
+ return this.#text;
52
+ }
53
+
54
+ setText(text: string): boolean {
55
+ if (text === this.#text) {
56
+ return false;
57
+ }
58
+ this.#text = text;
59
+ this.#cachedText = undefined;
60
+ this.#cachedWidth = undefined;
61
+ this.#cachedWidthEpoch = undefined;
62
+ this.#cachedLines = undefined;
63
+ return true;
64
+ }
65
+
66
+ setCustomBgFn(customBgFn?: (text: string) => string): void {
67
+ this.#customBgFn = customBgFn;
68
+ this.#cachedText = undefined;
69
+ this.#cachedWidth = undefined;
70
+ this.#cachedWidthEpoch = undefined;
71
+ this.#cachedLines = undefined;
72
+ }
73
+
74
+ /**
75
+ * Supply a foreground styler applied to the text at render time (e.g. a
76
+ * theme color resolver). Unlike baking the color into `text`, the styler
77
+ * re-runs on every render, so invalidating the component after a theme
78
+ * change re-resolves the color against the active theme.
79
+ */
80
+ setStyleFn(styleFn?: (text: string) => string): this {
81
+ this.#styleFn = styleFn;
82
+ this.#cachedText = undefined;
83
+ this.#cachedWidth = undefined;
84
+ this.#cachedWidthEpoch = undefined;
85
+ this.#cachedLines = undefined;
86
+ return this;
87
+ }
88
+
89
+ invalidate(): void {
90
+ this.#cachedText = undefined;
91
+ this.#cachedWidth = undefined;
92
+ this.#cachedWidthEpoch = undefined;
93
+ this.#cachedLines = undefined;
94
+ }
95
+
96
+ render(width: number): readonly string[] {
97
+ // Check cache
98
+ if (
99
+ this.#cachedLines &&
100
+ this.#cachedText === this.#text &&
101
+ this.#cachedWidth === width &&
102
+ this.#cachedWidthEpoch === getWidthConfigEpoch()
103
+ ) {
104
+ return this.#cachedLines;
105
+ }
106
+
107
+ // Don't render anything if there's no actual text
108
+ if (!this.#text || this.#text.trim() === "") {
109
+ const result: string[] = [];
110
+ this.#cachedText = this.#text;
111
+ this.#cachedWidth = width;
112
+ this.#cachedWidthEpoch = getWidthConfigEpoch();
113
+ this.#cachedLines = result;
114
+ return result;
115
+ }
116
+
117
+ // Replace tabs with 3 spaces
118
+ const normalizedText = replaceTabs(this.#styleFn ? this.#styleFn(this.#text) : this.#text);
119
+
120
+ // Calculate content width (subtract left/right margins)
121
+ const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
122
+ const contentWidth = Math.max(1, width - paddingX * 2);
123
+ // Wrap text (this preserves ANSI codes but does NOT pad)
124
+ const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
125
+
126
+ // Add margins and background to each line
127
+ const leftMargin = padding(paddingX);
128
+ const rightMargin = padding(paddingX);
129
+ const contentLines: string[] = [];
130
+ // Exact visible widths of `result` rows, published only when rows are
131
+ // `content + spaces` (customBgFn output width is not knowable here).
132
+ const resultWidths: number[] | undefined = this.#customBgFn ? undefined : [];
133
+
134
+ for (const line of wrappedLines) {
135
+ // Add margins
136
+ const lineWithMargins = leftMargin + line + rightMargin;
137
+
138
+ // Apply background if specified (this also pads to full width)
139
+ if (this.#customBgFn) {
140
+ contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.#customBgFn));
141
+ } else {
142
+ // No background - just pad to width with spaces
143
+ const visibleLen = visibleWidth(lineWithMargins);
144
+ const paddingNeeded = Math.max(0, width - visibleLen);
145
+ contentLines.push(lineWithMargins + padding(paddingNeeded));
146
+ resultWidths?.push(visibleLen + paddingNeeded);
147
+ }
148
+ }
149
+
150
+ // Add top/bottom padding (empty lines)
151
+ const emptyLine = padding(width);
152
+ const emptyLines: string[] = [];
153
+ for (let i = 0; i < this.#paddingY; i++) {
154
+ const line = this.#customBgFn ? applyBackgroundToLine(emptyLine, width, this.#customBgFn) : emptyLine;
155
+ emptyLines.push(line);
156
+ }
157
+
158
+ const result = [...emptyLines, ...contentLines, ...emptyLines];
159
+ if (resultWidths !== undefined) {
160
+ // Pad rows are exactly `width` cells wide.
161
+ const emptyWidths = new Array<number>(emptyLines.length).fill(width);
162
+ publishLineWidths(result, [...emptyWidths, ...resultWidths, ...emptyWidths]);
163
+ }
164
+
165
+ // Update cache
166
+ this.#cachedText = this.#text;
167
+ this.#cachedWidth = width;
168
+ this.#cachedWidthEpoch = getWidthConfigEpoch();
169
+ this.#cachedLines = result;
170
+
171
+ return result.length > 0 ? result : [""];
172
+ }
173
+ }
@@ -0,0 +1,69 @@
1
+ import type { Component } from "../tui";
2
+ import { padding, truncateToWidth } from "../utils";
3
+
4
+ /**
5
+ * Text component that truncates to fit viewport width
6
+ */
7
+ export class TruncatedText implements Component {
8
+ #text: string;
9
+ #paddingX: number;
10
+ #paddingY: number;
11
+ #cachedWidth = -1;
12
+ #cachedLines: string[] | undefined;
13
+
14
+ constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
15
+ this.#text = text;
16
+ this.#paddingX = paddingX;
17
+ this.#paddingY = paddingY;
18
+ }
19
+
20
+ invalidate(): void {
21
+ this.#cachedWidth = -1;
22
+ this.#cachedLines = undefined;
23
+ }
24
+
25
+ render(width: number): readonly string[] {
26
+ if (this.#cachedLines && this.#cachedWidth === width) {
27
+ return this.#cachedLines;
28
+ }
29
+ const result: string[] = [];
30
+
31
+ // Empty line padded to width
32
+ const emptyLine = padding(width);
33
+
34
+ // Add vertical padding above
35
+ for (let i = 0; i < this.#paddingY; i++) {
36
+ result.push(emptyLine);
37
+ }
38
+
39
+ // Calculate available width after horizontal padding
40
+ const availableWidth = Math.max(1, width - this.#paddingX * 2);
41
+
42
+ // Take only the first line (stop at newline)
43
+ let singleLineText = this.#text;
44
+ const newlineIndex = this.#text.indexOf("\n");
45
+ if (newlineIndex !== -1) {
46
+ singleLineText = this.#text.substring(0, newlineIndex);
47
+ }
48
+
49
+ // Truncate text if needed (accounting for ANSI codes)
50
+ const displayText = truncateToWidth(singleLineText, availableWidth);
51
+
52
+ // Add horizontal padding
53
+ const leftPadding = padding(this.#paddingX);
54
+ const rightPadding = padding(this.#paddingX);
55
+ const lineWithPadding = leftPadding + displayText + rightPadding;
56
+
57
+ // Don't pad to full width - avoids trailing spaces when copying
58
+ result.push(lineWithPadding);
59
+
60
+ // Add vertical padding below
61
+ for (let i = 0; i < this.#paddingY; i++) {
62
+ result.push(emptyLine);
63
+ }
64
+
65
+ this.#cachedWidth = width;
66
+ this.#cachedLines = result;
67
+ return result;
68
+ }
69
+ }