@fractaal/pi-tui 0.85.3 → 0.86.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 (184) hide show
  1. package/README.md +175 -19
  2. package/dist/alt-screen-search.d.ts +46 -0
  3. package/dist/alt-screen-search.d.ts.map +1 -0
  4. package/dist/alt-screen-search.js +282 -0
  5. package/dist/alt-screen-search.js.map +1 -0
  6. package/dist/autocomplete.d.ts +2 -1
  7. package/dist/autocomplete.d.ts.map +1 -1
  8. package/dist/autocomplete.js +85 -24
  9. package/dist/autocomplete.js.map +1 -1
  10. package/dist/colors.d.ts +73 -0
  11. package/dist/colors.d.ts.map +1 -0
  12. package/dist/colors.js +273 -0
  13. package/dist/colors.js.map +1 -0
  14. package/dist/components/alt-screen-flash.d.ts +13 -0
  15. package/dist/components/alt-screen-flash.d.ts.map +1 -0
  16. package/dist/components/alt-screen-flash.js +37 -0
  17. package/dist/components/alt-screen-flash.js.map +1 -0
  18. package/dist/components/box.d.ts +3 -1
  19. package/dist/components/box.d.ts.map +1 -1
  20. package/dist/components/box.js +38 -8
  21. package/dist/components/box.js.map +1 -1
  22. package/dist/components/cancellable-loader.d.ts.map +1 -1
  23. package/dist/components/cancellable-loader.js.map +1 -1
  24. package/dist/components/editor.d.ts +6 -1
  25. package/dist/components/editor.d.ts.map +1 -1
  26. package/dist/components/editor.js +118 -24
  27. package/dist/components/editor.js.map +1 -1
  28. package/dist/components/h-stack.d.ts +7 -0
  29. package/dist/components/h-stack.d.ts.map +1 -0
  30. package/dist/components/h-stack.js +43 -0
  31. package/dist/components/h-stack.js.map +1 -0
  32. package/dist/components/image.d.ts +12 -0
  33. package/dist/components/image.d.ts.map +1 -1
  34. package/dist/components/image.js +36 -3
  35. package/dist/components/image.js.map +1 -1
  36. package/dist/components/input.d.ts +12 -1
  37. package/dist/components/input.d.ts.map +1 -1
  38. package/dist/components/input.js +44 -5
  39. package/dist/components/input.js.map +1 -1
  40. package/dist/components/loader.d.ts +2 -0
  41. package/dist/components/loader.d.ts.map +1 -1
  42. package/dist/components/loader.js +10 -3
  43. package/dist/components/loader.js.map +1 -1
  44. package/dist/components/markdown.d.ts +5 -0
  45. package/dist/components/markdown.d.ts.map +1 -1
  46. package/dist/components/markdown.js +150 -9
  47. package/dist/components/markdown.js.map +1 -1
  48. package/dist/components/mouse-region.d.ts +12 -0
  49. package/dist/components/mouse-region.d.ts.map +1 -0
  50. package/dist/components/mouse-region.js +21 -0
  51. package/dist/components/mouse-region.js.map +1 -0
  52. package/dist/components/scroll-view.d.ts +59 -0
  53. package/dist/components/scroll-view.d.ts.map +1 -0
  54. package/dist/components/scroll-view.js +195 -0
  55. package/dist/components/scroll-view.js.map +1 -0
  56. package/dist/components/select-list.d.ts +4 -1
  57. package/dist/components/select-list.d.ts.map +1 -1
  58. package/dist/components/select-list.js +49 -2
  59. package/dist/components/select-list.js.map +1 -1
  60. package/dist/components/settings-list.d.ts +13 -3
  61. package/dist/components/settings-list.d.ts.map +1 -1
  62. package/dist/components/settings-list.js +82 -15
  63. package/dist/components/settings-list.js.map +1 -1
  64. package/dist/components/spacer.d.ts.map +1 -1
  65. package/dist/components/spacer.js.map +1 -1
  66. package/dist/components/stack.d.ts +32 -0
  67. package/dist/components/stack.d.ts.map +1 -0
  68. package/dist/components/stack.js +111 -0
  69. package/dist/components/stack.js.map +1 -0
  70. package/dist/components/text.d.ts.map +1 -1
  71. package/dist/components/text.js +7 -5
  72. package/dist/components/text.js.map +1 -1
  73. package/dist/components/truncated-text.d.ts.map +1 -1
  74. package/dist/components/truncated-text.js.map +1 -1
  75. package/dist/components/v-stack.d.ts +8 -0
  76. package/dist/components/v-stack.d.ts.map +1 -0
  77. package/dist/components/v-stack.js +26 -0
  78. package/dist/components/v-stack.js.map +1 -0
  79. package/dist/editor-component.d.ts.map +1 -1
  80. package/dist/fuzzy.d.ts.map +1 -1
  81. package/dist/fuzzy.js +23 -22
  82. package/dist/fuzzy.js.map +1 -1
  83. package/dist/index.d.ts +18 -6
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +20 -6
  86. package/dist/index.js.map +1 -1
  87. package/dist/keybindings.d.ts +82 -2
  88. package/dist/keybindings.d.ts.map +1 -1
  89. package/dist/keybindings.js +61 -2
  90. package/dist/keybindings.js.map +1 -1
  91. package/dist/keys.d.ts.map +1 -1
  92. package/dist/keys.js.map +1 -1
  93. package/dist/kill-ring.d.ts.map +1 -1
  94. package/dist/kill-ring.js.map +1 -1
  95. package/dist/latex.d.ts +10 -0
  96. package/dist/latex.d.ts.map +1 -0
  97. package/dist/latex.js +1378 -0
  98. package/dist/latex.js.map +1 -0
  99. package/dist/layout-node.d.ts +40 -0
  100. package/dist/layout-node.d.ts.map +1 -0
  101. package/dist/layout-node.js +6 -0
  102. package/dist/layout-node.js.map +1 -0
  103. package/dist/layout.d.ts +42 -0
  104. package/dist/layout.d.ts.map +1 -0
  105. package/dist/layout.js +344 -0
  106. package/dist/layout.js.map +1 -0
  107. package/dist/native-modifiers.d.ts +2 -1
  108. package/dist/native-modifiers.d.ts.map +1 -1
  109. package/dist/native-modifiers.js +3 -43
  110. package/dist/native-modifiers.js.map +1 -1
  111. package/dist/native-module-path.d.ts +7 -0
  112. package/dist/native-module-path.d.ts.map +1 -0
  113. package/dist/native-module-path.js +19 -0
  114. package/dist/native-module-path.js.map +1 -0
  115. package/dist/native-platform.d.ts +20 -0
  116. package/dist/native-platform.d.ts.map +1 -0
  117. package/dist/native-platform.js +42 -0
  118. package/dist/native-platform.js.map +1 -0
  119. package/dist/oklab.d.ts +43 -0
  120. package/dist/oklab.d.ts.map +1 -0
  121. package/dist/oklab.js +210 -0
  122. package/dist/oklab.js.map +1 -0
  123. package/dist/stdin-buffer.d.ts +8 -2
  124. package/dist/stdin-buffer.d.ts.map +1 -1
  125. package/dist/stdin-buffer.js +7 -2
  126. package/dist/stdin-buffer.js.map +1 -1
  127. package/dist/terminal-colors.d.ts +19 -2
  128. package/dist/terminal-colors.d.ts.map +1 -1
  129. package/dist/terminal-colors.js +13 -8
  130. package/dist/terminal-colors.js.map +1 -1
  131. package/dist/terminal-image.d.ts +29 -1
  132. package/dist/terminal-image.d.ts.map +1 -1
  133. package/dist/terminal-image.js +223 -18
  134. package/dist/terminal-image.js.map +1 -1
  135. package/dist/terminal.d.ts +16 -0
  136. package/dist/terminal.d.ts.map +1 -1
  137. package/dist/terminal.js +62 -46
  138. package/dist/terminal.js.map +1 -1
  139. package/dist/tui-alt-screen.d.ts +166 -0
  140. package/dist/tui-alt-screen.d.ts.map +1 -0
  141. package/dist/tui-alt-screen.js +1546 -0
  142. package/dist/tui-alt-screen.js.map +1 -0
  143. package/dist/tui-main-screen.d.ts +39 -0
  144. package/dist/tui-main-screen.d.ts.map +1 -0
  145. package/dist/tui-main-screen.js +594 -0
  146. package/dist/tui-main-screen.js.map +1 -0
  147. package/dist/tui.d.ts +178 -60
  148. package/dist/tui.d.ts.map +1 -1
  149. package/dist/tui.js +303 -641
  150. package/dist/tui.js.map +1 -1
  151. package/dist/undo-stack.d.ts.map +1 -1
  152. package/dist/undo-stack.js.map +1 -1
  153. package/dist/utils.d.ts +22 -0
  154. package/dist/utils.d.ts.map +1 -1
  155. package/dist/utils.js +245 -63
  156. package/dist/utils.js.map +1 -1
  157. package/dist/wheel-scroll.d.ts +23 -0
  158. package/dist/wheel-scroll.d.ts.map +1 -0
  159. package/dist/wheel-scroll.js +73 -0
  160. package/dist/wheel-scroll.js.map +1 -0
  161. package/dist/word-navigation.d.ts.map +1 -1
  162. package/dist/word-navigation.js.map +1 -1
  163. package/native/clipboard.h +208 -0
  164. package/native/darwin/README.md +22 -0
  165. package/native/darwin/build.sh +69 -0
  166. package/native/darwin/prebuilds/darwin-arm64/{darwin-modifiers.node → darwin-platform.node} +0 -0
  167. package/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  168. package/native/darwin/src/darwin-platform.m +132 -0
  169. package/native/linux/README.md +23 -0
  170. package/native/linux/build.sh +65 -0
  171. package/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  172. package/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  173. package/native/linux/src/clipboard-worker.h +70 -0
  174. package/native/linux/src/linux-platform-x11.c +274 -0
  175. package/native/napi.h +91 -0
  176. package/native/win32/README.md +31 -0
  177. package/native/win32/build.mjs +232 -0
  178. package/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  179. package/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  180. package/native/win32/src/win32-platform.c +241 -0
  181. package/package.json +19 -4
  182. package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  183. package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  184. package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
package/README.md CHANGED
@@ -4,25 +4,27 @@ Minimal terminal UI framework with differential rendering and synchronized outpu
4
4
 
5
5
  ## Features
6
6
 
7
- - **Differential Rendering**: Three-strategy rendering system that only updates what changed
7
+ - **Interchangeable Renderers**: Shared `TUI` interface with main-screen and alternate-screen implementations
8
+ - **Differential Rendering**: Updates only changed lines or viewport rows
9
+ - **Application-owned Scrolling**: Alternate-screen viewport supports mouse, trackpad, and keyboard navigation
8
10
  - **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker)
9
11
  - **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes
10
12
  - **Component-based**: Simple Component interface with render() method
11
13
  - **Theme Support**: Components accept theme interfaces for customizable styling
12
- - **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, Spacer, Image, Box, Container
14
+ - **Built-in Components**: Text, TruncatedText, Input, Editor, Markdown, Loader, SelectList, SettingsList, MouseRegion, Spacer, Image, Box, Container, VStack, HStack, ScrollView
13
15
  - **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols
14
16
  - **Autocomplete Support**: File paths and slash commands
15
17
 
16
18
  ## Quick Start
17
19
 
18
20
  ```typescript
19
- import { TUI, Text, Editor, ProcessTerminal, matchesKey } from "@earendil-works/pi-tui";
21
+ import { type TUI, Text, Editor, ProcessTerminal, TuiMainScreen, matchesKey } from "@earendil-works/pi-tui";
20
22
 
21
23
  // Create terminal
22
24
  const terminal = new ProcessTerminal();
23
25
 
24
- // Create TUI
25
- const tui = new TUI(terminal);
26
+ // Create the default main-screen renderer through the shared TUI interface
27
+ const tui: TUI = new TuiMainScreen(terminal);
26
28
 
27
29
  // Add components
28
30
  tui.addChild(new Text("Welcome to my app!"));
@@ -52,12 +54,20 @@ tui.start();
52
54
 
53
55
  ## Core API
54
56
 
55
- ### TUI
57
+ ### TUI interface and renderers
56
58
 
57
- Main container that manages components and rendering.
59
+ `TUI` is the shared interface for component management, focus, overlays, input, lifecycle, terminal queries, and rendering. Choose a concrete renderer only when constructing the application:
60
+
61
+ - `TuiMainScreen` renders into the main terminal buffer and preserves terminal scrollback.
62
+ - `TuiAltScreen` renders a fixed-height viewport in the alternate terminal buffer with application-owned scrolling. When stopped, it restores the main buffer and prints the complete final document.
58
63
 
59
64
  ```typescript
60
- const tui = new TUI(terminal);
65
+ import { type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui";
66
+
67
+ const tui: TUI = new TuiMainScreen(terminal);
68
+ // To use an application-owned viewport in the alternate terminal buffer instead:
69
+ // const tui: TUI = new TuiAltScreen(terminal);
70
+
61
71
  tui.addChild(component);
62
72
  tui.removeChild(component);
63
73
  tui.start();
@@ -68,6 +78,91 @@ tui.requestRender(); // Request a re-render
68
78
  tui.onDebug = () => console.log("Debug triggered");
69
79
  ```
70
80
 
81
+ ### Colors and terminal styles
82
+
83
+ Colors are values that can be converted or mixed before terminal rendering:
84
+
85
+ ```typescript
86
+ import {
87
+ colorToRgb,
88
+ foregroundAnsi,
89
+ getTerminalColorMode,
90
+ mixColors,
91
+ parseColor,
92
+ rgbColor,
93
+ styleText,
94
+ } from "@earendil-works/pi-tui";
95
+
96
+ const accent = parseColor("oklch(70% 0.12 220)");
97
+ const background = parseColor("#20242a");
98
+ const foreground = mixColors(accent, background, 0.2);
99
+
100
+ const text = styleText(
101
+ "Ready",
102
+ { fg: foreground, bg: background, bold: true },
103
+ getTerminalColorMode(),
104
+ );
105
+ ```
106
+
107
+ `Color` is an indexed ANSI color, an sRGB color, or an OKLCH color. Every color converts to sRGB, so color math such as `mixColors()` always works. Indices 0-15 follow the user's terminal palette, so their sRGB values are approximations. `styleText()` converts colors to truecolor or 256-color output based on the requested terminal mode.
108
+
109
+ `parseColor()` also accepts OKHSL, as in `okhsl(250 60% 55%)`; `okhslColor()` builds it in code and `colorToOkhsl()` reads any color's OKHSL channels. OKHSL saturation is relative to the most the sRGB gamut allows at the hue and lightness, so every value is in gamut and equal saturation looks equally colorful across hues. OKHSL colors are converted to sRGB when created.
110
+
111
+ Conversions are not cached. OKLCH colors, especially ones outside the sRGB gamut, are more expensive to convert than sRGB or indexed colors. For colors used on every render, convert once and reuse the result:
112
+
113
+ ```typescript
114
+ const { r, g, b } = colorToRgb(mixColors(accent, background, 0.2));
115
+ const foreground = rgbColor(r, g, b); // cheap to render repeatedly
116
+ const foregroundCode = foregroundAnsi(foreground, getTerminalColorMode());
117
+ ```
118
+
119
+ ### Alternate-screen viewport layouts
120
+
121
+ `TuiAltScreen` can render an explicit terminal-height layout. `VStack` and `HStack` allocate constrained regions, while `ScrollView` owns scrolling for one region. These semantics are intentionally unavailable on `TuiMainScreen`, where the terminal owns scrollback.
122
+
123
+ ```typescript
124
+ import {
125
+ Container,
126
+ isViewportTUI,
127
+ ScrollView,
128
+ Text,
129
+ VStack,
130
+ } from "@earendil-works/pi-tui";
131
+
132
+ const transcript = new Container();
133
+ transcript.addChild(new Text("History"));
134
+
135
+ const editorAndFooter = new VStack([
136
+ editor,
137
+ new Text("status"),
138
+ ]);
139
+
140
+ if (isViewportTUI(tui)) {
141
+ tui.setLayoutRoot(new VStack([
142
+ {
143
+ component: new ScrollView(transcript, {
144
+ follow: "end",
145
+ primary: true,
146
+ overscroll: "chain",
147
+ }),
148
+ basis: 0,
149
+ grow: 1,
150
+ minSize: 1,
151
+ },
152
+ {
153
+ component: editorAndFooter,
154
+ basis: "auto",
155
+ shrink: 1,
156
+ minSize: 1,
157
+ },
158
+ ]));
159
+ }
160
+ ```
161
+
162
+ Stack entries support `basis`, `grow`, `shrink`, `minSize`, `maxSize`, and responsive `visible` callbacks. Mouse-wheel input targets the scroll view under the pointer and unused delta chains to outer scroll views by default. The primary scroll view receives the alternate-screen keyboard navigation actions and wheel input over non-scrollable regions. It can also jump between OSC 133 semantic prompt markers, matching common terminal prompt-navigation shortcuts. Press `Ctrl+Shift+F` to open or close its bordered search panel. The panel shows the configured previous/next shortcuts and provides clickable arrow controls; by default, `Enter`/`Ctrl+G` and `Shift+Enter`/`Ctrl+Shift+G` move between matches, and `Escape` also closes search. `TuiAltScreenOptions.searchMatchStyle` and `searchCurrentMatchStyle` customize match highlighting, while `searchNavigationButtonStyle` styles each arrow button and receives its hover state. `TuiAltScreenOptions.scrollToEndIndicator` renders a clickable label centered on the last row of a `follow: "end"` primary scroll view while it is scrolled away from the end; clicking it resumes end-following.
163
+
164
+ Layout geometry is rebuilt for each requested frame. Stateful components are retained, and their existing rendered-line caches remain effective. Calling `render(width)` directly on these layout components produces an unbounded document, which is also used when alt mode restores the main screen.
165
+
71
166
  ### Overlays
72
167
 
73
168
  Overlays render components on top of existing content without replacing it. Useful for dialogs, menus, and modal UI.
@@ -120,6 +215,7 @@ handle.unfocus(); // Release focus to normal fallback
120
215
  handle.unfocus({ target: baseComponent }); // Release this overlay to a specific component
121
216
  handle.unfocus({ target: null }); // Release this overlay and leave focus empty
122
217
  handle.isFocused(); // Check if overlay has focus
218
+ handle.getBounds(); // Get last rendered terminal-relative bounds
123
219
 
124
220
  handle.unfocus();
125
221
  // Overlay loses focus; TUI falls back to another visible capturing overlay or the previous focus target.
@@ -154,7 +250,8 @@ All components implement:
154
250
  interface Component {
155
251
  render(width: number): string[];
156
252
  handleInput?(data: string): void;
157
- invalidate?(): void;
253
+ handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
254
+ invalidate(): void;
158
255
  }
159
256
  ```
160
257
 
@@ -162,10 +259,50 @@ interface Component {
162
259
  |--------|-------------|
163
260
  | `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. |
164
261
  | `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
165
- | `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. |
262
+ | `handleMouse?(event)` | Called by `TuiAltScreen` for normalized pointer input targeted at the component. |
263
+ | `invalidate()` | Required. Clear any cached render state so the next `render()` starts from scratch. Components without cached render state can use an empty implementation. |
166
264
 
167
265
  The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.
168
266
 
267
+ ### Mouse Input
268
+
269
+ `TuiAltScreen` normalizes SGR mouse input and hit-tests components and overlays. Events contain component-local `x`/`y`, absolute `screenX`/`screenY`, bounds, button, modifiers, click count, and wheel delta. `TuiMainScreen` does not capture mouse input because the terminal owns its scrollback.
270
+
271
+ ```typescript
272
+ import type { TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
273
+
274
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
275
+ if (event.type === "click" && event.button === "left") {
276
+ this.expanded = !this.expanded;
277
+ return { handled: true };
278
+ }
279
+ if (event.type === "press" && event.button === "left") {
280
+ return { handled: true, capture: true, focus: true };
281
+ }
282
+ if (event.type === "drag") {
283
+ this.updateFromPointer(event.x, event.y);
284
+ return { handled: true, render: true };
285
+ }
286
+ return undefined;
287
+ }
288
+ ```
289
+
290
+ Returning `handled` suppresses renderer-level fallback behavior. `capture` keeps subsequent drag and release events routed to the same component. `focus` requests keyboard focus. The optional `render` flag controls repainting: press, click, drag, and wheel default to rendering; move and release do not. Set `render: true` for a hover state that visibly changed, or `render: false` for a handled no-op. Render requests are coalesced and terminal output remains differential.
291
+
292
+ Unhandled gestures retain alternate-screen defaults: wheel input scrolls the nearest `ScrollView` and chains unused delta, primary-button drags select text, OSC 8 links open before parent click handlers, and unhandled right-click preserves configured paste behavior. A click is emitted only when press/release completes without a drag.
293
+
294
+ Use `MouseRegion` to add mouse behavior without changing a component's rendering:
295
+
296
+ ```typescript
297
+ const collapsible = new MouseRegion(content, (event) => {
298
+ if (event.type !== "click" || event.button !== "left") return undefined;
299
+ expanded = !expanded;
300
+ return { handled: true };
301
+ });
302
+ ```
303
+
304
+ `Container` and `Box` route events to nested children using geometry recorded by the last rendered frame, so pointer motion does not rerender children merely to hit-test them. Explicit `VStack`, `HStack`, and `ScrollView` layouts use the alternate-screen layout frame directly.
305
+
169
306
  ### Focusable Interface (IME Support)
170
307
 
171
308
  Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:
@@ -181,6 +318,8 @@ class MyInput implements Component, Focusable {
181
318
  // Emit marker right before the fake cursor
182
319
  return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
183
320
  }
321
+
322
+ invalidate(): void {}
184
323
  }
185
324
  ```
186
325
 
@@ -190,7 +329,7 @@ When a `Focusable` component has focus, TUI:
190
329
  3. Positions the hardware terminal cursor at that location
191
330
  4. Shows the hardware cursor only when `showHardwareCursor` is enabled
192
331
 
193
- The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the `TUI` constructor option, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface.
332
+ The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's `showHardwareCursor` argument or `setShowHardwareCursor(true)`. The `Editor` and `Input` built-in components already implement this interface.
194
333
 
195
334
  **Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child:
196
335
 
@@ -282,6 +421,8 @@ input.setValue("initial");
282
421
  input.getValue();
283
422
  ```
284
423
 
424
+ Clicking positions the cursor and gives the input keyboard focus in alternate-screen mode.
425
+
285
426
  **Key Bindings:**
286
427
  - `Enter` - Submit
287
428
  - `Ctrl+A` / `Ctrl+E` - Line start/end
@@ -317,6 +458,7 @@ editor.getPaddingX(); // Get current padding
317
458
  ```
318
459
 
319
460
  **Features:**
461
+ - Click-to-position cursor and clickable autocomplete rows in alternate-screen mode
320
462
  - Multi-line editing with word wrap
321
463
  - Slash command autocomplete (type `/`)
322
464
  - File path autocomplete (press `Tab`)
@@ -457,6 +599,8 @@ list.setFilter("opt"); // Filter items
457
599
  ```
458
600
 
459
601
  **Controls:**
602
+ - Mouse move/wheel: Highlight rows in alternate-screen mode
603
+ - Click: Select a row
460
604
  - Arrow keys: Navigate
461
605
  - Enter: Select
462
606
  - Escape: Cancel
@@ -497,6 +641,8 @@ settings.updateValue("theme", "light");
497
641
  ```
498
642
 
499
643
  **Controls:**
644
+ - Mouse move/wheel: Highlight rows in alternate-screen mode
645
+ - Click: Activate a row
500
646
  - Arrow keys: Navigate
501
647
  - Enter/Space: Activate (cycle value or open submenu)
502
648
  - Escape: Cancel
@@ -535,6 +681,10 @@ tui.addChild(image);
535
681
 
536
682
  Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically.
537
683
 
684
+ #### Alternate-screen image compatibility
685
+
686
+ `TuiAltScreen` supports inline images and partial viewport cropping in terminals that implement the Kitty graphics protocol, including Kitty and Ghostty. iTerm2's inline-image protocol does not provide operations to delete an existing placement or crop its source while scrolling. To prevent stale images from remaining over repainted content, `TuiAltScreen` renders image components as text placeholders in iTerm2. `TuiMainScreen` continues to render iTerm2 inline images normally.
687
+
538
688
  ## Autocomplete
539
689
 
540
690
  ### CombinedAutocompleteProvider
@@ -588,15 +738,17 @@ if (matchesKey(data, Key.enter)) {
588
738
  - With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")`
589
739
  - String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"`
590
740
 
591
- ## Differential Rendering
741
+ ## Rendering modes
592
742
 
593
- The TUI uses three rendering strategies:
743
+ `TuiMainScreen` uses three rendering strategies:
594
744
 
595
745
  1. **First Render**: Output all lines without clearing scrollback
596
- 2. **Width Changed or Change Above Viewport**: Clear screen and full re-render
597
- 3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines
746
+ 2. **Width Changed or Change Above Viewport**: Clear screen and fully re-render
747
+ 3. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines
598
748
 
599
- All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.
749
+ `TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Scrollbars support hover expansion, thumb dragging, and track-click jumping. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.
750
+
751
+ Both renderers wrap updates in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.
600
752
 
601
753
  ## Terminal Interface
602
754
 
@@ -678,6 +830,8 @@ class MyInteractiveComponent implements Component {
678
830
  return truncateToWidth(prefix + item, width);
679
831
  });
680
832
  }
833
+
834
+ invalidate(): void {}
681
835
  }
682
836
  ```
683
837
 
@@ -709,6 +863,8 @@ class MyComponent implements Component {
709
863
  // Pad to exact width (optional, for backgrounds)
710
864
  return [line + " ".repeat(width - visible)];
711
865
  }
866
+
867
+ invalidate(): void {}
712
868
  }
713
869
  ```
714
870
 
@@ -766,7 +922,7 @@ See `test/chat-simple.ts` for a complete chat interface example with:
766
922
 
767
923
  Run it:
768
924
  ```bash
769
- npx tsx test/chat-simple.ts
925
+ node test/chat-simple.ts
770
926
  ```
771
927
 
772
928
  ## Development
@@ -779,7 +935,7 @@ npm install
779
935
  npm run check
780
936
 
781
937
  # Run the demo
782
- npx tsx test/chat-simple.ts
938
+ node test/chat-simple.ts
783
939
  ```
784
940
 
785
941
  ### Debug logging
@@ -787,5 +943,5 @@ npx tsx test/chat-simple.ts
787
943
  Set `PI_TUI_WRITE_LOG` to capture the raw ANSI stream written to stdout.
788
944
 
789
945
  ```bash
790
- PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx test/chat-simple.ts
946
+ PI_TUI_WRITE_LOG=/tmp/tui-ansi.log node test/chat-simple.ts
791
947
  ```
@@ -0,0 +1,46 @@
1
+ import type { Component, Focusable } from "./tui.ts";
2
+ export interface AltScreenSearchSegment {
3
+ row: number;
4
+ startCol: number;
5
+ endCol: number;
6
+ }
7
+ export interface AltScreenSearchMatch {
8
+ segments: AltScreenSearchSegment[];
9
+ }
10
+ export interface AltScreenSearchResult {
11
+ matches: AltScreenSearchMatch[];
12
+ changed: boolean;
13
+ }
14
+ /** Cache the searchable corpus and matches while rendered transcript lines remain unchanged. */
15
+ export declare class AltScreenSearchIndex {
16
+ private sourceLines;
17
+ private corpus;
18
+ private normalizedQuery;
19
+ private matches;
20
+ search(lines: readonly string[], query: string): AltScreenSearchResult;
21
+ }
22
+ export declare function findAltScreenSearchMatches(lines: readonly string[], query: string): AltScreenSearchMatch[];
23
+ export declare function getAltScreenSearchMatchKey(match: AltScreenSearchMatch): string;
24
+ export declare class AltScreenSearchComponent implements Component, Focusable {
25
+ private readonly input;
26
+ private readonly onQueryChange;
27
+ private readonly navigationButtonStyle;
28
+ private resultCount;
29
+ private resultIndex;
30
+ private previousButtonStart;
31
+ private previousButtonEnd;
32
+ private nextButtonStart;
33
+ private nextButtonEnd;
34
+ private hoveredNavigationDirection;
35
+ private _focused;
36
+ constructor(onQueryChange: (query: string) => void, navigationButtonStyle?: (text: string, hovered: boolean) => string);
37
+ get focused(): boolean;
38
+ set focused(value: boolean);
39
+ setResult(index: number, count: number): void;
40
+ getNavigationDirectionAt(row: number, column: number): -1 | 1 | undefined;
41
+ setHoveredNavigationDirection(direction: -1 | 1 | undefined): boolean;
42
+ handleInput(data: string): void;
43
+ invalidate(): void;
44
+ render(width: number): string[];
45
+ }
46
+ //# sourceMappingURL=alt-screen-search.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"alt-screen-search.d.ts","sourceRoot":"","sources":["../src/alt-screen-search.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAmBrD,MAAM,WAAW,sBAAsB;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,oBAAoB;IACpC,QAAQ,EAAE,sBAAsB,EAAE,CAAC;CACnC;AAwHD,MAAM,WAAW,qBAAqB;IACrC,OAAO,EAAE,oBAAoB,EAAE,CAAC;IAChC,OAAO,EAAE,OAAO,CAAC;CACjB;AAED,gGAAgG;AAChG,qBAAa,oBAAoB;IAChC,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,MAAM,CAA2B;IACzC,OAAO,CAAC,eAAe,CAAqB;IAC5C,OAAO,CAAC,OAAO,CAA8B;IAE7C,MAAM,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,qBAAqB,CAqBrE;CACD;AAED,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,oBAAoB,EAAE,CAG1G;AAED,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,oBAAoB,GAAG,MAAM,CAI9E;AAED,qBAAa,wBAAyB,YAAW,SAAS,EAAE,SAAS;IACpE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAInB;IACH,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA0B;IACxD,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAA6C;IACnF,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,WAAW,CAAM;IACzB,OAAO,CAAC,mBAAmB,CAAM;IACjC,OAAO,CAAC,iBAAiB,CAAM;IAC/B,OAAO,CAAC,eAAe,CAAM;IAC7B,OAAO,CAAC,aAAa,CAAM;IAC3B,OAAO,CAAC,0BAA0B,CAAqB;IACvD,OAAO,CAAC,QAAQ,CAAS;IAEzB,YACC,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,EACtC,qBAAqB,GAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,MAAuB,EAIlF;IAED,IAAI,OAAO,IAAI,OAAO,CAErB;IAED,IAAI,OAAO,CAAC,KAAK,EAAE,OAAO,EAGzB;IAED,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAG5C;IAED,wBAAwB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,SAAS,CAKxE;IAED,6BAA6B,CAAC,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,SAAS,GAAG,OAAO,CAIpE;IAED,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAK9B;IAED,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAkE9B;CACD"}
@@ -0,0 +1,282 @@
1
+ import { Input } from "./components/input.js";
2
+ import { getKeybindings } from "./keybindings.js";
3
+ import { getGraphemeSegmenter, stripTerminalSequences, truncateToWidth, visibleWidth } from "./utils.js";
4
+ const segmenter = getGraphemeSegmenter();
5
+ const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
6
+ function buildSearchCorpus(lines) {
7
+ const chunks = [];
8
+ const spans = [];
9
+ let textLength = 0;
10
+ let pendingSeparator = false;
11
+ const appendSeparator = () => {
12
+ if (!pendingSeparator)
13
+ return;
14
+ chunks.push(" ");
15
+ textLength += 1;
16
+ pendingSeparator = false;
17
+ };
18
+ for (let row = 0; row < lines.length; row++) {
19
+ const line = stripTerminalSequences(lines[row] ?? "");
20
+ let column = 0;
21
+ // Rendered transcripts are overwhelmingly ASCII. Index complete non-space
22
+ // runs at once instead of segmenting and allocating one mapping per cell.
23
+ if (PRINTABLE_ASCII.test(line)) {
24
+ let index = 0;
25
+ while (index < line.length) {
26
+ if (line.charCodeAt(index) === 0x20) {
27
+ if (textLength > 0)
28
+ pendingSeparator = true;
29
+ column += 1;
30
+ index += 1;
31
+ continue;
32
+ }
33
+ let end = index + 1;
34
+ while (end < line.length && line.charCodeAt(end) !== 0x20)
35
+ end += 1;
36
+ appendSeparator();
37
+ const text = line.slice(index, end);
38
+ chunks.push(text);
39
+ spans.push({
40
+ textStart: textLength,
41
+ textEnd: textLength + text.length,
42
+ row,
43
+ startCol: column,
44
+ endCol: column + text.length,
45
+ linearColumns: true,
46
+ });
47
+ textLength += text.length;
48
+ column += text.length;
49
+ index = end;
50
+ }
51
+ }
52
+ else {
53
+ for (const grapheme of segmenter.segment(line)) {
54
+ const text = grapheme.segment;
55
+ const width = visibleWidth(text);
56
+ if (/^\s+$/u.test(text)) {
57
+ if (textLength > 0)
58
+ pendingSeparator = true;
59
+ column += width;
60
+ continue;
61
+ }
62
+ appendSeparator();
63
+ chunks.push(text);
64
+ spans.push({
65
+ textStart: textLength,
66
+ textEnd: textLength + text.length,
67
+ row,
68
+ startCol: column,
69
+ endCol: column + width,
70
+ linearColumns: false,
71
+ });
72
+ textLength += text.length;
73
+ column += width;
74
+ }
75
+ }
76
+ if (textLength > 0)
77
+ pendingSeparator = true;
78
+ }
79
+ return { text: chunks.join(""), spans };
80
+ }
81
+ function normalizeQuery(query) {
82
+ return query.replace(/\s+/gu, " ").trim();
83
+ }
84
+ function escapeRegExp(text) {
85
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
86
+ }
87
+ function findSearchCorpusMatches(corpus, normalizedQuery) {
88
+ if (!normalizedQuery)
89
+ return [];
90
+ const expression = new RegExp(escapeRegExp(normalizedQuery), "giu");
91
+ const matches = [];
92
+ let spanIndex = 0;
93
+ for (const match of corpus.text.matchAll(expression)) {
94
+ const start = match.index;
95
+ const end = start + match[0].length;
96
+ while (spanIndex < corpus.spans.length && corpus.spans[spanIndex].textEnd <= start)
97
+ spanIndex += 1;
98
+ const segments = [];
99
+ for (let index = spanIndex; index < corpus.spans.length; index++) {
100
+ const span = corpus.spans[index];
101
+ if (span.textStart >= end)
102
+ break;
103
+ if (span.textEnd <= start)
104
+ continue;
105
+ const startCol = span.linearColumns
106
+ ? span.startCol + Math.max(start, span.textStart) - span.textStart
107
+ : span.startCol;
108
+ const endCol = span.linearColumns ? span.startCol + Math.min(end, span.textEnd) - span.textStart : span.endCol;
109
+ const previous = segments[segments.length - 1];
110
+ if (previous && previous.row === span.row && startCol <= previous.endCol) {
111
+ previous.endCol = Math.max(previous.endCol, endCol);
112
+ }
113
+ else {
114
+ segments.push({ row: span.row, startCol, endCol });
115
+ }
116
+ }
117
+ while (spanIndex < corpus.spans.length && corpus.spans[spanIndex].textEnd <= end)
118
+ spanIndex += 1;
119
+ if (segments.length > 0)
120
+ matches.push({ segments });
121
+ }
122
+ return matches;
123
+ }
124
+ /** Cache the searchable corpus and matches while rendered transcript lines remain unchanged. */
125
+ export class AltScreenSearchIndex {
126
+ sourceLines;
127
+ corpus;
128
+ normalizedQuery;
129
+ matches = [];
130
+ search(lines, query) {
131
+ let sourceChanged = this.sourceLines?.length !== lines.length;
132
+ if (!sourceChanged && this.sourceLines) {
133
+ for (let index = 0; index < lines.length; index++) {
134
+ if (this.sourceLines[index] === lines[index])
135
+ continue;
136
+ sourceChanged = true;
137
+ break;
138
+ }
139
+ }
140
+ if (sourceChanged || !this.corpus) {
141
+ this.sourceLines = Array.from(lines);
142
+ this.corpus = buildSearchCorpus(lines);
143
+ }
144
+ const normalizedQuery = normalizeQuery(query);
145
+ const changed = sourceChanged || normalizedQuery !== this.normalizedQuery;
146
+ if (changed) {
147
+ this.normalizedQuery = normalizedQuery;
148
+ this.matches = findSearchCorpusMatches(this.corpus, normalizedQuery);
149
+ }
150
+ return { matches: this.matches, changed };
151
+ }
152
+ }
153
+ export function findAltScreenSearchMatches(lines, query) {
154
+ const normalizedQuery = normalizeQuery(query);
155
+ return normalizedQuery ? findSearchCorpusMatches(buildSearchCorpus(lines), normalizedQuery) : [];
156
+ }
157
+ export function getAltScreenSearchMatchKey(match) {
158
+ const first = match.segments[0];
159
+ const last = match.segments[match.segments.length - 1];
160
+ return first && last ? `${first.row}:${first.startCol}:${last.row}:${last.endCol}` : "";
161
+ }
162
+ export class AltScreenSearchComponent {
163
+ input = new Input({
164
+ prompt: " ",
165
+ placeholder: "Find in transcript",
166
+ placeholderStyle: (text) => `\x1b[2m${text}\x1b[22m`,
167
+ });
168
+ onQueryChange;
169
+ navigationButtonStyle;
170
+ resultCount = 0;
171
+ resultIndex = -1;
172
+ previousButtonStart = -1;
173
+ previousButtonEnd = -1;
174
+ nextButtonStart = -1;
175
+ nextButtonEnd = -1;
176
+ hoveredNavigationDirection;
177
+ _focused = false;
178
+ constructor(onQueryChange, navigationButtonStyle = (text) => text) {
179
+ this.onQueryChange = onQueryChange;
180
+ this.navigationButtonStyle = navigationButtonStyle;
181
+ }
182
+ get focused() {
183
+ return this._focused;
184
+ }
185
+ set focused(value) {
186
+ this._focused = value;
187
+ this.input.focused = value;
188
+ }
189
+ setResult(index, count) {
190
+ this.resultIndex = index;
191
+ this.resultCount = count;
192
+ }
193
+ getNavigationDirectionAt(row, column) {
194
+ if (row !== 2)
195
+ return undefined;
196
+ if (column >= this.previousButtonStart && column < this.previousButtonEnd)
197
+ return -1;
198
+ if (column >= this.nextButtonStart && column < this.nextButtonEnd)
199
+ return 1;
200
+ return undefined;
201
+ }
202
+ setHoveredNavigationDirection(direction) {
203
+ if (direction === this.hoveredNavigationDirection)
204
+ return false;
205
+ this.hoveredNavigationDirection = direction;
206
+ return true;
207
+ }
208
+ handleInput(data) {
209
+ const previous = this.input.getValue();
210
+ this.input.handleInput(data);
211
+ const query = this.input.getValue();
212
+ if (query !== previous)
213
+ this.onQueryChange(query);
214
+ }
215
+ invalidate() {
216
+ this.input.invalidate();
217
+ }
218
+ render(width) {
219
+ const safeWidth = Math.max(1, width);
220
+ const innerWidth = Math.max(0, safeWidth - 2);
221
+ const formatKey = (key) => key
222
+ ? key
223
+ .split("+")
224
+ .map((part) => {
225
+ if (process.platform === "darwin" && part.toLowerCase() === "alt")
226
+ return "Option";
227
+ return part.charAt(0).toUpperCase() + part.slice(1);
228
+ })
229
+ .join("+")
230
+ : "Unbound";
231
+ const keybindings = getKeybindings();
232
+ const previousKey = formatKey(keybindings.getKeys("tui.altScreen.searchPrevious")[0]);
233
+ const nextKey = formatKey(keybindings.getKeys("tui.altScreen.searchNext")[0]);
234
+ const query = this.input.getValue();
235
+ const result = !query
236
+ ? ""
237
+ : this.resultCount === 0
238
+ ? "No matches"
239
+ : `${this.resultIndex + 1}/${this.resultCount}`;
240
+ const resultSpace = Math.max(0, innerWidth - 3);
241
+ const visibleResult = truncateToWidth(result, resultSpace, "");
242
+ const resultText = visibleResult ? `\x1b[2m ${visibleResult} \x1b[22m` : "";
243
+ const inputWidth = Math.max(0, innerWidth - visibleWidth(resultText));
244
+ const inputLine = truncateToWidth(this.input.render(Math.max(1, inputWidth))[0] ?? "", inputWidth, "");
245
+ const inputPadding = " ".repeat(Math.max(0, inputWidth - visibleWidth(inputLine)));
246
+ const content = `${inputLine}${inputPadding}${resultText}`;
247
+ let previousButton = `↑ ${previousKey}`;
248
+ let nextButton = `↓ ${nextKey}`;
249
+ let separator = " · ";
250
+ const outerGapWidth = 1;
251
+ const availableControlsWidth = Math.max(0, innerWidth - outerGapWidth * 2 - 1);
252
+ let controlsWidth = visibleWidth(previousButton) + visibleWidth(separator) + visibleWidth(nextButton);
253
+ if (controlsWidth > availableControlsWidth) {
254
+ previousButton = "↑";
255
+ nextButton = "↓";
256
+ separator = " ";
257
+ controlsWidth = visibleWidth(previousButton) + visibleWidth(separator) + visibleWidth(nextButton);
258
+ }
259
+ const showButtons = controlsWidth <= availableControlsWidth;
260
+ const renderedButtons = showButtons
261
+ ? this.navigationButtonStyle(previousButton, this.hoveredNavigationDirection === -1) +
262
+ separator +
263
+ this.navigationButtonStyle(nextButton, this.hoveredNavigationDirection === 1)
264
+ : "";
265
+ const outerGapsWidth = showButtons ? outerGapWidth * 2 : 0;
266
+ const rightRuleWidth = renderedButtons && innerWidth > controlsWidth + outerGapsWidth ? 1 : 0;
267
+ const leftRuleWidth = Math.max(0, innerWidth - (showButtons ? controlsWidth : 0) - outerGapsWidth - rightRuleWidth);
268
+ const previousStart = 1 + leftRuleWidth + outerGapWidth;
269
+ this.previousButtonStart = showButtons ? previousStart : -1;
270
+ this.previousButtonEnd = showButtons ? previousStart + visibleWidth(previousButton) : -1;
271
+ this.nextButtonStart = showButtons ? this.previousButtonEnd + visibleWidth(separator) : -1;
272
+ this.nextButtonEnd = showButtons ? this.nextButtonStart + visibleWidth(nextButton) : -1;
273
+ if (safeWidth === 1)
274
+ return ["┌", "│", "└"];
275
+ return [
276
+ `┌${"─".repeat(innerWidth)}┐`,
277
+ `│${content}│`,
278
+ `└${"─".repeat(leftRuleWidth)}${renderedButtons ? " " : ""}${renderedButtons}${renderedButtons ? " " : ""}${"─".repeat(rightRuleWidth)}┘`,
279
+ ];
280
+ }
281
+ }
282
+ //# sourceMappingURL=alt-screen-search.js.map