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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/package.json +55 -0
  4. package/src/activity-presentation.ts +75 -0
  5. package/src/appearance.ts +632 -0
  6. package/src/background-surface.ts +125 -0
  7. package/src/color/palette.ts +186 -0
  8. package/src/color/preview.ts +157 -0
  9. package/src/color/resolver.ts +75 -0
  10. package/src/color/theme.ts +531 -0
  11. package/src/component-stack.ts +247 -0
  12. package/src/content/markdown-code.ts +60 -0
  13. package/src/content/terminal-text.ts +135 -0
  14. package/src/content/text.ts +105 -0
  15. package/src/controls/action-panel.ts +420 -0
  16. package/src/controls/dialog-button-bar.ts +287 -0
  17. package/src/controls/multi-select.ts +467 -0
  18. package/src/controls/picker-panel.ts +447 -0
  19. package/src/controls/screen-icon-actions.ts +134 -0
  20. package/src/controls/searchable-select.ts +394 -0
  21. package/src/controls/select-box.ts +224 -0
  22. package/src/controls/selectable-list.ts +433 -0
  23. package/src/controls/selection-action-bar.ts +382 -0
  24. package/src/controls/semantic-input.ts +23 -0
  25. package/src/controls/tab.ts +189 -0
  26. package/src/cursor.ts +288 -0
  27. package/src/decoration/editor-pills.ts +156 -0
  28. package/src/decoration/glyphs.ts +326 -0
  29. package/src/decoration/pointer-interaction.ts +123 -0
  30. package/src/decoration/powerline-pill.ts +104 -0
  31. package/src/decoration/status.ts +185 -0
  32. package/src/decoration/transient-pill.ts +176 -0
  33. package/src/diff/index.ts +21 -0
  34. package/src/diff/model.ts +76 -0
  35. package/src/diff/parse.ts +310 -0
  36. package/src/diff/pierre-syntax.ts +86 -0
  37. package/src/diff/render.ts +504 -0
  38. package/src/diff/structured.ts +264 -0
  39. package/src/diff/view.ts +75 -0
  40. package/src/editor/chrome.ts +341 -0
  41. package/src/editor/composition.ts +53 -0
  42. package/src/editor/layout.ts +81 -0
  43. package/src/editor/presentation.ts +35 -0
  44. package/src/editor/protocol.ts +188 -0
  45. package/src/editor.ts +41 -0
  46. package/src/extension.ts +30 -0
  47. package/src/folding.ts +192 -0
  48. package/src/host/cursor-bridge.ts +210 -0
  49. package/src/host/editor-bridge.ts +148 -0
  50. package/src/host/extension-host.ts +231 -0
  51. package/src/host/mouse-bridge.ts +212 -0
  52. package/src/host/mouse-dispatch.ts +400 -0
  53. package/src/host/mouse-input.ts +85 -0
  54. package/src/host/mouse-targets.ts +238 -0
  55. package/src/host/pi-layout-adapter.ts +298 -0
  56. package/src/host/pi-native-selection.ts +227 -0
  57. package/src/host/split-pane-bridge.ts +720 -0
  58. package/src/host/transcript-bridge.ts +236 -0
  59. package/src/index.ts +334 -0
  60. package/src/line-layout.ts +6 -0
  61. package/src/motion.ts +1409 -0
  62. package/src/mouse/events.ts +55 -0
  63. package/src/mouse/fullscreen-layout.ts +260 -0
  64. package/src/mouse/registry.ts +348 -0
  65. package/src/mouse.ts +37 -0
  66. package/src/native-binary.ts +189 -0
  67. package/src/overlay/anchored.ts +91 -0
  68. package/src/overlay/detail-card.ts +158 -0
  69. package/src/overlay/dialog.ts +266 -0
  70. package/src/overlay/floating.ts +106 -0
  71. package/src/overlay/framed-editor.ts +236 -0
  72. package/src/overlay/fullscreen.ts +170 -0
  73. package/src/overlay/hover-tooltip.ts +109 -0
  74. package/src/overlay/modal-mount.ts +118 -0
  75. package/src/panels.ts +207 -0
  76. package/src/render-cache.ts +80 -0
  77. package/src/render-epoch.ts +48 -0
  78. package/src/request-animation.ts +160 -0
  79. package/src/scrollbar.ts +27 -0
  80. package/src/selection.ts +148 -0
  81. package/src/split-pane.ts +232 -0
  82. package/src/status-presentation.ts +411 -0
  83. package/src/stream.ts +543 -0
  84. package/src/syntax.ts +316 -0
  85. package/src/terminal/bridge-client.ts +243 -0
  86. package/src/terminal/embedding.ts +6 -0
  87. package/src/terminal/output.ts +151 -0
  88. package/src/terminal/projection.ts +362 -0
  89. package/src/terminal/pty-host.ts +263 -0
  90. package/src/terminal/pty-pane.ts +308 -0
  91. package/src/terminal-colors.ts +334 -0
  92. package/src/tool/action.ts +170 -0
  93. package/src/tool/activity.ts +500 -0
  94. package/src/tool/call-preview.ts +44 -0
  95. package/src/tool/disclosure-action.ts +89 -0
  96. package/src/tool/expanded-region-viewport.ts +103 -0
  97. package/src/tool/index.ts +26 -0
  98. package/src/tool/output.ts +271 -0
  99. package/src/tool/transcript.ts +150 -0
  100. package/src/tool/view-region.ts +581 -0
  101. package/test/appearance.test.ts +183 -0
  102. package/test/bounded-stream.test.ts +150 -0
  103. package/test/bridge-client.test.ts +42 -0
  104. package/test/color-preview.test.ts +23 -0
  105. package/test/component-stack.test.ts +206 -0
  106. package/test/components.test.ts +1165 -0
  107. package/test/content-text.test.ts +21 -0
  108. package/test/cursor-bridge.test.ts +151 -0
  109. package/test/cursor.test.ts +147 -0
  110. package/test/editor-chrome.test.ts +119 -0
  111. package/test/editor-layout.test.ts +30 -0
  112. package/test/editor-registry.test.ts +203 -0
  113. package/test/editor-theme.test.ts +33 -0
  114. package/test/editor-token-pill.test.ts +72 -0
  115. package/test/floating-overlay.test.ts +57 -0
  116. package/test/folding.test.ts +147 -0
  117. package/test/harmonious-theme.test.ts +23 -0
  118. package/test/interaction.test.ts +104 -0
  119. package/test/key-icons.test.ts +62 -0
  120. package/test/markdown-code.test.ts +24 -0
  121. package/test/modal-overlay-mount.test.ts +94 -0
  122. package/test/motion.test.ts +649 -0
  123. package/test/mouse/bridge.test.ts +1302 -0
  124. package/test/mouse/extension.test.ts +205 -0
  125. package/test/mouse/registry.test.ts +140 -0
  126. package/test/mouse/screen-decoration.test.ts +167 -0
  127. package/test/native-binary.test.ts +52 -0
  128. package/test/palette.test.ts +32 -0
  129. package/test/panels.test.ts +74 -0
  130. package/test/pointer-components.test.ts +151 -0
  131. package/test/progress.test.ts +54 -0
  132. package/test/pty-host.test.ts +126 -0
  133. package/test/pty-pane.test.ts +62 -0
  134. package/test/public-surface.test.ts +75 -0
  135. package/test/render-cache.test.ts +25 -0
  136. package/test/render-epoch.test.ts +119 -0
  137. package/test/request-animation.test.ts +193 -0
  138. package/test/screen-icon-actions.test.ts +69 -0
  139. package/test/select-box.test.ts +86 -0
  140. package/test/select-pointer.test.ts +369 -0
  141. package/test/selectable-list.test.ts +258 -0
  142. package/test/selection/registry.test.ts +44 -0
  143. package/test/selection-action-bar.test.ts +204 -0
  144. package/test/split-pane-bridge.test.ts +944 -0
  145. package/test/split-pane.test.ts +134 -0
  146. package/test/status-presentation.test.ts +39 -0
  147. package/test/syntax.test.ts +59 -0
  148. package/test/terminal-colors.test.ts +115 -0
  149. package/test/terminal-embedding.test.ts +12 -0
  150. package/test/terminal-projection.test.ts +312 -0
  151. package/test/theme-colors.test.ts +175 -0
  152. package/test/tool-output.test.ts +160 -0
  153. package/test/tool-transcript.test.ts +1190 -0
  154. package/test/unified-diff.test.ts +691 -0
  155. package/themes/harmonious.json +61 -0
  156. package/tsconfig.json +16 -0
@@ -0,0 +1,326 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { KeyId } from "@earendil-works/pi-tui";
3
+ import { getTuiAppearance, type TuiIconPack } from "../appearance.ts";
4
+ import { type TuiBackgroundToken, type TuiColor, type TuiForegroundColor, tuiTheme } from "../color/theme.ts";
5
+
6
+ /** Every semantic icon token supported by all pi-libtui icon packs. */
7
+ export const TUI_ICON_NAMES = [
8
+ "settings",
9
+ "appearance",
10
+ "ux",
11
+ "animations",
12
+ "behavior",
13
+ "interaction",
14
+ "tools",
15
+ "toggle-on",
16
+ "toggle-off",
17
+ "checkbox-on",
18
+ "checkbox-off",
19
+ "comment",
20
+ "reaction",
21
+ "copy",
22
+ "paste",
23
+ "edit",
24
+ "delete",
25
+ "reset",
26
+ "close",
27
+ "cancel",
28
+ "confirm",
29
+ "search",
30
+ "selection",
31
+ "submit",
32
+ "warning",
33
+ "error",
34
+ "lightbulb",
35
+ "developer",
36
+ "user",
37
+ "code-mode",
38
+ "terminal",
39
+ "view-image",
40
+ "expand-closed",
41
+ "expand-open",
42
+ "fold-closed",
43
+ "fold-open",
44
+ "fold-unfold",
45
+ "fold-down",
46
+ "fold-up",
47
+ "fold",
48
+ "diff-hunk",
49
+ ] as const;
50
+
51
+ /** A semantic icon role resolved through the active icon pack. */
52
+ export type TuiIconName = (typeof TUI_ICON_NAMES)[number];
53
+
54
+ /** Icon-bearing content accepted by every shared pill renderer. */
55
+ export interface PillContent {
56
+ /** Semantic icon, a deliberate custom glyph, or an explicit iconless opt-out. */
57
+ icon: TuiIconName | { readonly glyph: string } | false;
58
+ /** Text shown after the icon. */
59
+ label: string;
60
+ /** Optional semantic icon tone; defaults to the pill foreground. */
61
+ iconTone?: TuiForegroundColor;
62
+ }
63
+
64
+ const ICONS: Record<TuiIconPack, Record<TuiIconName, string>> = {
65
+ "nerd-fonts": {
66
+ settings: "",
67
+ appearance: "󰃣",
68
+ ux: "󰮄",
69
+ animations: "",
70
+ behavior: "󰚩",
71
+ interaction: "󰌌",
72
+ tools: "",
73
+ "toggle-on": "",
74
+ "toggle-off": "",
75
+ "checkbox-on": "󰱒",
76
+ "checkbox-off": "󰄱",
77
+ comment: "",
78
+ reaction: "",
79
+ copy: "",
80
+ paste: "󰆒",
81
+ edit: "",
82
+ delete: "",
83
+ reset: "",
84
+ close: "󰅙",
85
+ cancel: "󰜺",
86
+ confirm: "",
87
+ search: "",
88
+ selection: "",
89
+ submit: "",
90
+ warning: "",
91
+ error: "",
92
+ lightbulb: "",
93
+ developer: "󱔘",
94
+ user: "󰷈",
95
+ "code-mode": "",
96
+ terminal: "",
97
+ "view-image": "",
98
+ "expand-closed": "",
99
+ "expand-open": "",
100
+ "fold-closed": "",
101
+ "fold-open": "",
102
+ "fold-unfold": "",
103
+ "fold-down": "",
104
+ "fold-up": "",
105
+ fold: "",
106
+ "diff-hunk": "",
107
+ },
108
+ unicode: {
109
+ settings: "⚙",
110
+ appearance: "✦",
111
+ ux: "●",
112
+ animations: "↺",
113
+ behavior: "⚗",
114
+ interaction: "⌨",
115
+ tools: "⚒",
116
+ "toggle-on": "●",
117
+ "toggle-off": "○",
118
+ "checkbox-on": "☑",
119
+ "checkbox-off": "☐",
120
+ comment: "✎",
121
+ reaction: "☺",
122
+ copy: "⧉",
123
+ paste: "⇲",
124
+ edit: "✎",
125
+ delete: "×",
126
+ reset: "↺",
127
+ close: "×",
128
+ cancel: "×",
129
+ confirm: "✓",
130
+ search: "⌕",
131
+ selection: "›",
132
+ submit: "➤",
133
+ warning: "⚠",
134
+ error: "×",
135
+ lightbulb: "💡",
136
+ developer: "◆",
137
+ user: "●",
138
+ "code-mode": "⌘",
139
+ terminal: ">_",
140
+ "view-image": "▣",
141
+ "expand-closed": "›",
142
+ "expand-open": "⌄",
143
+ "fold-closed": "›",
144
+ "fold-open": "⌄",
145
+ "fold-unfold": "↕",
146
+ "fold-down": "⌄",
147
+ "fold-up": "⌃",
148
+ fold: "•",
149
+ "diff-hunk": "≋",
150
+ },
151
+ emoji: {
152
+ settings: "⚙️",
153
+ appearance: "🎨",
154
+ ux: "👤",
155
+ animations: "🔄",
156
+ behavior: "🤖",
157
+ interaction: "⌨️",
158
+ tools: "🛠️",
159
+ "toggle-on": "✅",
160
+ "toggle-off": "⭕",
161
+ "checkbox-on": "☑️",
162
+ "checkbox-off": "⬜",
163
+ comment: "💬",
164
+ reaction: "🙂",
165
+ copy: "📋",
166
+ paste: "📥",
167
+ edit: "📝",
168
+ delete: "🗑️",
169
+ reset: "🔄",
170
+ close: "❌",
171
+ cancel: "❌",
172
+ confirm: "✅",
173
+ search: "🔍",
174
+ selection: "▶",
175
+ submit: "📤",
176
+ warning: "⚠️",
177
+ error: "❌",
178
+ lightbulb: "💡",
179
+ developer: "🛠️",
180
+ user: "👤",
181
+ "code-mode": "⌘",
182
+ terminal: "💻",
183
+ "view-image": "🖼️",
184
+ "expand-closed": "›",
185
+ "expand-open": "⌄",
186
+ "fold-closed": "›",
187
+ "fold-open": "⌄",
188
+ "fold-unfold": "↕",
189
+ "fold-down": "⌄",
190
+ "fold-up": "⌃",
191
+ fold: "•",
192
+ "diff-hunk": "≋",
193
+ },
194
+ };
195
+
196
+ /**
197
+ * Resolve a semantic icon through the active Unicode, Nerd Font, or emoji pack.
198
+ * @param name Semantic icon role.
199
+ * @returns The glyph owned by the active icon pack.
200
+ */
201
+ export function icon(name: TuiIconName): string {
202
+ return ICONS[getTuiAppearance().iconPack][name];
203
+ }
204
+
205
+ /**
206
+ * Resolve the left and right pill caps for a requested shape.
207
+ * @param powerline Whether to use Powerline caps; defaults to the active appearance setting.
208
+ * @returns A readonly `[left, right]` separator pair.
209
+ */
210
+ export function getTuiPillSeparators(powerline = getTuiAppearance().powerline): readonly [string, string] {
211
+ return powerline ? ["", ""] : ["▐", "▌"];
212
+ }
213
+
214
+ /**
215
+ * Render a label without terminal color state, using the configured pill shape.
216
+ * @param content Required icon decision and plain or already-styled label content.
217
+ * @returns The label surrounded by the active pill separators.
218
+ */
219
+ export function renderPillText(content: PillContent): string {
220
+ const [left, right] = getTuiPillSeparators();
221
+ const glyph = pillIcon(content.icon);
222
+ return `${left}${glyph ? `${glyph} ` : ""}${content.label}${right}`;
223
+ }
224
+
225
+ /** Resolve a semantic or custom pill icon. */
226
+ export function pillIcon(value: PillContent["icon"]): string {
227
+ if (value === false) return "";
228
+ return typeof value === "string" ? icon(value) : value.glyph;
229
+ }
230
+
231
+ /** Glyph family supported by reusable key hints. */
232
+ export type TuiKeyIconPack = "nerd-fonts" | "unicode";
233
+
234
+ const MODIFIER_ORDER = ["ctrl", "super", "alt", "shift"] as const;
235
+ const MODIFIERS: Record<TuiKeyIconPack, Record<(typeof MODIFIER_ORDER)[number], string>> = {
236
+ "nerd-fonts": { ctrl: "󰘴", super: "󰘳", alt: "󰘵", shift: "󰘶" },
237
+ unicode: { ctrl: "⌃", super: "⌘", alt: "⌥", shift: "⇧" },
238
+ };
239
+ const NAMED_KEYS: Record<TuiKeyIconPack, Record<string, string>> = {
240
+ "nerd-fonts": {
241
+ space: "󱁐",
242
+ tab: "󰌒",
243
+ enter: "󰌑",
244
+ return: "󰌑",
245
+ backspace: "󰁮",
246
+ escape: "⎋",
247
+ esc: "⎋",
248
+ },
249
+ unicode: {
250
+ space: "␣",
251
+ tab: "⇥",
252
+ enter: "⏎",
253
+ return: "⏎",
254
+ backspace: "⌫",
255
+ escape: "⎋",
256
+ esc: "⎋",
257
+ },
258
+ };
259
+ const KEYCAP_BACKGROUND = "badge.neutral" as const;
260
+
261
+ function activePack(): TuiKeyIconPack {
262
+ return getTuiAppearance().iconPack === "nerd-fonts" ? "nerd-fonts" : "unicode";
263
+ }
264
+
265
+ function letterIcon(letter: string, pack: TuiKeyIconPack): string {
266
+ const index = letter.codePointAt(0)! - "a".codePointAt(0)!;
267
+ return pack === "nerd-fonts" ? String.fromCodePoint(0xf0b08 + index) : String.fromCodePoint(0x1f170 + index);
268
+ }
269
+
270
+ /**
271
+ * Render a key identifier as compact reusable modifier and key glyphs.
272
+ * Unsupported bases remain literal instead of inventing a misleading icon.
273
+ * @param key Pi key identifier such as `ctrl+c`, `space`, or `escape`.
274
+ * @param pack Optional glyph family; defaults to Nerd Fonts only when the active appearance uses them.
275
+ * @returns An unstyled key-hint string.
276
+ */
277
+ export function keyIcon(key: KeyId, pack: TuiKeyIconPack = activePack()): string {
278
+ const parts = key.split("+");
279
+ const base = parts.pop() ?? key;
280
+ const modifiers = MODIFIER_ORDER.filter((modifier) => parts.includes(modifier)).map(
281
+ (modifier) => MODIFIERS[pack][modifier],
282
+ );
283
+ const normalized = base.toLowerCase();
284
+ const baseIcon = /^[a-z]$/u.test(normalized) ? letterIcon(normalized, pack) : (NAMED_KEYS[pack][normalized] ?? base);
285
+ return [...modifiers, baseIcon].join(" ");
286
+ }
287
+
288
+ /** Render an unstyled key glyph with portable Unicode text presentation. */
289
+ export function keyHintGlyph(key: KeyId, pack: TuiKeyIconPack = activePack()): string {
290
+ return textPresentation(keyIcon(key, pack), pack);
291
+ }
292
+
293
+ /**
294
+ * Render a key identifier as a subdued semantic keycap adapted to its destination background.
295
+ * @param theme Active Pi theme used for semantic coloring.
296
+ * @param key Pi key identifier to render.
297
+ * @param pack Optional explicit key glyph family.
298
+ * @param background Optional destination background restored after the keycap.
299
+ * @returns ANSI-styled key hint.
300
+ */
301
+ export function renderKeyHint(
302
+ theme: Theme,
303
+ key: KeyId,
304
+ pack?: TuiKeyIconPack,
305
+ background?: TuiBackgroundToken | TuiColor,
306
+ ): string {
307
+ const colors = tuiTheme(theme);
308
+ const keycapBackground = colors.color(KEYCAP_BACKGROUND);
309
+ const keycapForeground = colors.contrastBackground(keycapBackground);
310
+ const glyph = keyHintGlyph(key, pack);
311
+ const keycap = colors.bg(keycapBackground, colors.fg(keycapForeground, glyph));
312
+ return `${keycap}${destinationBackgroundAnsi(colors, background)}`;
313
+ }
314
+
315
+ function destinationBackgroundAnsi(
316
+ colors: ReturnType<typeof tuiTheme>,
317
+ background: TuiBackgroundToken | TuiColor | undefined,
318
+ ): string {
319
+ if (background === undefined) return "\x1b[49m";
320
+ return colors.bgAnsi(background);
321
+ }
322
+
323
+ function textPresentation(glyph: string, pack: TuiKeyIconPack | undefined): string {
324
+ if (pack !== "unicode") return glyph;
325
+ return glyph.replace(/[🄰-🅉🅰-🆉]/gu, (letter) => `${letter}\uFE0E`);
326
+ }
@@ -0,0 +1,123 @@
1
+ import type { MouseRect, TuiMouseEvent } from "../mouse/events.ts";
2
+
3
+ /**
4
+ * Domain-owned screen target used by {@link PointerInteractionController}.
5
+ * Rendering and identity stay with the caller; this contract only supplies
6
+ * the stable key and hit rectangle needed for pointer state.
7
+ */
8
+ export interface PointerInteractionOptions<Target> {
9
+ key(target: Target): string;
10
+ rect(target: Target): MouseRect;
11
+ }
12
+
13
+ /** Hover and click callbacks for a screen-positioned pointer controller. */
14
+ export interface PointerInteractionHandlers<Target> {
15
+ onHoverChange?(target: Target | undefined): void;
16
+ onActivate?(target: Target): void;
17
+ }
18
+
19
+ /**
20
+ * Shared pointer lifecycle for screen decorations.
21
+ *
22
+ * Feature packages own target meaning, rendering, and activation. This class
23
+ * owns only hit testing, hover state, primary-button capture, and bounds.
24
+ * Keeping that state in one place prevents pills, markers, and other inline
25
+ * decorations from growing subtly different click/hover implementations.
26
+ */
27
+ export class PointerInteractionController<Target> {
28
+ private targets: Target[] = [];
29
+ private hoveredKey: string | undefined;
30
+ private pressedTarget: Target | undefined;
31
+
32
+ constructor(private readonly options: PointerInteractionOptions<Target>) {}
33
+
34
+ /** Replace the current visible targets after a screen decoration render. */
35
+ setTargets(targets: readonly Target[]): void {
36
+ this.targets = [...targets];
37
+ if (this.hoveredKey !== undefined && !this.findByKey(this.hoveredKey)) this.hoveredKey = undefined;
38
+ if (this.pressedTarget !== undefined && !this.findByKey(this.options.key(this.pressedTarget))) {
39
+ this.pressedTarget = undefined;
40
+ }
41
+ }
42
+
43
+ /** Find the visible target containing an absolute terminal cell. */
44
+ targetAt(screenCol: number, screenRow: number): Target | undefined {
45
+ return this.targets.find((target) => contains(this.options.rect(target), screenCol, screenRow));
46
+ }
47
+
48
+ /** Return the currently hovered visible target, if any. */
49
+ hoveredTarget(): Target | undefined {
50
+ return this.hoveredKey === undefined ? undefined : this.findByKey(this.hoveredKey);
51
+ }
52
+
53
+ /** Change hover state and report whether the stable target changed. */
54
+ setHover(target: Target | undefined): boolean {
55
+ const next = target === undefined ? undefined : this.options.key(target);
56
+ if (this.hoveredKey === next) return false;
57
+ this.hoveredKey = next;
58
+ return true;
59
+ }
60
+
61
+ /** Return the union bounds of all currently visible targets. */
62
+ getBounds(): MouseRect | undefined {
63
+ if (this.targets.length === 0) return undefined;
64
+ const rects = this.targets.map((target) => this.options.rect(target));
65
+ const left = Math.min(...rects.map((rect) => rect.x));
66
+ const top = Math.min(...rects.map((rect) => rect.y));
67
+ const right = Math.max(...rects.map((rect) => rect.x + rect.width));
68
+ const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
69
+ return { x: left, y: top, width: right - left, height: bottom - top };
70
+ }
71
+
72
+ /**
73
+ * Handle one normalized pointer event in absolute screen coordinates.
74
+ * Primary-button release activates only the target that received the press.
75
+ */
76
+ handleMouse(event: TuiMouseEvent, handlers: PointerInteractionHandlers<Target> = {}): boolean {
77
+ if (event.type === "leave") {
78
+ this.pressedTarget = undefined;
79
+ if (this.setHover(undefined)) handlers.onHoverChange?.(undefined);
80
+ return false;
81
+ }
82
+
83
+ const target = this.targetAt(event.screenCol, event.screenRow);
84
+ if (event.type === "enter" || event.type === "move") {
85
+ if (this.setHover(target)) handlers.onHoverChange?.(target);
86
+ return target !== undefined;
87
+ }
88
+
89
+ if (event.type === "press") {
90
+ if (event.button !== undefined && event.button !== 0) return false;
91
+ this.pressedTarget = target;
92
+ return target !== undefined;
93
+ }
94
+
95
+ if (event.type === "release") {
96
+ if (event.button !== undefined && event.button !== 0) return false;
97
+ const pressedTarget = this.pressedTarget;
98
+ this.pressedTarget = undefined;
99
+ if (pressedTarget === undefined) return target !== undefined;
100
+ if (target !== undefined && this.options.key(target) === this.options.key(pressedTarget)) {
101
+ handlers.onActivate?.(pressedTarget);
102
+ }
103
+ return true;
104
+ }
105
+
106
+ return target !== undefined;
107
+ }
108
+
109
+ /** Clear all targets and transient pointer state. */
110
+ clear(): void {
111
+ this.targets = [];
112
+ this.hoveredKey = undefined;
113
+ this.pressedTarget = undefined;
114
+ }
115
+
116
+ private findByKey(key: string): Target | undefined {
117
+ return this.targets.find((target) => this.options.key(target) === key);
118
+ }
119
+ }
120
+
121
+ function contains(rect: MouseRect, col: number, row: number): boolean {
122
+ return col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height;
123
+ }
@@ -0,0 +1,104 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { sliceByColumn } from "@earendil-works/pi-tui";
3
+ import { getTuiAppearance } from "../appearance.ts";
4
+ import {
5
+ contrastBackgroundColor,
6
+ type TuiBackgroundPaint,
7
+ type TuiColor,
8
+ type TuiForegroundPaint,
9
+ tuiTheme,
10
+ } from "../color/theme.ts";
11
+ import { getTuiPillSeparators, type PillContent, pillIcon } from "./glyphs.ts";
12
+
13
+ /**
14
+ * Resolve the effective ANSI background at a visible terminal column.
15
+ * @param line Styled terminal line to inspect.
16
+ * @param column Zero-based visible column whose active background is needed.
17
+ * @returns The last applicable background SGR sequence, or the default-background reset.
18
+ */
19
+ export function backgroundAnsiAtColumn(line: string, column: number): string {
20
+ const prefix = sliceByColumn(line, 0, Math.max(0, column) + 1, true);
21
+ let background = "\x1b[49m";
22
+ const sgr = /\x1b\[([0-9;]*)m/gu;
23
+ for (const match of prefix.matchAll(sgr)) {
24
+ const params = match[1] === "" ? [0] : match[1]!.split(";").map(Number);
25
+ for (let index = 0; index < params.length; index += 1) {
26
+ const code = params[index];
27
+ if (code === undefined) continue;
28
+ if (code === 0 || code === 49) {
29
+ background = "\x1b[49m";
30
+ } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
31
+ background = `\x1b[${code}m`;
32
+ } else if (code === 48 && params[index + 1] === 5 && params[index + 2] !== undefined) {
33
+ background = `\x1b[48;5;${params[index + 2]}m`;
34
+ index += 2;
35
+ } else if (code === 48 && params[index + 1] === 2 && params[index + 4] !== undefined) {
36
+ background = `\x1b[48;2;${params[index + 2]};${params[index + 3]};${params[index + 4]}m`;
37
+ index += 4;
38
+ } else if (code === 38) {
39
+ // Skip extended foreground parameters. Their RGB/index values can
40
+ // otherwise look like basic background codes in this scan (for
41
+ // example `38;2;45;45;45m` contains the background code 45).
42
+ if (params[index + 1] === 5 && params[index + 2] !== undefined) index += 2;
43
+ else if (params[index + 1] === 2 && params[index + 4] !== undefined) index += 4;
44
+ }
45
+ }
46
+ }
47
+ return background;
48
+ }
49
+
50
+ /**
51
+ * Generate a pill surface that contrasts with its destination background.
52
+ * @param theme Active Pi theme used for semantic color generation and fallback background discovery.
53
+ * @param surroundingBackgroundAnsi ANSI state at the destination surface.
54
+ * @returns A resolved logical background suitable for `renderPill()`.
55
+ */
56
+ export function contrastingPillBackground(theme: Theme, surroundingBackgroundAnsi: string): TuiColor {
57
+ const background = backgroundAnsiAtColumn(`${surroundingBackgroundAnsi} `, 1);
58
+ return contrastBackgroundColor(theme, background);
59
+ }
60
+
61
+ /**
62
+ * Render a semantic label whose caps exactly match its body background.
63
+ * @param theme Active Pi theme used to resolve semantic and explicit colors.
64
+ * @param content Required icon decision and label; the label may contain non-background terminal styling.
65
+ * @param background Semantic or resolved logical pill surface.
66
+ * @param foreground Semantic foreground or generated swatch for the label.
67
+ * @param surroundingBackground Semantic or explicit destination surface restored around the caps.
68
+ * @param surroundingBackgroundAnsi Exact destination ANSI background; takes precedence over `surroundingBackground`.
69
+ * @param rounded Whether to use Powerline caps; defaults to the active appearance setting.
70
+ * @returns ANSI-styled pill text with the destination background restored after both caps.
71
+ */
72
+ export function renderPill(
73
+ theme: Theme,
74
+ content: PillContent,
75
+ background: TuiBackgroundPaint,
76
+ foreground: TuiForegroundPaint,
77
+ surroundingBackground?: TuiBackgroundPaint,
78
+ surroundingBackgroundAnsi?: string,
79
+ rounded = getTuiAppearance().powerline,
80
+ ): string {
81
+ const colors = tuiTheme(theme);
82
+ const destinationBackground =
83
+ surroundingBackgroundAnsi ?? (surroundingBackground ? colors.bgAnsi(surroundingBackground) : undefined);
84
+ const restore = destinationBackground ?? "\x1b[49m";
85
+ const capColor = colors.fgAnsi(typeof background === "string" ? colors.color(background) : background);
86
+ const effectiveForeground =
87
+ foreground === "text.primary"
88
+ ? colors.contrastBackground(typeof background === "string" ? colors.color(background) : background)
89
+ : foreground;
90
+ const [leftSeparator, rightSeparator] = getTuiPillSeparators(rounded);
91
+ // compositeTuiLine resets the style immediately before an overlay. Restore
92
+ // the destination background before the left cap or its transparent corner
93
+ // is painted with the terminal default instead of the container surface.
94
+ const left = `${destinationBackground ?? ""}${capColor}${leftSeparator}\x1b[39m${destinationBackground ?? ""}`;
95
+ const glyph = pillIcon(content.icon);
96
+ const bodyContent = glyph
97
+ ? content.label
98
+ ? `${colors.fg(content.iconTone ?? effectiveForeground, glyph)} ${colors.fg(effectiveForeground, content.label)}`
99
+ : colors.fg(content.iconTone ?? effectiveForeground, glyph)
100
+ : colors.fg(effectiveForeground, content.label);
101
+ const body = colors.bg(background, bodyContent);
102
+ const right = `${restore}${capColor}${rightSeparator}\x1b[39m${restore}`;
103
+ return `${left}${body}${right}`;
104
+ }