pi-libtui 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +55 -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,394 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
type Focusable,
|
|
5
|
+
getKeybindings,
|
|
6
|
+
Text,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { tuiTheme } from "../color/theme.ts";
|
|
11
|
+
import { icon } from "../decoration/glyphs.ts";
|
|
12
|
+
import type { TuiMouseEvent } from "../mouse.ts";
|
|
13
|
+
import { applyScrollbar } from "../scrollbar.ts";
|
|
14
|
+
import { DialogButtonBar } from "./dialog-button-bar.ts";
|
|
15
|
+
import { SelectableList, type SelectableListRenderContext } from "./selectable-list.ts";
|
|
16
|
+
import { SemanticInput } from "./semantic-input.ts";
|
|
17
|
+
|
|
18
|
+
/** One labeled value accepted by searchable and multi-select components. */
|
|
19
|
+
export interface SelectOption<Value extends string = string> {
|
|
20
|
+
/** Stable value returned to the owning feature. */
|
|
21
|
+
value: Value;
|
|
22
|
+
/** Human-readable option label. */
|
|
23
|
+
label: string;
|
|
24
|
+
/** Optional supporting text rendered after the label. */
|
|
25
|
+
description?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** State and available content width for a custom searchable-select row. */
|
|
29
|
+
export interface SearchableSelectRowContext {
|
|
30
|
+
width: number;
|
|
31
|
+
selected: boolean;
|
|
32
|
+
hovered: boolean;
|
|
33
|
+
theme: Theme;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Construction options for {@link SearchableSelect}. */
|
|
37
|
+
export interface SearchableSelectOptions<Value extends string> {
|
|
38
|
+
/** Title rendered above the picker unless the surrounding frame owns it. */
|
|
39
|
+
title: string;
|
|
40
|
+
/** Optional supporting text rendered below the title. */
|
|
41
|
+
description?: string;
|
|
42
|
+
/** Place each option description inline or wrapped below its selectable row. */
|
|
43
|
+
descriptionLayout?: "inline" | "below";
|
|
44
|
+
/** Render the title in the content. Disable when the dialog frame owns it. */
|
|
45
|
+
showTitle?: boolean;
|
|
46
|
+
/** Complete ordered set of searchable options. */
|
|
47
|
+
options: readonly SelectOption<Value>[];
|
|
48
|
+
/** Initially selected value; defaults to the first option. */
|
|
49
|
+
selected?: Value;
|
|
50
|
+
/** Active Pi theme used to derive semantic libtui colors. */
|
|
51
|
+
theme: Theme;
|
|
52
|
+
/** Receive the explicitly confirmed option value. */
|
|
53
|
+
onSelect(value: Value): void;
|
|
54
|
+
/** Close the picker without confirming. */
|
|
55
|
+
onCancel(): void;
|
|
56
|
+
/** Render option content after the picker-owned cursor prefix. */
|
|
57
|
+
renderOption?(option: SelectOption<Value>, context: SearchableSelectRowContext): string;
|
|
58
|
+
/** Render a candidate preview below the option list as selection changes. */
|
|
59
|
+
renderPreview?(option: SelectOption<Value>, width: number): readonly string[];
|
|
60
|
+
/** Invalidate the host after pointer or externally animated state changes. */
|
|
61
|
+
requestRender?(): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A fixed-layout searchable picker whose pointer selection is confirmed explicitly. */
|
|
65
|
+
export class SearchableSelect<Value extends string = string> implements Component, Focusable {
|
|
66
|
+
private static readonly MAX_VISIBLE = 12;
|
|
67
|
+
private readonly input: SemanticInput;
|
|
68
|
+
private readonly list: SelectableList<SelectOption<Value>>;
|
|
69
|
+
private filterActive = false;
|
|
70
|
+
private readonly title?: Text;
|
|
71
|
+
private readonly description?: Text;
|
|
72
|
+
private readonly buttons: DialogButtonBar<"cancel" | "save">;
|
|
73
|
+
private searchRow: number | undefined;
|
|
74
|
+
private listStart = 0;
|
|
75
|
+
private listHeight = 0;
|
|
76
|
+
private buttonRow: number | undefined;
|
|
77
|
+
private searchHovered = false;
|
|
78
|
+
private searchPressed = false;
|
|
79
|
+
private _focused = false;
|
|
80
|
+
private maxHeight: number | undefined;
|
|
81
|
+
private optionCount: number;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a searchable single-select component.
|
|
85
|
+
* @param options Choices, appearance, initial selection, and completion callbacks.
|
|
86
|
+
*/
|
|
87
|
+
constructor(private readonly options: SearchableSelectOptions<Value>) {
|
|
88
|
+
const colors = tuiTheme(options.theme);
|
|
89
|
+
const keybindings = getKeybindings();
|
|
90
|
+
this.input = new SemanticInput(options.theme);
|
|
91
|
+
this.title =
|
|
92
|
+
options.showTitle === false ? undefined : new Text(options.theme.bold(colors.fg("accent", options.title)), 0, 0);
|
|
93
|
+
this.description = options.description ? new Text(colors.fg("text.muted", options.description), 0, 0) : undefined;
|
|
94
|
+
const selected = Math.max(
|
|
95
|
+
0,
|
|
96
|
+
options.options.findIndex((option) => option.value === options.selected),
|
|
97
|
+
);
|
|
98
|
+
this.optionCount = options.options.length;
|
|
99
|
+
this.list = new SelectableList({
|
|
100
|
+
items: options.options,
|
|
101
|
+
selectedIndex: selected,
|
|
102
|
+
maxVisible: SearchableSelect.MAX_VISIBLE,
|
|
103
|
+
activateOnClick: false,
|
|
104
|
+
renderItem: (option, context) => this.renderOption(option, context),
|
|
105
|
+
requestRender: options.requestRender ?? (() => {}),
|
|
106
|
+
onActivate: (option) => options.onSelect(option.value),
|
|
107
|
+
onSelectionChange: () => options.requestRender?.(),
|
|
108
|
+
});
|
|
109
|
+
this.buttons = new DialogButtonBar({
|
|
110
|
+
theme: options.theme,
|
|
111
|
+
leading: () => colors.fg("text.muted", "↑↓ move"),
|
|
112
|
+
buttons: [
|
|
113
|
+
{
|
|
114
|
+
value: "cancel",
|
|
115
|
+
label: "Cancel",
|
|
116
|
+
icon: "cancel",
|
|
117
|
+
foreground: "text.primary",
|
|
118
|
+
background: "action.neutral",
|
|
119
|
+
shortcuts: keybindings.getKeys("tui.select.cancel"),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
value: "save",
|
|
123
|
+
label: "Save",
|
|
124
|
+
icon: "confirm",
|
|
125
|
+
foreground: "positive",
|
|
126
|
+
background: "action.positive",
|
|
127
|
+
shortcuts: keybindings.getKeys("tui.select.confirm"),
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
requestRender: options.requestRender ?? (() => {}),
|
|
131
|
+
onActivate: (action) => (action === "save" ? this.confirm() : options.onCancel()),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Bound the complete picker to the rows supplied by its dialog host. */
|
|
136
|
+
setMaxHeight(maxHeight: number): void {
|
|
137
|
+
this.maxHeight = Math.max(0, Math.floor(maxHeight));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Whether the component currently receives keyboard focus. */
|
|
141
|
+
get focused(): boolean {
|
|
142
|
+
return this._focused;
|
|
143
|
+
}
|
|
144
|
+
/** Update component focus and forward it to the active filter field. */
|
|
145
|
+
set focused(value: boolean) {
|
|
146
|
+
this._focused = value;
|
|
147
|
+
this.input.focused = value && this.filterActive;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Handle component-local filter, option-list, and action-button pointer input.
|
|
152
|
+
* @param event Pointer event from pi-libtui's shared component host.
|
|
153
|
+
* @returns True when the event targets an interactive region.
|
|
154
|
+
*/
|
|
155
|
+
onMouse(event: TuiMouseEvent): boolean {
|
|
156
|
+
if (event.type === "leave") {
|
|
157
|
+
this.clearPointerState(event);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (this.buttonRow !== undefined && event.row === this.buttonRow) {
|
|
162
|
+
this.leaveSearch();
|
|
163
|
+
this.leaveList(event);
|
|
164
|
+
this.buttons.onMouse({ ...event, row: 0 });
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
this.leaveButtons(event);
|
|
168
|
+
|
|
169
|
+
if (this.searchRow !== undefined && event.row === this.searchRow) {
|
|
170
|
+
this.leaveList(event);
|
|
171
|
+
this.searchHovered = true;
|
|
172
|
+
if (event.type === "press" && (event.button === undefined || event.button === 0)) this.searchPressed = true;
|
|
173
|
+
if (event.type === "release" && (event.button === undefined || event.button === 0)) {
|
|
174
|
+
const activate = this.searchPressed;
|
|
175
|
+
this.searchPressed = false;
|
|
176
|
+
if (activate) this.activateFilter();
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
this.leaveSearch();
|
|
181
|
+
|
|
182
|
+
if (event.row >= this.listStart && event.row < this.listStart + this.listHeight) {
|
|
183
|
+
return this.list.onMouse({ ...event, row: event.row - this.listStart });
|
|
184
|
+
}
|
|
185
|
+
this.leaveList(event);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Handle filter activation and editing, list navigation, confirmation, and cancellation.
|
|
191
|
+
* @param data Raw terminal input from Pi.
|
|
192
|
+
*/
|
|
193
|
+
handleInput(data: string): void {
|
|
194
|
+
const keybindings = getKeybindings();
|
|
195
|
+
if (this.filterActive) {
|
|
196
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
197
|
+
this.clearFilter();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (keybindings.matches(data, "tui.select.confirm")) {
|
|
201
|
+
this.confirm();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (keybindings.matches(data, "tui.select.up") || keybindings.matches(data, "tui.select.down")) {
|
|
205
|
+
this.list.handleInput(data);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.input.handleInput(data);
|
|
209
|
+
this.applyFilter();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (data === "/") {
|
|
213
|
+
this.activateFilter();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
217
|
+
this.options.onCancel();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.list.handleInput(data);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Clear cached child layout and pointer geometry after external state changes. */
|
|
224
|
+
invalidate(): void {
|
|
225
|
+
this.title?.invalidate();
|
|
226
|
+
this.description?.invalidate();
|
|
227
|
+
this.input.invalidate();
|
|
228
|
+
this.list.invalidate();
|
|
229
|
+
this.buttons.invalidate();
|
|
230
|
+
this.searchRow = undefined;
|
|
231
|
+
this.buttonRow = undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Render title content, filter field, result list, and explicit actions.
|
|
236
|
+
* @param width Available width in terminal cells.
|
|
237
|
+
* @returns Rendered terminal rows clipped to the supplied width.
|
|
238
|
+
*/
|
|
239
|
+
render(width: number): string[] {
|
|
240
|
+
const colors = tuiTheme(this.options.theme);
|
|
241
|
+
const lines = this.title ? [...this.title.render(width)] : [];
|
|
242
|
+
if (this.description) lines.push(...this.description.render(width));
|
|
243
|
+
this.searchRow = lines.length;
|
|
244
|
+
lines.push(this.renderSearch(width));
|
|
245
|
+
|
|
246
|
+
const buttons = this.buttons.render(width);
|
|
247
|
+
const selected = this.list.getSelectedItem();
|
|
248
|
+
const preview = selected && this.options.renderPreview ? [...this.options.renderPreview(selected, width)] : [];
|
|
249
|
+
const availableListRows = Math.max(
|
|
250
|
+
1,
|
|
251
|
+
(this.maxHeight ?? Number.POSITIVE_INFINITY) - lines.length - 1 - buttons.length - preview.length,
|
|
252
|
+
);
|
|
253
|
+
this.list.setMaxVisible(this.maxHeight === undefined ? SearchableSelect.MAX_VISIBLE : availableListRows);
|
|
254
|
+
this.listStart = lines.length;
|
|
255
|
+
const listLines = this.list.render(width);
|
|
256
|
+
this.listHeight = Math.max(1, listLines.length);
|
|
257
|
+
if (listLines.length > 0) {
|
|
258
|
+
const geometry = this.list.getGeometry();
|
|
259
|
+
lines.push(
|
|
260
|
+
...applyScrollbar(listLines, {
|
|
261
|
+
theme: this.options.theme,
|
|
262
|
+
width,
|
|
263
|
+
height: listLines.length,
|
|
264
|
+
offset: geometry?.startRow ?? 0,
|
|
265
|
+
total: geometry?.totalRows ?? this.optionCount,
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
} else lines.push(colors.fg("text.muted", " No matching options"));
|
|
269
|
+
if (preview.length > 0) lines.push("", ...preview);
|
|
270
|
+
|
|
271
|
+
lines.push("");
|
|
272
|
+
this.buttonRow = lines.length;
|
|
273
|
+
lines.push(...buttons);
|
|
274
|
+
return lines;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private renderSearch(width: number): string {
|
|
278
|
+
const colors = tuiTheme(this.options.theme);
|
|
279
|
+
const prefix = `${colors.fg("text.muted", icon("search"))} `;
|
|
280
|
+
const available = Math.max(0, width - visibleWidth(prefix));
|
|
281
|
+
let content: string;
|
|
282
|
+
if (this.filterActive) {
|
|
283
|
+
const inputLine = this.input.render(available)[0] ?? "";
|
|
284
|
+
content = inputLine.startsWith("> ") ? inputLine.slice(2) : inputLine;
|
|
285
|
+
} else if (this.input.getValue()) {
|
|
286
|
+
content = colors.fg("text.primary", this.input.getValue());
|
|
287
|
+
} else {
|
|
288
|
+
content = colors.fg("text.muted", "Filter…");
|
|
289
|
+
}
|
|
290
|
+
const row = truncateToWidth(`${prefix}${content}`, width, "");
|
|
291
|
+
const padded = row + " ".repeat(Math.max(0, width - visibleWidth(row)));
|
|
292
|
+
return this.searchHovered ? colors.bg("surface.raised", padded) : padded;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private renderOption(option: SelectOption<Value>, context: SelectableListRenderContext): string | string[] {
|
|
296
|
+
const colors = tuiTheme(this.options.theme);
|
|
297
|
+
const prefix = context.selected ? colors.fg("accent", "→ ") : " ";
|
|
298
|
+
const contentWidth = Math.max(0, context.width - visibleWidth(prefix));
|
|
299
|
+
let content: string;
|
|
300
|
+
if (this.options.renderOption) {
|
|
301
|
+
content = this.options.renderOption(option, {
|
|
302
|
+
width: contentWidth,
|
|
303
|
+
selected: context.selected,
|
|
304
|
+
hovered: context.hovered,
|
|
305
|
+
theme: this.options.theme,
|
|
306
|
+
});
|
|
307
|
+
} else {
|
|
308
|
+
const renderedLabel = this.renderMatchingLabel(option.label, context.selected);
|
|
309
|
+
const label = context.selected ? this.options.theme.bold(renderedLabel) : renderedLabel;
|
|
310
|
+
const description = option.description ? ` ${colors.fg("text.muted", option.description)}` : "";
|
|
311
|
+
content = `${label}${description}`;
|
|
312
|
+
}
|
|
313
|
+
const fit = (line: string): string => {
|
|
314
|
+
const clipped = truncateToWidth(line, context.width, "");
|
|
315
|
+
const padded = clipped + " ".repeat(Math.max(0, context.width - visibleWidth(clipped)));
|
|
316
|
+
return context.hovered && !context.selected ? colors.bg("surface.selected", padded) : padded;
|
|
317
|
+
};
|
|
318
|
+
const row = fit(`${prefix}${content}`);
|
|
319
|
+
if (this.options.descriptionLayout !== "below" || !option.description) return row;
|
|
320
|
+
const indent = " ".repeat(visibleWidth(prefix));
|
|
321
|
+
const descriptionWidth = Math.max(1, context.width - visibleWidth(indent));
|
|
322
|
+
const description = new Text(colors.fg("text.muted", option.description), 0, 0).render(descriptionWidth);
|
|
323
|
+
return [row, ...description.map((line) => fit(`${indent}${line}`))];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private renderMatchingLabel(label: string, selected: boolean): string {
|
|
327
|
+
const query = this.input.getValue();
|
|
328
|
+
const colors = tuiTheme(this.options.theme);
|
|
329
|
+
const base = (text: string) => (selected ? colors.fg("accent", text) : text);
|
|
330
|
+
if (!query) return base(label);
|
|
331
|
+
|
|
332
|
+
const matcher = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "giu");
|
|
333
|
+
const segments: string[] = [];
|
|
334
|
+
let end = 0;
|
|
335
|
+
for (const match of label.matchAll(matcher)) {
|
|
336
|
+
const start = match.index;
|
|
337
|
+
segments.push(base(label.slice(end, start)), colors.fg("warning", match[0]));
|
|
338
|
+
end = start + match[0].length;
|
|
339
|
+
}
|
|
340
|
+
segments.push(base(label.slice(end)));
|
|
341
|
+
return segments.join("");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private activateFilter(): void {
|
|
345
|
+
this.filterActive = true;
|
|
346
|
+
this.input.focused = this._focused;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private clearFilter(): void {
|
|
350
|
+
this.input.setValue("");
|
|
351
|
+
this.filterActive = false;
|
|
352
|
+
this.input.focused = false;
|
|
353
|
+
this.applyFilter();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private applyFilter(): void {
|
|
357
|
+
const query = this.input.getValue().toLowerCase();
|
|
358
|
+
const selected = this.list.getSelectedItem()?.value;
|
|
359
|
+
const filtered = this.options.options.filter(
|
|
360
|
+
(option) => option.value.toLowerCase().includes(query) || option.label.toLowerCase().includes(query),
|
|
361
|
+
);
|
|
362
|
+
this.optionCount = filtered.length;
|
|
363
|
+
const retained = selected === undefined ? -1 : filtered.findIndex((option) => option.value === selected);
|
|
364
|
+
this.list.setItems(filtered, retained >= 0 ? retained : 0);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private confirm(): void {
|
|
368
|
+
const selected = this.list.getSelectedItem();
|
|
369
|
+
if (selected) this.options.onSelect(selected.value);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private leaveSearch(): void {
|
|
373
|
+
this.searchHovered = false;
|
|
374
|
+
this.searchPressed = false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private leaveList(event: TuiMouseEvent): void {
|
|
378
|
+
this.list.onMouse({
|
|
379
|
+
...event,
|
|
380
|
+
row: event.row - this.listStart,
|
|
381
|
+
type: "leave",
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private leaveButtons(event: TuiMouseEvent): void {
|
|
386
|
+
this.buttons.onMouse({ ...event, type: "leave", row: 0 });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private clearPointerState(event: TuiMouseEvent): void {
|
|
390
|
+
this.leaveSearch();
|
|
391
|
+
this.leaveList(event);
|
|
392
|
+
this.leaveButtons(event);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, type Focusable, getKeybindings, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { tuiTheme } from "../color/theme.ts";
|
|
4
|
+
import { icon, keyHintGlyph } from "../decoration/glyphs.ts";
|
|
5
|
+
import { fitLine } from "../line-layout.ts";
|
|
6
|
+
import type { TuiMouseEvent } from "../mouse.ts";
|
|
7
|
+
import type { SelectOption } from "./searchable-select.ts";
|
|
8
|
+
import { SelectableList, type SelectableListRenderContext } from "./selectable-list.ts";
|
|
9
|
+
import { SemanticInput } from "./semantic-input.ts";
|
|
10
|
+
|
|
11
|
+
export interface SelectBoxRowContext {
|
|
12
|
+
width: number;
|
|
13
|
+
selected: boolean;
|
|
14
|
+
hovered: boolean;
|
|
15
|
+
theme: Theme;
|
|
16
|
+
query: string;
|
|
17
|
+
highlight(text: string): string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SelectBoxOptions<Value extends string> {
|
|
21
|
+
theme: Theme;
|
|
22
|
+
options: readonly SelectOption<Value>[];
|
|
23
|
+
selected?: Value;
|
|
24
|
+
title?: string;
|
|
25
|
+
bordered?: boolean;
|
|
26
|
+
filterable?: boolean;
|
|
27
|
+
showHint?: boolean;
|
|
28
|
+
onSelect(value: Value): void;
|
|
29
|
+
onPreview?(value: Value): void;
|
|
30
|
+
onCancel(): void;
|
|
31
|
+
renderOption?(option: SelectOption<Value>, context: SelectBoxRowContext): string;
|
|
32
|
+
renderPreview?(option: SelectOption<Value>, width: number): readonly string[];
|
|
33
|
+
requestRender?(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Compact single-select surface with immediate Enter/click selection and no action buttons. */
|
|
37
|
+
export class SelectBox<Value extends string = string> implements Component, Focusable {
|
|
38
|
+
private readonly list: SelectableList<SelectOption<Value>>;
|
|
39
|
+
private readonly filterInput: SemanticInput;
|
|
40
|
+
private _focused = false;
|
|
41
|
+
private maxHeight: number | undefined;
|
|
42
|
+
private listStart = 0;
|
|
43
|
+
private listHeight = 0;
|
|
44
|
+
private query = "";
|
|
45
|
+
private previewedValue: Value | undefined;
|
|
46
|
+
|
|
47
|
+
constructor(private readonly options: SelectBoxOptions<Value>) {
|
|
48
|
+
this.filterInput = new SemanticInput(options.theme);
|
|
49
|
+
const selected = Math.max(
|
|
50
|
+
0,
|
|
51
|
+
options.options.findIndex((option) => option.value === options.selected),
|
|
52
|
+
);
|
|
53
|
+
this.previewedValue = options.options[selected]?.value;
|
|
54
|
+
this.list = new SelectableList({
|
|
55
|
+
items: options.options,
|
|
56
|
+
selectedIndex: selected,
|
|
57
|
+
renderItem: (option, context) => this.renderOption(option, context),
|
|
58
|
+
requestRender: options.requestRender ?? (() => {}),
|
|
59
|
+
onSelectionChange: (option) => this.preview(option.value),
|
|
60
|
+
onActivate: (option) => options.onSelect(option.value),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get focused(): boolean {
|
|
65
|
+
return this._focused;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
set focused(value: boolean) {
|
|
69
|
+
this._focused = value;
|
|
70
|
+
this.filterInput.focused = value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setMaxHeight(height: number): void {
|
|
74
|
+
this.maxHeight = Math.max(1, Math.floor(height));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
handleInput(data: string): void {
|
|
78
|
+
const keybindings = getKeybindings();
|
|
79
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
80
|
+
this.options.onCancel();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
this.options.filterable !== false &&
|
|
85
|
+
!keybindings.matches(data, "tui.select.up") &&
|
|
86
|
+
!keybindings.matches(data, "tui.select.down") &&
|
|
87
|
+
!keybindings.matches(data, "tui.select.confirm")
|
|
88
|
+
) {
|
|
89
|
+
this.filterInput.handleInput(data);
|
|
90
|
+
this.setQuery(this.filterInput.getValue());
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.list.handleInput(data);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
onMouse(event: TuiMouseEvent): boolean {
|
|
97
|
+
if (event.type === "leave") return this.list.onMouse({ ...event, row: event.row - this.listStart });
|
|
98
|
+
if (event.row < this.listStart || event.row >= this.listStart + this.listHeight) return false;
|
|
99
|
+
const inset = this.options.bordered === false ? 0 : 1;
|
|
100
|
+
return this.list.onMouse({ ...event, row: event.row - this.listStart, col: event.col - inset });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
invalidate(): void {
|
|
104
|
+
this.list.invalidate();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
render(width: number): string[] {
|
|
108
|
+
const boundedWidth = Math.max(1, Math.floor(width));
|
|
109
|
+
const bordered = this.options.bordered !== false;
|
|
110
|
+
const innerWidth = Math.max(1, boundedWidth - (bordered ? 2 : 0));
|
|
111
|
+
const selected = this.list.getSelectedItem();
|
|
112
|
+
const preview = selected && this.options.renderPreview ? [...this.options.renderPreview(selected, innerWidth)] : [];
|
|
113
|
+
const titleRows = this.options.title ? 2 : 0;
|
|
114
|
+
const frameRows = bordered ? 2 : 0;
|
|
115
|
+
const filterRows = this.options.filterable === false ? 0 : 1;
|
|
116
|
+
const hintRows = this.options.showHint ? 1 : 0;
|
|
117
|
+
const previewRows = preview.length > 0 ? preview.length + 1 : 0;
|
|
118
|
+
const available = Math.max(
|
|
119
|
+
1,
|
|
120
|
+
(this.maxHeight ?? Number.POSITIVE_INFINITY) - titleRows - frameRows - filterRows - hintRows - previewRows,
|
|
121
|
+
);
|
|
122
|
+
this.list.setMaxVisible(available);
|
|
123
|
+
const list = this.list.render(innerWidth);
|
|
124
|
+
const colors = tuiTheme(this.options.theme);
|
|
125
|
+
const lines: string[] = [];
|
|
126
|
+
if (this.options.title) {
|
|
127
|
+
lines.push(this.options.theme.bold(colors.fg("heading", this.options.title)));
|
|
128
|
+
lines.push(colors.fg("border", "─".repeat(boundedWidth)));
|
|
129
|
+
}
|
|
130
|
+
if (bordered) lines.push(colors.fg("border", `╭${"─".repeat(innerWidth)}╮`));
|
|
131
|
+
if (this.options.filterable !== false) {
|
|
132
|
+
const prefix = `${icon("search")} `;
|
|
133
|
+
const renderedInput = this.filterInput.render(Math.max(1, innerWidth - visibleWidth(prefix)) + 2)[0] ?? "";
|
|
134
|
+
const input = renderedInput.startsWith("> ") ? renderedInput.slice(2) : renderedInput;
|
|
135
|
+
lines.push(colors.bg("surface.editor", fitLine(`${colors.fg("text.secondary", prefix)}${input}`, innerWidth)));
|
|
136
|
+
}
|
|
137
|
+
this.listStart = lines.length;
|
|
138
|
+
const listRows = list.length > 0 ? list : [colors.fg("text.muted", fitLine(" No matches", innerWidth))];
|
|
139
|
+
this.listHeight = listRows.length;
|
|
140
|
+
lines.push(
|
|
141
|
+
...listRows.map((line) =>
|
|
142
|
+
bordered ? `${colors.fg("border", "│")}${fitLine(line, innerWidth)}${colors.fg("border", "│")}` : line,
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
if (bordered) lines.push(colors.fg("border", `╰${"─".repeat(innerWidth)}╯`));
|
|
146
|
+
if (preview.length > 0) lines.push("", ...preview);
|
|
147
|
+
if (this.options.showHint) {
|
|
148
|
+
const confirm = getKeybindings().getKeys("tui.select.confirm")[0];
|
|
149
|
+
const cancel = getKeybindings().getKeys("tui.select.cancel")[0];
|
|
150
|
+
const hint = `${confirm ? `${keyHintGlyph(confirm)} select` : "select"} · ${cancel ? `${keyHintGlyph(cancel)} back` : "back"}`;
|
|
151
|
+
lines.push(colors.fg("text.muted", truncateToWidth(hint, boundedWidth, "")));
|
|
152
|
+
}
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private setQuery(query: string): void {
|
|
157
|
+
if (query === this.query) return;
|
|
158
|
+
const selectedValue = this.list.getSelectedItem()?.value;
|
|
159
|
+
this.query = query;
|
|
160
|
+
if (this.filterInput.getValue() !== query) this.filterInput.setValue(query);
|
|
161
|
+
const normalized = query.toLocaleLowerCase();
|
|
162
|
+
const filtered = normalized
|
|
163
|
+
? this.options.options.filter((option) =>
|
|
164
|
+
[option.label, option.description ?? "", option.value].some((value) =>
|
|
165
|
+
value.toLocaleLowerCase().includes(normalized),
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
: this.options.options;
|
|
169
|
+
const retained = filtered.findIndex((option) => option.value === selectedValue);
|
|
170
|
+
this.list.setItems(filtered, retained >= 0 ? retained : 0);
|
|
171
|
+
const selected = this.list.getSelectedItem();
|
|
172
|
+
if (selected) this.preview(selected.value);
|
|
173
|
+
this.options.requestRender?.();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private preview(value: Value): void {
|
|
177
|
+
if (value === this.previewedValue) return;
|
|
178
|
+
this.previewedValue = value;
|
|
179
|
+
this.options.onPreview?.(value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private renderOption(option: SelectOption<Value>, context: SelectableListRenderContext): string {
|
|
183
|
+
const colors = tuiTheme(this.options.theme);
|
|
184
|
+
const prefix = context.selected ? `${colors.fg("accent", "›")} ` : " ";
|
|
185
|
+
const available = Math.max(0, context.width - visibleWidth(prefix));
|
|
186
|
+
const content = this.options.renderOption
|
|
187
|
+
? this.options.renderOption(option, {
|
|
188
|
+
width: available,
|
|
189
|
+
selected: context.selected,
|
|
190
|
+
hovered: context.hovered,
|
|
191
|
+
theme: this.options.theme,
|
|
192
|
+
query: this.query,
|
|
193
|
+
highlight: (text) => highlightMatches(text, this.query, this.options.theme),
|
|
194
|
+
})
|
|
195
|
+
: highlightMatches(option.label, this.query, this.options.theme);
|
|
196
|
+
const row = fitLine(`${prefix}${truncateToWidth(content, available, "")}`, context.width);
|
|
197
|
+
if (context.selected) {
|
|
198
|
+
const background = colors.adjustForegroundBrightness(colors.color("surface.selected"), -0.08);
|
|
199
|
+
return colors.bg(background, row);
|
|
200
|
+
}
|
|
201
|
+
if (context.hovered) {
|
|
202
|
+
const background = colors.adjustForegroundBrightness(colors.color("surface.hover"), -0.08);
|
|
203
|
+
return colors.bg(background, row);
|
|
204
|
+
}
|
|
205
|
+
return row;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function highlightMatches(text: string, query: string, theme: Theme): string {
|
|
210
|
+
if (!query) return text;
|
|
211
|
+
const lower = text.toLocaleLowerCase();
|
|
212
|
+
const needle = query.toLocaleLowerCase();
|
|
213
|
+
if (!needle) return text;
|
|
214
|
+
const colors = tuiTheme(theme);
|
|
215
|
+
const parts: string[] = [];
|
|
216
|
+
let start = 0;
|
|
217
|
+
for (let index = lower.indexOf(needle, start); index >= 0; index = lower.indexOf(needle, start)) {
|
|
218
|
+
parts.push(text.slice(start, index));
|
|
219
|
+
parts.push(theme.bold(colors.fg("highlight", text.slice(index, index + query.length))));
|
|
220
|
+
start = index + query.length;
|
|
221
|
+
}
|
|
222
|
+
parts.push(text.slice(start));
|
|
223
|
+
return parts.join("");
|
|
224
|
+
}
|