pi-pretty-tui 0.2.9 → 0.3.1
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/README.md +52 -15
- package/README.zh-CN.md +52 -15
- package/extensions/ansi-style.ts +29 -0
- package/extensions/card-surface.ts +38 -0
- package/extensions/full-frame.ts +145 -0
- package/extensions/index.ts +437 -259
- package/extensions/nested-tools.ts +59 -13
- package/extensions/selection-ranges.ts +245 -0
- package/extensions/shell-highlight.ts +36 -0
- package/extensions/thought-presentation.ts +38 -0
- package/extensions/tool-presentation.ts +292 -0
- package/package.json +10 -4
|
@@ -53,11 +53,24 @@ const compactArgs = (value: string): string => {
|
|
|
53
53
|
export class NestedTools {
|
|
54
54
|
private calls = new Map<string, NestedCall>();
|
|
55
55
|
private incomplete = new Set<string>();
|
|
56
|
+
private renderCache = new Map<string, {
|
|
57
|
+
width: number; theme: any; palette: string; args: any; result: any;
|
|
58
|
+
expanded: boolean; partial: boolean; label: string; rootDetails: boolean;
|
|
59
|
+
projection: { lines: string[]; actions: Map<number, string> };
|
|
60
|
+
}>();
|
|
61
|
+
private countCache = new Map<string, number>();
|
|
62
|
+
private invalidateRender(rootId?: string) {
|
|
63
|
+
if (rootId) this.renderCache.delete(rootId);
|
|
64
|
+
else this.renderCache.clear();
|
|
65
|
+
this.countCache.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
// Local overrides can close one disclosure even after global expansion.
|
|
57
69
|
private expanded = new Map<string, boolean>();
|
|
58
|
-
clearExpansion() { this.expanded.clear(); }
|
|
70
|
+
clearExpansion() { this.expanded.clear(); this.invalidateRender(); }
|
|
59
71
|
clear() { this.calls.clear(); this.incomplete.clear(); this.clearExpansion(); }
|
|
60
72
|
toggle(key: string, globallyExpanded = false) {
|
|
73
|
+
this.invalidateRender(this.calls.has(key) ? this.root(key) : key.replace(/:(?:script|output)$/, ""));
|
|
61
74
|
this.expanded.set(key, !(this.expanded.get(key) ?? globallyExpanded));
|
|
62
75
|
}
|
|
63
76
|
private isExpanded(key: string, globallyExpanded: boolean): boolean {
|
|
@@ -74,15 +87,21 @@ export class NestedTools {
|
|
|
74
87
|
return [...this.calls.values()].filter((call) => call.parentId === parentId);
|
|
75
88
|
}
|
|
76
89
|
count(rootId: string): number {
|
|
77
|
-
|
|
90
|
+
const cached = this.countCache.get(rootId);
|
|
91
|
+
if (cached !== undefined) return cached;
|
|
92
|
+
const count = [...this.calls.values()].filter((call) => this.root(call.id) === rootId).length;
|
|
93
|
+
this.countCache.set(rootId, count);
|
|
94
|
+
return count;
|
|
78
95
|
}
|
|
79
96
|
start(event: any) {
|
|
80
97
|
if (!event.parentToolCallId || this.calls.has(event.toolCallId)) return;
|
|
81
98
|
const rootId = this.root(event.parentToolCallId);
|
|
99
|
+
this.invalidateRender(rootId);
|
|
82
100
|
if (this.count(rootId) >= NESTED_LIMITS.calls) { this.incomplete.add(rootId); return; }
|
|
83
101
|
const args = argsText(event.args);
|
|
84
102
|
const argsTruncated = args.length >= NESTED_LIMITS.args;
|
|
85
103
|
if (argsTruncated) this.incomplete.add(rootId);
|
|
104
|
+
this.countCache.clear();
|
|
86
105
|
this.calls.set(event.toolCallId, {
|
|
87
106
|
id: event.toolCallId, parentId: event.parentToolCallId, name: bounded(String(event.toolName ?? "tool"), 256),
|
|
88
107
|
args, argsTruncated, status: "running", startedAt: Date.now(),
|
|
@@ -91,6 +110,7 @@ export class NestedTools {
|
|
|
91
110
|
update(event: any, finished = false) {
|
|
92
111
|
const call = this.calls.get(event.toolCallId);
|
|
93
112
|
if (!call) return;
|
|
113
|
+
this.invalidateRender(this.root(call.id));
|
|
94
114
|
const text = resultText(finished ? event.result : event.partialResult);
|
|
95
115
|
const rootId = this.root(call.id);
|
|
96
116
|
const otherSize = [...this.calls.values()].filter((other) => other.id !== call.id && this.root(other.id) === rootId)
|
|
@@ -105,6 +125,7 @@ export class NestedTools {
|
|
|
105
125
|
}
|
|
106
126
|
}
|
|
107
127
|
finish(rootId: string): boolean {
|
|
128
|
+
this.invalidateRender(rootId);
|
|
108
129
|
let changed = false;
|
|
109
130
|
for (const call of this.calls.values()) {
|
|
110
131
|
if (this.root(call.id) === rootId && call.status === "running") {
|
|
@@ -124,6 +145,7 @@ export class NestedTools {
|
|
|
124
145
|
records = records.calls;
|
|
125
146
|
}
|
|
126
147
|
if (!Array.isArray(records)) return;
|
|
148
|
+
this.invalidateRender(rootId);
|
|
127
149
|
for (const record of records) {
|
|
128
150
|
if (typeof record?.id !== "string" || typeof record?.name !== "string" || record.id.endsWith("/?")) continue;
|
|
129
151
|
const existing = this.calls.get(record.id);
|
|
@@ -137,6 +159,7 @@ export class NestedTools {
|
|
|
137
159
|
const slash = record.id.lastIndexOf("/");
|
|
138
160
|
const candidate = record.id.slice(0, slash);
|
|
139
161
|
const parentId = this.calls.has(candidate) ? candidate : rootId;
|
|
162
|
+
this.countCache.clear();
|
|
140
163
|
this.calls.set(record.id, {
|
|
141
164
|
id: record.id, parentId, name: bounded(record.name, 256),
|
|
142
165
|
args: typeof record.args === "string" ? recordedArgs(record.args) : record.argumentsBytes !== undefined ? "[Arguments omitted by Pi]" : argsText(record.arguments),
|
|
@@ -172,6 +195,7 @@ export class NestedTools {
|
|
|
172
195
|
}
|
|
173
196
|
restore(data: any) {
|
|
174
197
|
if (data?.version !== 1 || typeof data.rootId !== "string" || !Array.isArray(data.calls)) return;
|
|
198
|
+
this.invalidateRender(data.rootId);
|
|
175
199
|
// Snapshot entries originate locally; still validate and bound every field.
|
|
176
200
|
let budget = NESTED_LIMITS.total;
|
|
177
201
|
for (const call of data.calls.slice(0, NESTED_LIMITS.calls)) {
|
|
@@ -186,13 +210,24 @@ export class NestedTools {
|
|
|
186
210
|
}
|
|
187
211
|
if (data.incomplete) this.incomplete.add(data.rootId);
|
|
188
212
|
}
|
|
189
|
-
render(owner: any, width: number, theme: any): { lines: string[]; actions: Map<number, string> } {
|
|
213
|
+
render(owner: any, width: number, theme: any, options: { rootDetails?: boolean } = {}): { lines: string[]; actions: Map<number, string> } {
|
|
190
214
|
const rootId = owner.toolCallId;
|
|
215
|
+
// Timer ticks and scrolling do not change tool content. Reuse wrapped
|
|
216
|
+
// detail rows and hit regions until data, layout, expansion or colors change.
|
|
217
|
+
const palette = ["dim", "muted", "text", "success", "error", "warning", "mdLink", "toolOutput"]
|
|
218
|
+
.map((role) => theme.fg(role, "x")).join("|") + theme.bold("x");
|
|
219
|
+
const expanded = Boolean(owner.expanded);
|
|
220
|
+
const partial = Boolean(owner.isPartial);
|
|
221
|
+
const rootDetails = options.rootDetails !== false;
|
|
222
|
+
const label = owner.toolDefinition?.label ?? owner.toolName;
|
|
223
|
+
const cached = this.renderCache.get(rootId);
|
|
224
|
+
if (cached && cached.width === width && cached.theme === theme && cached.palette === palette &&
|
|
225
|
+
cached.args === owner.args && cached.result === owner.result && cached.expanded === expanded &&
|
|
226
|
+
cached.partial === partial && cached.label === label && cached.rootDetails === rootDetails) return cached.projection;
|
|
191
227
|
const actions = new Map<number, string>();
|
|
192
228
|
const count = this.count(rootId);
|
|
193
229
|
const failed = [...this.calls.values()].filter((call) => this.root(call.id) === rootId && call.status === "error").length;
|
|
194
230
|
const color = owner.result?.isError ? "error" : owner.result && !owner.isPartial ? "success" : "dim";
|
|
195
|
-
const label = owner.toolDefinition?.label ?? owner.toolName;
|
|
196
231
|
const lines = [theme.fg(color, "● ") + theme.fg("text", theme.bold(label)) +
|
|
197
232
|
theme.fg("muted", `(${count} nested ${count === 1 ? "call" : "calls"}${failed ? ` · ${failed} failed` : ""})`)];
|
|
198
233
|
const detail = (text: string, prefix: string, action?: string) => {
|
|
@@ -210,7 +245,13 @@ export class NestedTools {
|
|
|
210
245
|
}
|
|
211
246
|
if (safeText(text).length > NESTED_LIMITS.output) push(theme.fg("dim", prefix + "[Preview truncated]"));
|
|
212
247
|
};
|
|
213
|
-
const
|
|
248
|
+
const childLists = new Map<string, NestedCall[]>();
|
|
249
|
+
for (const call of this.calls.values()) {
|
|
250
|
+
const siblings = childLists.get(call.parentId) ?? [];
|
|
251
|
+
siblings.push(call);
|
|
252
|
+
childLists.set(call.parentId, siblings);
|
|
253
|
+
}
|
|
254
|
+
const items = childLists.get(rootId) ?? [];
|
|
214
255
|
const visit = (call: NestedCall, indent: string, last: boolean, depth: number) => {
|
|
215
256
|
const prefix = indent + (last ? "└─ " : "├─ ");
|
|
216
257
|
const continuation = indent + (last ? " " : "│ ");
|
|
@@ -218,7 +259,7 @@ export class NestedTools {
|
|
|
218
259
|
const callColor = call.status === "ok" ? "success" : call.status === "error" ? "error" : call.status === "running" ? "dim" : "muted";
|
|
219
260
|
actions.set(lines.length, call.id);
|
|
220
261
|
const duration = call.durationMs === undefined ? "" : ` · ${call.durationMs < 1000 ? `${Math.round(call.durationMs)}ms` : `${(call.durationMs / 1000).toFixed(1)}s`}`;
|
|
221
|
-
const descendants =
|
|
262
|
+
const descendants = childLists.get(call.id) ?? [];
|
|
222
263
|
lines.push(theme.fg("dim", prefix) + theme.fg(callColor, icon + " ") + theme.fg("text", theme.bold(call.name)) + theme.fg("muted", `(${compactArgs(call.args)})${duration}`));
|
|
223
264
|
const expanded = this.isExpanded(call.id, Boolean(owner.expanded));
|
|
224
265
|
const first = call.output?.split("\n").find((line) => line.trim());
|
|
@@ -235,17 +276,22 @@ export class NestedTools {
|
|
|
235
276
|
else if (descendants.length) detail("[Deeper calls omitted]", continuation);
|
|
236
277
|
};
|
|
237
278
|
const code = typeof owner.args?.code === "string" ? owner.args.code : undefined;
|
|
238
|
-
if (code) {
|
|
279
|
+
if (code && options.rootDetails !== false) {
|
|
239
280
|
const key = `${rootId}:script`;
|
|
240
281
|
actions.set(lines.length, key); lines.push(theme.fg("dim", " ├─ ") + theme.fg("mdLink", "[Script]"));
|
|
241
282
|
if (this.isExpanded(key, Boolean(owner.expanded))) detail(code, " │ ", key);
|
|
242
283
|
}
|
|
243
|
-
items.forEach((call) => visit(call, " ", false, 0));
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
284
|
+
items.forEach((call, index) => visit(call, " ", options.rootDetails === false && index === items.length - 1, 0));
|
|
285
|
+
if (options.rootDetails !== false) {
|
|
286
|
+
const key = `${rootId}:output`;
|
|
287
|
+
actions.set(lines.length, key);
|
|
288
|
+
lines.push(theme.fg("dim", " └─ ") + theme.fg("mdLink", code ? "[Script output]" : "[Output]"));
|
|
289
|
+
if (this.isExpanded(key, Boolean(owner.expanded))) detail(resultText(owner.result) || (owner.isPartial || !owner.result ? "Running…" : "No text output"), " ", key);
|
|
290
|
+
}
|
|
248
291
|
if (this.incomplete.has(rootId)) lines.push(theme.fg("warning", " [Nested call record is incomplete or truncated]"));
|
|
249
|
-
|
|
292
|
+
const projection = { lines: lines.map((line) => truncateToWidth(line, Math.max(1, width), "…")), actions };
|
|
293
|
+
this.renderCache.set(rootId, { width, theme, palette, args: owner.args, result: owner.result,
|
|
294
|
+
expanded, partial, label, rootDetails, projection });
|
|
295
|
+
return projection;
|
|
250
296
|
}
|
|
251
297
|
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { ProcessTerminal, TuiAltScreen, sliceByColumn, stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
type Span = [number, number];
|
|
5
|
+
// Session-local APC annotations travel through opaque Container wrappers as
|
|
6
|
+
// zero-width text. They are removed before any terminal write or clipboard use.
|
|
7
|
+
const prefix = `\x1b_ppt-select-${randomBytes(6).toString("hex")}:`;
|
|
8
|
+
const suffix = "\x1b\\";
|
|
9
|
+
const marker = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "([0-9,;]*)\x1b\\\\", "g");
|
|
10
|
+
let enabled = false;
|
|
11
|
+
export const selectionMetadataActive = () => enabled;
|
|
12
|
+
export const removeSelectionMetadata = (text: string): string => text.includes(prefix) ? text.replace(marker, "") : text;
|
|
13
|
+
const subtract = (spans: Span[], start: number, end: number): Span[] => spans.flatMap(([a, b]) => {
|
|
14
|
+
if (end <= a || start >= b) return [[a, b] as Span];
|
|
15
|
+
const rest: Span[] = [];
|
|
16
|
+
if (a < start) rest.push([a, start]);
|
|
17
|
+
if (end < b) rest.push([end, b]);
|
|
18
|
+
return rest;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type Annotation = { width: number; excluded: Span[] };
|
|
22
|
+
const decoded = new Map<string, Annotation>();
|
|
23
|
+
const encoded = new Map<string, string>();
|
|
24
|
+
function annotation(payload: string): Annotation | undefined {
|
|
25
|
+
const cached = decoded.get(payload);
|
|
26
|
+
if (cached) return cached;
|
|
27
|
+
const fields = payload.split(";");
|
|
28
|
+
const width = Number(fields.shift());
|
|
29
|
+
if (!Number.isSafeInteger(width) || width < 0) return undefined;
|
|
30
|
+
const excluded: Span[] = [];
|
|
31
|
+
let cursor = 0;
|
|
32
|
+
for (const field of fields) {
|
|
33
|
+
if (!field) continue;
|
|
34
|
+
const values = field.split(",").map(Number);
|
|
35
|
+
if (values.length !== 2) return undefined;
|
|
36
|
+
const [a, b] = values;
|
|
37
|
+
if (!Number.isSafeInteger(a) || !Number.isSafeInteger(b) || a < cursor || b < a || b > width) return undefined;
|
|
38
|
+
if (cursor < a) excluded.push([cursor, a]);
|
|
39
|
+
cursor = b;
|
|
40
|
+
}
|
|
41
|
+
if (cursor < width) excluded.push([cursor, width]);
|
|
42
|
+
const result = { width, excluded };
|
|
43
|
+
if (decoded.size >= 1024) decoded.delete(decoded.keys().next().value!);
|
|
44
|
+
decoded.set(payload, result);
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Mark only known plugin decoration. Inner annotations are composed, never
|
|
49
|
+
* inferred from glyphs, so actual box art and code strings remain untouched. */
|
|
50
|
+
export function selectableRow(line: string, width: number, keep: Span[]): string {
|
|
51
|
+
if (!enabled || line.includes("\x1b_G") || line.includes("\x1b]1337;File=")) return line;
|
|
52
|
+
const payload = width + (keep.length ? ";" + keep.map(([a, b]) => a + "," + b).join(";") : "");
|
|
53
|
+
let tag = encoded.get(payload);
|
|
54
|
+
if (!tag) {
|
|
55
|
+
tag = prefix + payload + suffix;
|
|
56
|
+
if (encoded.size >= 1024) encoded.delete(encoded.keys().next().value!);
|
|
57
|
+
encoded.set(payload, tag);
|
|
58
|
+
}
|
|
59
|
+
return tag + line;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scrollBox(root: any, owner: any): any {
|
|
63
|
+
if (!root) return undefined;
|
|
64
|
+
if (root.scrollView === owner) return root;
|
|
65
|
+
for (const child of root.children ?? []) {
|
|
66
|
+
const found = scrollBox(child, owner);
|
|
67
|
+
if (found) return found;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function viewportRows(root: any, first: number, last: number, offsetX = 0, offsetY = 0, offscreen = false): Map<number, Array<{ line: string; x: number; left: number; right: number }>> {
|
|
72
|
+
const result = new Map<number, Array<{ line: string; x: number; left: number; right: number }>>();
|
|
73
|
+
const visit = (box: any) => {
|
|
74
|
+
if (!box?.rect || !box.clip) return;
|
|
75
|
+
const vertical = offscreen ? box.rect : box.clip;
|
|
76
|
+
if (vertical.y + offsetY > last || vertical.y + vertical.height + offsetY <= first) return;
|
|
77
|
+
if (box.children?.length) { for (const child of box.children) visit(child); return; }
|
|
78
|
+
if (!box.lines) return;
|
|
79
|
+
const start = Math.max(first - offsetY, box.rect.y, vertical.y);
|
|
80
|
+
const end = Math.min(last + 1 - offsetY, box.rect.y + box.rect.height, vertical.y + vertical.height);
|
|
81
|
+
for (let row = start; row < end; row++) {
|
|
82
|
+
const line = box.lines[row - box.rect.y + (box.lineOffset ?? 0)];
|
|
83
|
+
if (line === undefined) continue;
|
|
84
|
+
const records = result.get(row + offsetY) ?? [];
|
|
85
|
+
records.push({ line, x: box.rect.x + offsetX, left: Math.max(box.rect.x, box.clip.x) + offsetX,
|
|
86
|
+
right: Math.min(box.rect.x + box.rect.width, box.clip.x + box.clip.width) + offsetX });
|
|
87
|
+
result.set(row + offsetY, records);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
visit(root);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type Surface = { row: number; col: number; width: number; height: number; lines?: string[] };
|
|
95
|
+
|
|
96
|
+
/** Capture rows during the native compositor's single render, not by rendering
|
|
97
|
+
* a component again at copy time. Restore the exact previous own descriptor. */
|
|
98
|
+
function captureRender(target: any, name: string, receive: (rows: string[]) => void): () => void {
|
|
99
|
+
const method = target?.[name];
|
|
100
|
+
if (typeof method !== "function") return () => {};
|
|
101
|
+
const own = Object.getOwnPropertyDescriptor(target, name);
|
|
102
|
+
const capture = function (this: any, ...args: any[]) {
|
|
103
|
+
const result = method.apply(this, args);
|
|
104
|
+
if (Array.isArray(result)) receive(result);
|
|
105
|
+
return result;
|
|
106
|
+
};
|
|
107
|
+
Object.defineProperty(target, name, { configurable: true, writable: true, value: capture });
|
|
108
|
+
return () => {
|
|
109
|
+
if (target[name] !== capture) return;
|
|
110
|
+
if (own) Object.defineProperty(target, name, own);
|
|
111
|
+
else delete target[name];
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function excludedColumns(line: string): Span[] {
|
|
116
|
+
if (!line.includes(prefix)) return [];
|
|
117
|
+
const excluded: Span[] = [];
|
|
118
|
+
for (const match of line.matchAll(marker)) {
|
|
119
|
+
const tag = annotation(match[1]);
|
|
120
|
+
if (!tag) continue;
|
|
121
|
+
// A start annotation survives native right-edge clipping. Prefixes are
|
|
122
|
+
// normally only outer annotations and padding, so this stays inexpensive.
|
|
123
|
+
const origin = visibleWidth(line.slice(0, match.index));
|
|
124
|
+
for (const [a, b] of tag.excluded) excluded.push([origin + a, origin + b]);
|
|
125
|
+
}
|
|
126
|
+
return excluded;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function installSelectionCopy(): () => void {
|
|
130
|
+
const screen = TuiAltScreen.prototype as any;
|
|
131
|
+
const terminal = ProcessTerminal.prototype as any;
|
|
132
|
+
const key = Symbol.for("pretty-tui.selection-ranges");
|
|
133
|
+
if (screen[key]) return () => {};
|
|
134
|
+
const originalCopy = screen.getActiveSelectionText;
|
|
135
|
+
const originalWrite = terminal.write;
|
|
136
|
+
const originalOverlays = screen.compositeOverlays;
|
|
137
|
+
const originalFlashes = screen.compositeFlashes;
|
|
138
|
+
if (typeof originalCopy !== "function" || typeof originalWrite !== "function" ||
|
|
139
|
+
typeof originalOverlays !== "function" || typeof originalFlashes !== "function") return () => {};
|
|
140
|
+
const overlays = new WeakMap<object, Surface[]>();
|
|
141
|
+
const flashes = new WeakMap<object, Surface[]>();
|
|
142
|
+
const compositeOverlays = function (this: any, lines: string[], width: number, height: number) {
|
|
143
|
+
if (!this.overlayStack?.length) {
|
|
144
|
+
overlays.delete(this);
|
|
145
|
+
return originalOverlays.call(this, lines, width, height);
|
|
146
|
+
}
|
|
147
|
+
const captured: string[][] = [];
|
|
148
|
+
const restore = captureRender(this, "resolveFakeCursors", rows => captured.push(rows));
|
|
149
|
+
let result: string[];
|
|
150
|
+
try { result = originalOverlays.call(this, lines, width, height); }
|
|
151
|
+
finally { restore(); }
|
|
152
|
+
overlays.set(this, (this.renderedOverlayLayouts ?? []).map((box: Surface, i: number) => ({
|
|
153
|
+
row: box.row, col: box.col, width: box.width, height: box.height, lines: captured[i],
|
|
154
|
+
})));
|
|
155
|
+
return result;
|
|
156
|
+
};
|
|
157
|
+
const compositeFlashes = function (this: any, lines: string[], width: number, height: number) {
|
|
158
|
+
if (Array.isArray(this.flashes?.entries) && this.flashes.entries.length === 0) {
|
|
159
|
+
flashes.delete(this);
|
|
160
|
+
return originalFlashes.call(this, lines, width, height);
|
|
161
|
+
}
|
|
162
|
+
let captured: string[] = [];
|
|
163
|
+
const restore = captureRender(this.flashes, "render", rows => { captured = rows; });
|
|
164
|
+
let result: string[];
|
|
165
|
+
try { result = originalFlashes.call(this, lines, width, height); }
|
|
166
|
+
finally { restore(); }
|
|
167
|
+
flashes.set(this, captured.slice(-height).map((line, row) => ({
|
|
168
|
+
row, col: width - visibleWidth(line), width: visibleWidth(line), height: 1, lines: [line],
|
|
169
|
+
})));
|
|
170
|
+
return result;
|
|
171
|
+
};
|
|
172
|
+
const copy = function (this: any): string | undefined {
|
|
173
|
+
const selection = this.getSelectionBounds();
|
|
174
|
+
if (!selection) return undefined;
|
|
175
|
+
const inScroll = Boolean(selection.start.scrollView);
|
|
176
|
+
const scroll = inScroll ? scrollBox(this.currentLayout?.root, selection.start.scrollView) : undefined;
|
|
177
|
+
const lines: string[] | undefined = inScroll ? scroll?.scrollContentLines : this.previousScreen;
|
|
178
|
+
if (!lines) return originalCopy.call(this);
|
|
179
|
+
const viewport = inScroll
|
|
180
|
+
? viewportRows(scroll?.children?.[0], selection.start.row, selection.end.row,
|
|
181
|
+
-scroll.rect.x, selection.start.scrollView.scrollTop - scroll.rect.y, true)
|
|
182
|
+
: viewportRows(this.currentLayout?.root, selection.start.row, selection.end.row);
|
|
183
|
+
const surfaces: Surface[] = [];
|
|
184
|
+
if (!inScroll) {
|
|
185
|
+
const jump = this.scrollToEndIndicatorRect;
|
|
186
|
+
if (jump) surfaces.push({ row: jump.row, col: jump.column, width: jump.width, height: 1 });
|
|
187
|
+
surfaces.push(...overlays.get(this) ?? [], ...flashes.get(this) ?? []);
|
|
188
|
+
}
|
|
189
|
+
const copied: string[] = [];
|
|
190
|
+
for (let row = selection.start.row; row <= selection.end.row; row++) {
|
|
191
|
+
const line = lines[row] ?? "";
|
|
192
|
+
const columns = this.getSelectionColumns(line, row, selection);
|
|
193
|
+
// Composited/sliced metadata may no longer describe the full fragment.
|
|
194
|
+
// Prefer current raw leaves whenever layout information is available.
|
|
195
|
+
let excluded = this.currentLayout ? [] as Span[] : excludedColumns(line);
|
|
196
|
+
let annotated = line.includes(prefix);
|
|
197
|
+
const paint = (source: string | undefined, x: number, left: number, right: number) => {
|
|
198
|
+
if (right <= left) return;
|
|
199
|
+
// A later surface replaces lower decoration, including when the later
|
|
200
|
+
// surface is ordinary text with no private annotation at all.
|
|
201
|
+
excluded = subtract(excluded, left, right);
|
|
202
|
+
if (!source?.includes(prefix)) return;
|
|
203
|
+
annotated = true;
|
|
204
|
+
for (const [a, b] of excludedColumns(source)) {
|
|
205
|
+
const start = Math.max(left, x + a), end = Math.min(right, x + b);
|
|
206
|
+
if (end > start) excluded.push([start, end]);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
for (const record of viewport?.get(row) ?? []) paint(record.line, record.x, record.left, record.right);
|
|
210
|
+
for (const surface of surfaces) {
|
|
211
|
+
if (row < surface.row || row >= surface.row + surface.height) continue;
|
|
212
|
+
paint(surface.lines?.[row - surface.row], surface.col,
|
|
213
|
+
Math.max(0, surface.col), Math.min(visibleWidth(line), surface.col + surface.width));
|
|
214
|
+
}
|
|
215
|
+
let spans: Span[] = columns.end > columns.start ? [[columns.start, columns.end]] : [];
|
|
216
|
+
for (const [a, b] of excluded) spans = subtract(spans, a, b);
|
|
217
|
+
if (annotated && !spans.length) continue;
|
|
218
|
+
copied.push(spans.map(([a, b]) => stripTerminalSequences(sliceByColumn(line, a, Math.max(0, b - a), true))).join("").trimEnd());
|
|
219
|
+
}
|
|
220
|
+
const result = copied.join("\n");
|
|
221
|
+
return result.length ? result : undefined;
|
|
222
|
+
};
|
|
223
|
+
const write = function (this: any, data: string) { return originalWrite.call(this, removeSelectionMetadata(data)); };
|
|
224
|
+
const methods = [
|
|
225
|
+
[screen, "getActiveSelectionText", originalCopy, copy],
|
|
226
|
+
[screen, "compositeOverlays", originalOverlays, compositeOverlays],
|
|
227
|
+
[screen, "compositeFlashes", originalFlashes, compositeFlashes],
|
|
228
|
+
[terminal, "write", originalWrite, write],
|
|
229
|
+
] as const;
|
|
230
|
+
const originalOwn = methods.map(([target, name]) => Object.getOwnPropertyDescriptor(target, name));
|
|
231
|
+
screen[key] = { methods };
|
|
232
|
+
for (const [target, name, , patched] of methods) target[name] = patched;
|
|
233
|
+
enabled = true;
|
|
234
|
+
return () => {
|
|
235
|
+
enabled = false;
|
|
236
|
+
decoded.clear();
|
|
237
|
+
encoded.clear();
|
|
238
|
+
methods.forEach(([target, name, , patched], i) => {
|
|
239
|
+
if (target[name] !== patched) return;
|
|
240
|
+
if (originalOwn[i]) Object.defineProperty(target, name, originalOwn[i]!);
|
|
241
|
+
else delete target[name];
|
|
242
|
+
});
|
|
243
|
+
if (screen[key]?.methods === methods) delete screen[key];
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { highlightCode } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/** Shell highlighting only changes ANSI styling, never the command text. */
|
|
5
|
+
export function highlightShellCommand(command: string, theme: any, state: any): string {
|
|
6
|
+
const palette = ["syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable", "syntaxString",
|
|
7
|
+
"syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation"]
|
|
8
|
+
.map((role) => theme.fg(role, "x")).join("|");
|
|
9
|
+
const cached = state.prettyShellHighlight;
|
|
10
|
+
if (cached?.command === command && cached.palette === palette) return cached.text;
|
|
11
|
+
const highlighted = highlightCode(command, "bash").join("\n");
|
|
12
|
+
const text = stripTerminalSequences(highlighted) === command ? highlighted : command;
|
|
13
|
+
state.prettyShellHighlight = { command, palette, text };
|
|
14
|
+
return text;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Style Pi's native shell Text through its component contract, not instanceof:
|
|
18
|
+
* bundled/global/local Pi runtimes can carry distinct Text constructors. */
|
|
19
|
+
export function highlightNativeShellCall(owner: any, theme: any): void {
|
|
20
|
+
if (owner.toolName !== "bash") return;
|
|
21
|
+
const command = owner.args?.command;
|
|
22
|
+
const component = owner.callRendererComponent;
|
|
23
|
+
if (typeof command !== "string" || !command || typeof component?.text !== "string" ||
|
|
24
|
+
typeof component?.setText !== "function") return;
|
|
25
|
+
const state = owner.rendererState ??= {};
|
|
26
|
+
const previous = state.prettyShellCall;
|
|
27
|
+
const original = previous?.component === component && previous.command === command &&
|
|
28
|
+
previous.styled === component.text ? previous.original : component.text;
|
|
29
|
+
const promptStart = original.indexOf("$ " + command);
|
|
30
|
+
if (promptStart < 0) return;
|
|
31
|
+
const start = promptStart + 2;
|
|
32
|
+
const highlighted = highlightShellCommand(command, theme, state);
|
|
33
|
+
const text = original.slice(0, start) + highlighted + original.slice(start + command.length);
|
|
34
|
+
state.prettyShellCall = { component, command, original, styled: text };
|
|
35
|
+
if (text !== component.text) component.setText(text);
|
|
36
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Markdown, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/** Thought content is independent of activity grouping and mode defaults. */
|
|
4
|
+
export class ThoughtPresentation {
|
|
5
|
+
private cache = new WeakMap<object, {
|
|
6
|
+
text: string; theme: any; palette: string; markdownTheme: any; component: Markdown;
|
|
7
|
+
width?: number; lines?: string[];
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
header(label: string, width: number, theme: any): string {
|
|
11
|
+
return truncateToWidth(
|
|
12
|
+
theme.fg("mdLink", "● ") + theme.fg("text", theme.bold(label)),
|
|
13
|
+
Math.max(1, width), "…",
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
details(owner: any, text: string, theme: any, contentWidth: number): string[] {
|
|
18
|
+
const palette = ["dim", "muted", "mdHeading", "mdCode", "mdCodeBlock", "mdLink"]
|
|
19
|
+
.map((role) => theme.fg(role, "x")).join("|");
|
|
20
|
+
let detail = this.cache.get(owner);
|
|
21
|
+
if (!detail || detail.text !== text || detail.theme !== theme || detail.palette !== palette || detail.markdownTheme !== owner.markdownTheme) {
|
|
22
|
+
detail = {
|
|
23
|
+
text, theme, palette, markdownTheme: owner.markdownTheme,
|
|
24
|
+
component: new Markdown(text, 0, 0, owner.markdownTheme, {
|
|
25
|
+
color: (line: string) => theme.fg("muted", line), italic: true,
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
this.cache.set(owner, detail);
|
|
29
|
+
}
|
|
30
|
+
if (detail.width === contentWidth && detail.lines) return detail.lines;
|
|
31
|
+
const lines = detail.component.render(Math.max(1, contentWidth));
|
|
32
|
+
detail.width = contentWidth;
|
|
33
|
+
detail.lines = lines.map((line, index) =>
|
|
34
|
+
theme.fg("dim", index === lines.length - 1 ? " └ " : " │ ") + line,
|
|
35
|
+
);
|
|
36
|
+
return detail.lines;
|
|
37
|
+
}
|
|
38
|
+
}
|