@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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/native-revision +1 -0
  4. package/package.json +58 -0
  5. package/src/activity-presentation.ts +75 -0
  6. package/src/appearance.ts +632 -0
  7. package/src/background-surface.ts +125 -0
  8. package/src/color/palette.ts +186 -0
  9. package/src/color/preview.ts +157 -0
  10. package/src/color/resolver.ts +75 -0
  11. package/src/color/theme.ts +531 -0
  12. package/src/component-stack.ts +247 -0
  13. package/src/content/markdown-code.ts +60 -0
  14. package/src/content/terminal-text.ts +135 -0
  15. package/src/content/text.ts +105 -0
  16. package/src/controls/action-panel.ts +420 -0
  17. package/src/controls/dialog-button-bar.ts +287 -0
  18. package/src/controls/multi-select.ts +467 -0
  19. package/src/controls/picker-panel.ts +447 -0
  20. package/src/controls/screen-icon-actions.ts +134 -0
  21. package/src/controls/searchable-select.ts +394 -0
  22. package/src/controls/select-box.ts +224 -0
  23. package/src/controls/selectable-list.ts +433 -0
  24. package/src/controls/selection-action-bar.ts +382 -0
  25. package/src/controls/semantic-input.ts +23 -0
  26. package/src/controls/tab.ts +189 -0
  27. package/src/cursor.ts +288 -0
  28. package/src/decoration/editor-pills.ts +156 -0
  29. package/src/decoration/glyphs.ts +326 -0
  30. package/src/decoration/pointer-interaction.ts +123 -0
  31. package/src/decoration/powerline-pill.ts +104 -0
  32. package/src/decoration/status.ts +185 -0
  33. package/src/decoration/transient-pill.ts +176 -0
  34. package/src/diff/index.ts +21 -0
  35. package/src/diff/model.ts +76 -0
  36. package/src/diff/parse.ts +310 -0
  37. package/src/diff/pierre-syntax.ts +86 -0
  38. package/src/diff/render.ts +504 -0
  39. package/src/diff/structured.ts +264 -0
  40. package/src/diff/view.ts +75 -0
  41. package/src/editor/chrome.ts +341 -0
  42. package/src/editor/composition.ts +53 -0
  43. package/src/editor/layout.ts +81 -0
  44. package/src/editor/presentation.ts +35 -0
  45. package/src/editor/protocol.ts +188 -0
  46. package/src/editor.ts +41 -0
  47. package/src/extension.ts +30 -0
  48. package/src/folding.ts +192 -0
  49. package/src/host/cursor-bridge.ts +210 -0
  50. package/src/host/editor-bridge.ts +148 -0
  51. package/src/host/extension-host.ts +231 -0
  52. package/src/host/mouse-bridge.ts +212 -0
  53. package/src/host/mouse-dispatch.ts +400 -0
  54. package/src/host/mouse-input.ts +85 -0
  55. package/src/host/mouse-targets.ts +238 -0
  56. package/src/host/pi-layout-adapter.ts +298 -0
  57. package/src/host/pi-native-selection.ts +227 -0
  58. package/src/host/split-pane-bridge.ts +720 -0
  59. package/src/host/transcript-bridge.ts +236 -0
  60. package/src/index.ts +334 -0
  61. package/src/line-layout.ts +6 -0
  62. package/src/motion.ts +1409 -0
  63. package/src/mouse/events.ts +55 -0
  64. package/src/mouse/fullscreen-layout.ts +260 -0
  65. package/src/mouse/registry.ts +348 -0
  66. package/src/mouse.ts +37 -0
  67. package/src/native-binary.ts +189 -0
  68. package/src/overlay/anchored.ts +91 -0
  69. package/src/overlay/detail-card.ts +158 -0
  70. package/src/overlay/dialog.ts +266 -0
  71. package/src/overlay/floating.ts +106 -0
  72. package/src/overlay/framed-editor.ts +236 -0
  73. package/src/overlay/fullscreen.ts +170 -0
  74. package/src/overlay/hover-tooltip.ts +109 -0
  75. package/src/overlay/modal-mount.ts +118 -0
  76. package/src/panels.ts +207 -0
  77. package/src/render-cache.ts +80 -0
  78. package/src/render-epoch.ts +48 -0
  79. package/src/request-animation.ts +160 -0
  80. package/src/scrollbar.ts +27 -0
  81. package/src/selection.ts +148 -0
  82. package/src/split-pane.ts +232 -0
  83. package/src/status-presentation.ts +411 -0
  84. package/src/stream.ts +543 -0
  85. package/src/syntax.ts +316 -0
  86. package/src/terminal/bridge-client.ts +243 -0
  87. package/src/terminal/embedding.ts +6 -0
  88. package/src/terminal/output.ts +151 -0
  89. package/src/terminal/projection.ts +362 -0
  90. package/src/terminal/pty-host.ts +263 -0
  91. package/src/terminal/pty-pane.ts +308 -0
  92. package/src/terminal-colors.ts +334 -0
  93. package/src/tool/action.ts +170 -0
  94. package/src/tool/activity.ts +500 -0
  95. package/src/tool/call-preview.ts +44 -0
  96. package/src/tool/disclosure-action.ts +89 -0
  97. package/src/tool/expanded-region-viewport.ts +103 -0
  98. package/src/tool/index.ts +26 -0
  99. package/src/tool/output.ts +271 -0
  100. package/src/tool/transcript.ts +150 -0
  101. package/src/tool/view-region.ts +581 -0
  102. package/test/appearance.test.ts +183 -0
  103. package/test/bounded-stream.test.ts +150 -0
  104. package/test/bridge-client.test.ts +42 -0
  105. package/test/color-preview.test.ts +23 -0
  106. package/test/component-stack.test.ts +206 -0
  107. package/test/components.test.ts +1165 -0
  108. package/test/content-text.test.ts +21 -0
  109. package/test/cursor-bridge.test.ts +151 -0
  110. package/test/cursor.test.ts +147 -0
  111. package/test/editor-chrome.test.ts +119 -0
  112. package/test/editor-layout.test.ts +30 -0
  113. package/test/editor-registry.test.ts +203 -0
  114. package/test/editor-theme.test.ts +33 -0
  115. package/test/editor-token-pill.test.ts +72 -0
  116. package/test/floating-overlay.test.ts +57 -0
  117. package/test/folding.test.ts +147 -0
  118. package/test/harmonious-theme.test.ts +23 -0
  119. package/test/interaction.test.ts +104 -0
  120. package/test/key-icons.test.ts +62 -0
  121. package/test/markdown-code.test.ts +24 -0
  122. package/test/modal-overlay-mount.test.ts +94 -0
  123. package/test/motion.test.ts +649 -0
  124. package/test/mouse/bridge.test.ts +1302 -0
  125. package/test/mouse/extension.test.ts +205 -0
  126. package/test/mouse/registry.test.ts +140 -0
  127. package/test/mouse/screen-decoration.test.ts +167 -0
  128. package/test/native-binary.test.ts +52 -0
  129. package/test/palette.test.ts +32 -0
  130. package/test/panels.test.ts +74 -0
  131. package/test/pointer-components.test.ts +151 -0
  132. package/test/progress.test.ts +54 -0
  133. package/test/pty-host.test.ts +126 -0
  134. package/test/pty-pane.test.ts +62 -0
  135. package/test/public-surface.test.ts +75 -0
  136. package/test/render-cache.test.ts +25 -0
  137. package/test/render-epoch.test.ts +119 -0
  138. package/test/request-animation.test.ts +193 -0
  139. package/test/screen-icon-actions.test.ts +69 -0
  140. package/test/select-box.test.ts +86 -0
  141. package/test/select-pointer.test.ts +369 -0
  142. package/test/selectable-list.test.ts +258 -0
  143. package/test/selection/registry.test.ts +44 -0
  144. package/test/selection-action-bar.test.ts +204 -0
  145. package/test/split-pane-bridge.test.ts +944 -0
  146. package/test/split-pane.test.ts +134 -0
  147. package/test/status-presentation.test.ts +39 -0
  148. package/test/syntax.test.ts +59 -0
  149. package/test/terminal-colors.test.ts +115 -0
  150. package/test/terminal-embedding.test.ts +12 -0
  151. package/test/terminal-projection.test.ts +312 -0
  152. package/test/theme-colors.test.ts +175 -0
  153. package/test/tool-output.test.ts +160 -0
  154. package/test/tool-transcript.test.ts +1190 -0
  155. package/test/unified-diff.test.ts +691 -0
  156. package/themes/harmonious.json +61 -0
  157. package/tsconfig.json +16 -0
@@ -0,0 +1,447 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ type Focusable,
5
+ fuzzyFilter,
6
+ Key,
7
+ type KeybindingsManager,
8
+ matchesKey,
9
+ truncateToWidth,
10
+ visibleWidth,
11
+ } from "@earendil-works/pi-tui";
12
+ import { tuiTheme } from "../color/theme.ts";
13
+ import { renderKeyHint } from "../decoration/glyphs.ts";
14
+ import { fitLine } from "../line-layout.ts";
15
+ import type { TuiMouseEvent } from "../mouse.ts";
16
+ import { DialogButtonBar } from "./dialog-button-bar.ts";
17
+ import type { SelectOption } from "./searchable-select.ts";
18
+ import { SelectableList, type SelectableListRenderContext } from "./selectable-list.ts";
19
+ import { SemanticInput } from "./semantic-input.ts";
20
+
21
+ /** One searchable option displayed by {@link PickerPanel}. */
22
+ export interface PickerOption<Value extends string = string> extends SelectOption<Value> {
23
+ /** Optional text used instead of the visible label and description for fuzzy matching. */
24
+ searchText?: string;
25
+ }
26
+
27
+ /** Layout and appearance available to a custom picker-row renderer. */
28
+ export interface PickerRowContext {
29
+ /** Available row width in terminal cells. */
30
+ width: number;
31
+ /** Active Pi theme for non-color text attributes. */
32
+ theme: Theme;
33
+ }
34
+
35
+ /** Minimum host surface required by {@link PickerPanel}. */
36
+ export interface PickerPanelHost {
37
+ /** Terminal dimensions used to bound the panel height. */
38
+ terminal: { rows: number };
39
+ /** Request a host render after interactive state changes. */
40
+ requestRender(): void;
41
+ }
42
+
43
+ /** Construction options for {@link PickerPanel}. */
44
+ export interface PickerPanelOptions<Value extends string> {
45
+ /** Host terminal and render-request surface. */
46
+ tui: PickerPanelHost;
47
+ /** Active Pi theme used to derive semantic libtui colors. */
48
+ theme: Theme;
49
+ /** Pi keybinding manager used for navigation, confirmation, and cancellation. */
50
+ keybindings: KeybindingsManager;
51
+ /** Title rendered in the panel border. */
52
+ title: string;
53
+ /** Complete ordered set of searchable options. */
54
+ options: readonly PickerOption<Value>[];
55
+ /** Initially selected value; defaults to the first option. */
56
+ selected?: Value;
57
+ /** Maximum number of option rows displayed at once. */
58
+ maxVisible?: number;
59
+ /** Text displayed when the current query has no matches. */
60
+ emptyMessage?: string;
61
+ /** Label for the confirmation button. Defaults to Save. */
62
+ confirmLabel?: string;
63
+ /** Optional picker-specific shortcuts appended to the navigation hint. */
64
+ navigationHint?: string;
65
+ /** Custom option renderer; output is fitted to the available width. */
66
+ renderOption?: (option: PickerOption<Value>, context: PickerRowContext) => string;
67
+ /** Receive the confirmed option value. */
68
+ onSelect(value: Value): void;
69
+ /** Close the picker without selecting. */
70
+ onCancel(): void;
71
+ }
72
+
73
+ const MIN_WIDTH = 24;
74
+ const PANEL_ROWS = 4;
75
+
76
+ function framed(theme: Theme, content: string, innerWidth: number): string {
77
+ const colors = tuiTheme(theme);
78
+ return `${colors.fg("border", "│")}${fitLine(content, innerWidth)}${colors.fg("border", "│")}`;
79
+ }
80
+
81
+ function topBorder(theme: Theme, title: string, position: string, width: number): string {
82
+ const colors = tuiTheme(theme);
83
+ const label = `${colors.fg("border", "─ ")}${colors.fg("accent", theme.bold(title))}${colors.fg("text.muted", ` ${position}`)} `;
84
+ const clipped = truncateToWidth(label, Math.max(0, width - 2), "");
85
+ return `${colors.fg("border", "╭")}${clipped}${colors.fg("border", `${"─".repeat(Math.max(0, width - visibleWidth(clipped) - 2))}╮`)}`;
86
+ }
87
+
88
+ /** A domain-free, searchable single-select panel for bottom-sheet overlays. */
89
+ export class PickerPanel<Value extends string = string> implements Component, Focusable {
90
+ private readonly maxVisible: number;
91
+ private readonly input: SemanticInput;
92
+ private readonly list: SelectableList<PickerOption<Value>>;
93
+ private readonly buttons: DialogButtonBar<"cancel" | "save">;
94
+ private filtered: readonly PickerOption<Value>[];
95
+ private searching = false;
96
+ private _focused = false;
97
+ private searchRow: number | undefined;
98
+ private listStart = 0;
99
+ private listHeight = 0;
100
+ private contentWidth = 0;
101
+ private buttonRow: number | undefined;
102
+ private searchHovered = false;
103
+ private searchPressed = false;
104
+
105
+ /**
106
+ * Create a searchable bottom-sheet picker.
107
+ * @param options Host, choices, theme, keybindings, and completion callbacks.
108
+ */
109
+ constructor(private readonly options: PickerPanelOptions<Value>) {
110
+ this.maxVisible = Math.max(1, options.maxVisible ?? 8);
111
+ this.filtered = [...options.options];
112
+ const selected = Math.max(
113
+ 0,
114
+ options.options.findIndex((option) => option.value === options.selected),
115
+ );
116
+ this.input = new SemanticInput(options.theme);
117
+ this.input.onSubmit = () => this.choose();
118
+ this.input.onEscape = () => this.clearSearch();
119
+ this.list = new SelectableList({
120
+ items: this.filtered,
121
+ selectedIndex: selected,
122
+ maxVisible: this.maxVisible,
123
+ wrap: false,
124
+ activateOnClick: false,
125
+ renderItem: (option, context) => this.renderOption(option, context),
126
+ requestRender: () => options.tui.requestRender(),
127
+ onActivate: (option) => this.options.onSelect(option.value),
128
+ });
129
+ this.buttons = new DialogButtonBar({
130
+ theme: options.theme,
131
+ leading: () => this.renderNavigationHint(),
132
+ buttons: [
133
+ {
134
+ value: "cancel",
135
+ label: "Cancel",
136
+ icon: "cancel",
137
+ foreground: "text.primary",
138
+ background: "action.neutral",
139
+ shortcuts: options.keybindings.getKeys("tui.select.cancel"),
140
+ },
141
+ {
142
+ value: "save",
143
+ label: options.confirmLabel ?? "Save",
144
+ icon: "confirm",
145
+ foreground: "positive",
146
+ background: "action.positive",
147
+ shortcuts: options.keybindings.getKeys("tui.select.confirm"),
148
+ },
149
+ ],
150
+ requestRender: () => options.tui.requestRender(),
151
+ onActivate: (action) => (action === "save" ? this.choose() : this.options.onCancel()),
152
+ });
153
+ }
154
+
155
+ /** Whether the panel currently receives keyboard focus. */
156
+ get focused(): boolean {
157
+ return this._focused;
158
+ }
159
+
160
+ /** Update panel focus and forward it to the search field while searching. */
161
+ set focused(value: boolean) {
162
+ this._focused = value;
163
+ this.input.focused = value && this.searching;
164
+ }
165
+
166
+ /** Value to select when the current row is removed: the row above, or the next row when already first. */
167
+ getFallbackValueAfterRemoval(): Value | undefined {
168
+ const index = this.list.getSelectedIndex();
169
+ return (this.filtered[index - 1] ?? this.filtered[index + 1])?.value;
170
+ }
171
+
172
+ /**
173
+ * Handle component-local search, list, and action-button pointer input.
174
+ *
175
+ * The list and button bar receive the same rendered geometry they own for
176
+ * every other shared picker. The panel only translates coordinates through
177
+ * its frame and reserves the search row for filter activation.
178
+ *
179
+ * @param event Pointer event from pi-libtui's shared component host.
180
+ * @returns True when the event targets a panel region.
181
+ */
182
+ onMouse(event: TuiMouseEvent): boolean {
183
+ if (event.type === "leave") {
184
+ this.clearPointerState(event);
185
+ return false;
186
+ }
187
+
188
+ const contentColumn = event.col - 1;
189
+ if (this.buttonRow !== undefined && event.row === this.buttonRow) {
190
+ this.leaveSearch();
191
+ this.leaveList(event);
192
+ this.buttons.onMouse({ ...event, row: 0, col: contentColumn });
193
+ return true;
194
+ }
195
+ this.leaveButtons(event);
196
+
197
+ if (
198
+ this.searchRow !== undefined &&
199
+ event.row === this.searchRow &&
200
+ contentColumn >= 0 &&
201
+ contentColumn < this.contentWidth
202
+ ) {
203
+ this.leaveList(event);
204
+ if (!this.searchHovered) {
205
+ this.searchHovered = true;
206
+ this.options.tui.requestRender();
207
+ }
208
+ if (event.type === "press" && (event.button === undefined || event.button === 0)) this.searchPressed = true;
209
+ if (event.type === "release" && (event.button === undefined || event.button === 0)) {
210
+ const activate = this.searchPressed;
211
+ this.searchPressed = false;
212
+ if (activate) this.activateSearch();
213
+ }
214
+ return true;
215
+ }
216
+ this.leaveSearch();
217
+
218
+ if (event.row >= this.listStart && event.row < this.listStart + this.listHeight) {
219
+ return this.list.onMouse({ ...event, row: event.row - this.listStart, col: contentColumn });
220
+ }
221
+ this.leaveList(event);
222
+ return false;
223
+ }
224
+
225
+ /**
226
+ * Handle search activation, filtering, navigation, selection, and cancellation.
227
+ * @param data Raw terminal input from Pi.
228
+ */
229
+ handleInput(data: string): void {
230
+ if (this.searching) {
231
+ if (this.options.keybindings.matches(data, "tui.select.cancel")) {
232
+ this.clearSearch();
233
+ return;
234
+ }
235
+ if (this.options.keybindings.matches(data, "tui.select.confirm")) {
236
+ this.choose();
237
+ return;
238
+ }
239
+ if (
240
+ this.options.keybindings.matches(data, "tui.select.up") ||
241
+ this.options.keybindings.matches(data, "tui.select.down")
242
+ ) {
243
+ this.handleListInput(data);
244
+ return;
245
+ }
246
+ this.input.handleInput(data);
247
+ this.applyFilter();
248
+ return;
249
+ }
250
+ if (this.buttons.handleInput(data)) return;
251
+ if (this.options.keybindings.matches(data, "tui.select.cancel") || data === "q") {
252
+ this.options.onCancel();
253
+ return;
254
+ }
255
+ if (data === "/" || matchesKey(data, Key.ctrl("f"))) {
256
+ this.activateSearch();
257
+ return;
258
+ }
259
+ if (matchesKey(data, Key.home)) {
260
+ this.list.setSelectedIndex(0);
261
+ return;
262
+ }
263
+ if (data === "G" || matchesKey(data, Key.end)) {
264
+ this.list.setSelectedIndex(this.filtered.length - 1);
265
+ return;
266
+ }
267
+ this.handleListInput(data);
268
+ }
269
+
270
+ /** Clear cached child layout and pointer geometry after external state changes. */
271
+ invalidate(): void {
272
+ this.input.invalidate();
273
+ this.list.invalidate();
274
+ this.buttons.invalidate();
275
+ this.searchRow = undefined;
276
+ this.listStart = 0;
277
+ this.listHeight = 0;
278
+ this.contentWidth = 0;
279
+ this.buttonRow = undefined;
280
+ this.clearPointerState({
281
+ type: "leave",
282
+ row: -1,
283
+ col: -1,
284
+ screenRow: -1,
285
+ screenCol: -1,
286
+ button: undefined,
287
+ wheel: undefined,
288
+ shift: false,
289
+ alt: false,
290
+ ctrl: false,
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Render the bordered picker within the host terminal's current height.
296
+ * @param width Available width in terminal cells.
297
+ * @returns Rendered terminal rows, or no rows when the terminal is too small.
298
+ */
299
+ render(width: number): string[] {
300
+ const colors = tuiTheme(this.options.theme);
301
+ const terminalRows = this.options.tui.terminal.rows;
302
+ if (width < MIN_WIDTH || terminalRows < PANEL_ROWS + 1) {
303
+ this.searchRow = undefined;
304
+ this.listStart = 0;
305
+ this.listHeight = 0;
306
+ this.contentWidth = 0;
307
+ this.buttonRow = undefined;
308
+ return [];
309
+ }
310
+ const innerWidth = width - 2;
311
+ this.contentWidth = innerWidth;
312
+ const availableRows = Math.max(1, terminalRows - PANEL_ROWS);
313
+ this.list.setMaxVisible(Math.min(this.maxVisible, availableRows));
314
+ const options = this.filtered;
315
+ const selectedIndex = this.list.getSelectedIndex();
316
+ const position = options.length === 0 ? "0/0" : `${selectedIndex + 1}/${options.length}`;
317
+ const lines = [topBorder(this.options.theme, this.options.title, position, width)];
318
+
319
+ this.searchRow = lines.length;
320
+ lines.push(framed(this.options.theme, this.searchLine(innerWidth), innerWidth));
321
+
322
+ this.listStart = lines.length;
323
+ const listLines = this.list.render(innerWidth);
324
+ this.listHeight = Math.max(1, listLines.length);
325
+ if (listLines.length === 0) {
326
+ lines.push(
327
+ framed(
328
+ this.options.theme,
329
+ colors.fg("text.muted", this.options.emptyMessage ?? "No matching options."),
330
+ innerWidth,
331
+ ),
332
+ );
333
+ } else {
334
+ lines.push(...listLines.map((line) => framed(this.options.theme, line, innerWidth)));
335
+ }
336
+
337
+ this.buttonRow = lines.length;
338
+ const buttonLine = this.buttons.render(innerWidth)[0] ?? "";
339
+ lines.push(framed(this.options.theme, buttonLine, innerWidth));
340
+ lines.push(colors.fg("border", `╰${"─".repeat(innerWidth)}╯`));
341
+ return lines;
342
+ }
343
+
344
+ private visibleOptions(): PickerOption<Value>[] {
345
+ const options = [...this.options.options];
346
+ const query = this.input.getValue();
347
+ return query
348
+ ? fuzzyFilter(options, query, (option) => option.searchText ?? `${option.label} ${option.description ?? ""}`)
349
+ : options;
350
+ }
351
+
352
+ private applyFilter(): void {
353
+ this.filtered = this.visibleOptions();
354
+ this.list.setItems(this.filtered, 0);
355
+ }
356
+
357
+ private activateSearch(): void {
358
+ this.searching = true;
359
+ this.input.focused = this._focused;
360
+ this.options.tui.requestRender();
361
+ }
362
+
363
+ private clearSearch(): void {
364
+ this.searching = false;
365
+ this.input.focused = false;
366
+ this.input.setValue("");
367
+ this.filtered = [...this.options.options];
368
+ const selected = this.options.options.findIndex((option) => option.value === this.options.selected);
369
+ this.list.setItems(this.filtered, selected >= 0 ? selected : 0);
370
+ }
371
+
372
+ private choose(): void {
373
+ const option = this.list.getSelectedItem();
374
+ if (option) this.options.onSelect(option.value);
375
+ }
376
+
377
+ private handleListInput(data: string): void {
378
+ if (this.options.keybindings.matches(data, "tui.select.up")) {
379
+ this.list.handleInput("k");
380
+ return;
381
+ }
382
+ if (this.options.keybindings.matches(data, "tui.select.down")) {
383
+ this.list.handleInput("j");
384
+ return;
385
+ }
386
+ this.list.handleInput(data);
387
+ }
388
+
389
+ private searchLine(width: number): string {
390
+ const colors = tuiTheme(this.options.theme);
391
+ const content = !this.searching
392
+ ? colors.fg("text.muted", "/ search")
393
+ : (() => {
394
+ const prefix = colors.fg("text.muted", "search ");
395
+ const rendered = this.input.render(Math.max(1, width - visibleWidth(prefix)))[0] ?? "";
396
+ return prefix + (rendered.startsWith("> ") ? rendered.slice(2) : rendered);
397
+ })();
398
+ return this.searchHovered ? colors.bg("surface.raised", fitLine(content, width)) : content;
399
+ }
400
+
401
+ private renderOption(option: PickerOption<Value>, context: SelectableListRenderContext): string {
402
+ const colors = tuiTheme(this.options.theme);
403
+ const cursor = context.selected ? colors.fg("accent", "›") : " ";
404
+ const prefix = ` ${cursor} `;
405
+ const contentWidth = Math.max(0, context.width - visibleWidth(prefix));
406
+ const content = this.options.renderOption
407
+ ? this.options.renderOption(option, { width: contentWidth, theme: this.options.theme })
408
+ : `${option.label}${option.description ? ` ${colors.fg("text.muted", option.description)}` : ""}`;
409
+ const row = `${prefix}${fitLine(content, contentWidth)}`;
410
+ if (context.selected) return colors.bg("surface.selected", row);
411
+ return context.hovered ? colors.bg("surface.hover", row) : row;
412
+ }
413
+
414
+ private renderNavigationHint(): string {
415
+ const colors = tuiTheme(this.options.theme);
416
+ const keys = [
417
+ this.options.keybindings.getKeys("tui.select.up")[0],
418
+ this.options.keybindings.getKeys("tui.select.down")[0],
419
+ ].filter((key): key is NonNullable<typeof key> => key !== undefined);
420
+ const hints = keys.map((key) => renderKeyHint(this.options.theme, key));
421
+ const navigation = `${hints.join(" ")}${hints.length > 0 ? " " : ""}${colors.fg("text.muted", "move")}`;
422
+ return this.options.navigationHint
423
+ ? `${navigation} ${colors.fg("text.muted", `· ${this.options.navigationHint}`)}`
424
+ : navigation;
425
+ }
426
+
427
+ private leaveSearch(): void {
428
+ const changed = this.searchHovered || this.searchPressed;
429
+ this.searchHovered = false;
430
+ this.searchPressed = false;
431
+ if (changed) this.options.tui.requestRender();
432
+ }
433
+
434
+ private leaveList(event: TuiMouseEvent): void {
435
+ this.list.onMouse({ ...event, row: event.row - this.listStart, col: event.col - 1, type: "leave" });
436
+ }
437
+
438
+ private leaveButtons(event: TuiMouseEvent): void {
439
+ this.buttons.onMouse({ ...event, type: "leave", row: 0, col: event.col - 1 });
440
+ }
441
+
442
+ private clearPointerState(event: TuiMouseEvent): void {
443
+ this.leaveSearch();
444
+ this.leaveList(event);
445
+ this.leaveButtons(event);
446
+ }
447
+ }
@@ -0,0 +1,134 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { compositeTuiLine, type KeyId, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { backgroundAnsiAtColumn, renderPill } from "../decoration/powerline-pill.ts";
4
+ import type { MouseRect, MouseRegistry } from "../mouse.ts";
5
+ import { compositeHoverTooltip } from "../overlay/hover-tooltip.ts";
6
+
7
+ export interface ScreenIconAction<Value extends string = string> {
8
+ readonly value: Value;
9
+ readonly glyph: string | (() => string);
10
+ readonly tooltip: string | (() => string);
11
+ readonly shortcuts?: readonly KeyId[];
12
+ readonly visible?: () => boolean;
13
+ }
14
+
15
+ export interface ScreenIconActionsOptions<Value extends string> {
16
+ readonly id: string;
17
+ readonly theme: Theme;
18
+ readonly registry: MouseRegistry;
19
+ readonly actions: readonly ScreenIconAction<Value>[];
20
+ readonly priority?: number;
21
+ readonly pointerPriority?: number;
22
+ onActivate(value: Value): void;
23
+ }
24
+
25
+ export interface ScreenIconActionsMount {
26
+ readonly reservedWidth: number;
27
+ dispose(): void;
28
+ }
29
+
30
+ interface ActionGeometry<Value extends string> extends MouseRect {
31
+ readonly value: Value;
32
+ }
33
+
34
+ function resolvedText(value: string | (() => string)): string {
35
+ return typeof value === "function" ? value() : value;
36
+ }
37
+
38
+ /** Width reserved by a top-right icon action group with the given maximum action count. */
39
+ export function screenIconActionsWidth(actionCount: number): number {
40
+ return Math.max(0, Math.floor(actionCount)) * 3 + Math.max(0, Math.floor(actionCount) - 1);
41
+ }
42
+
43
+ /** Mount icon-only actions at the terminal's top-right corner, with hover tooltips. */
44
+ export function mountScreenIconActions<Value extends string>(
45
+ options: ScreenIconActionsOptions<Value>,
46
+ ): ScreenIconActionsMount {
47
+ let geometry: readonly ActionGeometry<Value>[] = [];
48
+ let hovered: Value | undefined;
49
+ let pressed: Value | undefined;
50
+ let disposed = false;
51
+ const removeDecorator = options.registry.registerScreenDecorator({
52
+ id: options.id,
53
+ priority: options.priority ?? 10_000,
54
+ decorate(screen, context) {
55
+ if (context.hasOverlay) {
56
+ geometry = [];
57
+ hovered = undefined;
58
+ pressed = undefined;
59
+ return screen;
60
+ }
61
+ const visible = options.actions.filter((action) => action.visible?.() !== false);
62
+ const rendered = visible.map((action) => {
63
+ const active = action.value === hovered || action.value === pressed;
64
+ return renderPill(
65
+ options.theme,
66
+ { icon: { glyph: resolvedText(action.glyph) }, label: "" },
67
+ active ? "surface.selected" : "surface.inset",
68
+ active ? "accent" : "text.primary",
69
+ undefined,
70
+ "\x1b[49m",
71
+ );
72
+ });
73
+ const gap = 1;
74
+ const widths = rendered.map(visibleWidth);
75
+ const barWidth = widths.reduce((total, width) => total + width, 0) + gap * Math.max(0, rendered.length - 1);
76
+ const x = Math.max(0, context.width - barWidth);
77
+ let cursor = x;
78
+ geometry = visible.map((action, index) => {
79
+ const width = widths[index] ?? 0;
80
+ const item = { x: cursor, y: 0, width, height: 1, value: action.value };
81
+ cursor += width + gap;
82
+ return item;
83
+ });
84
+ if (rendered.length === 0) return screen;
85
+ const base = screen[0] ?? "";
86
+ const destination = backgroundAnsiAtColumn(base, x);
87
+ const row = rendered.join(`${destination} ${destination}`);
88
+ const result = [...screen];
89
+ result[0] = compositeTuiLine(base, row, x, barWidth, context.width);
90
+ const action = visible.find((candidate) => candidate.value === hovered);
91
+ const rect = geometry.find((candidate) => candidate.value === hovered);
92
+ return action && rect
93
+ ? compositeHoverTooltip(result, context, options.theme, {
94
+ rect,
95
+ label: resolvedText(action.tooltip),
96
+ ...(action.shortcuts?.[0] ? { shortcut: action.shortcuts[0] } : {}),
97
+ })
98
+ : result;
99
+ },
100
+ });
101
+ const removeRegions = options.actions.map((action) =>
102
+ options.registry.registerOverlayRegion({
103
+ id: `${options.id}.${action.value}`,
104
+ priority: options.pointerPriority ?? 1_000,
105
+ getRect: () => geometry.find((candidate) => candidate.value === action.value),
106
+ onMouse(event) {
107
+ if (event.type === "leave") {
108
+ if (hovered === action.value) hovered = undefined;
109
+ if (pressed === action.value) pressed = undefined;
110
+ return false;
111
+ }
112
+ hovered = action.value;
113
+ if (event.type === "press" && event.button === 0) pressed = action.value;
114
+ if (event.type === "release") {
115
+ pressed = undefined;
116
+ if (event.button === 0 || event.button === undefined) options.onActivate(action.value);
117
+ }
118
+ return true;
119
+ },
120
+ }),
121
+ );
122
+ return {
123
+ reservedWidth: screenIconActionsWidth(options.actions.length),
124
+ dispose() {
125
+ if (disposed) return;
126
+ disposed = true;
127
+ geometry = [];
128
+ hovered = undefined;
129
+ pressed = undefined;
130
+ removeDecorator();
131
+ for (const remove of removeRegions) remove();
132
+ },
133
+ };
134
+ }