@luan.sh/pi-libtui 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/native-revision +1 -0
- package/package.json +58 -0
- package/src/activity-presentation.ts +75 -0
- package/src/appearance.ts +632 -0
- package/src/background-surface.ts +125 -0
- package/src/color/palette.ts +186 -0
- package/src/color/preview.ts +157 -0
- package/src/color/resolver.ts +75 -0
- package/src/color/theme.ts +531 -0
- package/src/component-stack.ts +247 -0
- package/src/content/markdown-code.ts +60 -0
- package/src/content/terminal-text.ts +135 -0
- package/src/content/text.ts +105 -0
- package/src/controls/action-panel.ts +420 -0
- package/src/controls/dialog-button-bar.ts +287 -0
- package/src/controls/multi-select.ts +467 -0
- package/src/controls/picker-panel.ts +447 -0
- package/src/controls/screen-icon-actions.ts +134 -0
- package/src/controls/searchable-select.ts +394 -0
- package/src/controls/select-box.ts +224 -0
- package/src/controls/selectable-list.ts +433 -0
- package/src/controls/selection-action-bar.ts +382 -0
- package/src/controls/semantic-input.ts +23 -0
- package/src/controls/tab.ts +189 -0
- package/src/cursor.ts +288 -0
- package/src/decoration/editor-pills.ts +156 -0
- package/src/decoration/glyphs.ts +326 -0
- package/src/decoration/pointer-interaction.ts +123 -0
- package/src/decoration/powerline-pill.ts +104 -0
- package/src/decoration/status.ts +185 -0
- package/src/decoration/transient-pill.ts +176 -0
- package/src/diff/index.ts +21 -0
- package/src/diff/model.ts +76 -0
- package/src/diff/parse.ts +310 -0
- package/src/diff/pierre-syntax.ts +86 -0
- package/src/diff/render.ts +504 -0
- package/src/diff/structured.ts +264 -0
- package/src/diff/view.ts +75 -0
- package/src/editor/chrome.ts +341 -0
- package/src/editor/composition.ts +53 -0
- package/src/editor/layout.ts +81 -0
- package/src/editor/presentation.ts +35 -0
- package/src/editor/protocol.ts +188 -0
- package/src/editor.ts +41 -0
- package/src/extension.ts +30 -0
- package/src/folding.ts +192 -0
- package/src/host/cursor-bridge.ts +210 -0
- package/src/host/editor-bridge.ts +148 -0
- package/src/host/extension-host.ts +231 -0
- package/src/host/mouse-bridge.ts +212 -0
- package/src/host/mouse-dispatch.ts +400 -0
- package/src/host/mouse-input.ts +85 -0
- package/src/host/mouse-targets.ts +238 -0
- package/src/host/pi-layout-adapter.ts +298 -0
- package/src/host/pi-native-selection.ts +227 -0
- package/src/host/split-pane-bridge.ts +720 -0
- package/src/host/transcript-bridge.ts +236 -0
- package/src/index.ts +334 -0
- package/src/line-layout.ts +6 -0
- package/src/motion.ts +1409 -0
- package/src/mouse/events.ts +55 -0
- package/src/mouse/fullscreen-layout.ts +260 -0
- package/src/mouse/registry.ts +348 -0
- package/src/mouse.ts +37 -0
- package/src/native-binary.ts +189 -0
- package/src/overlay/anchored.ts +91 -0
- package/src/overlay/detail-card.ts +158 -0
- package/src/overlay/dialog.ts +266 -0
- package/src/overlay/floating.ts +106 -0
- package/src/overlay/framed-editor.ts +236 -0
- package/src/overlay/fullscreen.ts +170 -0
- package/src/overlay/hover-tooltip.ts +109 -0
- package/src/overlay/modal-mount.ts +118 -0
- package/src/panels.ts +207 -0
- package/src/render-cache.ts +80 -0
- package/src/render-epoch.ts +48 -0
- package/src/request-animation.ts +160 -0
- package/src/scrollbar.ts +27 -0
- package/src/selection.ts +148 -0
- package/src/split-pane.ts +232 -0
- package/src/status-presentation.ts +411 -0
- package/src/stream.ts +543 -0
- package/src/syntax.ts +316 -0
- package/src/terminal/bridge-client.ts +243 -0
- package/src/terminal/embedding.ts +6 -0
- package/src/terminal/output.ts +151 -0
- package/src/terminal/projection.ts +362 -0
- package/src/terminal/pty-host.ts +263 -0
- package/src/terminal/pty-pane.ts +308 -0
- package/src/terminal-colors.ts +334 -0
- package/src/tool/action.ts +170 -0
- package/src/tool/activity.ts +500 -0
- package/src/tool/call-preview.ts +44 -0
- package/src/tool/disclosure-action.ts +89 -0
- package/src/tool/expanded-region-viewport.ts +103 -0
- package/src/tool/index.ts +26 -0
- package/src/tool/output.ts +271 -0
- package/src/tool/transcript.ts +150 -0
- package/src/tool/view-region.ts +581 -0
- package/test/appearance.test.ts +183 -0
- package/test/bounded-stream.test.ts +150 -0
- package/test/bridge-client.test.ts +42 -0
- package/test/color-preview.test.ts +23 -0
- package/test/component-stack.test.ts +206 -0
- package/test/components.test.ts +1165 -0
- package/test/content-text.test.ts +21 -0
- package/test/cursor-bridge.test.ts +151 -0
- package/test/cursor.test.ts +147 -0
- package/test/editor-chrome.test.ts +119 -0
- package/test/editor-layout.test.ts +30 -0
- package/test/editor-registry.test.ts +203 -0
- package/test/editor-theme.test.ts +33 -0
- package/test/editor-token-pill.test.ts +72 -0
- package/test/floating-overlay.test.ts +57 -0
- package/test/folding.test.ts +147 -0
- package/test/harmonious-theme.test.ts +23 -0
- package/test/interaction.test.ts +104 -0
- package/test/key-icons.test.ts +62 -0
- package/test/markdown-code.test.ts +24 -0
- package/test/modal-overlay-mount.test.ts +94 -0
- package/test/motion.test.ts +649 -0
- package/test/mouse/bridge.test.ts +1302 -0
- package/test/mouse/extension.test.ts +205 -0
- package/test/mouse/registry.test.ts +140 -0
- package/test/mouse/screen-decoration.test.ts +167 -0
- package/test/native-binary.test.ts +52 -0
- package/test/palette.test.ts +32 -0
- package/test/panels.test.ts +74 -0
- package/test/pointer-components.test.ts +151 -0
- package/test/progress.test.ts +54 -0
- package/test/pty-host.test.ts +126 -0
- package/test/pty-pane.test.ts +62 -0
- package/test/public-surface.test.ts +75 -0
- package/test/render-cache.test.ts +25 -0
- package/test/render-epoch.test.ts +119 -0
- package/test/request-animation.test.ts +193 -0
- package/test/screen-icon-actions.test.ts +69 -0
- package/test/select-box.test.ts +86 -0
- package/test/select-pointer.test.ts +369 -0
- package/test/selectable-list.test.ts +258 -0
- package/test/selection/registry.test.ts +44 -0
- package/test/selection-action-bar.test.ts +204 -0
- package/test/split-pane-bridge.test.ts +944 -0
- package/test/split-pane.test.ts +134 -0
- package/test/status-presentation.test.ts +39 -0
- package/test/syntax.test.ts +59 -0
- package/test/terminal-colors.test.ts +115 -0
- package/test/terminal-embedding.test.ts +12 -0
- package/test/terminal-projection.test.ts +312 -0
- package/test/theme-colors.test.ts +175 -0
- package/test/tool-output.test.ts +160 -0
- package/test/tool-transcript.test.ts +1190 -0
- package/test/unified-diff.test.ts +691 -0
- package/themes/harmonious.json +61 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
Key,
|
|
5
|
+
type KeybindingsManager,
|
|
6
|
+
type KeyId,
|
|
7
|
+
matchesKey,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
} from "@earendil-works/pi-tui";
|
|
11
|
+
import { tuiTheme } from "../color/theme.ts";
|
|
12
|
+
import { renderKeyHint } from "../decoration/glyphs.ts";
|
|
13
|
+
import { fitLine } from "../line-layout.ts";
|
|
14
|
+
|
|
15
|
+
/** One selectable action displayed by {@link ActionPanel}. */
|
|
16
|
+
export interface ActionPanelOption<Value extends string = string> {
|
|
17
|
+
/** Stable value passed to selection and activation callbacks. */
|
|
18
|
+
value: Value;
|
|
19
|
+
/** Human-readable action label. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Optional supporting text rendered after the label. */
|
|
22
|
+
description?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Layout and appearance available to a custom action-row renderer. */
|
|
26
|
+
export interface ActionPanelRowContext {
|
|
27
|
+
/** Available row width in terminal cells. */
|
|
28
|
+
width: number;
|
|
29
|
+
/** Zero-based index in the complete option list. */
|
|
30
|
+
index: number;
|
|
31
|
+
/** One-based numeric shortcut, when numeric shortcuts are enabled. */
|
|
32
|
+
shortcut?: number;
|
|
33
|
+
/** Active Pi theme for non-color text attributes. */
|
|
34
|
+
theme: Theme;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Overlay-local mouse input. Row and column are zero-based within the rendered panel. */
|
|
38
|
+
export interface ActionPanelMouseEvent {
|
|
39
|
+
/** Pointer lifecycle event translated into panel-local coordinates. */
|
|
40
|
+
type: "enter" | "move" | "leave" | "press" | "release";
|
|
41
|
+
/** Zero-based row within the rendered panel. */
|
|
42
|
+
row: number;
|
|
43
|
+
/** Zero-based column within the rendered panel. */
|
|
44
|
+
col: number;
|
|
45
|
+
/** Zero-based mouse button for press and release events. */
|
|
46
|
+
button?: 0 | 1 | 2;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Rectangular terminal-cell geometry. */
|
|
50
|
+
export interface ActionPanelRect {
|
|
51
|
+
/** Zero-based column relative to the owning component. */
|
|
52
|
+
x: number;
|
|
53
|
+
/** Zero-based row relative to the owning component. */
|
|
54
|
+
y: number;
|
|
55
|
+
/** Width in terminal cells. */
|
|
56
|
+
width: number;
|
|
57
|
+
/** Height in terminal rows. */
|
|
58
|
+
height: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Geometry and identity for one rendered action row. */
|
|
62
|
+
export interface ActionPanelRowGeometry<Value extends string = string> extends ActionPanelRect {
|
|
63
|
+
/** Zero-based index in the complete option list. */
|
|
64
|
+
index: number;
|
|
65
|
+
/** Stable value associated with the row. */
|
|
66
|
+
value: Value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A single rendered row hosted inside an ActionPanel frame. */
|
|
70
|
+
export interface ActionPanelFooter {
|
|
71
|
+
/** Render the footer within the supplied terminal-cell width. */
|
|
72
|
+
render(width: number): string[];
|
|
73
|
+
/** Clear cached footer layout after external state changes. */
|
|
74
|
+
invalidate?(): void;
|
|
75
|
+
/** Handle keyboard input; return true when the footer consumes it. */
|
|
76
|
+
handleInput?(data: string): boolean | undefined;
|
|
77
|
+
/** Handle footer-local pointer input; return true when consumed. */
|
|
78
|
+
handleMouse?(event: ActionPanelMouseEvent): boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Geometry for the most recent render, in coordinates local to its first cell. */
|
|
82
|
+
export interface ActionPanelGeometry<Value extends string = string> extends ActionPanelRect {
|
|
83
|
+
/** Interactive geometry for each currently visible action row. */
|
|
84
|
+
rows: readonly ActionPanelRowGeometry<Value>[];
|
|
85
|
+
/** Footer geometry when the panel has a structural footer. */
|
|
86
|
+
footer?: ActionPanelRect;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Construction options for {@link ActionPanel}. */
|
|
90
|
+
export interface ActionPanelOptions<Value extends string> {
|
|
91
|
+
/** Active Pi theme used to derive semantic libtui colors. */
|
|
92
|
+
theme: Theme;
|
|
93
|
+
/** Pi keybinding manager used for navigation, confirmation, and cancellation. */
|
|
94
|
+
keybindings: KeybindingsManager;
|
|
95
|
+
/** Title rendered in the panel border. */
|
|
96
|
+
title: string;
|
|
97
|
+
/** Complete ordered set of selectable actions. */
|
|
98
|
+
options: readonly ActionPanelOption<Value>[];
|
|
99
|
+
/** Initially selected value; defaults to the first option. */
|
|
100
|
+
selected?: Value;
|
|
101
|
+
/** Maximum number of action rows displayed at once. */
|
|
102
|
+
maxVisible?: number;
|
|
103
|
+
/** Maximum panel width in terminal cells. */
|
|
104
|
+
maxWidth?: number;
|
|
105
|
+
/** Maximum panel height in terminal rows. */
|
|
106
|
+
maxHeight?: number;
|
|
107
|
+
/** Set false when the surrounding dialog already presents keyboard help. */
|
|
108
|
+
showHint?: boolean;
|
|
109
|
+
/** Replaces the hint with a separated, single-row structural footer. */
|
|
110
|
+
footer?: ActionPanelFooter;
|
|
111
|
+
/** Enable direct activation with the number keys 1 through 9. */
|
|
112
|
+
numberShortcuts?: boolean;
|
|
113
|
+
/** When false, a click only changes selection; Enter and number shortcuts still activate. */
|
|
114
|
+
activateOnClick?: boolean;
|
|
115
|
+
/** Text displayed when no actions are available. */
|
|
116
|
+
emptyMessage?: string;
|
|
117
|
+
/** Custom action-row renderer; output is fitted to the available width. */
|
|
118
|
+
renderOption?: (option: ActionPanelOption<Value>, context: ActionPanelRowContext) => string;
|
|
119
|
+
/** Request a host render after interactive state changes. */
|
|
120
|
+
requestRender(): void;
|
|
121
|
+
/** Receive selection changes that do not activate the action. */
|
|
122
|
+
onSelectionChange?(value: Value): void;
|
|
123
|
+
/** Receive the activated action value. */
|
|
124
|
+
onSelect(value: Value): void;
|
|
125
|
+
/** Close or dismiss the panel without activation. */
|
|
126
|
+
onCancel(): void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const MIN_WIDTH = 12;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A compact, domain-free action picker for anchored overlays.
|
|
133
|
+
*
|
|
134
|
+
* The component deliberately does not know how the overlay is positioned.
|
|
135
|
+
* Consumers translate screen mouse coordinates to overlay-local coordinates,
|
|
136
|
+
* then pass them to handleMouse().
|
|
137
|
+
*/
|
|
138
|
+
export class ActionPanel<Value extends string = string> implements Component {
|
|
139
|
+
private selectedIndex: number;
|
|
140
|
+
private hoverIndex: number | undefined;
|
|
141
|
+
private pressedIndex: number | undefined;
|
|
142
|
+
private footerPointerInside = false;
|
|
143
|
+
private geometry: ActionPanelGeometry<Value> | undefined;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create an action panel.
|
|
147
|
+
* @param config Actions, theme, interaction callbacks, and layout bounds.
|
|
148
|
+
*/
|
|
149
|
+
constructor(private readonly config: ActionPanelOptions<Value>) {
|
|
150
|
+
const selected = config.options.findIndex((option) => option.value === config.selected);
|
|
151
|
+
this.selectedIndex = selected >= 0 ? selected : 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Handle navigation, activation, cancellation, numeric shortcuts, and footer input.
|
|
156
|
+
* @param data Raw terminal input from Pi.
|
|
157
|
+
*/
|
|
158
|
+
handleInput(data: string): void {
|
|
159
|
+
if (this.config.keybindings.matches(data, "tui.select.cancel") || matchesKey(data, Key.escape) || data === "q") {
|
|
160
|
+
this.config.onCancel();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (this.config.numberShortcuts && /^[1-9]$/.test(data)) {
|
|
164
|
+
const index = Number(data) - 1;
|
|
165
|
+
const option = this.config.options[index];
|
|
166
|
+
if (option) this.config.onSelect(option.value);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (data === "j" || this.config.keybindings.matches(data, "tui.select.down")) this.move(1);
|
|
170
|
+
else if (data === "k" || this.config.keybindings.matches(data, "tui.select.up")) this.move(-1);
|
|
171
|
+
else if (matchesKey(data, Key.home)) this.select(0);
|
|
172
|
+
else if (data === "G" || matchesKey(data, Key.end)) this.select(this.config.options.length - 1);
|
|
173
|
+
else if (this.config.keybindings.matches(data, "tui.select.confirm")) this.choose(this.selectedIndex);
|
|
174
|
+
else this.config.footer?.handleInput?.(data);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Handle pointer input expressed in panel-local coordinates.
|
|
179
|
+
* @param event Pointer lifecycle event to route to rows or the footer.
|
|
180
|
+
* @returns True when the event belongs to the panel.
|
|
181
|
+
*/
|
|
182
|
+
handleMouse(event: ActionPanelMouseEvent): boolean {
|
|
183
|
+
if (event.type === "leave") {
|
|
184
|
+
const changed = this.hoverIndex !== undefined || this.pressedIndex !== undefined;
|
|
185
|
+
this.hoverIndex = undefined;
|
|
186
|
+
this.pressedIndex = undefined;
|
|
187
|
+
this.leaveFooter(event);
|
|
188
|
+
if (changed) this.config.requestRender();
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const geometry = this.geometry;
|
|
193
|
+
if (!geometry) return false;
|
|
194
|
+
const inside = event.col >= 0 && event.col < geometry.width && event.row >= 0 && event.row < geometry.height;
|
|
195
|
+
if (!inside) {
|
|
196
|
+
this.updateHover(undefined);
|
|
197
|
+
this.leaveFooter(event);
|
|
198
|
+
if (event.type === "release") this.pressedIndex = undefined;
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const footer = geometry.footer;
|
|
203
|
+
if (
|
|
204
|
+
footer &&
|
|
205
|
+
event.col >= footer.x &&
|
|
206
|
+
event.col < footer.x + footer.width &&
|
|
207
|
+
event.row >= footer.y &&
|
|
208
|
+
event.row < footer.y + footer.height
|
|
209
|
+
) {
|
|
210
|
+
this.updateHover(undefined);
|
|
211
|
+
this.pressedIndex = undefined;
|
|
212
|
+
const translated = {
|
|
213
|
+
...event,
|
|
214
|
+
row: event.row - footer.y,
|
|
215
|
+
col: event.col - footer.x,
|
|
216
|
+
};
|
|
217
|
+
if (!this.footerPointerInside) {
|
|
218
|
+
this.footerPointerInside = true;
|
|
219
|
+
this.config.footer?.handleMouse?.({ ...translated, type: "enter" });
|
|
220
|
+
}
|
|
221
|
+
if (event.type !== "enter") this.config.footer?.handleMouse?.(translated);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
this.leaveFooter(event);
|
|
225
|
+
|
|
226
|
+
const row = geometry.rows.find(
|
|
227
|
+
(candidate) =>
|
|
228
|
+
event.col >= candidate.x &&
|
|
229
|
+
event.col < candidate.x + candidate.width &&
|
|
230
|
+
event.row >= candidate.y &&
|
|
231
|
+
event.row < candidate.y + candidate.height,
|
|
232
|
+
);
|
|
233
|
+
this.updateHover(row?.index);
|
|
234
|
+
|
|
235
|
+
if (event.type === "press" && (event.button === undefined || event.button === 0)) {
|
|
236
|
+
this.pressedIndex = row?.index;
|
|
237
|
+
if (row) this.setSelected(row.index);
|
|
238
|
+
} else if (event.type === "release" && (event.button === undefined || event.button === 0)) {
|
|
239
|
+
const pressed = this.pressedIndex;
|
|
240
|
+
this.pressedIndex = undefined;
|
|
241
|
+
if (row && pressed === row.index && this.config.activateOnClick !== false) this.choose(row.index);
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** @returns The selected value, or undefined when the option list is empty. */
|
|
247
|
+
getSelectedValue(): Value | undefined {
|
|
248
|
+
return this.config.options[this.selectedIndex]?.value;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** @returns A defensive copy of the most recent rendered geometry, if rendered. */
|
|
252
|
+
getGeometry(): ActionPanelGeometry<Value> | undefined {
|
|
253
|
+
if (!this.geometry) return undefined;
|
|
254
|
+
return {
|
|
255
|
+
...this.geometry,
|
|
256
|
+
rows: this.geometry.rows.map((row) => ({ ...row })),
|
|
257
|
+
footer: this.geometry.footer ? { ...this.geometry.footer } : undefined,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Clear cached panel and footer geometry after external state changes. */
|
|
262
|
+
invalidate(): void {
|
|
263
|
+
this.geometry = undefined;
|
|
264
|
+
this.config.footer?.invalidate?.();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Render the framed panel and update its interactive geometry.
|
|
269
|
+
* @param availableWidth Width offered by the host in terminal cells.
|
|
270
|
+
* @returns Rendered terminal rows, or an empty array when the bounds are too small.
|
|
271
|
+
*/
|
|
272
|
+
render(availableWidth: number): string[] {
|
|
273
|
+
const colors = tuiTheme(this.config.theme);
|
|
274
|
+
const width = Math.max(0, Math.min(availableWidth, this.config.maxWidth ?? availableWidth));
|
|
275
|
+
const boundedHeight = Math.max(0, Math.floor(this.config.maxHeight ?? Number.POSITIVE_INFINITY));
|
|
276
|
+
const chromeRows = this.config.footer ? 4 : this.config.showHint === false ? 2 : 3;
|
|
277
|
+
if (width < MIN_WIDTH || boundedHeight < chromeRows + 1) {
|
|
278
|
+
this.geometry = undefined;
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
const innerWidth = width - 2;
|
|
282
|
+
this.clampSelection();
|
|
283
|
+
|
|
284
|
+
const maxRowsByHeight = Math.max(1, boundedHeight - chromeRows);
|
|
285
|
+
const visibleCount = Math.min(
|
|
286
|
+
Math.max(1, this.config.maxVisible ?? 9),
|
|
287
|
+
maxRowsByHeight,
|
|
288
|
+
Math.max(1, this.config.options.length),
|
|
289
|
+
);
|
|
290
|
+
const start = Math.max(
|
|
291
|
+
0,
|
|
292
|
+
Math.min(this.selectedIndex - visibleCount + 1, Math.max(0, this.config.options.length - visibleCount)),
|
|
293
|
+
);
|
|
294
|
+
const visible = this.config.options.slice(start, start + visibleCount);
|
|
295
|
+
const position =
|
|
296
|
+
this.config.options.length === 0 ? "0/0" : `${this.selectedIndex + 1}/${this.config.options.length}`;
|
|
297
|
+
const fixedTitleWidth = visibleWidth(`─ ${position} `);
|
|
298
|
+
const visibleTitle = truncateToWidth(this.config.title, Math.max(0, innerWidth - fixedTitleWidth), "");
|
|
299
|
+
const usedTitleWidth = visibleWidth(`─ ${visibleTitle} ${position} `);
|
|
300
|
+
const lines = [
|
|
301
|
+
colors.fg("border", "╭─ ") +
|
|
302
|
+
colors.fg("accent", this.config.theme.bold(visibleTitle)) +
|
|
303
|
+
colors.fg("text.muted", ` ${position}`) +
|
|
304
|
+
colors.fg("border", ` ${"─".repeat(Math.max(0, innerWidth - usedTitleWidth))}╮`),
|
|
305
|
+
];
|
|
306
|
+
const rows: ActionPanelRowGeometry<Value>[] = [];
|
|
307
|
+
|
|
308
|
+
if (visible.length === 0) {
|
|
309
|
+
const empty = colors.fg("text.muted", this.config.emptyMessage ?? "No options.");
|
|
310
|
+
lines.push(`${colors.fg("border", "│")}${fitLine(` ${empty}`, innerWidth)}${colors.fg("border", "│")}`);
|
|
311
|
+
} else {
|
|
312
|
+
for (const [offset, option] of visible.entries()) {
|
|
313
|
+
const index = start + offset;
|
|
314
|
+
const y = lines.length;
|
|
315
|
+
lines.push(
|
|
316
|
+
`${colors.fg("border", "│")}${this.renderRow(option, index, innerWidth)}${colors.fg("border", "│")}`,
|
|
317
|
+
);
|
|
318
|
+
rows.push({
|
|
319
|
+
x: 1,
|
|
320
|
+
y,
|
|
321
|
+
width: innerWidth,
|
|
322
|
+
height: 1,
|
|
323
|
+
index,
|
|
324
|
+
value: option.value,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let footer: ActionPanelRect | undefined;
|
|
330
|
+
if (this.config.footer) {
|
|
331
|
+
lines.push(colors.fg("border", `├${"─".repeat(innerWidth)}┤`));
|
|
332
|
+
const y = lines.length;
|
|
333
|
+
const content = this.config.footer.render(innerWidth)[0] ?? "";
|
|
334
|
+
lines.push(`${colors.fg("border", "│")}${fitLine(content, innerWidth)}${colors.fg("border", "│")}`);
|
|
335
|
+
footer = { x: 1, y, width: innerWidth, height: 1 };
|
|
336
|
+
} else if (this.config.showHint !== false) {
|
|
337
|
+
const keys = this.config.numberShortcuts
|
|
338
|
+
? "↑↓/jk navigate 1-9/enter select esc/q cancel"
|
|
339
|
+
: "↑↓/jk navigate enter select esc/q cancel";
|
|
340
|
+
lines.push(
|
|
341
|
+
`${colors.fg("border", "│")}${fitLine(` ${colors.fg("text.muted", keys)}`, innerWidth)}${colors.fg("border", "│")}`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
lines.push(colors.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
345
|
+
this.geometry = { x: 0, y: 0, width, height: lines.length, rows, footer };
|
|
346
|
+
return lines;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private renderRow(option: ActionPanelOption<Value>, index: number, width: number): string {
|
|
350
|
+
const colors = tuiTheme(this.config.theme);
|
|
351
|
+
const shortcut = this.config.numberShortcuts && index < 9 ? index + 1 : undefined;
|
|
352
|
+
const active = index === this.selectedIndex || index === this.hoverIndex;
|
|
353
|
+
const cursor = index === this.selectedIndex ? colors.fg("accent", "›") : " ";
|
|
354
|
+
const hint =
|
|
355
|
+
shortcut === undefined
|
|
356
|
+
? undefined
|
|
357
|
+
: renderKeyHint(
|
|
358
|
+
this.config.theme,
|
|
359
|
+
String(shortcut) as KeyId,
|
|
360
|
+
undefined,
|
|
361
|
+
active ? "surface.selected" : undefined,
|
|
362
|
+
);
|
|
363
|
+
const prefix = hint === undefined ? ` ${cursor} ` : ` ${cursor}${hint} `;
|
|
364
|
+
const contentWidth = Math.max(0, width - visibleWidth(prefix));
|
|
365
|
+
const content = this.config.renderOption
|
|
366
|
+
? this.config.renderOption(option, {
|
|
367
|
+
width: contentWidth,
|
|
368
|
+
index,
|
|
369
|
+
shortcut,
|
|
370
|
+
theme: this.config.theme,
|
|
371
|
+
})
|
|
372
|
+
: `${option.label}${option.description ? ` ${colors.fg("text.muted", option.description)}` : ""}`;
|
|
373
|
+
const line = `${prefix}${fitLine(content, contentWidth)}`;
|
|
374
|
+
return active ? colors.bg("surface.selected", line) : line;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private move(delta: number): void {
|
|
378
|
+
if (this.config.options.length === 0) return;
|
|
379
|
+
this.setSelected(Math.max(0, Math.min(this.config.options.length - 1, this.selectedIndex + delta)));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private select(index: number): void {
|
|
383
|
+
if (this.config.options.length === 0) return;
|
|
384
|
+
this.setSelected(Math.max(0, Math.min(this.config.options.length - 1, index)));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private setSelected(index: number): void {
|
|
388
|
+
if (this.selectedIndex === index) return;
|
|
389
|
+
this.selectedIndex = index;
|
|
390
|
+
const option = this.config.options[index];
|
|
391
|
+
if (option) this.config.onSelectionChange?.(option.value);
|
|
392
|
+
this.config.requestRender();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private updateHover(index: number | undefined): void {
|
|
396
|
+
if (this.hoverIndex === index) return;
|
|
397
|
+
this.hoverIndex = index;
|
|
398
|
+
this.config.requestRender();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private choose(index: number): void {
|
|
402
|
+
const option = this.config.options[index];
|
|
403
|
+
if (option) this.config.onSelect(option.value);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private leaveFooter(event: ActionPanelMouseEvent): void {
|
|
407
|
+
if (!this.footerPointerInside) return;
|
|
408
|
+
this.footerPointerInside = false;
|
|
409
|
+
this.config.footer?.handleMouse?.({
|
|
410
|
+
...event,
|
|
411
|
+
type: "leave",
|
|
412
|
+
row: -1,
|
|
413
|
+
col: -1,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private clampSelection(): void {
|
|
418
|
+
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, Math.max(0, this.config.options.length - 1)));
|
|
419
|
+
}
|
|
420
|
+
}
|