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,581 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { BackgroundSurface, TOOL_SURFACE_BACKGROUND } from "../background-surface.ts";
5
+ import { tuiTheme } from "../color/theme.ts";
6
+ import { sanitizeTuiField } from "../content/terminal-text.ts";
7
+ import { icon } from "../decoration/glyphs.ts";
8
+ import {
9
+ clearFoldingCurrent,
10
+ ensureFoldingRegistry,
11
+ FOLD_TARGET_AT_ROW,
12
+ type FoldTarget,
13
+ type FoldTargetAtRow,
14
+ } from "../folding.ts";
15
+ import {
16
+ preserveViewportOnResize,
17
+ TEXT_INTERACTION_TARGET,
18
+ type TextInteractionTarget,
19
+ type TuiMouseEvent,
20
+ } from "../mouse.ts";
21
+ import { ExpandedRegionViewport } from "./expanded-region-viewport.ts";
22
+
23
+ /** Append the local open/closed disclosure glyph without changing row grammar. */
24
+ export function appendFoldingChevron(line: string, width: number, theme: Theme, open: boolean): string {
25
+ const boundedWidth = Math.max(0, Math.floor(width));
26
+ if (boundedWidth === 0) return "";
27
+ const glyph = tuiTheme(theme).fg("text.muted", ` ${icon(open ? "fold-open" : "fold-closed")}`);
28
+ const glyphWidth = visibleWidth(glyph);
29
+ if (glyphWidth >= boundedWidth) return truncateToWidth(glyph, boundedWidth, "…");
30
+ const available = boundedWidth - glyphWidth;
31
+ const compact = line.replace(/\s+$/u, "");
32
+ const fitted = visibleWidth(compact) <= available ? compact : truncateToWidth(compact, available, "…");
33
+ return `${fitted}${glyph}`;
34
+ }
35
+
36
+ export interface ToolViewMode {
37
+ id: string;
38
+ component: Component;
39
+ /** Short affordance shown after this mode, for example "12 more lines". */
40
+ nextHint?: string;
41
+ /** Explicit activation target. Defaults to the following mode. */
42
+ activate?: string;
43
+ /** Make one exact rendered row the activation target instead of appending a control. */
44
+ activationRow?: number | "last" | "omission";
45
+ }
46
+
47
+ /** Component-owned omission row exposed after the component has rendered. */
48
+ export interface OmissionRowProvider {
49
+ getOmissionRow(): number | undefined;
50
+ /** Update the renderer-owned omission row's semantic hover state. */
51
+ setOmissionRowHovered?(hovered: boolean): void;
52
+ }
53
+
54
+ export interface ToolViewRegionOptions {
55
+ theme: Theme;
56
+ modes: readonly ToolViewMode[];
57
+ initialMode?: string;
58
+ requestRender(): void;
59
+ onModeChange?(mode: string): void;
60
+ onInput?(data: string, mode: string): boolean;
61
+ /** Dispose mode components removed by streaming updates. Defaults to true. */
62
+ disposeReplacedModes?: boolean;
63
+ /** Maximum rendered rows for an expanded payload viewport. */
64
+ maxHeight?: number;
65
+ /** Let the default viewport grow to fit an unusually tall collapsed preview. */
66
+ allowExpandedGrowth?: boolean;
67
+ }
68
+
69
+ type InteractionTarget = "body" | "header";
70
+ type PressedInteraction = { target: InteractionTarget; button: 0 | 1 | 2 };
71
+
72
+ /**
73
+ * Owns one payload's modes, fold state, pointer state, and bounded expanded viewport.
74
+ *
75
+ * The action header delegates to this object through `onHeaderMouse` and
76
+ * `handleHeaderInput`; it does not maintain a second disclosure state machine.
77
+ */
78
+ export class ToolViewRegion implements Component, TextInteractionTarget, FoldTarget, FoldTargetAtRow {
79
+ readonly [TEXT_INTERACTION_TARGET] = true as const;
80
+ private modes: readonly ToolViewMode[];
81
+ private modeIndex: number;
82
+ private hoveredTarget: InteractionTarget | undefined;
83
+ private focused = false;
84
+ private pressed: PressedInteraction | undefined;
85
+ private hintGeometry: Array<{ row: number; width: number }> = [];
86
+ private sourceHintGeometry: Array<{ row: number; width: number }> = [];
87
+ private renderedWidth = 0;
88
+ private renderedHeight = 0;
89
+ private readonly expandedViewport: ExpandedRegionViewport;
90
+ private readonly expandedSurface: BackgroundSurface;
91
+ private removeFoldingTarget: (() => void) | undefined;
92
+ private rendered = false;
93
+ private disposed = false;
94
+
95
+ constructor(private readonly options: ToolViewRegionOptions) {
96
+ if (options.modes.length === 0) throw new Error("ToolViewRegion requires at least one mode");
97
+ this.modes = options.modes;
98
+ const requested = options.initialMode ? options.modes.findIndex((mode) => mode.id === options.initialMode) : 0;
99
+ this.modeIndex = requested >= 0 ? requested : 0;
100
+ this.expandedViewport = new ExpandedRegionViewport(
101
+ (width) => this.renderMode(width),
102
+ options.maxHeight ?? 20,
103
+ options.allowExpandedGrowth === true,
104
+ options.theme,
105
+ options.requestRender,
106
+ );
107
+ this.expandedSurface = new BackgroundSurface({
108
+ theme: options.theme,
109
+ background: TOOL_SURFACE_BACKGROUND,
110
+ component: this.expandedViewport,
111
+ });
112
+ }
113
+
114
+ /** Replace streamed mode contents without replacing this interaction target. */
115
+ updateModes(modes: readonly ToolViewMode[], requestedMode?: string): void {
116
+ if (modes.length === 0) throw new Error("ToolViewRegion requires at least one mode");
117
+ const previousMode = this.getMode();
118
+ const previousComponent = this.mode().component as Component & { setViewportFocus?(focused: boolean): void };
119
+ const headerHovered = this.hoveredTarget === "header";
120
+ this.setOmissionRowHovered(false);
121
+ if (this.options.disposeReplacedModes !== false) disposeRemovedModes(this.modes, modes);
122
+ this.modes = modes;
123
+ const wanted = requestedMode ?? previousMode;
124
+ const requested = modes.findIndex((mode) => mode.id === wanted);
125
+ this.modeIndex = requested >= 0 ? requested : 0;
126
+ this.hoveredTarget = headerHovered && modes.length > 1 ? "header" : undefined;
127
+ if (this.mode().component !== previousComponent) {
128
+ this.pressed = undefined;
129
+ if (this.focused) previousComponent.setViewportFocus?.(false);
130
+ }
131
+ this.hintGeometry = [];
132
+ this.sourceHintGeometry = [];
133
+ if (this.focused) {
134
+ (this.mode().component as Component & { setViewportFocus?(focused: boolean): void }).setViewportFocus?.(true);
135
+ }
136
+ this.expandedViewport.invalidate();
137
+ this.syncFoldingTarget();
138
+ }
139
+
140
+ getMode(): string {
141
+ return this.mode().id;
142
+ }
143
+
144
+ canExpand(): boolean {
145
+ return this.modes.length > 1;
146
+ }
147
+
148
+ isExpanded(): boolean {
149
+ return this.modeIndex !== 0;
150
+ }
151
+
152
+ isFolded(): boolean {
153
+ return !this.isExpanded();
154
+ }
155
+
156
+ /** Whether the collapsed mode already exposes its own primary control. */
157
+ hasCollapsedPrimaryTarget(): boolean {
158
+ const mode = this.mode();
159
+ return mode.activationRow !== undefined || mode.nextHint !== undefined;
160
+ }
161
+
162
+ [FOLD_TARGET_AT_ROW](_row: number): FoldTarget | undefined {
163
+ return this.canExpand() ? this : undefined;
164
+ }
165
+
166
+ open(): void {
167
+ if (this.canExpand() && this.isFolded()) this.activate();
168
+ }
169
+
170
+ /** Keep the default expanded viewport at least as tall as the rendered preview. */
171
+ ensureExpandedHeight(rows: number): void {
172
+ this.expandedViewport.ensureMinimumHeight(rows);
173
+ }
174
+
175
+ close(): void {
176
+ this.collapse();
177
+ }
178
+
179
+ setMode(id: string): void {
180
+ const index = this.modes.findIndex((mode) => mode.id === id);
181
+ if (index < 0 || index === this.modeIndex) return;
182
+ this.setOmissionRowHovered(false);
183
+ const previous = this.mode().component as Component & { setViewportFocus?(focused: boolean): void };
184
+ if (this.focused) previous.setViewportFocus?.(false);
185
+ preserveViewportOnResize();
186
+ this.modeIndex = index;
187
+ if (this.focused)
188
+ (this.mode().component as Component & { setViewportFocus?(focused: boolean): void }).setViewportFocus?.(true);
189
+ this.expandedViewport.invalidate();
190
+ this.expandedViewport.scrollToStart();
191
+ this.hintGeometry = [];
192
+ this.sourceHintGeometry = [];
193
+ this.hoveredTarget = undefined;
194
+ this.pressed = undefined;
195
+ this.options.onModeChange?.(id);
196
+ this.options.requestRender();
197
+ }
198
+
199
+ /** Toggle between the collapsed mode and the current mode's activation target. */
200
+ toggle(): void {
201
+ if (this.modeIndex === 0) this.activate();
202
+ else this.collapse();
203
+ }
204
+
205
+ /** Activate the current mode's explicit target, or advance once. */
206
+ activate(): void {
207
+ const target = this.mode().activate;
208
+ if (target) this.setMode(target);
209
+ else this.advance(1);
210
+ }
211
+
212
+ /** Return to the first, collapsed mode. */
213
+ collapse(): void {
214
+ if (this.modeIndex !== 0) this.setMode(this.modes[0]!.id);
215
+ }
216
+
217
+ setViewportFocus(focused: boolean): void {
218
+ if (this.focused === focused) {
219
+ (this.mode().component as Component & { setViewportFocus?(focused: boolean): void }).setViewportFocus?.(focused);
220
+ return;
221
+ }
222
+ this.focused = focused;
223
+ if (focused && this.canExpand()) ensureFoldingRegistry().setCurrent(this);
224
+ else clearFoldingCurrent(this);
225
+ (this.mode().component as Component & { setViewportFocus?(focused: boolean): void }).setViewportFocus?.(focused);
226
+ this.options.requestRender();
227
+ }
228
+
229
+ isViewportFocused(): boolean {
230
+ return this.focused;
231
+ }
232
+
233
+ handleViewportInput(data: string): boolean {
234
+ if (this.focused && this.canExpand()) ensureFoldingRegistry().setCurrent(this);
235
+ const child = this.mode().component as Component & {
236
+ setViewportFocus?(focused: boolean): void;
237
+ handleViewportInput?(data: string): boolean;
238
+ handleInput?(data: string): boolean;
239
+ };
240
+ if (child.handleViewportInput?.(data) ?? child.handleInput?.(data) ?? false) {
241
+ this.expandedViewport.invalidate();
242
+ return true;
243
+ }
244
+ if (this.options.onInput?.(data, this.getMode())) {
245
+ this.expandedViewport.invalidate();
246
+ return true;
247
+ }
248
+ if (this.modeIndex !== 0 && this.expandedViewport.handleViewportInput(data)) return true;
249
+ if (
250
+ this.canExpand() &&
251
+ this.isFolded() &&
252
+ !this.hasCollapsedPrimaryTarget() &&
253
+ (matchesKey(data, "enter") || matchesKey(data, "space"))
254
+ ) {
255
+ this.activate();
256
+ return true;
257
+ }
258
+ if (this.canExpand() && matchesKey(data, "right")) {
259
+ this.advance(1);
260
+ return true;
261
+ }
262
+ if (this.canExpand() && matchesKey(data, "left")) {
263
+ this.advance(-1);
264
+ return true;
265
+ }
266
+ return false;
267
+ }
268
+
269
+ /** Handle Enter/Space while the action header owns keyboard focus. */
270
+ handleHeaderInput(data: string): boolean {
271
+ if (!this.canExpand() || (!matchesKey(data, "enter") && !matchesKey(data, "space"))) return false;
272
+ if (this.isExpanded()) this.collapse();
273
+ else this.open();
274
+ return true;
275
+ }
276
+
277
+ /** Whether the action header is currently hovered by the pointer. */
278
+ isHeaderHovered(): boolean {
279
+ return this.hoveredTarget === "header";
280
+ }
281
+
282
+ /** Route header pointer events through this region's single disclosure FSM. */
283
+ onHeaderMouse(event: TuiMouseEvent, width: number, height = 1): boolean {
284
+ const boundedWidth = Math.max(0, Math.floor(width));
285
+ const boundedHeight = Math.max(1, Math.floor(height));
286
+ const inside =
287
+ this.canExpand() && event.row >= 0 && event.row < boundedHeight && event.col >= 0 && event.col < boundedWidth;
288
+ const headerCanOpen = this.isFolded();
289
+ const interactive = inside && (this.isExpanded() || headerCanOpen);
290
+ if (event.type === "leave") {
291
+ const changed = this.hoveredTarget === "header" || this.pressed?.target === "header";
292
+ if (this.hoveredTarget === "header") this.hoveredTarget = undefined;
293
+ if (this.pressed?.target === "header") this.pressed = undefined;
294
+ this.releaseCurrentIfUnowned();
295
+ if (changed) this.options.requestRender();
296
+ return false;
297
+ }
298
+ if (interactive) ensureFoldingRegistry().setCurrent(this);
299
+ if (event.type === "enter" || event.type === "move") {
300
+ const changed = this.hoveredTarget !== (interactive ? "header" : undefined);
301
+ this.hoveredTarget = interactive ? "header" : undefined;
302
+ if (changed) this.options.requestRender();
303
+ return interactive;
304
+ }
305
+ if (event.type === "press" && inside && event.button === 0 && interactive) {
306
+ this.pressed = { target: "header", button: 0 };
307
+ return true;
308
+ }
309
+ if (event.type === "press" && inside && event.button === 2 && interactive) {
310
+ this.pressed = { target: "header", button: 2 };
311
+ return true;
312
+ }
313
+ if (event.type === "drag") {
314
+ if (this.pressed?.target === "header") this.pressed = undefined;
315
+ return false;
316
+ }
317
+ if (event.type === "release") {
318
+ const pressed = this.pressed;
319
+ const changed = this.hoveredTarget === "header" || pressed?.target === "header";
320
+ this.pressed = pressed?.target === "header" ? undefined : pressed;
321
+ this.hoveredTarget = undefined;
322
+ const activate =
323
+ pressed?.target === "header" &&
324
+ (pressed.button === 0 || pressed.button === 2) &&
325
+ inside &&
326
+ this.isFolded() &&
327
+ headerCanOpen;
328
+ const collapse =
329
+ pressed?.target === "header" && (pressed.button === 0 || pressed.button === 2) && inside && this.isExpanded();
330
+ if (activate) this.open();
331
+ if (collapse) this.collapse();
332
+ if (changed && !activate && !collapse) this.options.requestRender();
333
+ return activate || collapse;
334
+ }
335
+ return false;
336
+ }
337
+
338
+ onMouse(event: TuiMouseEvent): boolean {
339
+ if (this.modeIndex !== 0 && event.type === "wheel" && this.expandedViewport.onMouse(event)) return true;
340
+ if (event.type === "leave") {
341
+ const changed = this.hoveredTarget === "body" || this.pressed?.target === "body";
342
+ if (this.hoveredTarget === "body") this.hoveredTarget = undefined;
343
+ this.setOmissionRowHovered(false);
344
+ if (this.pressed?.target === "body") this.pressed = undefined;
345
+ this.releaseCurrentIfUnowned();
346
+ if (changed) {
347
+ this.expandedSurface.invalidate();
348
+ this.options.requestRender();
349
+ }
350
+ return this.dispatchToMode(event);
351
+ }
352
+ const hit = this.hitControl(event.row, event.col);
353
+ const inside = this.inside(event.row, event.col);
354
+ if (inside && this.canExpand()) ensureFoldingRegistry().setCurrent(this);
355
+ if (event.type === "enter" || event.type === "move") {
356
+ const hoverable = this.isFolded() && hit;
357
+ const nextHover = hoverable ? "body" : undefined;
358
+ if (this.hoveredTarget !== nextHover) {
359
+ this.hoveredTarget = nextHover;
360
+ this.setOmissionRowHovered(hoverable);
361
+ if (this.modeIndex !== 0) this.expandedSurface.invalidate();
362
+ this.options.requestRender();
363
+ }
364
+ return hoverable || this.dispatchToMode(event);
365
+ }
366
+ if (event.type === "press" && event.button === 0 && this.isFolded() && hit) {
367
+ this.pressed = { target: "body", button: 0 };
368
+ return true;
369
+ }
370
+ if (event.type === "press" && event.button === 2 && this.isExpanded() && inside) {
371
+ this.pressed = { target: "body", button: 2 };
372
+ return true;
373
+ }
374
+ if (event.type === "drag") {
375
+ if (this.pressed?.target === "body") this.pressed = undefined;
376
+ return false;
377
+ }
378
+ if (event.type === "release") {
379
+ const pressed = this.pressed;
380
+ const changed = this.hoveredTarget === "body" || pressed?.target === "body";
381
+ const activate =
382
+ event.button === 0 && this.isFolded() && hit && pressed?.target === "body" && pressed.button === 0;
383
+ const collapse =
384
+ event.button === 2 && this.isExpanded() && inside && pressed?.target === "body" && pressed.button === 2;
385
+ this.pressed = pressed?.target === "body" ? undefined : pressed;
386
+ this.hoveredTarget = undefined;
387
+ this.setOmissionRowHovered(false);
388
+ if (activate) this.activate();
389
+ if (collapse) this.collapse();
390
+ if (changed && !activate && !collapse) this.options.requestRender();
391
+ return activate || collapse || hit || this.dispatchToMode(event);
392
+ }
393
+ return this.dispatchToMode(event);
394
+ }
395
+
396
+ render(width: number): string[] {
397
+ const boundedWidth = Math.max(0, Math.floor(width));
398
+ this.renderedWidth = boundedWidth;
399
+ if (boundedWidth === 0) {
400
+ this.setOmissionRowHovered(false);
401
+ this.renderedHeight = 0;
402
+ this.hintGeometry = [];
403
+ this.sourceHintGeometry = [];
404
+ return [];
405
+ }
406
+ this.rendered = true;
407
+ this.syncFoldingTarget();
408
+ if (this.modeIndex !== 0) {
409
+ const rendered = this.expandedSurface.render(boundedWidth);
410
+ this.renderedHeight = rendered.length;
411
+ // Collapsed controls are not active in the expanded payload. Do not
412
+ // carry their old geometry into body clicks after a mode transition.
413
+ this.hintGeometry = [];
414
+ return rendered;
415
+ }
416
+ const rendered = this.renderMode(boundedWidth);
417
+ this.hintGeometry = this.sourceHintGeometry;
418
+ return rendered;
419
+ }
420
+
421
+ private renderMode(boundedWidth: number): string[] {
422
+ const mode = this.mode();
423
+ this.setOmissionRowHovered(this.isFolded() && this.hoveredTarget === "body" && mode.activationRow === "omission");
424
+ const lines = mode.component.render(boundedWidth);
425
+ this.sourceHintGeometry = [];
426
+ const colors = tuiTheme(this.options.theme);
427
+ if (mode.activationRow !== undefined) {
428
+ const row = resolveActivationRow(mode, lines.length);
429
+ if (row === undefined || !Number.isInteger(row) || row < 0 || row >= lines.length) {
430
+ this.renderedHeight = lines.length;
431
+ return lines;
432
+ }
433
+ const provider = mode.activationRow === "omission" ? omissionProvider(mode.component) : undefined;
434
+ const semanticOmission = provider?.setOmissionRowHovered !== undefined;
435
+ const control =
436
+ this.modeIndex === 0 && this.canExpand() && !semanticOmission
437
+ ? appendFoldingChevron(lines[row]!, boundedWidth, this.options.theme, false)
438
+ : lines[row]!;
439
+ if (this.isFolded())
440
+ this.sourceHintGeometry.push({
441
+ row,
442
+ width: mode.activationRow === "omission" ? boundedWidth : visibleWidth(control),
443
+ });
444
+ this.renderedHeight = lines.length;
445
+ return lines.map((line, index) =>
446
+ index === row
447
+ ? this.hoveredTarget === "body" && !semanticOmission
448
+ ? this.styleControl(control, colors)
449
+ : control
450
+ : line,
451
+ );
452
+ }
453
+ if (!mode.nextHint || this.modes.length === 1 || this.modeIndex !== 0) {
454
+ this.renderedHeight = lines.length;
455
+ return lines;
456
+ }
457
+ const plain = truncateToWidth(`… ${sanitizeTuiField(mode.nextHint)}`, boundedWidth, "…");
458
+ // Keep the disclosure adjacent to the omitted middle, rather than making
459
+ // it read like a footer after the last payload row.
460
+ const hintRow = Math.floor(lines.length / 2);
461
+ if (this.isFolded()) this.sourceHintGeometry.push({ row: hintRow, width: visibleWidth(plain) });
462
+ const rendered = [...lines.slice(0, hintRow), this.styleControl(plain, colors), ...lines.slice(hintRow)];
463
+ this.renderedHeight = rendered.length;
464
+ return rendered;
465
+ }
466
+
467
+ invalidate(): void {
468
+ this.hintGeometry = [];
469
+ this.sourceHintGeometry = [];
470
+ this.expandedSurface.invalidate();
471
+ for (const mode of this.modes) mode.component.invalidate();
472
+ }
473
+
474
+ /** Invalidate only the bounded expanded projection, preserving child pointer state. */
475
+ invalidateExpanded(): void {
476
+ this.expandedSurface.invalidate();
477
+ }
478
+
479
+ dispose(): void {
480
+ if (this.disposed) return;
481
+ this.disposed = true;
482
+ this.removeFoldingTarget?.();
483
+ this.removeFoldingTarget = undefined;
484
+ this.expandedSurface.dispose();
485
+ if (this.options.disposeReplacedModes !== false) disposeModes(this.modes);
486
+ }
487
+
488
+ private mode(): ToolViewMode {
489
+ return this.modes[this.modeIndex]!;
490
+ }
491
+
492
+ private setOmissionRowHovered(hovered: boolean): void {
493
+ const provider = this.mode().component as Partial<OmissionRowProvider>;
494
+ provider.setOmissionRowHovered?.(hovered);
495
+ }
496
+
497
+ private advance(delta: number): void {
498
+ const count = this.modes.length;
499
+ const next = Math.max(0, Math.min(count - 1, this.modeIndex + delta));
500
+ if (next === this.modeIndex) return;
501
+ this.expandedViewport.invalidate();
502
+ this.setMode(this.modes[next]!.id);
503
+ }
504
+
505
+ private syncFoldingTarget(): void {
506
+ if (!this.disposed && this.rendered && this.canExpand() && !this.removeFoldingTarget) {
507
+ this.removeFoldingTarget = ensureFoldingRegistry().register(this);
508
+ } else if ((this.disposed || !this.canExpand()) && this.removeFoldingTarget) {
509
+ this.removeFoldingTarget();
510
+ this.removeFoldingTarget = undefined;
511
+ }
512
+ }
513
+
514
+ private styleControl(plain: string, colors: ReturnType<typeof tuiTheme>): string {
515
+ if (this.hoveredTarget === "body")
516
+ return colors.bg(
517
+ "surface.hover",
518
+ colors.fg("text.secondary", `${plain}${" ".repeat(Math.max(0, this.renderedWidth - visibleWidth(plain)))}`),
519
+ );
520
+ return colors.fg("text.muted", plain);
521
+ }
522
+
523
+ private hitControl(row: number, col: number): boolean {
524
+ return this.hintGeometry.some((geometry) => row === geometry.row && col >= 0 && col < geometry.width);
525
+ }
526
+
527
+ private inside(row: number, col: number): boolean {
528
+ return row >= 0 && row < this.renderedHeight && col >= 0 && col < this.renderedWidth;
529
+ }
530
+
531
+ private dispatchToMode(event: TuiMouseEvent): boolean {
532
+ const handler = (this.mode().component as Component & { onMouse?(event: TuiMouseEvent): boolean }).onMouse;
533
+ if (!handler) return false;
534
+ const scrollTop = this.modeIndex !== 0 ? this.expandedViewport.scrollOffset : 0;
535
+ const handled = handler.call(this.mode().component, { ...event, row: event.row + scrollTop });
536
+ if (handled) this.expandedViewport.invalidate();
537
+ return handled;
538
+ }
539
+
540
+ private releaseCurrentIfUnowned(): void {
541
+ if (this.hoveredTarget === undefined && this.pressed === undefined && !this.focused) clearFoldingCurrent(this);
542
+ }
543
+ }
544
+
545
+ function resolveActivationRow(mode: ToolViewMode, lineCount: number): number | undefined {
546
+ const row =
547
+ mode.activationRow === "last"
548
+ ? lineCount - 1
549
+ : mode.activationRow === "omission"
550
+ ? omissionRow(mode.component)
551
+ : mode.activationRow;
552
+ if (row === undefined) return undefined;
553
+ return row;
554
+ }
555
+
556
+ function omissionRow(component: Component): number | undefined {
557
+ const provider = component as Partial<OmissionRowProvider>;
558
+ const row = provider.getOmissionRow?.();
559
+ return typeof row === "number" && Number.isInteger(row) && row >= 0 ? row : undefined;
560
+ }
561
+
562
+ function omissionProvider(component: Component): Partial<OmissionRowProvider> | undefined {
563
+ const provider = component as Partial<OmissionRowProvider>;
564
+ return typeof provider.getOmissionRow === "function" && typeof provider.setOmissionRowHovered === "function"
565
+ ? provider
566
+ : undefined;
567
+ }
568
+
569
+ function disposeRemovedModes(previous: readonly ToolViewMode[], next: readonly ToolViewMode[]): void {
570
+ const retained = new Set(next.map((mode) => mode.component));
571
+ disposeModes(previous.filter((mode) => !retained.has(mode.component)));
572
+ }
573
+
574
+ function disposeModes(modes: readonly ToolViewMode[]): void {
575
+ const disposed = new Set<Component>();
576
+ for (const { component } of modes) {
577
+ if (disposed.has(component)) continue;
578
+ disposed.add(component);
579
+ (component as Component & { dispose?(): void }).dispose?.();
580
+ }
581
+ }