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.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +55 -0
- package/src/activity-presentation.ts +75 -0
- package/src/appearance.ts +632 -0
- package/src/background-surface.ts +125 -0
- package/src/color/palette.ts +186 -0
- package/src/color/preview.ts +157 -0
- package/src/color/resolver.ts +75 -0
- package/src/color/theme.ts +531 -0
- package/src/component-stack.ts +247 -0
- package/src/content/markdown-code.ts +60 -0
- package/src/content/terminal-text.ts +135 -0
- package/src/content/text.ts +105 -0
- package/src/controls/action-panel.ts +420 -0
- package/src/controls/dialog-button-bar.ts +287 -0
- package/src/controls/multi-select.ts +467 -0
- package/src/controls/picker-panel.ts +447 -0
- package/src/controls/screen-icon-actions.ts +134 -0
- package/src/controls/searchable-select.ts +394 -0
- package/src/controls/select-box.ts +224 -0
- package/src/controls/selectable-list.ts +433 -0
- package/src/controls/selection-action-bar.ts +382 -0
- package/src/controls/semantic-input.ts +23 -0
- package/src/controls/tab.ts +189 -0
- package/src/cursor.ts +288 -0
- package/src/decoration/editor-pills.ts +156 -0
- package/src/decoration/glyphs.ts +326 -0
- package/src/decoration/pointer-interaction.ts +123 -0
- package/src/decoration/powerline-pill.ts +104 -0
- package/src/decoration/status.ts +185 -0
- package/src/decoration/transient-pill.ts +176 -0
- package/src/diff/index.ts +21 -0
- package/src/diff/model.ts +76 -0
- package/src/diff/parse.ts +310 -0
- package/src/diff/pierre-syntax.ts +86 -0
- package/src/diff/render.ts +504 -0
- package/src/diff/structured.ts +264 -0
- package/src/diff/view.ts +75 -0
- package/src/editor/chrome.ts +341 -0
- package/src/editor/composition.ts +53 -0
- package/src/editor/layout.ts +81 -0
- package/src/editor/presentation.ts +35 -0
- package/src/editor/protocol.ts +188 -0
- package/src/editor.ts +41 -0
- package/src/extension.ts +30 -0
- package/src/folding.ts +192 -0
- package/src/host/cursor-bridge.ts +210 -0
- package/src/host/editor-bridge.ts +148 -0
- package/src/host/extension-host.ts +231 -0
- package/src/host/mouse-bridge.ts +212 -0
- package/src/host/mouse-dispatch.ts +400 -0
- package/src/host/mouse-input.ts +85 -0
- package/src/host/mouse-targets.ts +238 -0
- package/src/host/pi-layout-adapter.ts +298 -0
- package/src/host/pi-native-selection.ts +227 -0
- package/src/host/split-pane-bridge.ts +720 -0
- package/src/host/transcript-bridge.ts +236 -0
- package/src/index.ts +334 -0
- package/src/line-layout.ts +6 -0
- package/src/motion.ts +1409 -0
- package/src/mouse/events.ts +55 -0
- package/src/mouse/fullscreen-layout.ts +260 -0
- package/src/mouse/registry.ts +348 -0
- package/src/mouse.ts +37 -0
- package/src/native-binary.ts +189 -0
- package/src/overlay/anchored.ts +91 -0
- package/src/overlay/detail-card.ts +158 -0
- package/src/overlay/dialog.ts +266 -0
- package/src/overlay/floating.ts +106 -0
- package/src/overlay/framed-editor.ts +236 -0
- package/src/overlay/fullscreen.ts +170 -0
- package/src/overlay/hover-tooltip.ts +109 -0
- package/src/overlay/modal-mount.ts +118 -0
- package/src/panels.ts +207 -0
- package/src/render-cache.ts +80 -0
- package/src/render-epoch.ts +48 -0
- package/src/request-animation.ts +160 -0
- package/src/scrollbar.ts +27 -0
- package/src/selection.ts +148 -0
- package/src/split-pane.ts +232 -0
- package/src/status-presentation.ts +411 -0
- package/src/stream.ts +543 -0
- package/src/syntax.ts +316 -0
- package/src/terminal/bridge-client.ts +243 -0
- package/src/terminal/embedding.ts +6 -0
- package/src/terminal/output.ts +151 -0
- package/src/terminal/projection.ts +362 -0
- package/src/terminal/pty-host.ts +263 -0
- package/src/terminal/pty-pane.ts +308 -0
- package/src/terminal-colors.ts +334 -0
- package/src/tool/action.ts +170 -0
- package/src/tool/activity.ts +500 -0
- package/src/tool/call-preview.ts +44 -0
- package/src/tool/disclosure-action.ts +89 -0
- package/src/tool/expanded-region-viewport.ts +103 -0
- package/src/tool/index.ts +26 -0
- package/src/tool/output.ts +271 -0
- package/src/tool/transcript.ts +150 -0
- package/src/tool/view-region.ts +581 -0
- package/test/appearance.test.ts +183 -0
- package/test/bounded-stream.test.ts +150 -0
- package/test/bridge-client.test.ts +42 -0
- package/test/color-preview.test.ts +23 -0
- package/test/component-stack.test.ts +206 -0
- package/test/components.test.ts +1165 -0
- package/test/content-text.test.ts +21 -0
- package/test/cursor-bridge.test.ts +151 -0
- package/test/cursor.test.ts +147 -0
- package/test/editor-chrome.test.ts +119 -0
- package/test/editor-layout.test.ts +30 -0
- package/test/editor-registry.test.ts +203 -0
- package/test/editor-theme.test.ts +33 -0
- package/test/editor-token-pill.test.ts +72 -0
- package/test/floating-overlay.test.ts +57 -0
- package/test/folding.test.ts +147 -0
- package/test/harmonious-theme.test.ts +23 -0
- package/test/interaction.test.ts +104 -0
- package/test/key-icons.test.ts +62 -0
- package/test/markdown-code.test.ts +24 -0
- package/test/modal-overlay-mount.test.ts +94 -0
- package/test/motion.test.ts +649 -0
- package/test/mouse/bridge.test.ts +1302 -0
- package/test/mouse/extension.test.ts +205 -0
- package/test/mouse/registry.test.ts +140 -0
- package/test/mouse/screen-decoration.test.ts +167 -0
- package/test/native-binary.test.ts +52 -0
- package/test/palette.test.ts +32 -0
- package/test/panels.test.ts +74 -0
- package/test/pointer-components.test.ts +151 -0
- package/test/progress.test.ts +54 -0
- package/test/pty-host.test.ts +126 -0
- package/test/pty-pane.test.ts +62 -0
- package/test/public-surface.test.ts +75 -0
- package/test/render-cache.test.ts +25 -0
- package/test/render-epoch.test.ts +119 -0
- package/test/request-animation.test.ts +193 -0
- package/test/screen-icon-actions.test.ts +69 -0
- package/test/select-box.test.ts +86 -0
- package/test/select-pointer.test.ts +369 -0
- package/test/selectable-list.test.ts +258 -0
- package/test/selection/registry.test.ts +44 -0
- package/test/selection-action-bar.test.ts +204 -0
- package/test/split-pane-bridge.test.ts +944 -0
- package/test/split-pane.test.ts +134 -0
- package/test/status-presentation.test.ts +39 -0
- package/test/syntax.test.ts +59 -0
- package/test/terminal-colors.test.ts +115 -0
- package/test/terminal-embedding.test.ts +12 -0
- package/test/terminal-projection.test.ts +312 -0
- package/test/theme-colors.test.ts +175 -0
- package/test/tool-output.test.ts +160 -0
- package/test/tool-transcript.test.ts +1190 -0
- package/test/unified-diff.test.ts +691 -0
- package/themes/harmonious.json +61 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { BackgroundSurface, TOOL_SURFACE_BACKGROUND } from "../background-surface.ts";
|
|
4
|
+
import { type TuiBackgroundToken, tuiTheme } from "../color/theme.ts";
|
|
5
|
+
import { ComponentStack } from "../component-stack.ts";
|
|
6
|
+
import { sanitizeTuiText } from "../content/terminal-text.ts";
|
|
7
|
+
import { renderUnifiedDiff, type UnifiedDiffModel, UnifiedDiffView } from "../diff/index.ts";
|
|
8
|
+
import type { TuiMouseEvent } from "../mouse.ts";
|
|
9
|
+
import { getTuiRenderEpoch } from "../render-epoch.ts";
|
|
10
|
+
import { TerminalOutput } from "../terminal/output.ts";
|
|
11
|
+
import { LiveToolAction, type ToolActionView } from "./action.ts";
|
|
12
|
+
import { ToolDisclosureAction } from "./disclosure-action.ts";
|
|
13
|
+
import { ToolOutput, type ToolOutputViewport } from "./output.ts";
|
|
14
|
+
import { ToolTranscript } from "./transcript.ts";
|
|
15
|
+
import { ToolViewRegion } from "./view-region.ts";
|
|
16
|
+
|
|
17
|
+
export interface ToolActivityAction extends Component {
|
|
18
|
+
dispose?(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ToolOutputUpdate = "replace" | "cumulative";
|
|
22
|
+
export type TerminalOutputUpdate = ToolOutputUpdate | "cumulative-tail";
|
|
23
|
+
|
|
24
|
+
export type ToolActivityPayload =
|
|
25
|
+
| { kind: "text"; text: string; revision: number; update?: ToolOutputUpdate }
|
|
26
|
+
| {
|
|
27
|
+
kind: "terminal";
|
|
28
|
+
text: string;
|
|
29
|
+
revision?: number;
|
|
30
|
+
/** `cumulative-tail` guarantees a truncated cumulative PTY snapshot. */
|
|
31
|
+
update?: TerminalOutputUpdate;
|
|
32
|
+
}
|
|
33
|
+
| { kind: "diff"; model: UnifiedDiffModel }
|
|
34
|
+
| { kind: "component"; preview: Component; full?: Component; nextHint?: string };
|
|
35
|
+
|
|
36
|
+
export interface ToolActivityView {
|
|
37
|
+
action: ToolActionView;
|
|
38
|
+
running?: boolean;
|
|
39
|
+
payload?: ToolActivityPayload;
|
|
40
|
+
failure?: string;
|
|
41
|
+
mode?: "preview" | "full";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ToolActivityOptions {
|
|
45
|
+
theme: Theme;
|
|
46
|
+
view: ToolActivityView;
|
|
47
|
+
requestRender(): void;
|
|
48
|
+
previewRows?: number;
|
|
49
|
+
fullRows?: number;
|
|
50
|
+
/** Maximum total height of an expanded payload view. */
|
|
51
|
+
maxHeight?: number;
|
|
52
|
+
maxCharacters?: number;
|
|
53
|
+
/** Row selection used when bounded plain-text output is truncated. */
|
|
54
|
+
textSelection?: ToolOutputViewport["selection"];
|
|
55
|
+
/** Optional deliberate override for the shared multiline tool surface. */
|
|
56
|
+
surface?: TuiBackgroundToken;
|
|
57
|
+
/** Columns of leading whitespace under the action for every payload row. */
|
|
58
|
+
bodyIndent?: number;
|
|
59
|
+
/** Tool-specific action grammar; payload rendering remains generic. */
|
|
60
|
+
action?: ToolActivityAction;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Batteries-included streaming tool activity for plain and terminal output. */
|
|
64
|
+
export class ToolActivity implements Component {
|
|
65
|
+
private options: ToolActivityOptions;
|
|
66
|
+
private readonly requestRender = () => this.options.requestRender();
|
|
67
|
+
private action: ToolActivityAction;
|
|
68
|
+
private readonly disclosureAction: ToolDisclosureAction;
|
|
69
|
+
private readonly textOutput: ToolOutput;
|
|
70
|
+
private terminalOutput: TerminalOutput | undefined;
|
|
71
|
+
private terminalPayloadRevision: string | undefined;
|
|
72
|
+
private streamPayloadKind: "text" | "terminal" | undefined;
|
|
73
|
+
private region: ToolViewRegion;
|
|
74
|
+
private detachedRegionWasDisposed = false;
|
|
75
|
+
private transcript: ToolTranscript;
|
|
76
|
+
private readonly surfacedTranscript: BackgroundSurface | undefined;
|
|
77
|
+
private view: ToolActivityView;
|
|
78
|
+
private renderedPayloadWidth = 0;
|
|
79
|
+
private diffDisclosureWidth = 0;
|
|
80
|
+
private diffDisclosure = false;
|
|
81
|
+
private renderedEpoch = getTuiRenderEpoch();
|
|
82
|
+
|
|
83
|
+
constructor(options: ToolActivityOptions) {
|
|
84
|
+
this.options = options;
|
|
85
|
+
this.view = options.view;
|
|
86
|
+
this.action =
|
|
87
|
+
options.action ??
|
|
88
|
+
new LiveToolAction({
|
|
89
|
+
theme: options.theme,
|
|
90
|
+
view: options.view.action,
|
|
91
|
+
running: options.view.running,
|
|
92
|
+
requestRender: this.requestRender,
|
|
93
|
+
});
|
|
94
|
+
this.textOutput = new ToolOutput({
|
|
95
|
+
theme: options.theme,
|
|
96
|
+
viewport: { maxRows: previewRowsFor(options, 6), selection: options.textSelection ?? "head-tail" },
|
|
97
|
+
maxCharacters: options.maxCharacters,
|
|
98
|
+
});
|
|
99
|
+
this.replaceOutput();
|
|
100
|
+
this.region = this.createRegion();
|
|
101
|
+
this.setOutputRows(this.region.getMode());
|
|
102
|
+
this.disclosureAction = new ToolDisclosureAction(options.theme, this.action, this.region, this.requestRender);
|
|
103
|
+
this.transcript = this.createTranscript();
|
|
104
|
+
this.surfacedTranscript = new BackgroundSurface({
|
|
105
|
+
theme: options.theme,
|
|
106
|
+
component: this.transcript,
|
|
107
|
+
background: options.surface ?? TOOL_SURFACE_BACKGROUND,
|
|
108
|
+
minimumRows: 3,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Reuse Pi's previous component during streaming, or create the first instance. */
|
|
113
|
+
static reuse(previous: object | undefined, options: ToolActivityOptions): ToolActivity {
|
|
114
|
+
if (previous instanceof ToolActivity && previous.canReuse(options)) {
|
|
115
|
+
previous.options = options;
|
|
116
|
+
previous.update(options.view, options.action, true);
|
|
117
|
+
return previous;
|
|
118
|
+
}
|
|
119
|
+
if (previous instanceof ToolActivity) previous.dispose();
|
|
120
|
+
return new ToolActivity(options);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
update(view: ToolActivityView, action?: ToolActivityAction, replaceAction = false): void {
|
|
124
|
+
const previousPayload = this.view.payload;
|
|
125
|
+
const previouslyRenderedBody = hasRenderedBody(this.view);
|
|
126
|
+
const nextRenderedBody = hasRenderedBody(view);
|
|
127
|
+
const previewRows = previewRowsFor(this.options, 6);
|
|
128
|
+
const payloadChanged =
|
|
129
|
+
payloadStructureChanged(this.view.payload, view.payload) ||
|
|
130
|
+
payloadHasMore(this.view.payload, previewRows) !== payloadHasMore(view.payload, previewRows);
|
|
131
|
+
const modeChanged = view.mode !== this.view.mode;
|
|
132
|
+
const failureChanged = view.failure !== this.view.failure;
|
|
133
|
+
const currentMode = this.region.getMode();
|
|
134
|
+
this.view = view;
|
|
135
|
+
if (payloadChanged) {
|
|
136
|
+
this.diffDisclosureWidth = 0;
|
|
137
|
+
this.diffDisclosure = false;
|
|
138
|
+
}
|
|
139
|
+
let recreatedRegion = false;
|
|
140
|
+
if (!previouslyRenderedBody && nextRenderedBody && this.detachedRegionWasDisposed) {
|
|
141
|
+
this.region = this.createRegion();
|
|
142
|
+
this.disclosureAction.setRegion(this.region);
|
|
143
|
+
this.detachedRegionWasDisposed = false;
|
|
144
|
+
recreatedRegion = true;
|
|
145
|
+
}
|
|
146
|
+
if (replaceAction) {
|
|
147
|
+
const nextAction =
|
|
148
|
+
action ??
|
|
149
|
+
(this.action instanceof LiveToolAction
|
|
150
|
+
? this.action
|
|
151
|
+
: new LiveToolAction({
|
|
152
|
+
theme: this.options.theme,
|
|
153
|
+
view: view.action,
|
|
154
|
+
running: view.running,
|
|
155
|
+
requestRender: this.requestRender,
|
|
156
|
+
}));
|
|
157
|
+
if (nextAction !== this.action) {
|
|
158
|
+
this.disclosureAction.setAction(nextAction);
|
|
159
|
+
this.action = nextAction;
|
|
160
|
+
}
|
|
161
|
+
} else if (action && action !== this.action) {
|
|
162
|
+
this.disclosureAction.setAction(action);
|
|
163
|
+
this.action = action;
|
|
164
|
+
}
|
|
165
|
+
if (this.action instanceof LiveToolAction) this.action.update(view.action, view.running);
|
|
166
|
+
this.replaceOutput();
|
|
167
|
+
if ((payloadChanged || failureChanged) && !recreatedRegion)
|
|
168
|
+
this.updateRegionModes(modeChanged ? undefined : currentMode);
|
|
169
|
+
else this.region.invalidateExpanded();
|
|
170
|
+
if (modeChanged) this.region.setMode(view.mode ?? "preview");
|
|
171
|
+
this.setOutputRows(this.region.getMode());
|
|
172
|
+
this.transcript.setBody(this.bodyComponents());
|
|
173
|
+
// ToolTranscript disposes removed bodies even when the activity has not
|
|
174
|
+
// rendered yet. Remember every removal so a later payload cannot reuse the
|
|
175
|
+
// disposed region (or its disposed nested component).
|
|
176
|
+
if (previouslyRenderedBody && !nextRenderedBody) this.detachedRegionWasDisposed = true;
|
|
177
|
+
disposePayload(previousPayload, view.payload);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
render(width: number): string[] {
|
|
181
|
+
this.refreshForRenderEpoch();
|
|
182
|
+
const renderedWidth = Math.max(0, Math.floor(width));
|
|
183
|
+
const payloadWidth = Math.max(0, renderedWidth - this.bodyIndent());
|
|
184
|
+
this.renderedPayloadWidth = payloadWidth;
|
|
185
|
+
this.ensureWrappedTextDisclosure(payloadWidth);
|
|
186
|
+
this.ensureDiffDisclosure(payloadWidth);
|
|
187
|
+
if (this.region.isFolded() && payloadWidth > 0) {
|
|
188
|
+
const preview = this.region.render(payloadWidth);
|
|
189
|
+
this.region.ensureExpandedHeight(preview.length);
|
|
190
|
+
}
|
|
191
|
+
return (this.surfacedTranscript ?? this.transcript).render(width);
|
|
192
|
+
}
|
|
193
|
+
handleViewportInput(data: string): boolean {
|
|
194
|
+
return this.transcript.handleViewportInput(data);
|
|
195
|
+
}
|
|
196
|
+
onMouse(event: TuiMouseEvent): boolean {
|
|
197
|
+
return this.transcript.onMouse(event);
|
|
198
|
+
}
|
|
199
|
+
get children(): readonly Component[] {
|
|
200
|
+
return this.transcript.children;
|
|
201
|
+
}
|
|
202
|
+
getSpans() {
|
|
203
|
+
return this.transcript.getSpans();
|
|
204
|
+
}
|
|
205
|
+
invalidate(): void {
|
|
206
|
+
(this.surfacedTranscript ?? this.transcript).invalidate();
|
|
207
|
+
}
|
|
208
|
+
dispose(): void {
|
|
209
|
+
this.disclosureAction.dispose();
|
|
210
|
+
this.terminalOutput?.dispose();
|
|
211
|
+
disposePayload(this.view.payload);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private replaceOutput(): void {
|
|
215
|
+
const payload = this.view.payload;
|
|
216
|
+
const streamKind = payload?.kind === "text" || payload?.kind === "terminal" ? payload.kind : undefined;
|
|
217
|
+
if (streamKind !== this.streamPayloadKind) {
|
|
218
|
+
this.textOutput.reset();
|
|
219
|
+
this.terminalOutput?.setText("");
|
|
220
|
+
this.terminalPayloadRevision = undefined;
|
|
221
|
+
this.streamPayloadKind = streamKind;
|
|
222
|
+
} else if (payload?.kind !== "terminal") {
|
|
223
|
+
this.terminalPayloadRevision = undefined;
|
|
224
|
+
}
|
|
225
|
+
const output = payload?.kind === "text" || payload?.kind === "terminal" ? payload.text : "";
|
|
226
|
+
if (payload?.kind === "terminal") {
|
|
227
|
+
const revision =
|
|
228
|
+
payload.revision === undefined ? undefined : `${payload.revision}:${payload.update ?? "replace"}`;
|
|
229
|
+
if (revision !== undefined && revision === this.terminalPayloadRevision) return;
|
|
230
|
+
this.terminalPayloadRevision = revision;
|
|
231
|
+
const terminal = this.ensureTerminalOutput();
|
|
232
|
+
if (payload.update === "cumulative") terminal.appendCumulative(output);
|
|
233
|
+
else if (payload.update === "cumulative-tail") terminal.appendCumulativeTail(output);
|
|
234
|
+
else terminal.setText(output);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const revision = payload?.kind === "text" ? payload.revision : output.length;
|
|
238
|
+
if (payload?.kind === "text" && payload.update === "cumulative") {
|
|
239
|
+
this.textOutput.appendCumulative(output, revision);
|
|
240
|
+
} else {
|
|
241
|
+
this.textOutput.replace({ text: output, revision });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private refreshForRenderEpoch(): void {
|
|
246
|
+
const epoch = getTuiRenderEpoch();
|
|
247
|
+
if (epoch === this.renderedEpoch) return;
|
|
248
|
+
this.renderedEpoch = epoch;
|
|
249
|
+
const mode = this.region.getMode();
|
|
250
|
+
this.updateRegionModes(mode);
|
|
251
|
+
this.transcript.setBody(this.bodyComponents());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private createRegion(): ToolViewRegion {
|
|
255
|
+
const previewRows =
|
|
256
|
+
this.view.payload?.kind === "diff" ? previewRowsFor(this.options, 24) : previewRowsFor(this.options, 6);
|
|
257
|
+
const expandedRows = this.options.maxHeight ?? Math.max(21, previewRows + 2);
|
|
258
|
+
return new ToolViewRegion({
|
|
259
|
+
theme: this.options.theme,
|
|
260
|
+
modes: this.regionModes(),
|
|
261
|
+
initialMode: this.view.mode ?? "preview",
|
|
262
|
+
requestRender: this.requestRender,
|
|
263
|
+
onModeChange: (mode) => this.setOutputRows(mode),
|
|
264
|
+
disposeReplacedModes: false,
|
|
265
|
+
maxHeight: payloadHeight(expandedRows),
|
|
266
|
+
allowExpandedGrowth: this.options.maxHeight === undefined,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private ensureWrappedTextDisclosure(width: number): void {
|
|
271
|
+
const payload = this.view.payload;
|
|
272
|
+
if (width <= 0 || (payload?.kind !== "text" && payload?.kind !== "terminal")) return;
|
|
273
|
+
const shouldExpand = hasMoreText(payload.text, previewRowsFor(this.options, 6), width);
|
|
274
|
+
if (this.region.isExpanded() || shouldExpand === this.region.canExpand()) return;
|
|
275
|
+
this.updateRegionModes(this.region.getMode(), shouldExpand);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private ensureDiffDisclosure(width: number): void {
|
|
279
|
+
const payload = this.view.payload;
|
|
280
|
+
if (width <= 0 || payload?.kind !== "diff" || this.region.isExpanded() || width === this.diffDisclosureWidth)
|
|
281
|
+
return;
|
|
282
|
+
const previewRows = previewRowsFor(this.options, 24);
|
|
283
|
+
const fullRows = this.options.fullRows ?? 500;
|
|
284
|
+
const theme = tuiTheme(this.options.theme);
|
|
285
|
+
const surface = this.options.surface ?? TOOL_SURFACE_BACKGROUND;
|
|
286
|
+
const preview = renderUnifiedDiff(payload.model, {
|
|
287
|
+
width,
|
|
288
|
+
theme,
|
|
289
|
+
surface,
|
|
290
|
+
viewport: { maxRows: previewRows, selection: this.view.running ? "tail" : "head-tail" },
|
|
291
|
+
maxRenderedRows: previewRows + 1,
|
|
292
|
+
});
|
|
293
|
+
const full = renderUnifiedDiff(payload.model, {
|
|
294
|
+
width,
|
|
295
|
+
theme,
|
|
296
|
+
surface,
|
|
297
|
+
viewport: { maxRows: fullRows, selection: "head-tail" },
|
|
298
|
+
maxRenderedRows: fullRows + 1,
|
|
299
|
+
});
|
|
300
|
+
const shouldExpand = preview.omittedRows > full.omittedRows || preview.lines.length < full.lines.length;
|
|
301
|
+
this.diffDisclosureWidth = width;
|
|
302
|
+
if (shouldExpand === this.diffDisclosure) return;
|
|
303
|
+
this.diffDisclosure = shouldExpand;
|
|
304
|
+
this.updateRegionModes(this.region.getMode());
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private updateRegionModes(mode?: string, forceTextDisclosure = false): void {
|
|
308
|
+
this.region.updateModes(this.regionModes(forceTextDisclosure), mode);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private regionModes(forceTextDisclosure = false) {
|
|
312
|
+
const payload = this.view.payload;
|
|
313
|
+
let preview: Component = this.textOutput;
|
|
314
|
+
let full: Component = this.textOutput;
|
|
315
|
+
let nextHint: string | undefined;
|
|
316
|
+
let activationRow: "omission" | undefined;
|
|
317
|
+
let foldable = false;
|
|
318
|
+
const previewRows = previewRowsFor(this.options, 6);
|
|
319
|
+
const diffPreviewRows = previewRowsFor(this.options, 24);
|
|
320
|
+
if (
|
|
321
|
+
(payload?.kind === "text" || payload?.kind === "terminal") &&
|
|
322
|
+
(forceTextDisclosure || hasMoreText(payload.text, previewRows, this.renderedPayloadWidth))
|
|
323
|
+
) {
|
|
324
|
+
foldable = true;
|
|
325
|
+
activationRow = "omission";
|
|
326
|
+
}
|
|
327
|
+
if (payload?.kind === "terminal") preview = full = this.ensureTerminalOutput();
|
|
328
|
+
else if (payload?.kind === "diff") {
|
|
329
|
+
preview = new UnifiedDiffView({
|
|
330
|
+
model: payload.model,
|
|
331
|
+
theme: tuiTheme(this.options.theme),
|
|
332
|
+
requestRender: this.requestRender,
|
|
333
|
+
surface: this.options.surface ?? TOOL_SURFACE_BACKGROUND,
|
|
334
|
+
viewport: { maxRows: diffPreviewRows, selection: this.view.running ? "tail" : "head-tail" },
|
|
335
|
+
maxRenderedRows: diffPreviewRows + 1,
|
|
336
|
+
});
|
|
337
|
+
const fullRows = this.options.fullRows ?? 500;
|
|
338
|
+
full = new UnifiedDiffView({
|
|
339
|
+
model: payload.model,
|
|
340
|
+
theme: tuiTheme(this.options.theme),
|
|
341
|
+
requestRender: this.requestRender,
|
|
342
|
+
surface: this.options.surface ?? TOOL_SURFACE_BACKGROUND,
|
|
343
|
+
viewport: { maxRows: fullRows, selection: "head-tail" },
|
|
344
|
+
maxRenderedRows: fullRows + 1,
|
|
345
|
+
});
|
|
346
|
+
foldable = this.diffDisclosure;
|
|
347
|
+
if (foldable) activationRow = "omission";
|
|
348
|
+
} else if (payload?.kind === "component") {
|
|
349
|
+
preview = payload.preview;
|
|
350
|
+
full = payload.full ?? payload.preview;
|
|
351
|
+
foldable = Boolean(payload.full && payload.full !== payload.preview);
|
|
352
|
+
nextHint = payload.nextHint;
|
|
353
|
+
}
|
|
354
|
+
if (this.view.failure) {
|
|
355
|
+
foldable = true;
|
|
356
|
+
const error = textComponent(this.view.failure);
|
|
357
|
+
full = payload ? new ComponentStack([full, error]) : error;
|
|
358
|
+
if (!payload) preview = textComponent("");
|
|
359
|
+
}
|
|
360
|
+
const modes = [
|
|
361
|
+
{
|
|
362
|
+
id: "preview",
|
|
363
|
+
component: preview,
|
|
364
|
+
nextHint,
|
|
365
|
+
activationRow,
|
|
366
|
+
activate: "full",
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
id: "full",
|
|
370
|
+
component: full,
|
|
371
|
+
activate: "preview",
|
|
372
|
+
},
|
|
373
|
+
];
|
|
374
|
+
return foldable ? modes : modes.slice(0, 1);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private rowsForMode(mode: string = this.view.mode ?? "preview"): number {
|
|
378
|
+
if (mode === "full") return this.options.fullRows ?? 500;
|
|
379
|
+
return this.view.payload?.kind === "diff" ? previewRowsFor(this.options, 24) : previewRowsFor(this.options, 6);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private setOutputRows(mode: string): void {
|
|
383
|
+
const maxRows = this.rowsForMode(mode);
|
|
384
|
+
this.textOutput.setViewport({ maxRows, selection: this.options.textSelection ?? "head-tail" });
|
|
385
|
+
this.terminalOutput?.setMaxRows(maxRows);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private createTranscript(): ToolTranscript {
|
|
389
|
+
return new ToolTranscript({
|
|
390
|
+
theme: this.options.theme,
|
|
391
|
+
action: this.disclosureAction,
|
|
392
|
+
body: this.bodyComponents(),
|
|
393
|
+
maxRows: this.options.maxHeight ?? (this.options.fullRows ?? 500) + 20,
|
|
394
|
+
bodyIndent: this.bodyIndent(),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private bodyIndent(): number {
|
|
399
|
+
return Math.max(0, Math.floor(this.options.bodyIndent ?? 0));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private bodyComponents(): Component[] {
|
|
403
|
+
return hasRenderedBody(this.view) ? [this.region] : [];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private ensureTerminalOutput(): TerminalOutput {
|
|
407
|
+
if (!this.terminalOutput) {
|
|
408
|
+
this.terminalOutput = new TerminalOutput({
|
|
409
|
+
requestRender: this.requestRender,
|
|
410
|
+
maxRows: this.rowsForMode(),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return this.terminalOutput;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private canReuse(options: ToolActivityOptions): boolean {
|
|
417
|
+
return (
|
|
418
|
+
this.options.theme === options.theme &&
|
|
419
|
+
this.options.previewRows === options.previewRows &&
|
|
420
|
+
this.options.fullRows === options.fullRows &&
|
|
421
|
+
this.options.maxHeight === options.maxHeight &&
|
|
422
|
+
this.options.maxCharacters === options.maxCharacters &&
|
|
423
|
+
(this.options.textSelection ?? "head-tail") === (options.textSelection ?? "head-tail") &&
|
|
424
|
+
(this.options.bodyIndent ?? 0) === (options.bodyIndent ?? 0) &&
|
|
425
|
+
(this.options.surface ?? TOOL_SURFACE_BACKGROUND) === (options.surface ?? TOOL_SURFACE_BACKGROUND)
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function hasMoreText(text: string, previewRows: number, width?: number): boolean {
|
|
431
|
+
if (width !== undefined && width > 0) {
|
|
432
|
+
// Disclosure needs only a conservative answer; never wrap hidden output during animated renders.
|
|
433
|
+
const budget = Math.max(256, Math.max(1, Math.floor(previewRows)) * Math.max(1, Math.floor(width)) * 2);
|
|
434
|
+
if (text.length > budget) return true;
|
|
435
|
+
const visibleText = sanitizeTuiText(text).replace(/\n$/u, "");
|
|
436
|
+
const wrapped = visibleText.length === 0 ? [] : wrapTextWithAnsi(visibleText, width);
|
|
437
|
+
return wrapped.length > previewRows;
|
|
438
|
+
}
|
|
439
|
+
if (text.length > previewRows * 160) return true;
|
|
440
|
+
let rows = text.length === 0 ? 0 : 1;
|
|
441
|
+
for (let index = 0; index < text.length && rows <= previewRows; index += 1) {
|
|
442
|
+
if (text.charCodeAt(index) === 10 && index < text.length - 1) rows += 1;
|
|
443
|
+
}
|
|
444
|
+
return rows > previewRows;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function previewRowsFor(options: Pick<ToolActivityOptions, "previewRows" | "maxHeight">, defaultRows: number): number {
|
|
448
|
+
const requested = options.previewRows ?? defaultRows;
|
|
449
|
+
const available =
|
|
450
|
+
options.maxHeight === undefined || !Number.isFinite(options.maxHeight)
|
|
451
|
+
? requested
|
|
452
|
+
: Math.max(1, Math.floor(options.maxHeight) - 1);
|
|
453
|
+
return Math.max(1, Math.min(requested, available));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function payloadHeight(maxHeight: number | undefined): number | undefined {
|
|
457
|
+
if (maxHeight === undefined || !Number.isFinite(maxHeight)) return maxHeight;
|
|
458
|
+
// ToolActivity's action is the fold header, but lives outside the payload
|
|
459
|
+
// region. Reserve exactly one row for it when callers bound the whole fold.
|
|
460
|
+
return Math.max(1, Math.floor(maxHeight) - 1);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function hasRenderedBody(view: ToolActivityView): boolean {
|
|
464
|
+
return view.payload !== undefined || Boolean(view.failure);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function payloadHasMore(payload: ToolActivityPayload | undefined, previewRows: number): boolean {
|
|
468
|
+
if (payload?.kind === "text" || payload?.kind === "terminal") return hasMoreText(payload.text, previewRows);
|
|
469
|
+
return payload?.kind === "component" && Boolean(payload.full && payload.full !== payload.preview);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function payloadStructureChanged(
|
|
473
|
+
left: ToolActivityPayload | undefined,
|
|
474
|
+
right: ToolActivityPayload | undefined,
|
|
475
|
+
): boolean {
|
|
476
|
+
if (left?.kind !== right?.kind) return true;
|
|
477
|
+
if (left?.kind === "diff" && right?.kind === "diff") return left.model !== right.model;
|
|
478
|
+
if (left?.kind === "component" && right?.kind === "component") {
|
|
479
|
+
return left.preview !== right.preview || left.full !== right.full || left.nextHint !== right.nextHint;
|
|
480
|
+
}
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function textComponent(text: string): Component {
|
|
485
|
+
const safeText = sanitizeTuiText(text);
|
|
486
|
+
return { render: () => (safeText ? safeText.split("\n") : []), invalidate() {} };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function disposePayload(payload: ToolActivityPayload | undefined, retainedPayload?: ToolActivityPayload): void {
|
|
490
|
+
if (payload?.kind !== "component") return;
|
|
491
|
+
const retained = payloadComponents(retainedPayload);
|
|
492
|
+
for (const component of payloadComponents(payload)) {
|
|
493
|
+
if (!retained.has(component)) (component as Component & { dispose?(): void }).dispose?.();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function payloadComponents(payload: ToolActivityPayload | undefined): Set<Component> {
|
|
498
|
+
if (payload?.kind !== "component") return new Set();
|
|
499
|
+
return new Set([payload.preview, ...(payload.full ? [payload.full] : [])]);
|
|
500
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
const pendingCalls = new WeakMap<object, ToolCallPreview>();
|
|
4
|
+
|
|
5
|
+
/** Provisional call-row ownership shared by Pi tool renderers and replay. */
|
|
6
|
+
export class ToolCallPreview implements Component {
|
|
7
|
+
private hidden = false;
|
|
8
|
+
|
|
9
|
+
constructor(private readonly component: Component) {}
|
|
10
|
+
|
|
11
|
+
render(width: number): string[] {
|
|
12
|
+
return this.hidden ? [] : this.component.render(width);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
invalidate(): void {
|
|
16
|
+
if (!this.hidden) this.component.invalidate();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
hide(): void {
|
|
20
|
+
if (this.hidden) return;
|
|
21
|
+
this.hidden = true;
|
|
22
|
+
(this.component as Component & { dispose?(): void }).dispose?.();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
dispose(): void {
|
|
26
|
+
this.hide();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Register the row shown before a result exists for this renderer state. */
|
|
31
|
+
export function toolCallPreview(state: object, component: Component): ToolCallPreview {
|
|
32
|
+
const preview = new ToolCallPreview(component);
|
|
33
|
+
pendingCalls.get(state)?.hide();
|
|
34
|
+
pendingCalls.set(state, preview);
|
|
35
|
+
return preview;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Hide a provisional call row before rendering its partial, final, or restored result. */
|
|
39
|
+
export function settleToolCallPreview(state: object): void {
|
|
40
|
+
const preview = pendingCalls.get(state);
|
|
41
|
+
if (!preview) return;
|
|
42
|
+
preview.hide();
|
|
43
|
+
pendingCalls.delete(state);
|
|
44
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { tuiTheme } from "../color/theme.ts";
|
|
5
|
+
import { TEXT_INTERACTION_TARGET, type TextInteractionTarget, type TuiMouseEvent } from "../mouse.ts";
|
|
6
|
+
import { appendFoldingChevron, type ToolViewRegion } from "./view-region.ts";
|
|
7
|
+
|
|
8
|
+
/** Make an existing action row the primary disclosure control without owning fold state. */
|
|
9
|
+
export class ToolDisclosureAction implements Component, TextInteractionTarget {
|
|
10
|
+
readonly [TEXT_INTERACTION_TARGET] = true as const;
|
|
11
|
+
private width = 0;
|
|
12
|
+
private height = 1;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly theme: Theme,
|
|
16
|
+
private action: Component,
|
|
17
|
+
private region: ToolViewRegion,
|
|
18
|
+
private readonly requestRender: () => void,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
setAction(action: Component): void {
|
|
22
|
+
if (action === this.action) return;
|
|
23
|
+
(this.action as Component & { dispose?(): void }).dispose?.();
|
|
24
|
+
this.action = action;
|
|
25
|
+
this.requestRender();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Semantic header only, for enclosing activity sections; never renders the payload. */
|
|
29
|
+
getActivityLabel(): string {
|
|
30
|
+
return this.action
|
|
31
|
+
.render(100)
|
|
32
|
+
.map((line) => stripTerminalSequences(line).trim())
|
|
33
|
+
.join(" ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setRegion(region: ToolViewRegion): void {
|
|
37
|
+
if (region === this.region) return;
|
|
38
|
+
const focused = this.region.isViewportFocused();
|
|
39
|
+
this.region = region;
|
|
40
|
+
if (focused) region.setViewportFocus(true);
|
|
41
|
+
this.requestRender();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
render(width: number): string[] {
|
|
45
|
+
this.width = Math.max(0, Math.floor(width));
|
|
46
|
+
const lines = this.action.render(this.width);
|
|
47
|
+
this.height = Math.max(1, lines.length);
|
|
48
|
+
const headerCanOpen = this.region.isFolded() && !this.region.hasCollapsedPrimaryTarget();
|
|
49
|
+
const decorated =
|
|
50
|
+
this.region.canExpand() && (this.region.isExpanded() || headerCanOpen) && lines.length > 0
|
|
51
|
+
? [appendFoldingChevron(lines[0]!, this.width, this.theme, this.region.isExpanded()), ...lines.slice(1)]
|
|
52
|
+
: lines;
|
|
53
|
+
if (!this.region.isHeaderHovered() || !this.region.canExpand()) return decorated;
|
|
54
|
+
const colors = tuiTheme(this.theme);
|
|
55
|
+
const foreground = colors.contrastBackground(colors.color("surface.hover"));
|
|
56
|
+
return decorated.map((line) => {
|
|
57
|
+
const plain = stripTerminalSequences(line);
|
|
58
|
+
return colors.bg(
|
|
59
|
+
"surface.hover",
|
|
60
|
+
colors.fg(foreground, `${plain}${" ".repeat(Math.max(0, this.width - visibleWidth(plain)))}`),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setViewportFocus(focused: boolean): void {
|
|
66
|
+
this.region.setViewportFocus(focused);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
isViewportFocused(): boolean {
|
|
70
|
+
return this.region.isViewportFocused();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
onMouse(event: TuiMouseEvent): boolean {
|
|
74
|
+
return this.region.onHeaderMouse(event, this.width, this.height);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
handleViewportInput(data: string): boolean {
|
|
78
|
+
return this.region.handleHeaderInput(data);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
invalidate(): void {
|
|
82
|
+
this.action.invalidate();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
dispose(): void {
|
|
86
|
+
(this.action as Component & { dispose?(): void }).dispose?.();
|
|
87
|
+
this.region.dispose();
|
|
88
|
+
}
|
|
89
|
+
}
|