pi-compact-tools 0.13.1 → 0.14.0
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 -26
- package/extensions/compact-tools-activity.ts +10 -2
- package/extensions/compact-tools-claude.ts +20 -26
- package/extensions/compact-tools-codex-rows.ts +465 -0
- package/extensions/compact-tools-codex.ts +453 -0
- package/extensions/compact-tools-command.ts +196 -0
- package/extensions/compact-tools-custom.ts +9 -0
- package/extensions/compact-tools-grouping.ts +130 -31
- package/extensions/compact-tools-invocation.ts +30 -1
- package/extensions/compact-tools-layout.ts +124 -27
- package/extensions/compact-tools-palette.ts +6 -7
- package/extensions/compact-tools-release.ts +30 -7
- package/extensions/compact-tools-runtime.ts +16 -0
- package/extensions/compact-tools-shell.ts +277 -0
- package/extensions/compact-tools-silent.ts +4 -1
- package/extensions/compact-tools-types.ts +10 -2
- package/extensions/compact-tools-viewport.ts +5 -7
- package/extensions/compact-tools.ts +115 -38
- package/package.json +10 -6
- package/release-notes.json +23 -0
- package/schemas/compact-tools.schema.json +3 -3
- package/scripts/check-resources.mjs +0 -82
|
@@ -5,8 +5,24 @@ import {
|
|
|
5
5
|
type ExtensionContext,
|
|
6
6
|
type Theme,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { Container, Spacer, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
9
|
-
import { claudeFailure, lookHint, lookKind, pathOf, type LookKind } from "./compact-tools-claude.ts";
|
|
8
|
+
import { Container, Spacer, Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
9
|
+
import { CLAUDE_RESULT_PREFIX, claudeFailure, lookHint, lookKind, pathOf, type LookKind } from "./compact-tools-claude.ts";
|
|
10
|
+
import {
|
|
11
|
+
CODEX_BULLET,
|
|
12
|
+
CODEX_INDENT,
|
|
13
|
+
CODEX_MARGIN,
|
|
14
|
+
CODEX_PREVIEW_ROWS,
|
|
15
|
+
CODEX_RESULT_PREFIX,
|
|
16
|
+
codexDisclosure,
|
|
17
|
+
exploreActions,
|
|
18
|
+
exploreFailure,
|
|
19
|
+
exploreLines,
|
|
20
|
+
openExplorations,
|
|
21
|
+
paintSegments,
|
|
22
|
+
wrapSegments,
|
|
23
|
+
type ExploreAction,
|
|
24
|
+
type Segment,
|
|
25
|
+
} from "./compact-tools-codex.ts";
|
|
10
26
|
import { formatDurationMs, type RowStatus } from "./compact-tools-core.ts";
|
|
11
27
|
import { hookMethod } from "./compact-tools-hook.ts";
|
|
12
28
|
import { getTextResult } from "./compact-tools-invocation.ts";
|
|
@@ -25,7 +41,8 @@ const PHRASES: ReadonlyArray<{ kind: LookKind; running: string; done: string; si
|
|
|
25
41
|
{ kind: "list", running: "listing", done: "listed", singular: "directory", plural: "directories" },
|
|
26
42
|
];
|
|
27
43
|
|
|
28
|
-
|
|
44
|
+
/** A click opens one group; Ctrl+O opens every row and its output, which is rarely what a reader wants here. */
|
|
45
|
+
export const EXPAND_HINT = "(click to expand)";
|
|
29
46
|
|
|
30
47
|
/** The fields of Pi's tool row a group summary reads. */
|
|
31
48
|
export interface ToolRowLike {
|
|
@@ -38,6 +55,7 @@ export interface ToolRowLike {
|
|
|
38
55
|
render(width: number): string[];
|
|
39
56
|
handleMouse?(event: unknown): unknown;
|
|
40
57
|
invalidate(): void;
|
|
58
|
+
setExpanded?(expanded: boolean): void;
|
|
41
59
|
}
|
|
42
60
|
|
|
43
61
|
/** Whether a call without its result can still get one; the row itself answers the same way. */
|
|
@@ -71,6 +89,17 @@ function kindOf(row: ToolRowLike): LookKind | undefined {
|
|
|
71
89
|
return kind;
|
|
72
90
|
}
|
|
73
91
|
|
|
92
|
+
// Codex parses a command into what it does, once for each set of arguments as they stream in.
|
|
93
|
+
const explorations = new WeakMap<object, { args: unknown; actions: ExploreAction[] | undefined }>();
|
|
94
|
+
|
|
95
|
+
function exploreOf(row: ToolRowLike): ExploreAction[] | undefined {
|
|
96
|
+
const cached = explorations.get(row);
|
|
97
|
+
if (cached && cached.args === row.args) return cached.actions;
|
|
98
|
+
const actions = exploreActions(row.toolName, argsOf(row));
|
|
99
|
+
explorations.set(row, { args: row.args, actions });
|
|
100
|
+
return actions;
|
|
101
|
+
}
|
|
102
|
+
|
|
74
103
|
function lastRunning(rows: readonly ToolRowLike[], canRun: CanRun): ToolRowLike | undefined {
|
|
75
104
|
for (let index = rows.length - 1; index >= 0; index--) {
|
|
76
105
|
if (memberStatus(rows[index]!, canRun) === "running") return rows[index];
|
|
@@ -149,7 +178,7 @@ type GroupState = { expanded: boolean; lastHostExpanded: boolean; header?: Heade
|
|
|
149
178
|
type Paint = { theme: Theme; chrome: (text: string) => string; key: string };
|
|
150
179
|
|
|
151
180
|
/** Theme colors a group line uses; a theme that changes any of them draws the lines anew. */
|
|
152
|
-
const PAINT_COLORS = ["toolTitle", "error", "success", "muted", "dim", "text", "borderMuted"] as const;
|
|
181
|
+
const PAINT_COLORS = ["toolTitle", "error", "success", "muted", "dim", "text", "borderMuted", "accent"] as const;
|
|
153
182
|
|
|
154
183
|
function headerStill(cache: HeaderCache, rows: readonly ToolRowLike[], paint: string, width: number, expanded: boolean): boolean {
|
|
155
184
|
if (cache.paint !== paint || cache.width !== width || cache.expanded !== expanded || cache.rows.length !== rows.length) return false;
|
|
@@ -165,7 +194,9 @@ export class ToolGroupComponent extends Container {
|
|
|
165
194
|
members: readonly unknown[],
|
|
166
195
|
private readonly state: GroupState,
|
|
167
196
|
private readonly draw: (rows: readonly ToolRowLike[], width: number, expanded: boolean) => string[],
|
|
168
|
-
private readonly onToggle: () => void,
|
|
197
|
+
private readonly onToggle: (expanded: boolean) => void,
|
|
198
|
+
/** Lines beneath an opened group's members, which a click closes too. */
|
|
199
|
+
footer?: (width: number) => string[],
|
|
169
200
|
) {
|
|
170
201
|
super();
|
|
171
202
|
// Ctrl+O expands every tool row in Pi; the group follows it both ways.
|
|
@@ -174,24 +205,26 @@ export class ToolGroupComponent extends Container {
|
|
|
174
205
|
state.lastHostExpanded = hostExpanded;
|
|
175
206
|
state.expanded = hostExpanded;
|
|
176
207
|
}
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
} as Component;
|
|
188
|
-
this.addChild(header);
|
|
189
|
-
if (state.expanded) for (const member of members) this.addChild(member as Component);
|
|
208
|
+
const handleMouse = (event: { type?: string; button?: string }) => {
|
|
209
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
210
|
+
captureViewport();
|
|
211
|
+
this.state.expanded = !this.state.expanded;
|
|
212
|
+
this.onToggle(this.state.expanded);
|
|
213
|
+
return { handled: true };
|
|
214
|
+
};
|
|
215
|
+
this.addChild({ render: (width) => this.draw(this.rows, width, this.state.expanded), invalidate() {}, handleMouse } as Component);
|
|
216
|
+
if (!state.expanded) return;
|
|
217
|
+
for (const member of members) this.addChild(member as Component);
|
|
218
|
+
if (footer) this.addChild({ render: footer, invalidate() {}, handleMouse } as Component);
|
|
190
219
|
}
|
|
191
220
|
}
|
|
192
221
|
|
|
193
|
-
|
|
194
|
-
|
|
222
|
+
/** Which rows fold into a group: what Claude Code counts as looking around, or what Codex parses as exploring. */
|
|
223
|
+
export type GroupStyle = "claude" | "codex";
|
|
224
|
+
|
|
225
|
+
function isGroupable(child: unknown, style: GroupStyle = "claude"): child is ToolRowLike {
|
|
226
|
+
if (!isKind(child, ToolExecutionComponent)) return false;
|
|
227
|
+
return style === "codex" ? exploreOf(child as ToolRowLike) !== undefined : kindOf(child as ToolRowLike) !== undefined;
|
|
195
228
|
}
|
|
196
229
|
|
|
197
230
|
type AssistantLike = { lastMessage?: { content?: Array<{ type: string; text?: string }> } };
|
|
@@ -219,6 +252,7 @@ function isFoldable(child: unknown): boolean {
|
|
|
219
252
|
export function groupChildren(
|
|
220
253
|
children: readonly unknown[],
|
|
221
254
|
makeGroup: (rows: ToolRowLike[], members: unknown[]) => Component,
|
|
255
|
+
style: GroupStyle = "claude",
|
|
222
256
|
): unknown[] {
|
|
223
257
|
const grouped: unknown[] = [];
|
|
224
258
|
let rows: ToolRowLike[] = [];
|
|
@@ -230,7 +264,7 @@ export function groupChildren(
|
|
|
230
264
|
members = [];
|
|
231
265
|
};
|
|
232
266
|
for (const child of children) {
|
|
233
|
-
if (isGroupable(child)) {
|
|
267
|
+
if (isGroupable(child, style)) {
|
|
234
268
|
rows.push(child);
|
|
235
269
|
members.push(...pending, child);
|
|
236
270
|
pending = [];
|
|
@@ -249,7 +283,7 @@ export function groupChildren(
|
|
|
249
283
|
return grouped;
|
|
250
284
|
}
|
|
251
285
|
|
|
252
|
-
/** Claude
|
|
286
|
+
/** Claude and Codex styles: runs of reads, searches, and listings fold into one group each. */
|
|
253
287
|
export class ToolGroupController {
|
|
254
288
|
private context: ExtensionContext | undefined;
|
|
255
289
|
private readonly states = new WeakMap<object, GroupState>();
|
|
@@ -268,10 +302,12 @@ export class ToolGroupController {
|
|
|
268
302
|
const chat = findChat(tui);
|
|
269
303
|
if (chat) {
|
|
270
304
|
hookMethod(chat, "render", "claude.groups", (self, args, original) => {
|
|
271
|
-
|
|
305
|
+
const style = this.runtime.config.style;
|
|
306
|
+
if (!this.context || isSilent() || (style !== "claude" && style !== "codex")) return original.apply(self, args);
|
|
272
307
|
const children = self.children as unknown[];
|
|
273
308
|
this.paint = this.resolvePaint();
|
|
274
|
-
|
|
309
|
+
openExplorations.clear();
|
|
310
|
+
self.children = groupChildren(children, (rows, members) => this.makeGroup(rows, members, tui), style);
|
|
275
311
|
try {
|
|
276
312
|
return original.apply(self, args);
|
|
277
313
|
} finally {
|
|
@@ -297,15 +333,35 @@ export class ToolGroupController {
|
|
|
297
333
|
this.states.set(key, state);
|
|
298
334
|
}
|
|
299
335
|
const groupState = state;
|
|
300
|
-
|
|
301
|
-
|
|
336
|
+
const draw = (group: readonly ToolRowLike[], width: number, expanded: boolean) => this.drawCached(group, width, expanded, groupState);
|
|
337
|
+
if (this.runtime.config.style !== "codex") return new ToolGroupComponent(rows, members, state, draw, () => tui.requestRender());
|
|
338
|
+
// Codex opens a group to the whole of each call, and closes it from one "− Show less" beneath them.
|
|
339
|
+
const toggle = (expanded: boolean) => {
|
|
340
|
+
for (const row of rows) row.setExpanded?.(expanded);
|
|
341
|
+
tui.requestRender();
|
|
342
|
+
};
|
|
343
|
+
const footer = (width: number) => {
|
|
344
|
+
const theme = this.paint?.theme ?? this.context?.ui.theme;
|
|
345
|
+
return theme ? [truncateToWidth(theme.fg("dim", codexDisclosure(width, true)), Math.max(1, width), "…")] : [];
|
|
346
|
+
};
|
|
347
|
+
const group = new ToolGroupComponent(rows, members, state, draw, toggle, footer);
|
|
348
|
+
// Read once the group has followed Ctrl+O, which it does as it is made. A call that joins an
|
|
349
|
+
// opened group opens with it, as every call in an opened Codex group shows whole.
|
|
350
|
+
if (state.expanded) {
|
|
351
|
+
for (const row of rows) {
|
|
352
|
+
openExplorations.add(row.toolCallId);
|
|
353
|
+
if (!row.expanded) row.setExpanded?.(true);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return group;
|
|
302
357
|
}
|
|
303
358
|
|
|
304
359
|
private resolvePaint(): Paint | undefined {
|
|
305
360
|
const theme = this.context?.ui.theme;
|
|
306
361
|
if (!theme) return undefined;
|
|
307
362
|
const chrome = chromePainter(theme);
|
|
308
|
-
|
|
363
|
+
// The style is part of the key: a group drawn in one style is never reused in another.
|
|
364
|
+
const key = this.runtime.config.style + PAINT_COLORS.map((color) => theme.fg(color, "x")).join("") + theme.bold("x") + chrome("x");
|
|
309
365
|
return { theme, chrome, key };
|
|
310
366
|
}
|
|
311
367
|
|
|
@@ -338,6 +394,7 @@ export class ToolGroupController {
|
|
|
338
394
|
* looks at.
|
|
339
395
|
*/
|
|
340
396
|
private draw(rows: readonly ToolRowLike[], width: number, expanded: boolean, paint: Paint): string[] {
|
|
397
|
+
if (this.runtime.config.style === "codex") return this.drawCodex(rows, width, expanded, paint);
|
|
341
398
|
const { theme, chrome } = paint;
|
|
342
399
|
const fit = (line: string) => truncateToWidth(line, Math.max(1, width), "…");
|
|
343
400
|
const running = lastRunning(rows, this.canRun);
|
|
@@ -346,7 +403,7 @@ export class ToolGroupController {
|
|
|
346
403
|
const status: RowStatus = running ? "running" : statuses.includes("error") ? "error"
|
|
347
404
|
: statuses.includes("pending") ? "pending" : "success";
|
|
348
405
|
const summary = summarizeGroup(rows, undefined, this.canRun);
|
|
349
|
-
let title =
|
|
406
|
+
let title = ` ${paintIndicator(theme, status, this.runtime.frame)} ${theme.fg("toolTitle", theme.bold(summary))}`;
|
|
350
407
|
if (running) {
|
|
351
408
|
// The folded row is not drawn, so the line that pulses for it keeps the clock going.
|
|
352
409
|
this.runtime.keepAnimating(running.toolCallId);
|
|
@@ -355,10 +412,52 @@ export class ToolGroupController {
|
|
|
355
412
|
title += `${elapsed >= 2000 ? chrome(` · ${formatDurationMs(elapsed)}`) : ""}…`;
|
|
356
413
|
}
|
|
357
414
|
if (!expanded) title += chrome(` ${EXPAND_HINT}`);
|
|
358
|
-
const lines = ["", fit(
|
|
415
|
+
const lines = ["", fit(title)];
|
|
359
416
|
const hintText = groupHint(rows, this.canRun);
|
|
360
|
-
if (hintText) lines.push(fit(chrome(
|
|
361
|
-
if (!expanded) for (const failure of groupFailures(rows)) lines.push(fit(chrome(
|
|
417
|
+
if (hintText) lines.push(fit(chrome(`${CLAUDE_RESULT_PREFIX}${hintText}`)));
|
|
418
|
+
if (!expanded) for (const failure of groupFailures(rows)) lines.push(fit(chrome(CLAUDE_RESULT_PREFIX) + theme.fg("error", failure)));
|
|
419
|
+
return lines;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Codex's exploration: `• Exploring` while a call runs and `• Explored` after,
|
|
424
|
+
* then a line for what each call did, three rows of them until the group opens.
|
|
425
|
+
* A failed call says so at the end of its line and in the head's count.
|
|
426
|
+
*/
|
|
427
|
+
private drawCodex(rows: readonly ToolRowLike[], width: number, expanded: boolean, paint: Paint): string[] {
|
|
428
|
+
const { theme } = paint;
|
|
429
|
+
const fit = (line: string) => truncateToWidth(line, Math.max(1, width), "…");
|
|
430
|
+
const running = lastRunning(rows, this.canRun);
|
|
431
|
+
const statuses = rows.map((row) => memberStatus(row, this.canRun));
|
|
432
|
+
if (running) this.runtime.keepAnimating(running.toolCallId);
|
|
433
|
+
const bullet = running ? paintIndicator(theme, "running", this.runtime.frame, CODEX_BULLET)
|
|
434
|
+
: statuses.includes("pending") ? paintIndicator(theme, "pending", 0, CODEX_BULLET) : theme.fg("dim", CODEX_BULLET);
|
|
435
|
+
const failed = statuses.filter((status) => status === "error").length;
|
|
436
|
+
let head = `${CODEX_MARGIN}${bullet} ${theme.bold(running ? "Exploring" : "Explored")}`;
|
|
437
|
+
if (failed > 0) head += theme.bold(theme.fg("error", ` · ${failed} failed`));
|
|
438
|
+
// Opened, Codex lists the calls themselves in place of the summary; each brings its own blank line.
|
|
439
|
+
if (expanded) return [];
|
|
440
|
+
const lines = ["", fit(head)];
|
|
441
|
+
const calls = rows.map((row) => {
|
|
442
|
+
const actions = exploreOf(row) ?? [];
|
|
443
|
+
const failure = row.result && !row.isPartial
|
|
444
|
+
? exploreFailure(row.toolName, getTextResult(row.result), row.result.isError === true, actions) : undefined;
|
|
445
|
+
return { actions, failure };
|
|
446
|
+
});
|
|
447
|
+
const details: string[] = [];
|
|
448
|
+
for (const line of exploreLines(calls)) {
|
|
449
|
+
const verb = `${line.verb} `;
|
|
450
|
+
const segments: Segment[] = line.parts.map((part) => ({ text: part.text, color: part.dim ? "dim" : undefined }));
|
|
451
|
+
if (line.failure) segments.push({ text: line.failure.text, color: line.failure.quiet ? "dim" : "error" });
|
|
452
|
+
const available = Math.max(1, width - visibleWidth(CODEX_INDENT) - verb.length);
|
|
453
|
+
wrapSegments(segments, available, available).forEach((row, index) => {
|
|
454
|
+
details.push((index === 0 ? theme.fg("accent", verb) : " ".repeat(verb.length)) + paintSegments(row, theme));
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
details.slice(0, CODEX_PREVIEW_ROWS).forEach((row, index) => {
|
|
458
|
+
lines.push(fit(theme.fg("dim", index === 0 ? CODEX_RESULT_PREFIX : CODEX_INDENT) + row));
|
|
459
|
+
});
|
|
460
|
+
lines.push(fit(theme.fg("dim", codexDisclosure(width, false))));
|
|
362
461
|
return lines;
|
|
363
462
|
}
|
|
364
463
|
}
|
|
@@ -290,9 +290,38 @@ function summarizeFileOperation(executable: string, words: string[]): string | u
|
|
|
290
290
|
return undefined;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/** One step split at its unquoted pipes: `cat a | tee b` is two commands, the second writing a file. */
|
|
294
|
+
function pipelineStages(segment: string): string[] {
|
|
295
|
+
const stages: string[] = [];
|
|
296
|
+
let current = "";
|
|
297
|
+
let quote: string | undefined;
|
|
298
|
+
for (let index = 0; index < segment.length; index++) {
|
|
299
|
+
const character = segment[index]!;
|
|
300
|
+
if (quote) {
|
|
301
|
+
if (character === "\\" && quote === '"' && index + 1 < segment.length) {
|
|
302
|
+
current += character + segment[++index];
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (character === quote) quote = undefined;
|
|
306
|
+
} else if (character === "'" || character === '"' || character === "`") {
|
|
307
|
+
quote = character;
|
|
308
|
+
} else if (character === "\\" && index + 1 < segment.length) {
|
|
309
|
+
current += character + segment[++index];
|
|
310
|
+
continue;
|
|
311
|
+
} else if (character === "|") {
|
|
312
|
+
if (current.trim()) stages.push(current.trim());
|
|
313
|
+
current = "";
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
current += character;
|
|
317
|
+
}
|
|
318
|
+
if (current.trim()) stages.push(current.trim());
|
|
319
|
+
return stages;
|
|
320
|
+
}
|
|
321
|
+
|
|
293
322
|
/** A calm collapsed label that describes intent while retaining primary command targets. */
|
|
294
323
|
export function summarizeShellCommand(name: string, command: string): string {
|
|
295
|
-
const allSegments = shellSegments(normalizeLineEndings(command));
|
|
324
|
+
const allSegments = shellSegments(normalizeLineEndings(command)).flatMap(pipelineStages);
|
|
296
325
|
const segments = allSegments.filter((segment) => !/^\s*(?:cd|pushd|popd)\b/iu.test(segment));
|
|
297
326
|
const primary = segments[0] ?? allSegments[0] ?? "";
|
|
298
327
|
if (!primary) return name === "powershell" ? "Prepare PowerShell command" : "Prepare shell command";
|
|
@@ -15,7 +15,7 @@ import { highlightMarkdown } from "./compact-tools-markdown.ts";
|
|
|
15
15
|
import { paintChrome } from "./compact-tools-palette.ts";
|
|
16
16
|
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
17
17
|
|
|
18
|
-
class CachedComponent implements Component {
|
|
18
|
+
export class CachedComponent implements Component {
|
|
19
19
|
private cachedWidth?: number;
|
|
20
20
|
private cachedLines?: string[];
|
|
21
21
|
|
|
@@ -160,6 +160,20 @@ export function prefixedLines(text: string, firstPrefix: string, continuationPre
|
|
|
160
160
|
});
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/**
|
|
164
|
+
* A sentence under a tree prefix, wrapped at words as a terminal UI wraps prose:
|
|
165
|
+
* a word moves to the next row whole, and only a word longer than a row is split.
|
|
166
|
+
*/
|
|
167
|
+
export function prefixedSentence(text: string, firstPrefix: string, continuationPrefix: string): Component {
|
|
168
|
+
const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
|
|
169
|
+
const lines = text.replace(/\t/g, " ").split("\n");
|
|
170
|
+
return new CachedComponent((width) => {
|
|
171
|
+
const available = Math.max(1, width - prefixWidth);
|
|
172
|
+
return lines.flatMap((line) => wrapTextWithAnsi(line, available).map((row) => truncateToWidth(row, available, "")))
|
|
173
|
+
.map((row, index) => `${index === 0 ? firstPrefix : continuationPrefix}${row}`);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
163
177
|
/**
|
|
164
178
|
* Output as Claude Code previews it. Each line is wrapped to the width, and rows
|
|
165
179
|
* rather than lines are counted: collapsed, `maxRows` show, all of them when only
|
|
@@ -179,7 +193,7 @@ export function claudeRows(
|
|
|
179
193
|
const wrapWidth = maxRows === undefined ? available : Math.min(available, Math.max(width - 10, 10));
|
|
180
194
|
let rows = lines.flatMap((line) => hardWrapTextWithAnsi(line, wrapWidth).map((row) => row.trimEnd()));
|
|
181
195
|
if (maxRows !== undefined && rows.length > maxRows + 1) {
|
|
182
|
-
rows = [...rows.slice(0, maxRows), more(rows.length - maxRows)];
|
|
196
|
+
rows = [...rows.slice(0, maxRows), truncateToWidth(more(rows.length - maxRows), available, "…")];
|
|
183
197
|
}
|
|
184
198
|
return rows.map((row, index) => `${index === 0 ? firstPrefix : continuationPrefix}${row}`);
|
|
185
199
|
});
|
|
@@ -209,10 +223,14 @@ export function hardWrapTextWithAnsi(text: string, width: number): string[] {
|
|
|
209
223
|
return wrapped;
|
|
210
224
|
}
|
|
211
225
|
|
|
212
|
-
export function renderToolCall(
|
|
226
|
+
export function renderToolCall(
|
|
227
|
+
title: string,
|
|
228
|
+
details: string | undefined,
|
|
229
|
+
theme: Theme,
|
|
230
|
+
continuationPrefix = paintChrome(theme, " │ "),
|
|
231
|
+
): Component {
|
|
213
232
|
const text = details ? `${title} ${details}` : title;
|
|
214
233
|
const firstPrefix = " ";
|
|
215
|
-
const continuationPrefix = paintChrome(theme, " │ ");
|
|
216
234
|
const prefixWidth = visibleWidth(continuationPrefix);
|
|
217
235
|
return new CachedComponent((width) => {
|
|
218
236
|
const lines = hardWrapTextWithAnsi(text, width - prefixWidth);
|
|
@@ -224,12 +242,12 @@ export function styleMultiline(text: string, style: (line: string) => string): s
|
|
|
224
242
|
return normalizeLineEndings(text).split("\n").map(style).join("\n");
|
|
225
243
|
}
|
|
226
244
|
|
|
227
|
-
export function renderOutput(output: string, theme: Theme, isError: boolean): Component | undefined {
|
|
245
|
+
export function renderOutput(output: string, theme: Theme, isError: boolean, rail = paintChrome(theme, " │ ")): Component | undefined {
|
|
228
246
|
const normalized = normalizeLineEndings(output).trimEnd();
|
|
229
247
|
if (!normalized) return undefined;
|
|
230
248
|
const color = isError ? "error" : "toolOutput";
|
|
231
249
|
const styled = normalized.split("\n").map((line) => theme.fg(color, line)).join("\n");
|
|
232
|
-
return prefixedText(styled,
|
|
250
|
+
return prefixedText(styled, rail);
|
|
233
251
|
}
|
|
234
252
|
|
|
235
253
|
export type CodeDiffLineKind = "context" | "add" | "remove" | "separator";
|
|
@@ -249,7 +267,7 @@ export type CodeDiffOptions = {
|
|
|
249
267
|
|
|
250
268
|
const DIFF_SEPARATOR = "\u22ee";
|
|
251
269
|
|
|
252
|
-
function parseUnifiedPatch(patch: string): CodeDiffLine[] {
|
|
270
|
+
function parseUnifiedPatch(patch: string, tab: string): CodeDiffLine[] {
|
|
253
271
|
const result: CodeDiffLine[] = [];
|
|
254
272
|
let oldLine = 0;
|
|
255
273
|
let newLine = 0;
|
|
@@ -265,7 +283,7 @@ function parseUnifiedPatch(patch: string): CodeDiffLine[] {
|
|
|
265
283
|
}
|
|
266
284
|
if (hunk < 0 || line.startsWith("\")) continue;
|
|
267
285
|
const prefix = line[0];
|
|
268
|
-
const content = line.slice(1).replace(/\t/gu,
|
|
286
|
+
const content = line.slice(1).replace(/\t/gu, tab);
|
|
269
287
|
if (prefix === "+") {
|
|
270
288
|
result.push({ kind: "add", lineNumber: newLine++, content, hunk });
|
|
271
289
|
} else if (prefix === "-") {
|
|
@@ -278,7 +296,7 @@ function parseUnifiedPatch(patch: string): CodeDiffLine[] {
|
|
|
278
296
|
return result;
|
|
279
297
|
}
|
|
280
298
|
|
|
281
|
-
function parseDisplayDiff(diff: string): CodeDiffLine[] {
|
|
299
|
+
function parseDisplayDiff(diff: string, tab: string): CodeDiffLine[] {
|
|
282
300
|
const result: CodeDiffLine[] = [];
|
|
283
301
|
let hunk = 0;
|
|
284
302
|
for (const line of normalizeLineEndings(diff).split("\n")) {
|
|
@@ -291,17 +309,20 @@ function parseDisplayDiff(diff: string): CodeDiffLine[] {
|
|
|
291
309
|
result.push({
|
|
292
310
|
kind: match[1] === "+" ? "add" : match[1] === "-" ? "remove" : "context",
|
|
293
311
|
lineNumber: Number(match[2]),
|
|
294
|
-
content: match[3]!.replace(/\t/gu,
|
|
312
|
+
content: match[3]!.replace(/\t/gu, tab),
|
|
295
313
|
hunk,
|
|
296
314
|
});
|
|
297
315
|
}
|
|
298
316
|
return result;
|
|
299
317
|
}
|
|
300
318
|
|
|
301
|
-
/**
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Parse a standard patch, falling back to Pi's display-oriented numbered diff. A tab
|
|
321
|
+
* becomes `tab`: three columns as Pi draws code, four as Codex does.
|
|
322
|
+
*/
|
|
323
|
+
export function parseCodeDiff(patch: string, displayDiff = "", tab = " "): CodeDiffLine[] {
|
|
324
|
+
const parsedPatch = patch ? parseUnifiedPatch(patch, tab) : [];
|
|
325
|
+
return parsedPatch.length > 0 ? parsedPatch : parseDisplayDiff(displayDiff, tab);
|
|
305
326
|
}
|
|
306
327
|
|
|
307
328
|
/** Drop context that sits far from any change so a collapsed preview still reaches the edit. */
|
|
@@ -358,14 +379,11 @@ function highlightCodeLines(lines: CodeDiffLine[], language: string | undefined,
|
|
|
358
379
|
}
|
|
359
380
|
let end = index;
|
|
360
381
|
while (end < lines.length && lines[end]!.kind !== "separator") end++;
|
|
361
|
-
const
|
|
362
|
-
const contents = block.map((line) => line.content);
|
|
382
|
+
const contents = lines.slice(index, end).map((line) => line.content);
|
|
363
383
|
const highlighted = language === "markdown"
|
|
364
384
|
? highlightMarkdown(contents, theme)
|
|
365
385
|
: highlightCode(contents.join("\n"), language);
|
|
366
|
-
|
|
367
|
-
rendered[index + offset] = highlighted[offset] ?? line.content;
|
|
368
|
-
});
|
|
386
|
+
for (let offset = 0; offset < contents.length; offset++) rendered[index + offset] = highlighted[offset] ?? contents[offset]!;
|
|
369
387
|
index = end;
|
|
370
388
|
}
|
|
371
389
|
return rendered;
|
|
@@ -411,14 +429,18 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
|
|
|
411
429
|
// read at an offset or a diff hunk never reads one out of a comment.
|
|
412
430
|
const first = lines[0];
|
|
413
431
|
const shebangLine = first?.lineNumber === 1 && first.kind !== "separator" ? first.content : undefined;
|
|
414
|
-
|
|
415
|
-
|
|
432
|
+
// ToolExecutionComponent builds result components even when silent mode hides
|
|
433
|
+
// the row. Defer syntax work until the code is actually drawn, and keep it
|
|
434
|
+
// across width changes so resize does not highlight the same source again.
|
|
435
|
+
let highlighted: string[] | undefined;
|
|
436
|
+
const numberWidth = lines.reduce((maximum, line) => Math.max(maximum, String(line.lineNumber ?? "").length), 1);
|
|
416
437
|
const prefix = options.rail ?? paintChrome(theme, " \u2502 ");
|
|
417
438
|
const prefixWidth = visibleWidth(prefix);
|
|
418
439
|
const gutterWidth = numberWidth + (options.signs ? 3 : 2);
|
|
419
440
|
const addedTint = options.signs ? diffTintRgb(theme, "toolDiffAdded") : undefined;
|
|
420
441
|
const removedTint = options.signs ? diffTintRgb(theme, "toolDiffRemoved") : undefined;
|
|
421
442
|
return new CachedComponent((width) => {
|
|
443
|
+
highlighted ??= highlightCodeLines(lines, resolveLanguage(path, shebangLine), theme);
|
|
422
444
|
const bodyWidth = Math.max(1, width - prefixWidth);
|
|
423
445
|
const codeWidth = Math.max(1, bodyWidth - gutterWidth);
|
|
424
446
|
const output: string[] = [];
|
|
@@ -428,7 +450,7 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
|
|
|
428
450
|
return;
|
|
429
451
|
}
|
|
430
452
|
const tint = line.kind === "add" ? addedTint : line.kind === "remove" ? removedTint : undefined;
|
|
431
|
-
for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted[index] ?? line.content, codeWidth).entries()) {
|
|
453
|
+
for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted![index] ?? line.content, codeWidth).entries()) {
|
|
432
454
|
const gutter = codeGutter(
|
|
433
455
|
line.kind,
|
|
434
456
|
chunkIndex === 0 ? line.lineNumber : undefined,
|
|
@@ -487,6 +509,82 @@ export function renderCodeView(code: string, path: string, theme: Theme, options
|
|
|
487
509
|
return renderCodeRows(lines, path, theme, { signs: false, footer: options.footer, rail: options.rail });
|
|
488
510
|
}
|
|
489
511
|
|
|
512
|
+
/**
|
|
513
|
+
* Diff rows the way Codex draws them: `{line} {sign}{code}` after `indent`, the
|
|
514
|
+
* number dim and the sign in its change color, and an added or removed row tinted
|
|
515
|
+
* across the whole width. A wrapped row continues under the code; `⋮` marks a gap
|
|
516
|
+
* between hunks. `maxRows` keeps the first rows, as Codex's collapsed preview does.
|
|
517
|
+
*/
|
|
518
|
+
export function renderCodexDiff(
|
|
519
|
+
lines: readonly CodeDiffLine[],
|
|
520
|
+
path: string,
|
|
521
|
+
theme: Theme,
|
|
522
|
+
indent: string,
|
|
523
|
+
maxRows?: number,
|
|
524
|
+
): Component | undefined {
|
|
525
|
+
if (lines.length === 0) return undefined;
|
|
526
|
+
const first = lines[0];
|
|
527
|
+
const shebangLine = first?.lineNumber === 1 && first.kind !== "separator" ? first.content : undefined;
|
|
528
|
+
let highlighted: string[] | undefined;
|
|
529
|
+
const numberWidth = lines.reduce((maximum, line) => Math.max(maximum, String(line.lineNumber ?? "").length), 1);
|
|
530
|
+
const indentWidth = visibleWidth(indent);
|
|
531
|
+
const addedTint = diffTintRgb(theme, "toolDiffAdded");
|
|
532
|
+
const removedTint = diffTintRgb(theme, "toolDiffRemoved");
|
|
533
|
+
return new CachedComponent((width) => {
|
|
534
|
+
highlighted ??= highlightCodeLines([...lines], resolveLanguage(path, shebangLine), theme);
|
|
535
|
+
const codeWidth = Math.max(1, width - indentWidth - numberWidth - 2);
|
|
536
|
+
const output: string[] = [];
|
|
537
|
+
for (const [index, line] of lines.entries()) {
|
|
538
|
+
if (maxRows !== undefined && output.length >= maxRows) break;
|
|
539
|
+
if (line.kind === "separator") {
|
|
540
|
+
output.push(indent + " ".repeat(numberWidth + 1) + theme.fg("dim", line.content));
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const tint = line.kind === "add" ? addedTint : line.kind === "remove" ? removedTint : undefined;
|
|
544
|
+
const sign = line.kind === "add" ? theme.fg("toolDiffAdded", "+") : line.kind === "remove" ? theme.fg("toolDiffRemoved", "-") : " ";
|
|
545
|
+
for (const [chunkIndex, chunk] of hardSliceAnsi(highlighted[index] ?? line.content, codeWidth).entries()) {
|
|
546
|
+
const gutter = chunkIndex === 0
|
|
547
|
+
? theme.fg("dim", `${String(line.lineNumber ?? "").padStart(numberWidth)} `) + sign
|
|
548
|
+
: " ".repeat(numberWidth + 2);
|
|
549
|
+
// Narrower than the gutter itself, a row is cut rather than drawn past the edge.
|
|
550
|
+
const row = truncateToWidth(indent + gutter + chunk, Math.max(1, width), "");
|
|
551
|
+
output.push(tint ? fillRgb(theme, tint, row + " ".repeat(Math.max(0, width - visibleWidth(row)))) : row);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return maxRows === undefined ? output : output.slice(0, maxRows);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* A row that never draws past the pane's edge. Chrome has a width of its own, so in
|
|
560
|
+
* a pane narrower than a prefix the lines are cut to it. Lines are checked again only
|
|
561
|
+
* when the component draws new ones.
|
|
562
|
+
*/
|
|
563
|
+
export class WithinWidth implements Component {
|
|
564
|
+
private source: string[] | undefined;
|
|
565
|
+
private width: number | undefined;
|
|
566
|
+
private lines: string[] = [];
|
|
567
|
+
|
|
568
|
+
constructor(private readonly inner: Component) {}
|
|
569
|
+
|
|
570
|
+
render(width: number): string[] {
|
|
571
|
+
const lines = this.inner.render(width);
|
|
572
|
+
if (lines !== this.source || width !== this.width) {
|
|
573
|
+
this.source = lines;
|
|
574
|
+
this.width = width;
|
|
575
|
+
this.lines = lines.some((line) => visibleWidth(line) > width)
|
|
576
|
+
? lines.map((line) => visibleWidth(line) > width ? truncateToWidth(line, Math.max(1, width), "") : line)
|
|
577
|
+
: lines;
|
|
578
|
+
}
|
|
579
|
+
return this.lines;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
invalidate(): void {
|
|
583
|
+
this.inner.invalidate?.();
|
|
584
|
+
this.source = undefined;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
490
588
|
/** Limit a result preview by rendered rows while preserving the full source component for expansion. */
|
|
491
589
|
export function limitComponentLines(
|
|
492
590
|
component: Component,
|
|
@@ -498,13 +596,12 @@ export function limitComponentLines(
|
|
|
498
596
|
return new CachedComponent((width) => {
|
|
499
597
|
const lines = component.render(width);
|
|
500
598
|
if (lines.length <= maximumLines) return lines;
|
|
501
|
-
return [...lines.slice(0, maximumLines), footer(lines.length - maximumLines)];
|
|
599
|
+
return [...lines.slice(0, maximumLines), truncateToWidth(footer(lines.length - maximumLines), Math.max(1, width), "…")];
|
|
502
600
|
}, () => component.invalidate?.());
|
|
503
601
|
}
|
|
504
602
|
|
|
505
603
|
/** Indent another renderer's component under the result rail so it reads as part of the row. */
|
|
506
|
-
export function railComponent(component: Component, theme: Theme): Component {
|
|
507
|
-
const rail = paintChrome(theme, " │ ");
|
|
604
|
+
export function railComponent(component: Component, theme: Theme, rail = paintChrome(theme, " │ ")): Component {
|
|
508
605
|
const railWidth = visibleWidth(rail);
|
|
509
606
|
return new CachedComponent(
|
|
510
607
|
(width) => component.render(Math.max(1, width - railWidth)).map((line) => rail + line),
|
|
@@ -512,8 +609,8 @@ export function railComponent(component: Component, theme: Theme): Component {
|
|
|
512
609
|
);
|
|
513
610
|
}
|
|
514
611
|
|
|
515
|
-
export function renderArguments(args: ToolArgs, theme: Theme): Component {
|
|
612
|
+
export function renderArguments(args: ToolArgs, theme: Theme, rail = paintChrome(theme, " │ ")): Component {
|
|
516
613
|
const json = JSON.stringify(args, null, 2) ?? "{}";
|
|
517
614
|
const styled = styleMultiline(json, (line) => theme.fg("toolOutput", line));
|
|
518
|
-
return prefixedText(styled,
|
|
615
|
+
return prefixedText(styled, rail);
|
|
519
616
|
}
|
|
@@ -122,18 +122,17 @@ function ramp(theme: Theme, low: ContrastBand, high: ContrastBand): ColorRamp |
|
|
|
122
122
|
* A row's status dot; a running one pulses between the theme's endpoints as frames advance.
|
|
123
123
|
* Every running row repaints its dot on every frame, so the pulse is painted once per palette.
|
|
124
124
|
*/
|
|
125
|
-
export function paintIndicator(theme: Theme, status: RowStatus, frame: number): string {
|
|
126
|
-
if (status !== "running") return theme.fg(indicatorTone(status),
|
|
127
|
-
const pulse = runningPulse(theme);
|
|
125
|
+
export function paintIndicator(theme: Theme, status: RowStatus, frame: number, glyph = indicatorGlyph(status)): string {
|
|
126
|
+
if (status !== "running") return theme.fg(indicatorTone(status), glyph);
|
|
127
|
+
const pulse = runningPulse(theme, glyph);
|
|
128
128
|
return pulse[((frame % pulse.length) + pulse.length) % pulse.length]!;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/** Every step of the running dot's pulse, painted once per palette. */
|
|
132
|
-
function runningPulse(theme: Theme): readonly string[] {
|
|
133
|
-
return perPalette(theme,
|
|
131
|
+
/** Every step of the running dot's pulse, painted once per palette and glyph. */
|
|
132
|
+
function runningPulse(theme: Theme, glyph: string): readonly string[] {
|
|
133
|
+
return perPalette(theme, `running-pulse:${glyph}`, () => {
|
|
134
134
|
const pulse = ramp(theme, PULSE_LOW, PULSE_HIGH);
|
|
135
135
|
return Array.from({ length: RUNNING_INDICATOR_FRAME_COUNT }, (_, step) => {
|
|
136
|
-
const glyph = indicatorGlyph("running", step);
|
|
137
136
|
return pulse
|
|
138
137
|
? colorizeRgb(theme, interpolateRgb(pulse.from, pulse.to, indicatorStrength("running", step)), glyph)
|
|
139
138
|
: theme.fg(indicatorTone("running", step), glyph);
|