@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,382 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, compositeTuiLine, type KeyId, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { getTuiAppearance } from "../appearance.ts";
4
+ import { tuiTheme } from "../color/theme.ts";
5
+ import { getTuiPillSeparators, icon, keyHintGlyph, type TuiIconName } from "../decoration/glyphs.ts";
6
+ import { backgroundAnsiAtColumn } from "../decoration/powerline-pill.ts";
7
+ import type { MouseRect, MouseRegistry, ScreenDecorationContext, TuiMouseEvent, ViewportRect } from "../mouse.ts";
8
+ import type { SelectionPoint } from "../selection.ts";
9
+
10
+ /** One domain-owned action displayed by {@link SelectionActionBar}. */
11
+ export interface SelectionActionBarAction<Value extends string = string> {
12
+ /** Stable value passed to the activation callback. */
13
+ value: Value;
14
+ /** Short action label. */
15
+ label: string;
16
+ /** Optional semantic action icon. */
17
+ icon?: TuiIconName;
18
+ /** User-configured shortcuts; the first is displayed and every shortcut activates. */
19
+ shortcuts?: readonly KeyId[];
20
+ }
21
+
22
+ /** Absolute or component-local geometry for one rendered action. */
23
+ export interface SelectionActionBarItemGeometry<Value extends string = string> extends MouseRect {
24
+ index: number;
25
+ value: Value;
26
+ }
27
+
28
+ /** Geometry recorded by the most recent action-bar render. */
29
+ export interface SelectionActionBarGeometry<Value extends string = string> extends MouseRect {
30
+ actions: readonly SelectionActionBarItemGeometry<Value>[];
31
+ }
32
+
33
+ /** Construction options for {@link SelectionActionBar}. */
34
+ export interface SelectionActionBarOptions<Value extends string> {
35
+ /** Active Pi theme used for semantic colors. */
36
+ theme: Theme;
37
+ /** Domain-owned actions in visual order. */
38
+ actions: readonly SelectionActionBarAction<Value>[];
39
+ /** Request a host render after pointer state changes. */
40
+ requestRender(): void;
41
+ /** Receive a clicked or keyboard-activated action value. */
42
+ onActivate(value: Value): void;
43
+ }
44
+
45
+ /** A visible selection that can anchor a mounted action bar. */
46
+ export interface SelectionActionBarTarget {
47
+ /** Visible selection endpoints in absolute terminal coordinates. */
48
+ selection: { start: SelectionPoint; end: SelectionPoint };
49
+ /** Plain selected text used to refine placement when geometry includes trailing cells. */
50
+ selectedText?: string;
51
+ }
52
+
53
+ /** Host integration for a screen-positioned {@link SelectionActionBar}. */
54
+ export interface SelectionActionBarMountOptions<Value extends string> extends SelectionActionBarOptions<Value> {
55
+ /** Shared fullscreen decoration and pointer registry. */
56
+ registry: MouseRegistry;
57
+ /** Stable diagnostic identity shared by the decorator and pointer region. */
58
+ id: string;
59
+ /** Screen decoration priority. Defaults to zero. */
60
+ priority?: number;
61
+ /** Pointer hit priority. Defaults to 200. */
62
+ pointerPriority?: number;
63
+ /** Resolve the current eligible selection, or hide the bar. */
64
+ getTarget(context: ScreenDecorationContext): SelectionActionBarTarget | undefined;
65
+ /** Additional host visibility gate when the decoration context is not authoritative. */
66
+ isHidden?(): boolean;
67
+ }
68
+
69
+ /** Lifecycle returned by {@link mountSelectionActionBar}. */
70
+ export interface SelectionActionBarMount {
71
+ /** Clear current placement and pointer geometry until the next decoration pass. */
72
+ invalidate(): void;
73
+ /** Remove the decorator and pointer region. Idempotent. */
74
+ dispose(): void;
75
+ }
76
+
77
+ /** Inputs for positioning a selection action bar around a visible selection. */
78
+ export interface SelectionActionBarPlacementRequest {
79
+ /** Visible selection endpoints in absolute terminal coordinates. */
80
+ selection: { start: SelectionPoint; end: SelectionPoint };
81
+ /**
82
+ * Plain text represented by the selection. When a one-row selection has a
83
+ * terminal endpoint beyond its content (for example, a line selection), its
84
+ * first line supplies the visual span used to center the bar.
85
+ */
86
+ selectedText?: string;
87
+ /** Natural or fitted action-bar width. */
88
+ barWidth: number;
89
+ /** Complete terminal width. */
90
+ screenWidth: number;
91
+ /** Complete terminal height. */
92
+ screenHeight: number;
93
+ /** Optional transcript viewport used to keep the bar beside transcript content. */
94
+ viewport?: ViewportRect;
95
+ }
96
+
97
+ /**
98
+ * Center a one-row action bar above a selection, flipping below at the top edge.
99
+ * @param request Selection geometry, bar width, terminal bounds, and optional transcript viewport.
100
+ * @returns A visible absolute terminal rectangle, or undefined when neither adjacent row is available.
101
+ */
102
+ export function placeSelectionActionBar(request: SelectionActionBarPlacementRequest): MouseRect | undefined {
103
+ const width = Math.min(Math.max(0, Math.floor(request.barWidth)), Math.max(0, request.screenWidth));
104
+ if (width === 0 || request.screenHeight <= 0) return undefined;
105
+ const top = Math.min(request.selection.start.row, request.selection.end.row);
106
+ const bottom = Math.max(request.selection.start.row, request.selection.end.row);
107
+ const boundsTop = request.viewport?.y ?? 0;
108
+ const boundsBottom = request.viewport ? request.viewport.y + request.viewport.height - 1 : request.screenHeight - 1;
109
+ const y = top - 1 >= boundsTop ? top - 1 : bottom + 1 <= boundsBottom ? bottom + 1 : undefined;
110
+ if (y === undefined || y < 0 || y >= request.screenHeight) return undefined;
111
+ const leftBound = request.viewport?.x ?? 0;
112
+ const rightBound = request.viewport
113
+ ? Math.min(request.screenWidth, request.viewport.x + request.viewport.width)
114
+ : request.screenWidth;
115
+ const effectiveWidth = Math.min(width, Math.max(0, rightBound - leftBound));
116
+ if (effectiveWidth === 0) return undefined;
117
+ const sameRow = request.selection.start.row === request.selection.end.row;
118
+ const selectionStartColumn = Math.min(request.selection.start.col, request.selection.end.col);
119
+ const selectionEndColumn = Math.max(request.selection.start.col, request.selection.end.col);
120
+ const selectedLineWidth = sameRow ? visibleWidth(request.selectedText?.split("\n", 1)[0] ?? "") : 0;
121
+ const endColumn =
122
+ selectedLineWidth > 0 ? Math.min(selectionEndColumn, selectionStartColumn + selectedLineWidth) : selectionEndColumn;
123
+ const center = sameRow
124
+ ? selectedLineWidth > 0
125
+ ? (selectionStartColumn + endColumn) / 2
126
+ : (request.selection.start.col + request.selection.end.col + 1) / 2
127
+ : request.selection.start.col;
128
+ const x = Math.max(leftBound, Math.min(rightBound - effectiveWidth, Math.round(center - effectiveWidth / 2)));
129
+ return { x, y, width: effectiveWidth, height: 1 };
130
+ }
131
+
132
+ /**
133
+ * Mount a selection action bar into the shared fullscreen decorator and pointer registries.
134
+ * The consumer owns action meaning and selection eligibility; pi-libtui owns presentation mechanics.
135
+ */
136
+ export function mountSelectionActionBar<Value extends string>(
137
+ options: SelectionActionBarMountOptions<Value>,
138
+ ): SelectionActionBarMount {
139
+ let placement: MouseRect | undefined;
140
+ let disposed = false;
141
+ const invalidate = () => {
142
+ placement = undefined;
143
+ bar.invalidate();
144
+ };
145
+ const bar = new SelectionActionBar({
146
+ theme: options.theme,
147
+ actions: options.actions,
148
+ requestRender: options.requestRender,
149
+ onActivate(value) {
150
+ invalidate();
151
+ options.onActivate(value);
152
+ },
153
+ });
154
+ const removeDecorator = options.registry.registerScreenDecorator({
155
+ id: options.id,
156
+ priority: options.priority,
157
+ decorate(screen, context) {
158
+ const target = context.hasOverlay || options.isHidden?.() === true ? undefined : options.getTarget(context);
159
+ if (!target) {
160
+ invalidate();
161
+ return screen;
162
+ }
163
+ bar.render(context.viewport?.width ?? context.width);
164
+ const barWidth = bar.getGeometry()?.width ?? 0;
165
+ placement = placeSelectionActionBar({
166
+ selection: target.selection,
167
+ ...(target.selectedText !== undefined ? { selectedText: target.selectedText } : {}),
168
+ barWidth,
169
+ screenWidth: context.width,
170
+ screenHeight: context.height,
171
+ viewport: context.viewport,
172
+ });
173
+ return placement ? bar.composite(screen, placement) : screen;
174
+ },
175
+ });
176
+ const removeRegion = options.registry.registerOverlayRegion({
177
+ id: options.id,
178
+ priority: options.pointerPriority ?? 200,
179
+ getRect: () => placement,
180
+ onMouse: (event) => bar.onMouse(event),
181
+ });
182
+ return {
183
+ invalidate,
184
+ dispose() {
185
+ if (disposed) return;
186
+ disposed = true;
187
+ invalidate();
188
+ removeDecorator();
189
+ removeRegion();
190
+ },
191
+ };
192
+ }
193
+
194
+ /** Connected, one-row selection action surface with keyboard and pointer handling. */
195
+ export class SelectionActionBar<Value extends string = string> implements Component {
196
+ private hoverIndex: number | undefined;
197
+ private pressedIndex: number | undefined;
198
+ private geometry: SelectionActionBarGeometry<Value> | undefined;
199
+
200
+ constructor(private readonly config: SelectionActionBarOptions<Value>) {}
201
+
202
+ /**
203
+ * Match every configured shortcut and activate its action.
204
+ * @param data Raw terminal input.
205
+ * @returns True when the bar consumed a configured shortcut.
206
+ */
207
+ handleInput(data: string): boolean {
208
+ const index = this.config.actions.findIndex((action) => action.shortcuts?.some((key) => matchesKey(data, key)));
209
+ if (index < 0) return false;
210
+ this.activate(index);
211
+ return true;
212
+ }
213
+
214
+ /** Handle pointer input in bar-local coordinates. */
215
+ onMouse(event: TuiMouseEvent): boolean {
216
+ if (event.type === "drag" || event.type === "wheel") return false;
217
+ if (event.type === "leave") {
218
+ const changed = this.hoverIndex !== undefined || this.pressedIndex !== undefined;
219
+ this.hoverIndex = undefined;
220
+ this.pressedIndex = undefined;
221
+ if (changed) this.config.requestRender();
222
+ return false;
223
+ }
224
+ const action = this.geometry?.actions.find(
225
+ (candidate) => event.col >= candidate.x && event.col < candidate.x + candidate.width,
226
+ );
227
+ this.updateHover(action?.index);
228
+ if (event.type === "press" && (event.button === undefined || event.button === 0)) {
229
+ this.pressedIndex = action?.index;
230
+ } else if (event.type === "release" && (event.button === undefined || event.button === 0)) {
231
+ const pressed = this.pressedIndex;
232
+ this.pressedIndex = undefined;
233
+ if (action && pressed === action.index) this.activate(action.index);
234
+ }
235
+ return action !== undefined;
236
+ }
237
+
238
+ /** @returns A defensive copy of the latest local geometry. */
239
+ getGeometry(): SelectionActionBarGeometry<Value> | undefined {
240
+ return this.geometry
241
+ ? { ...this.geometry, actions: this.geometry.actions.map((action) => ({ ...action })) }
242
+ : undefined;
243
+ }
244
+
245
+ /** Clear cached geometry. */
246
+ invalidate(): void {
247
+ this.geometry = undefined;
248
+ }
249
+
250
+ /**
251
+ * Render the connected action surface.
252
+ * @param width Available terminal cells.
253
+ * @returns One rendered row, or no rows when all actions cannot fit.
254
+ */
255
+ render(width: number): string[] {
256
+ return this.renderRow(width, "\x1b[49m");
257
+ }
258
+
259
+ /**
260
+ * Composite the action bar over one absolute screen row without changing layout height.
261
+ * @param screen Current styled terminal screen.
262
+ * @param placement Absolute action-bar rectangle returned by {@link placeSelectionActionBar}.
263
+ * @returns A decorated screen copy and updated component-local hit geometry.
264
+ */
265
+ composite(screen: readonly string[], placement: MouseRect): string[] {
266
+ const result = [...screen];
267
+ const base = result[placement.y];
268
+ if (base === undefined) return result;
269
+ const destination = backgroundAnsiAtColumn(base, placement.x);
270
+ const rendered = this.renderRow(placement.width, destination)[0];
271
+ if (!rendered) return result;
272
+ // A narrow placement can select compact/icon mode, leaving unused cells
273
+ // in the requested rectangle. Composite only the cells actually painted
274
+ // by the bar so its transparent tail keeps the destination surface.
275
+ const renderedWidth = Math.min(placement.width, visibleWidth(rendered));
276
+ if (renderedWidth === 0) return result;
277
+ result[placement.y] = compositeTuiLine(
278
+ base,
279
+ rendered,
280
+ placement.x,
281
+ renderedWidth,
282
+ Math.max(visibleWidth(base), placement.x + renderedWidth),
283
+ );
284
+ return result;
285
+ }
286
+
287
+ private renderRow(width: number, destinationBackground: string): string[] {
288
+ const bounded = Math.max(0, Math.floor(width));
289
+ const mode = (["full", "compact", "icon"] as const).find((candidate) => this.measure(candidate) <= bounded);
290
+ if (!mode || this.config.actions.length === 0) {
291
+ this.geometry = undefined;
292
+ return [];
293
+ }
294
+ const colors = tuiTheme(this.config.theme);
295
+ const preferredBaseColor = colors.color("action.neutral");
296
+ const preferredBaseBackground = colors.bgAnsi(preferredBaseColor);
297
+ const baseColor =
298
+ backgroundAnsiAtColumn(`${destinationBackground} `, 0) === preferredBaseBackground
299
+ ? colors.color("surface.raised")
300
+ : preferredBaseColor;
301
+ const baseBackground = colors.bgAnsi(baseColor);
302
+ // Hover is a semantic surface, not the black/white contrast color of the
303
+ // resting action background. The latter is a foreground choice and made
304
+ // the hovered action an unreadable white-on-white block on dark themes.
305
+ const hoverColor = colors.color("surface.hover");
306
+ const hoverBackground = colors.bgAnsi(hoverColor);
307
+ const labelForeground = colors.fgAnsi("text.primary");
308
+ const iconForeground = colors.fgAnsi("text.secondary");
309
+ const hoverForeground = colors.fgAnsi(hoverColor);
310
+ const hoverTextForeground = colors.fgAnsi(colors.contrastBackground(hoverColor));
311
+ const rounded = getTuiAppearance().powerline;
312
+ const [left, right] = getTuiPillSeparators(rounded);
313
+ const firstActive = this.hoverIndex === 0 || this.pressedIndex === 0;
314
+ const lastIndex = this.config.actions.length - 1;
315
+ const lastActive = this.hoverIndex === lastIndex || this.pressedIndex === lastIndex;
316
+ const leftCapColor = firstActive ? hoverColor : baseColor;
317
+ const rightCapColor = lastActive ? hoverColor : baseColor;
318
+ const leftCapBackground = colors.bgAnsi(leftCapColor);
319
+ const pieces = [`${destinationBackground}${colors.fgAnsi(leftCapColor)}${left}\x1b[39m${leftCapBackground}`];
320
+ const actions: SelectionActionBarItemGeometry<Value>[] = [];
321
+ let x = visibleWidth(left);
322
+ for (const [index, action] of this.config.actions.entries()) {
323
+ const active = index === this.hoverIndex || index === this.pressedIndex;
324
+ const content = this.actionContent(
325
+ action,
326
+ mode,
327
+ active ? hoverTextForeground : labelForeground,
328
+ active ? hoverTextForeground : iconForeground,
329
+ );
330
+ const actionWidth = visibleWidth(content);
331
+ pieces.push(active ? hoverBackground : baseBackground, content);
332
+ actions.push({ x, y: 0, width: actionWidth, height: 1, index, value: action.value });
333
+ x += actionWidth;
334
+ if (index < this.config.actions.length - 1) {
335
+ const nextActive = index + 1 === this.hoverIndex || index + 1 === this.pressedIndex;
336
+ const divider = rounded ? (nextActive ? "" : active ? "" : "") : "│";
337
+ pieces.push(baseBackground, active || nextActive ? hoverForeground : iconForeground, divider);
338
+ x += visibleWidth(divider);
339
+ }
340
+ }
341
+ pieces.push(destinationBackground, colors.fgAnsi(rightCapColor), right, "\x1b[39m", destinationBackground);
342
+ this.geometry = { x: 0, y: 0, width: x + visibleWidth(right), height: 1, actions };
343
+ return [pieces.join("")];
344
+ }
345
+
346
+ private measure(mode: "full" | "compact" | "icon"): number {
347
+ const actionWidth = this.config.actions.reduce(
348
+ (total, action) => total + visibleWidth(this.actionContent(action, mode, "", "")),
349
+ 0,
350
+ );
351
+ return actionWidth + Math.max(0, this.config.actions.length - 1) + 2;
352
+ }
353
+
354
+ private actionContent(
355
+ action: SelectionActionBarAction<Value>,
356
+ mode: "full" | "compact" | "icon",
357
+ labelForeground: string,
358
+ iconForeground: string,
359
+ ): string {
360
+ const actionIcon = action.icon ? icon(action.icon) : "";
361
+ const shortcut = action.shortcuts?.[0];
362
+ const key = shortcut ? `${iconForeground}${keyHintGlyph(shortcut)}` : "";
363
+ const parts =
364
+ mode === "full"
365
+ ? [actionIcon ? `${iconForeground}${actionIcon} ` : "", `${labelForeground}${action.label}`, key]
366
+ : mode === "compact"
367
+ ? [actionIcon ? `${iconForeground}${actionIcon}` : `${labelForeground}${action.label}`, key]
368
+ : [actionIcon ? `${iconForeground}${actionIcon}` : key ? key : `${labelForeground}${action.label}`];
369
+ return ` ${parts.filter(Boolean).join(" ")} `;
370
+ }
371
+
372
+ private updateHover(index: number | undefined): void {
373
+ if (this.hoverIndex === index) return;
374
+ this.hoverIndex = index;
375
+ this.config.requestRender();
376
+ }
377
+
378
+ private activate(index: number): void {
379
+ const action = this.config.actions[index];
380
+ if (action) this.config.onActivate(action.value);
381
+ }
382
+ }
@@ -0,0 +1,23 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Input } from "@earendil-works/pi-tui";
3
+ import { markInputCursor } from "../cursor.ts";
4
+
5
+ /** Pi's single-line input with libtui's semantic insertion cursor. */
6
+ export class SemanticInput extends Input {
7
+ /**
8
+ * Create a Pi single-line input with a semantic insertion cursor.
9
+ * @param theme Active Pi theme used to derive cursor colors.
10
+ */
11
+ constructor(private readonly theme: Theme) {
12
+ super();
13
+ }
14
+
15
+ /**
16
+ * Render the native input and replace its cursor marker with the configured semantic cursor.
17
+ * @param width Available width in terminal cells.
18
+ * @returns Rendered input rows with the insertion cursor applied.
19
+ */
20
+ override render(width: number): string[] {
21
+ return super.render(width).map((line) => markInputCursor(line, { theme: this.theme, role: "insertion" }));
22
+ }
23
+ }
@@ -0,0 +1,189 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { tuiTheme } from "../color/theme.ts";
4
+ import { icon, type TuiIconName } from "../decoration/glyphs.ts";
5
+ import { renderPill } from "../decoration/powerline-pill.ts";
6
+ import type { TuiMouseEvent } from "../mouse.ts";
7
+
8
+ /** One selectable item rendered by a {@link TabBar}. */
9
+ export interface Tab {
10
+ /** Stable value reported to {@link TabBar.onChange} when the tab is selected. */
11
+ id: string;
12
+ /** Human-readable text rendered after the optional icon. */
13
+ label: string;
14
+ /** Semantic icon resolved from the active pi-libtui icon pack on each render. */
15
+ icon?: TuiIconName | { readonly glyph: string };
16
+ }
17
+
18
+ /**
19
+ * A single-line tab selector with keyboard, hover, and primary-click navigation.
20
+ *
21
+ * Tab icons are semantic and therefore follow live pi-libtui appearance changes.
22
+ */
23
+ export class TabBar implements Component {
24
+ private activeIndex: number;
25
+ private hoverIndex: number | undefined;
26
+ private hoverCloseIndex: number | undefined;
27
+ private pressedIndex: number | undefined;
28
+ private pressedClose = false;
29
+ private dragTargetIndex: number | undefined;
30
+ private renderedTabs: Array<{ index: number; x: number; width: number; closeX?: number; closeWidth?: number }> = [];
31
+ /** Called after the active tab changes, with the selected tab and its zero-based index. */
32
+ onChange?: (tab: Tab, index: number) => void;
33
+ /** Called when a tab's shared close affordance is clicked. Defining it enables closing every tab. */
34
+ onClose?: (tab: Tab, index: number) => void;
35
+ /** Called after a primary-button drag requests a new zero-based tab position. */
36
+ onMove?: (tab: Tab, fromIndex: number, toIndex: number) => void;
37
+
38
+ /**
39
+ * Creates a tab bar.
40
+ *
41
+ * @param tabs Ordered tabs to render and navigate. The array is not mutated.
42
+ * @param theme Pi theme used through pi-libtui's semantic color adapter.
43
+ * @param initialIndex Initially active zero-based tab index. Values outside the tab range are clamped.
44
+ */
45
+ constructor(
46
+ private readonly tabs: readonly Tab[],
47
+ private readonly theme: Theme,
48
+ initialIndex = 0,
49
+ ) {
50
+ this.activeIndex = Math.max(0, Math.min(initialIndex, tabs.length - 1));
51
+ }
52
+
53
+ /**
54
+ * Handles left/right arrows and Vim-style `h`/`l` navigation.
55
+ *
56
+ * @param data Raw terminal input sequence.
57
+ * @returns `true` when the input is a supported navigation key, even if selection cannot change.
58
+ */
59
+ handleInput(data: string): boolean {
60
+ if (matchesKey(data, "right") || data === "l") {
61
+ this.select((this.activeIndex + 1) % this.tabs.length);
62
+ return true;
63
+ }
64
+ if (matchesKey(data, "left") || data === "h") {
65
+ this.select((this.activeIndex - 1 + this.tabs.length) % this.tabs.length);
66
+ return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ /**
72
+ * Updates hover state and selects a tab on a primary-button release.
73
+ *
74
+ * Call {@link render} before routing pointer events so visible tab geometry is available.
75
+ *
76
+ * @param event Pointer event in coordinates local to the tab bar.
77
+ * @returns `true` when the event hits a rendered tab; leave events clear hover and return `false`.
78
+ */
79
+ onMouse(event: TuiMouseEvent): boolean {
80
+ if (event.type === "leave") {
81
+ this.hoverIndex = undefined;
82
+ this.hoverCloseIndex = undefined;
83
+ if (event.button === undefined) {
84
+ this.pressedIndex = undefined;
85
+ this.pressedClose = false;
86
+ this.dragTargetIndex = undefined;
87
+ }
88
+ return false;
89
+ }
90
+ const hit =
91
+ event.row === 0
92
+ ? this.renderedTabs.find((tab) => event.col >= tab.x && event.col < tab.x + tab.width)
93
+ : undefined;
94
+ this.hoverIndex = hit?.index;
95
+ const closeHit =
96
+ hit?.closeX !== undefined &&
97
+ hit.closeWidth !== undefined &&
98
+ event.col >= hit.closeX &&
99
+ event.col < hit.closeX + hit.closeWidth;
100
+ this.hoverCloseIndex = closeHit ? hit.index : undefined;
101
+ if (event.type === "press" && event.button === 0 && hit) {
102
+ this.pressedIndex = hit.index;
103
+ this.pressedClose = closeHit;
104
+ this.dragTargetIndex = hit.index;
105
+ return true;
106
+ }
107
+ if (event.type === "drag" && event.button === 0 && hit && this.pressedIndex !== undefined) {
108
+ this.dragTargetIndex = hit.index;
109
+ return true;
110
+ }
111
+ if (event.type === "release" && event.button === 0 && hit) {
112
+ const from = this.pressedIndex;
113
+ const tab = this.tabs[from ?? hit.index];
114
+ if (tab && from !== undefined && this.pressedClose && closeHit && from === hit.index) {
115
+ this.onClose?.(tab, from);
116
+ } else if (tab && from !== undefined && this.dragTargetIndex !== undefined && from !== this.dragTargetIndex) {
117
+ this.onMove?.(tab, from, this.dragTargetIndex);
118
+ } else if (from === undefined && closeHit) {
119
+ const direct = this.tabs[hit.index];
120
+ if (direct) this.onClose?.(direct, hit.index);
121
+ } else this.select(hit.index);
122
+ this.pressedIndex = undefined;
123
+ this.pressedClose = false;
124
+ this.dragTargetIndex = undefined;
125
+ }
126
+ return hit !== undefined;
127
+ }
128
+
129
+ /** Clears cached pointer geometry. A later {@link render} rebuilds it. */
130
+ invalidate(): void {
131
+ this.renderedTabs = [];
132
+ }
133
+
134
+ /**
135
+ * Renders the tab bar as one terminal line and records geometry for pointer handling.
136
+ *
137
+ * @param width Maximum terminal-cell width. Negative and fractional values are normalized.
138
+ * @returns A one-element array containing the truncated, ANSI-styled tab line.
139
+ */
140
+ render(width: number): string[] {
141
+ const colors = tuiTheme(this.theme);
142
+ const closeGlyph = this.onClose ? icon("close") : undefined;
143
+ const chunks = this.tabs.map((tab, index) => {
144
+ const active = index === this.activeIndex;
145
+ const close = closeGlyph
146
+ ? ` ${colors.fg(index === this.hoverCloseIndex ? "accent" : "text.muted", closeGlyph)} `
147
+ : "";
148
+ return renderPill(
149
+ this.theme,
150
+ { icon: tab.icon ?? false, label: `${tab.label}${close}` },
151
+ active ? "surface.selected" : index === this.hoverIndex ? "surface.hover" : "surface.raised",
152
+ active ? "accent" : "text.secondary",
153
+ undefined,
154
+ "\x1b[49m",
155
+ );
156
+ });
157
+ const boundedWidth = Math.max(0, Math.floor(width));
158
+ let x = 0;
159
+ this.renderedTabs = this.tabs.flatMap((_tab, index) => {
160
+ const tabWidth = visibleWidth(chunks[index] ?? "");
161
+ const renderedWidth = Math.max(0, Math.min(tabWidth, boundedWidth - x));
162
+ const closeWidth = closeGlyph ? visibleWidth(closeGlyph) : undefined;
163
+ const closeX = closeGlyph ? x + tabWidth - 2 - visibleWidth(closeGlyph) : undefined;
164
+ const span =
165
+ renderedWidth > 0
166
+ ? [
167
+ {
168
+ index,
169
+ x,
170
+ width: renderedWidth,
171
+ ...(closeX !== undefined && closeWidth !== undefined && closeX + closeWidth <= x + renderedWidth
172
+ ? { closeX, closeWidth }
173
+ : {}),
174
+ },
175
+ ]
176
+ : [];
177
+ x += tabWidth + 1;
178
+ return span;
179
+ });
180
+ return [`\x1b[49m${truncateToWidth(chunks.join(" "), boundedWidth, "")}\x1b[49m`];
181
+ }
182
+
183
+ private select(index: number): void {
184
+ if (this.tabs.length === 0 || index === this.activeIndex) return;
185
+ this.activeIndex = index;
186
+ const tab = this.tabs[index];
187
+ if (tab) this.onChange?.(tab, index);
188
+ }
189
+ }