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.
@@ -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
- return [...this.calls.values()].filter((call) => this.root(call.id) === rootId).length;
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 items = this.children(rootId);
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 = this.children(call.id);
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
- const key = `${rootId}:output`;
245
- actions.set(lines.length, key);
246
- lines.push(theme.fg("dim", " └─ ") + theme.fg("mdLink", code ? "[Script output]" : "[Output]"));
247
- if (this.isExpanded(key, Boolean(owner.expanded))) detail(resultText(owner.result) || (owner.isPartial || !owner.result ? "Running…" : "No text output"), " ", key);
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
- return { lines: lines.map((line) => truncateToWidth(line, Math.max(1, width), "…")), actions };
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
+ }