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,103 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import { matchesKey } from "@earendil-works/pi-tui";
4
+ import type { TuiMouseEvent } from "../mouse.ts";
5
+ import { getTuiRenderEpoch } from "../render-epoch.ts";
6
+ import { applyScrollbar } from "../scrollbar.ts";
7
+
8
+ /** Shared bounded payload viewport for every expanded mode. */
9
+ export class ExpandedRegionViewport implements Component {
10
+ private maxHeight: number;
11
+ private readonly allowGrowth: boolean;
12
+ private scrollTop = 0;
13
+ private renderedWidth = 0;
14
+ private renderedBodyHeight = 0;
15
+ private renderedContentHeight = 0;
16
+ private cachedWidth = -1;
17
+ private cachedEpoch = -1;
18
+ private cachedLines: string[] | undefined;
19
+
20
+ constructor(
21
+ private readonly renderSource: (width: number) => string[],
22
+ maxHeight: number,
23
+ allowGrowth: boolean,
24
+ private readonly theme: Theme,
25
+ private readonly requestRender: () => void,
26
+ ) {
27
+ this.maxHeight = Number.isFinite(maxHeight) ? Math.max(1, Math.floor(maxHeight)) : 20;
28
+ this.allowGrowth = allowGrowth;
29
+ }
30
+
31
+ ensureMinimumHeight(rows: number): void {
32
+ if (!this.allowGrowth || !Number.isFinite(rows)) return;
33
+ this.maxHeight = Math.max(this.maxHeight, Math.max(1, Math.floor(rows)));
34
+ }
35
+
36
+ render(width: number): string[] {
37
+ this.renderedWidth = Math.max(0, Math.floor(width));
38
+ const epoch = getTuiRenderEpoch();
39
+ if (this.cachedWidth !== this.renderedWidth || this.cachedEpoch !== epoch || !this.cachedLines) {
40
+ this.cachedWidth = this.renderedWidth;
41
+ this.cachedEpoch = epoch;
42
+ this.cachedLines = this.renderSource(this.renderedWidth);
43
+ }
44
+ const lines = this.cachedLines;
45
+ this.renderedContentHeight = lines.length;
46
+ this.renderedBodyHeight = this.maxHeight;
47
+ const maxScrollTop = Math.max(0, lines.length - this.renderedBodyHeight);
48
+ this.scrollTop = Math.max(0, Math.min(this.scrollTop, maxScrollTop));
49
+ const visible = lines.slice(this.scrollTop, this.scrollTop + this.renderedBodyHeight);
50
+ return applyScrollbar(visible, {
51
+ theme: this.theme,
52
+ width: this.renderedWidth,
53
+ height: this.renderedBodyHeight,
54
+ offset: this.scrollTop,
55
+ total: lines.length,
56
+ });
57
+ }
58
+
59
+ get scrollOffset(): number {
60
+ return this.scrollTop;
61
+ }
62
+
63
+ handleViewportInput(data: string): boolean {
64
+ if (matchesKey(data, "up")) return this.scrollBy(-1);
65
+ if (matchesKey(data, "down")) return this.scrollBy(1);
66
+ if (matchesKey(data, "pageUp")) return this.scrollBy(-Math.max(1, this.renderedBodyHeight - 1));
67
+ if (matchesKey(data, "pageDown")) return this.scrollBy(Math.max(1, this.renderedBodyHeight - 1));
68
+ if (matchesKey(data, "home")) return this.scrollTo(0);
69
+ if (matchesKey(data, "end")) return this.scrollTo(Number.MAX_SAFE_INTEGER);
70
+ return false;
71
+ }
72
+
73
+ onMouse(event: TuiMouseEvent): boolean {
74
+ if (event.type !== "wheel" || event.wheel === undefined) return false;
75
+ return this.scrollBy(event.wheel === -1 ? -3 : 3);
76
+ }
77
+
78
+ invalidate(): void {
79
+ this.cachedLines = undefined;
80
+ this.cachedWidth = -1;
81
+ this.cachedEpoch = -1;
82
+ }
83
+
84
+ scrollToStart(): void {
85
+ this.scrollTop = 0;
86
+ }
87
+
88
+ private maxScrollTop(): number {
89
+ return Math.max(0, this.renderedContentHeight - this.renderedBodyHeight);
90
+ }
91
+
92
+ private scrollTo(next: number): boolean {
93
+ const clamped = Math.max(0, Math.min(this.maxScrollTop(), Math.floor(next)));
94
+ if (clamped === this.scrollTop) return false;
95
+ this.scrollTop = clamped;
96
+ this.requestRender();
97
+ return true;
98
+ }
99
+
100
+ private scrollBy(delta: number): boolean {
101
+ return this.scrollTo(this.scrollTop + delta);
102
+ }
103
+ }
@@ -0,0 +1,26 @@
1
+ export type {
2
+ LiveToolActionOptions,
3
+ ToolActionOptions,
4
+ ToolActionView,
5
+ ToolTranscriptStatus,
6
+ } from "./action.ts";
7
+ export { LiveToolAction, ToolAction } from "./action.ts";
8
+ export type {
9
+ TerminalOutputUpdate,
10
+ ToolActivityAction,
11
+ ToolActivityOptions,
12
+ ToolActivityPayload,
13
+ ToolActivityView,
14
+ ToolOutputUpdate,
15
+ } from "./activity.ts";
16
+ export { ToolActivity } from "./activity.ts";
17
+ export { settleToolCallPreview, toolCallPreview } from "./call-preview.ts";
18
+ export { ToolDisclosureAction } from "./disclosure-action.ts";
19
+ export type { ToolOutputOptions, ToolOutputView, ToolOutputViewport } from "./output.ts";
20
+ export { ToolOutput } from "./output.ts";
21
+ export type { ToolTranscriptOptions } from "./transcript.ts";
22
+ export { ToolTranscript } from "./transcript.ts";
23
+ export { mountTranscriptProjection } from "../host/transcript-bridge.ts";
24
+ export type { TranscriptEntry, TranscriptProjection } from "../host/transcript-bridge.ts";
25
+ export type { OmissionRowProvider, ToolViewMode, ToolViewRegionOptions } from "./view-region.ts";
26
+ export { ToolViewRegion } from "./view-region.ts";
@@ -0,0 +1,271 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
+ import { tuiTheme } from "../color/theme.ts";
5
+ import { sanitizeTuiAnsiChunk } from "../content/terminal-text.ts";
6
+ import { RenderedLinesCache } from "../render-cache.ts";
7
+ import { BoundedStreamBuffer, type BoundedStreamSnapshot } from "../stream.ts";
8
+
9
+ /** Complete replace-style state for a streaming tool output surface. */
10
+ export interface ToolOutputView {
11
+ text: string;
12
+ /** Monotonic caller-owned content revision used for exact render caching. */
13
+ revision: number;
14
+ }
15
+
16
+ /** Explicit bounded row policy selected by a tool-owned view mode. */
17
+ export interface ToolOutputViewport {
18
+ maxRows: number;
19
+ selection?: "head" | "tail" | "head-tail";
20
+ }
21
+
22
+ /** Bounds and appearance for a streaming tool output surface. */
23
+ export interface ToolOutputOptions {
24
+ theme: Theme;
25
+ initial?: ToolOutputView;
26
+ viewport?: ToolOutputViewport;
27
+ /** Maximum retained output budget, measured as UTF-8 bytes by the stream buffer. */
28
+ maxCharacters?: number;
29
+ }
30
+
31
+ /**
32
+ * A bounded, ANSI-safe output component with explicit append and replace paths.
33
+ *
34
+ * Callers own stream semantics, revisions, and named view modes; the component
35
+ * owns retention, bounded row selection, wrapping, and stable output references.
36
+ */
37
+ export class ToolOutput implements Component {
38
+ private readonly cache = new RenderedLinesCache();
39
+ private readonly stream: BoundedStreamBuffer;
40
+ private readonly renderStreams = new Map<string, BoundedStreamBuffer>();
41
+ private streamSnapshot: BoundedStreamSnapshot;
42
+ private ansiPending = "";
43
+ private plainAscii: boolean;
44
+ private currentRevision: number;
45
+ private hasRevision: boolean;
46
+ private cumulativeSourceLength: number;
47
+ private viewport: Required<ToolOutputViewport>;
48
+ private readonly maxCharacters: number;
49
+ private omissionRow: number | undefined;
50
+
51
+ constructor(private readonly options: ToolOutputOptions) {
52
+ this.maxCharacters = finitePositive(options.maxCharacters, 1_000_000);
53
+ this.viewport = normalizeViewport(options.viewport);
54
+ this.stream = new BoundedStreamBuffer({ maxBytes: this.maxCharacters, omissionMarker: () => "" });
55
+ this.streamSnapshot = this.stream.snapshot();
56
+ this.plainAscii = true;
57
+ this.currentRevision = 0;
58
+ this.hasRevision = false;
59
+ this.cumulativeSourceLength = 0;
60
+ this.omissionRow = undefined;
61
+ this.reset(options.initial);
62
+ }
63
+
64
+ getOmissionRow(): number | undefined {
65
+ return this.omissionRow;
66
+ }
67
+
68
+ /** Replace the complete visible stream state. */
69
+ replace(view: ToolOutputView): void {
70
+ if (this.hasRevision && view.revision === this.currentRevision) return;
71
+ this.reset(view);
72
+ }
73
+
74
+ /** Start a new stream, even when its first revision matches the previous stream. */
75
+ reset(view?: ToolOutputView): void {
76
+ const source = view?.text ?? "";
77
+ const sanitized = sanitizeTuiAnsiChunk(source, Math.min(this.maxCharacters, 4_096));
78
+ const safeText = sanitized.text;
79
+ this.streamSnapshot = this.stream.replaceFinal(safeText);
80
+ this.plainAscii = isPlainAscii(safeText);
81
+ this.ansiPending = "";
82
+ this.currentRevision = view?.revision ?? 0;
83
+ this.hasRevision = view !== undefined;
84
+ this.cumulativeSourceLength = source.length;
85
+ this.omissionRow = undefined;
86
+ this.renderStreams.clear();
87
+ this.cache.clear();
88
+ }
89
+
90
+ /** Consume a cumulative append-only snapshot without copying its retained prefix. */
91
+ appendCumulative(text: string, revision: number): void {
92
+ if (text.length < this.cumulativeSourceLength) {
93
+ this.replace({ text, revision });
94
+ return;
95
+ }
96
+ const chunk = text.slice(this.cumulativeSourceLength);
97
+ this.append(chunk, revision);
98
+ }
99
+
100
+ /** Append one stream chunk and advance to the caller-owned revision. */
101
+ append(chunk: string, revision: number): void {
102
+ this.cumulativeSourceLength += chunk.length;
103
+ const sanitized = sanitizeTuiAnsiChunk(this.ansiPending + chunk, Math.min(this.maxCharacters, 4_096));
104
+ this.ansiPending = sanitized.pending;
105
+ const safeChunk = sanitized.text;
106
+ if (safeChunk.length > 0) {
107
+ this.plainAscii &&= isPlainAscii(safeChunk);
108
+ this.streamSnapshot = this.stream.append(safeChunk);
109
+ for (const stream of this.renderStreams.values()) stream.append(safeChunk);
110
+ } else {
111
+ this.streamSnapshot = this.stream.snapshot();
112
+ }
113
+ this.currentRevision = revision;
114
+ this.hasRevision = true;
115
+ this.omissionRow = undefined;
116
+ this.cache.clear();
117
+ }
118
+
119
+ /** Apply an explicit bounded row policy chosen by the owning tool mode. */
120
+ setViewport(viewport: ToolOutputViewport): void {
121
+ const next = normalizeViewport(viewport);
122
+ if (this.viewport.maxRows === next.maxRows && this.viewport.selection === next.selection) return;
123
+ this.viewport = next;
124
+ this.omissionRow = undefined;
125
+ this.renderStreams.clear();
126
+ this.cache.clear();
127
+ }
128
+
129
+ render(width: number): string[] {
130
+ const key = `${this.currentRevision}\0${this.viewport.maxRows}\0${this.viewport.selection}\0${this.streamSnapshot.totalBytes}\0${this.streamSnapshot.omittedBytes}\0${this.streamSnapshot.truncated}\0${this.plainAscii}`;
131
+ return this.cache.get(width, key, () => this.renderFresh(width));
132
+ }
133
+
134
+ private renderFresh(width: number): string[] {
135
+ this.omissionRow = undefined;
136
+ if (width <= 0) return [];
137
+ const maximum = this.viewport.maxRows;
138
+ const boundedText = this.renderText(maximum, width);
139
+ const visibleText = boundedText.replace(/\n$/, "");
140
+ const wrapped =
141
+ boundedText.length === 0
142
+ ? []
143
+ : this.plainAscii
144
+ ? wrapPlainAscii(visibleText, width)
145
+ : wrapTextWithAnsi(visibleText, width);
146
+ const prefix = this.streamSnapshot.truncated ? 1 : 0;
147
+ const available = maximum - prefix;
148
+ if (available === 0) {
149
+ const colors = tuiTheme(this.options.theme);
150
+ this.omissionRow = 0;
151
+ return [
152
+ truncateToWidth(
153
+ colors.fg("text.muted", `… ${this.streamSnapshot.omittedBytes} earlier characters discarded`),
154
+ width,
155
+ "…",
156
+ ),
157
+ ];
158
+ }
159
+ const bounded = boundRows(wrapped, available, width, this.options.theme, this.viewport.selection);
160
+ if (!this.streamSnapshot.truncated) {
161
+ this.omissionRow = bounded.omissionRow;
162
+ return bounded.lines;
163
+ }
164
+ const colors = tuiTheme(this.options.theme);
165
+ this.omissionRow = 0;
166
+ return [
167
+ truncateToWidth(
168
+ colors.fg("text.muted", `… ${this.streamSnapshot.omittedBytes} earlier characters discarded`),
169
+ width,
170
+ "…",
171
+ ),
172
+ ...bounded.lines,
173
+ ];
174
+ }
175
+
176
+ private renderText(rows: number, width: number): string {
177
+ // Two source code units per visible cell leaves room for ANSI and wide
178
+ // glyphs without rescanning retained history on every streamed frame.
179
+ const budget = Math.max(256, rows * Math.max(1, width) * 2);
180
+ const key = `${this.viewport.maxRows}\0${width}`;
181
+ let stream = this.renderStreams.get(key);
182
+ if (stream === undefined) {
183
+ if (this.renderStreams.size >= 4) this.renderStreams.clear();
184
+ stream = new BoundedStreamBuffer({
185
+ maxBytes: budget,
186
+ omissionMarker: () => "\n… output clipped before wrapping …\n",
187
+ });
188
+ stream.replaceFinal(this.streamSnapshot.text);
189
+ this.renderStreams.set(key, stream);
190
+ }
191
+ return stream.snapshot().text;
192
+ }
193
+
194
+ invalidate(): void {
195
+ this.cache.clear();
196
+ }
197
+ }
198
+
199
+ function finitePositive(value: number | undefined, fallback: number): number {
200
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : fallback;
201
+ }
202
+
203
+ function isPlainAscii(text: string): boolean {
204
+ return /^[\x20-\x7e\n]*$/u.test(text);
205
+ }
206
+
207
+ function wrapPlainAscii(text: string, width: number): string[] {
208
+ const rows: string[] = [];
209
+ for (const line of text.split("\n")) {
210
+ if (line.length === 0) rows.push("");
211
+ else for (let index = 0; index < line.length; index += width) rows.push(line.slice(index, index + width));
212
+ }
213
+ return rows;
214
+ }
215
+
216
+ interface BoundedRows {
217
+ readonly lines: string[];
218
+ readonly omissionRow?: number;
219
+ }
220
+
221
+ function boundRows(
222
+ lines: readonly string[],
223
+ maximum: number,
224
+ width: number,
225
+ theme: Theme,
226
+ selection: Required<ToolOutputViewport>["selection"],
227
+ ): BoundedRows {
228
+ if (lines.length <= maximum) return { lines: [...lines] };
229
+ const colors = tuiTheme(theme);
230
+ if (maximum === 1)
231
+ return {
232
+ lines: [truncateToWidth(colors.fg("text.muted", `… ${lines.length} rows`), width, "…")],
233
+ omissionRow: 0,
234
+ };
235
+ const visibleRows = maximum - 1;
236
+ if (selection === "head") {
237
+ return {
238
+ lines: [
239
+ ...lines.slice(0, visibleRows),
240
+ truncateToWidth(colors.fg("text.muted", `… ${lines.length - visibleRows} rows omitted …`), width, "…"),
241
+ ],
242
+ omissionRow: visibleRows,
243
+ };
244
+ }
245
+ if (selection === "tail") {
246
+ return {
247
+ lines: [
248
+ truncateToWidth(colors.fg("text.muted", `… ${lines.length - visibleRows} rows omitted …`), width, "…"),
249
+ ...lines.slice(-visibleRows),
250
+ ],
251
+ omissionRow: 0,
252
+ };
253
+ }
254
+ const head = Math.floor(visibleRows / 2);
255
+ const tail = visibleRows - head;
256
+ const omitted = lines.length - head - tail;
257
+ return {
258
+ lines: [
259
+ ...lines.slice(0, head),
260
+ truncateToWidth(colors.fg("text.muted", `… ${omitted} rows omitted …`), width, "…"),
261
+ ...lines.slice(lines.length - tail),
262
+ ],
263
+ omissionRow: head,
264
+ };
265
+ }
266
+
267
+ function normalizeViewport(viewport: ToolOutputViewport | undefined): Required<ToolOutputViewport> {
268
+ const requested = viewport?.maxRows ?? 6;
269
+ const maxRows = Number.isFinite(requested) ? Math.min(10_000, Math.max(1, Math.floor(requested))) : 6;
270
+ return { maxRows, selection: viewport?.selection ?? "head-tail" };
271
+ }
@@ -0,0 +1,150 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import { ComponentStack, type ComponentStackSpan } from "../component-stack.ts";
4
+ import { FOLD_TARGET_AT_ROW, type FoldTarget, type FoldTargetAtRow, foldTargetAt } from "../folding.ts";
5
+ import { TEXT_INTERACTION_TARGET, type TextInteractionTarget, type TuiMouseEvent } from "../mouse.ts";
6
+ import { ToolAction, type ToolActionView } from "./action.ts";
7
+
8
+ export interface ToolTranscriptOptions {
9
+ theme: Theme;
10
+ view?: ToolActionView;
11
+ action?: Component;
12
+ body?: readonly Component[];
13
+ maxRows?: number;
14
+ /** Columns of leading whitespace added to every payload row. */
15
+ bodyIndent?: number;
16
+ }
17
+
18
+ /**
19
+ * A copy-friendly tool transcript: one action sentence followed by its payload.
20
+ * Payload components render at the caller's width minus `bodyIndent`; the
21
+ * transcript does not impose a background.
22
+ */
23
+ export class ToolTranscript implements Component {
24
+ private readonly stack: ComponentStack;
25
+ private readonly maxRows: number | undefined;
26
+ private readonly bodyIndent: number;
27
+ private bodies: TranscriptBody[];
28
+
29
+ constructor(options: ToolTranscriptOptions) {
30
+ const action =
31
+ options.action ?? (options.view ? new ToolAction({ theme: options.theme, view: options.view }) : undefined);
32
+ if (!action) throw new Error("ToolTranscript requires an action or view");
33
+ this.bodyIndent = Math.max(0, Math.floor(options.bodyIndent ?? 0));
34
+ this.bodies = (options.body ?? []).map((component) => new TranscriptBody(component, this.bodyIndent));
35
+ this.maxRows = normalizedRows(options.maxRows);
36
+ this.stack = new ComponentStack([action, ...this.bodies], { maxHeight: this.maxRows });
37
+ }
38
+
39
+ /** Replace payload components while retaining wrappers for stable component identities. */
40
+ setBody(components: readonly Component[]): void {
41
+ const available = [...this.bodies];
42
+ this.bodies = components.map((component) => {
43
+ const existingIndex = available.findIndex((body) => body.wraps(component));
44
+ return existingIndex < 0
45
+ ? new TranscriptBody(component, this.bodyIndent)
46
+ : available.splice(existingIndex, 1)[0]!;
47
+ });
48
+ for (const body of available) body.dispose();
49
+ this.stack.setChildren([this.stack.getChildren()[0]!, ...this.bodies]);
50
+ }
51
+
52
+ render(width: number): string[] {
53
+ return this.stack.render(width);
54
+ }
55
+
56
+ handleViewportInput(data: string): boolean {
57
+ return this.stack.handleViewportInput(data);
58
+ }
59
+
60
+ onMouse(event: TuiMouseEvent): boolean {
61
+ return this.stack.onMouse(event);
62
+ }
63
+
64
+ get children(): readonly Component[] {
65
+ return this.stack.getChildren();
66
+ }
67
+
68
+ getSpans(): readonly ComponentStackSpan[] {
69
+ const spans = this.stack.getSpans();
70
+ if (this.maxRows === undefined) return spans;
71
+ return spans
72
+ .filter((span) => span.row < this.maxRows!)
73
+ .map((span) => ({ ...span, height: Math.min(span.height, this.maxRows! - span.row) }));
74
+ }
75
+
76
+ invalidate(): void {
77
+ this.stack.invalidate();
78
+ }
79
+
80
+ dispose(): void {
81
+ for (const component of new Set(this.stack.getChildren()))
82
+ (component as Component & { dispose?(): void }).dispose?.();
83
+ }
84
+ }
85
+
86
+ /** Structural payload wrapper that indents rows and preserves nested viewport and fold contracts. */
87
+ class TranscriptBody implements Component, TextInteractionTarget, FoldTargetAtRow {
88
+ readonly [TEXT_INTERACTION_TARGET] = true as const;
89
+ private readonly gutter: string;
90
+
91
+ constructor(
92
+ private component: Component,
93
+ private readonly indent: number,
94
+ ) {
95
+ this.gutter = " ".repeat(indent);
96
+ }
97
+
98
+ wraps(component: Component): boolean {
99
+ return this.component === component;
100
+ }
101
+
102
+ render(width: number): string[] {
103
+ const inner = Math.max(0, Math.floor(width) - this.indent);
104
+ if (this.indent === 0) return this.component.render(inner);
105
+ if (inner === 0) return [];
106
+ return this.component.render(inner).map((line) => `${this.gutter}${line}`);
107
+ }
108
+
109
+ invalidate(): void {
110
+ this.component.invalidate();
111
+ }
112
+
113
+ dispose(): void {
114
+ (this.component as Component & { dispose?(): void }).dispose?.();
115
+ }
116
+
117
+ setViewportFocus(focused: boolean): void {
118
+ this.interactive()?.setViewportFocus?.(focused);
119
+ }
120
+
121
+ isViewportFocused(): boolean {
122
+ return this.interactive()?.isViewportFocused?.() ?? false;
123
+ }
124
+
125
+ handleViewportInput(data: string): boolean {
126
+ return this.interactive()?.handleViewportInput?.(data) ?? false;
127
+ }
128
+
129
+ onMouse(event: TuiMouseEvent): boolean {
130
+ return this.interactive()?.onMouse?.({ ...event, col: event.col - this.indent }) ?? false;
131
+ }
132
+
133
+ [FOLD_TARGET_AT_ROW](row: number): FoldTarget | undefined {
134
+ return foldTargetAt(this.component, row);
135
+ }
136
+
137
+ private interactive() {
138
+ return this.component as Component & {
139
+ setViewportFocus?(focused: boolean): void;
140
+ isViewportFocused?(): boolean;
141
+ handleViewportInput?(data: string): boolean;
142
+ onMouse?(event: TuiMouseEvent): boolean;
143
+ };
144
+ }
145
+ }
146
+
147
+ function normalizedRows(rows: number | undefined): number | undefined {
148
+ if (rows === undefined || !Number.isFinite(rows)) return undefined;
149
+ return Math.max(0, Math.floor(rows));
150
+ }