pi-compact-tools 0.10.0 → 0.11.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 +44 -60
- package/extensions/compact-tools-claude.ts +227 -0
- package/extensions/compact-tools-config.ts +9 -5
- package/extensions/compact-tools-grouping.ts +282 -0
- package/extensions/compact-tools-invocation.ts +1 -1
- package/extensions/compact-tools-layout.ts +64 -17
- package/extensions/compact-tools-palette.ts +10 -0
- package/extensions/compact-tools-release.ts +13 -11
- package/extensions/compact-tools-runtime.ts +10 -0
- package/extensions/compact-tools-silent.ts +9 -3
- package/extensions/compact-tools-types.ts +4 -0
- package/extensions/compact-tools.ts +218 -223
- package/package.json +2 -2
- package/release-notes.json +7 -0
- package/schemas/compact-tools.schema.json +7 -1
- package/scripts/check-resources.mjs +3 -2
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AssistantMessageComponent,
|
|
3
|
+
ToolExecutionComponent,
|
|
4
|
+
type AgentToolResult,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Container, Spacer, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
8
|
+
import { claudeFailure, lookHint, lookKind, pathOf, type LookKind } from "./compact-tools-claude.ts";
|
|
9
|
+
import { formatDurationMs, type RowStatus } from "./compact-tools-core.ts";
|
|
10
|
+
import { hookMethod } from "./compact-tools-hook.ts";
|
|
11
|
+
import { getTextResult } from "./compact-tools-invocation.ts";
|
|
12
|
+
import { chromePainter, paintIndicator } from "./compact-tools-palette.ts";
|
|
13
|
+
import type { ToolRuntime } from "./compact-tools-runtime.ts";
|
|
14
|
+
import { findChat, isKind, isSilent } from "./compact-tools-silent.ts";
|
|
15
|
+
import { captureViewport } from "./compact-tools-viewport.ts";
|
|
16
|
+
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
17
|
+
|
|
18
|
+
const WIDGET_KEY = "compact-tools-groups";
|
|
19
|
+
|
|
20
|
+
/** Claude Code's words for each way of looking around, in the order it lists them. */
|
|
21
|
+
const PHRASES: ReadonlyArray<{ kind: LookKind; running: string; done: string; singular: string; plural: string }> = [
|
|
22
|
+
{ kind: "search", running: "searching for", done: "searched for", singular: "pattern", plural: "patterns" },
|
|
23
|
+
{ kind: "read", running: "reading", done: "read", singular: "file", plural: "files" },
|
|
24
|
+
{ kind: "list", running: "listing", done: "listed", singular: "directory", plural: "directories" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const EXPAND_HINT = "(ctrl+o to expand)";
|
|
28
|
+
|
|
29
|
+
/** The fields of Pi's tool row a group summary reads. */
|
|
30
|
+
export interface ToolRowLike {
|
|
31
|
+
toolName: string;
|
|
32
|
+
toolCallId: string;
|
|
33
|
+
args: unknown;
|
|
34
|
+
result?: AgentToolResult<unknown> & { isError?: boolean };
|
|
35
|
+
isPartial: boolean;
|
|
36
|
+
expanded: boolean;
|
|
37
|
+
render(width: number): string[];
|
|
38
|
+
handleMouse?(event: unknown): unknown;
|
|
39
|
+
invalidate(): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function memberStatus(row: ToolRowLike): RowStatus {
|
|
43
|
+
if (!row.result || row.isPartial) return "running";
|
|
44
|
+
return row.result.isError ? "error" : "success";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function argsOf(row: ToolRowLike): ToolArgs {
|
|
48
|
+
return typeof row.args === "object" && row.args !== null ? row.args as ToolArgs : {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Keyed by the row and checked against its arguments, which stream in: a command is
|
|
52
|
+
// parsed once rather than on every frame the whole transcript repaints.
|
|
53
|
+
const kinds = new WeakMap<object, { args: unknown; kind: LookKind | undefined }>();
|
|
54
|
+
|
|
55
|
+
function kindOf(row: ToolRowLike): LookKind | undefined {
|
|
56
|
+
const cached = kinds.get(row);
|
|
57
|
+
if (cached && cached.args === row.args) return cached.kind;
|
|
58
|
+
const kind = lookKind(row.toolName, argsOf(row));
|
|
59
|
+
kinds.set(row, { args: row.args, kind });
|
|
60
|
+
return kind;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function lastRunning(rows: readonly ToolRowLike[]): ToolRowLike | undefined {
|
|
64
|
+
for (let index = rows.length - 1; index >= 0; index--) {
|
|
65
|
+
if (memberStatus(rows[index]!) === "running") return rows[index];
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How many patterns, files, and directories a group covers, counted as Claude Code
|
|
72
|
+
* counts them: files by distinct path, and reads by command only when no file was read.
|
|
73
|
+
*/
|
|
74
|
+
export function countGroup(rows: readonly ToolRowLike[]): Record<LookKind, number> {
|
|
75
|
+
const counts: Record<LookKind, number> = { search: 0, read: 0, list: 0 };
|
|
76
|
+
const paths = new Set<string>();
|
|
77
|
+
let readCommands = 0;
|
|
78
|
+
for (const row of rows) {
|
|
79
|
+
const kind = kindOf(row);
|
|
80
|
+
if (kind === "read" && row.toolName === "read") paths.add(pathOf(argsOf(row)));
|
|
81
|
+
else if (kind === "read") readCommands++;
|
|
82
|
+
else if (kind) counts[kind]++;
|
|
83
|
+
}
|
|
84
|
+
counts.read = paths.size > 0 ? paths.size : readCommands;
|
|
85
|
+
return counts;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* "Searched for 2 patterns, read 3 files". While any call in the group still runs,
|
|
90
|
+
* every verb reads in progress. `bold` marks the counts, as Claude Code does.
|
|
91
|
+
*/
|
|
92
|
+
export function summarizeGroup(rows: readonly ToolRowLike[], bold: (text: string) => string = (text) => text): string {
|
|
93
|
+
const counts = countGroup(rows);
|
|
94
|
+
const running = lastRunning(rows) !== undefined;
|
|
95
|
+
const text = PHRASES.filter((phrase) => counts[phrase.kind] > 0)
|
|
96
|
+
.map((phrase) => {
|
|
97
|
+
const count = counts[phrase.kind];
|
|
98
|
+
return `${running ? phrase.running : phrase.done} ${bold(String(count))} ${count === 1 ? phrase.singular : phrase.plural}`;
|
|
99
|
+
})
|
|
100
|
+
.join(", ");
|
|
101
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** While a call runs, the line under its group names what it looks at. */
|
|
105
|
+
export function groupHint(rows: readonly ToolRowLike[]): string | undefined {
|
|
106
|
+
const running = lastRunning(rows);
|
|
107
|
+
if (!running) return undefined;
|
|
108
|
+
return lookHint(running.toolName, argsOf(running)) || undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Claude Code keeps a failure in view beneath the folded group. */
|
|
112
|
+
export function groupFailures(rows: readonly ToolRowLike[]): string[] {
|
|
113
|
+
return rows.filter((row) => memberStatus(row) === "error")
|
|
114
|
+
.map((row) => claudeFailure(row.toolName, getTextResult(row.result!)).headline);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type GroupState = { expanded: boolean; lastHostExpanded: boolean };
|
|
118
|
+
|
|
119
|
+
/** A run of tool rows drawn as one summary line, which a click or Ctrl+O opens into the rows themselves. */
|
|
120
|
+
export class ToolGroupComponent extends Container {
|
|
121
|
+
constructor(
|
|
122
|
+
readonly rows: readonly ToolRowLike[],
|
|
123
|
+
/** Every child the group folded, in order: its tool rows and the thinking between them. */
|
|
124
|
+
members: readonly unknown[],
|
|
125
|
+
private readonly state: GroupState,
|
|
126
|
+
private readonly draw: (rows: readonly ToolRowLike[], width: number, expanded: boolean) => string[],
|
|
127
|
+
private readonly onToggle: () => void,
|
|
128
|
+
) {
|
|
129
|
+
super();
|
|
130
|
+
// Ctrl+O expands every tool row in Pi; the group follows it both ways.
|
|
131
|
+
const hostExpanded = rows[0]!.expanded;
|
|
132
|
+
if (state.lastHostExpanded !== hostExpanded) {
|
|
133
|
+
state.lastHostExpanded = hostExpanded;
|
|
134
|
+
state.expanded = hostExpanded;
|
|
135
|
+
}
|
|
136
|
+
const header: Component = {
|
|
137
|
+
render: (width) => this.draw(this.rows, width, this.state.expanded),
|
|
138
|
+
invalidate() {},
|
|
139
|
+
handleMouse: (event: { type?: string; button?: string }) => {
|
|
140
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
141
|
+
captureViewport();
|
|
142
|
+
this.state.expanded = !this.state.expanded;
|
|
143
|
+
this.onToggle();
|
|
144
|
+
return { handled: true };
|
|
145
|
+
},
|
|
146
|
+
} as Component;
|
|
147
|
+
this.addChild(header);
|
|
148
|
+
if (state.expanded) for (const member of members) this.addChild(member as Component);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isGroupable(child: unknown): child is ToolRowLike {
|
|
153
|
+
return isKind(child, ToolExecutionComponent) && kindOf(child as ToolRowLike) !== undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type AssistantLike = { lastMessage?: { content?: Array<{ type: string; text?: string }> } };
|
|
157
|
+
|
|
158
|
+
/** A turn with no text for the reader: it only thought, then handed off to tools. */
|
|
159
|
+
function isThinkingOnly(child: unknown): boolean {
|
|
160
|
+
if (!isKind(child, AssistantMessageComponent)) return false;
|
|
161
|
+
const content = (child as AssistantLike).lastMessage?.content ?? [];
|
|
162
|
+
return !content.some((block) => block.type === "text" && block.text?.trim());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What may sit between two calls of one group: a turn that only thought, and the
|
|
167
|
+
* status lines Pi writes into the chat itself, such as "Tool output: collapsed".
|
|
168
|
+
*/
|
|
169
|
+
function isFoldable(child: unknown): boolean {
|
|
170
|
+
return isThinkingOnly(child) || isKind(child, Text) || isKind(child, Spacer);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Replace each run of reads, searches, and listings with one group. What sits
|
|
175
|
+
* between two of them folds into the group, as Claude Code keeps thinking out
|
|
176
|
+
* of the way; anything else, text, a command, an edit, ends the run.
|
|
177
|
+
*/
|
|
178
|
+
export function groupChildren(
|
|
179
|
+
children: readonly unknown[],
|
|
180
|
+
makeGroup: (rows: ToolRowLike[], members: unknown[]) => Component,
|
|
181
|
+
): unknown[] {
|
|
182
|
+
const grouped: unknown[] = [];
|
|
183
|
+
let rows: ToolRowLike[] = [];
|
|
184
|
+
let members: unknown[] = [];
|
|
185
|
+
let pending: unknown[] = [];
|
|
186
|
+
const flush = () => {
|
|
187
|
+
if (rows.length > 0) grouped.push(makeGroup(rows, members));
|
|
188
|
+
rows = [];
|
|
189
|
+
members = [];
|
|
190
|
+
};
|
|
191
|
+
for (const child of children) {
|
|
192
|
+
if (isGroupable(child)) {
|
|
193
|
+
rows.push(child);
|
|
194
|
+
members.push(...pending, child);
|
|
195
|
+
pending = [];
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (rows.length > 0 && isFoldable(child)) {
|
|
199
|
+
pending.push(child);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
flush();
|
|
203
|
+
grouped.push(...pending, child);
|
|
204
|
+
pending = [];
|
|
205
|
+
}
|
|
206
|
+
flush();
|
|
207
|
+
grouped.push(...pending);
|
|
208
|
+
return grouped;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Claude style: runs of reads, searches, and listings fold into one line each. */
|
|
212
|
+
export class ToolGroupController {
|
|
213
|
+
private context: ExtensionContext | undefined;
|
|
214
|
+
private readonly states = new WeakMap<object, GroupState>();
|
|
215
|
+
|
|
216
|
+
constructor(private readonly runtime: ToolRuntime) {}
|
|
217
|
+
|
|
218
|
+
bind(ctx: ExtensionContext): void {
|
|
219
|
+
this.dispose();
|
|
220
|
+
this.context = ctx;
|
|
221
|
+
// An invisible widget is the extension API's way to reach Pi's TUI and its chat.
|
|
222
|
+
ctx.ui.setWidget(WIDGET_KEY, (tui) => {
|
|
223
|
+
const chat = findChat(tui);
|
|
224
|
+
if (chat) {
|
|
225
|
+
hookMethod(chat, "render", "claude.groups", (self, args, original) => {
|
|
226
|
+
if (!this.context || isSilent() || this.runtime.config.style !== "claude") return original.apply(self, args);
|
|
227
|
+
const children = self.children as unknown[];
|
|
228
|
+
self.children = groupChildren(children, (rows, members) => this.makeGroup(rows, members, tui));
|
|
229
|
+
try {
|
|
230
|
+
return original.apply(self, args);
|
|
231
|
+
} finally {
|
|
232
|
+
self.children = children;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return { render: () => [], invalidate() {} };
|
|
237
|
+
}, { placement: "belowEditor" });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
dispose(): void {
|
|
241
|
+
this.context?.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
242
|
+
this.context = undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private makeGroup(rows: ToolRowLike[], members: unknown[], tui: { requestRender(): void }): Component {
|
|
246
|
+
const key = rows[0]!;
|
|
247
|
+
let state = this.states.get(key);
|
|
248
|
+
if (!state) {
|
|
249
|
+
state = { expanded: key.expanded, lastHostExpanded: key.expanded };
|
|
250
|
+
this.states.set(key, state);
|
|
251
|
+
}
|
|
252
|
+
return new ToolGroupComponent(rows, members, state, (group, width, expanded) => this.draw(group, width, expanded),
|
|
253
|
+
() => tui.requestRender());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* A group's line reads like any other row: its status dot, then what it did in
|
|
258
|
+
* the title style. While it runs the dot pulses and the line below names what it
|
|
259
|
+
* looks at.
|
|
260
|
+
*/
|
|
261
|
+
private draw(rows: readonly ToolRowLike[], width: number, expanded: boolean): string[] {
|
|
262
|
+
const theme = this.context?.ui.theme;
|
|
263
|
+
if (!theme) return [];
|
|
264
|
+
const chrome = chromePainter(theme);
|
|
265
|
+
const fit = (line: string) => truncateToWidth(line, Math.max(1, width), "…");
|
|
266
|
+
const running = lastRunning(rows);
|
|
267
|
+
const failed = rows.some((row) => memberStatus(row) === "error");
|
|
268
|
+
const status: RowStatus = running ? "running" : failed ? "error" : "success";
|
|
269
|
+
let title = `${paintIndicator(theme, status, this.runtime.frame)} ${theme.fg("toolTitle", theme.bold(summarizeGroup(rows)))}`;
|
|
270
|
+
if (running) {
|
|
271
|
+
const started = this.runtime.timing(running.toolCallId)?.startedAt;
|
|
272
|
+
const elapsed = started === undefined ? 0 : Date.now() - started;
|
|
273
|
+
title += `${elapsed >= 2000 ? chrome(` · ${formatDurationMs(elapsed)}`) : ""}…`;
|
|
274
|
+
}
|
|
275
|
+
if (!expanded) title += chrome(` ${EXPAND_HINT}`);
|
|
276
|
+
const lines = ["", fit(` ${title}`)];
|
|
277
|
+
const hintText = groupHint(rows);
|
|
278
|
+
if (hintText) lines.push(fit(chrome(` └ ${hintText}`)));
|
|
279
|
+
if (!expanded) for (const failure of groupFailures(rows)) lines.push(fit(chrome(" └ ") + theme.fg("error", failure)));
|
|
280
|
+
return lines;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -72,7 +72,7 @@ export function getArgumentDetails(name: string, args: ToolArgs): ToolArgs {
|
|
|
72
72
|
return collectArgumentDetails(name, args);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
function shellSegments(command: string): string[] {
|
|
75
|
+
export function shellSegments(command: string): string[] {
|
|
76
76
|
const segments: string[] = [];
|
|
77
77
|
let current = "";
|
|
78
78
|
let quote: "'" | '"' | "`" | undefined;
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
Container,
|
|
5
5
|
sliceByColumn,
|
|
6
6
|
stripTerminalSequences,
|
|
7
|
+
truncateToWidth,
|
|
7
8
|
visibleWidth,
|
|
8
9
|
wrapTextWithAnsi,
|
|
9
10
|
} from "@earendil-works/pi-tui";
|
|
@@ -86,6 +87,48 @@ export function prefixedText(text: string, firstPrefix: string, continuationPref
|
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Lines under a tree prefix. With `truncate`, each keeps to one row and ends in an
|
|
92
|
+
* ellipsis, the way Claude Code previews; otherwise each wraps in full. Lines are
|
|
93
|
+
* handled one by one, so the indentation that output starts with survives.
|
|
94
|
+
*/
|
|
95
|
+
export function prefixedLines(text: string, firstPrefix: string, continuationPrefix: string, truncate: boolean): Component {
|
|
96
|
+
const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
|
|
97
|
+
const lines = text.replace(/\t/g, " ").split("\n");
|
|
98
|
+
return new CachedComponent((width) => {
|
|
99
|
+
const available = Math.max(1, width - prefixWidth);
|
|
100
|
+
const rows = truncate
|
|
101
|
+
? lines.map((line) => truncateToWidth(line, available, "…"))
|
|
102
|
+
: lines.flatMap((line) => hardWrapTextWithAnsi(line, available));
|
|
103
|
+
return rows.map((row, index) => `${index === 0 ? firstPrefix : continuationPrefix}${row}`);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Output as Claude Code previews it. Each line is wrapped to the width, and rows
|
|
109
|
+
* rather than lines are counted: collapsed, `maxRows` show, all of them when only
|
|
110
|
+
* one more would be hidden, and then `more(hidden)`. Claude Code wraps ten
|
|
111
|
+
* columns short of the terminal, so previews break where its own do.
|
|
112
|
+
*/
|
|
113
|
+
export function claudeRows(
|
|
114
|
+
lines: readonly string[],
|
|
115
|
+
firstPrefix: string,
|
|
116
|
+
continuationPrefix: string,
|
|
117
|
+
maxRows: number | undefined,
|
|
118
|
+
more: (hidden: number) => string,
|
|
119
|
+
): Component {
|
|
120
|
+
const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix));
|
|
121
|
+
return new CachedComponent((width) => {
|
|
122
|
+
const available = Math.max(1, width - prefixWidth);
|
|
123
|
+
const wrapWidth = maxRows === undefined ? available : Math.min(available, Math.max(width - 10, 10));
|
|
124
|
+
let rows = lines.flatMap((line) => hardWrapTextWithAnsi(line, wrapWidth).map((row) => row.trimEnd()));
|
|
125
|
+
if (maxRows !== undefined && rows.length > maxRows + 1) {
|
|
126
|
+
rows = [...rows.slice(0, maxRows), more(rows.length - maxRows)];
|
|
127
|
+
}
|
|
128
|
+
return rows.map((row, index) => `${index === 0 ? firstPrefix : continuationPrefix}${row}`);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
89
132
|
export function hardWrapTextWithAnsi(text: string, width: number): string[] {
|
|
90
133
|
const safeWidth = Math.max(1, width);
|
|
91
134
|
const wrapped: string[] = [];
|
|
@@ -145,6 +188,7 @@ export type CodeDiffLine = {
|
|
|
145
188
|
export type CodeDiffOptions = {
|
|
146
189
|
/** Context lines kept around each change; omit to keep every context line the diff carries. */
|
|
147
190
|
contextLines?: number;
|
|
191
|
+
rail?: string;
|
|
148
192
|
};
|
|
149
193
|
|
|
150
194
|
const DIFF_SEPARATOR = "\u22ee";
|
|
@@ -216,13 +260,12 @@ export function trimDiffContext(lines: CodeDiffLine[], contextLines: number): Co
|
|
|
216
260
|
});
|
|
217
261
|
const result: CodeDiffLine[] = [];
|
|
218
262
|
lines.forEach((line, index) => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
}
|
|
263
|
+
const previous = result[result.length - 1];
|
|
264
|
+
// One mark per gap: context dropped next to a hunk break the diff already marks
|
|
265
|
+
// would otherwise draw two.
|
|
266
|
+
if (previous?.kind === "separator" && (!kept[index] || line.kind === "separator")) return;
|
|
267
|
+
if (kept[index]) result.push(line);
|
|
268
|
+
else if (previous) result.push({ kind: "separator", content: DIFF_SEPARATOR, hunk: line.hunk });
|
|
226
269
|
});
|
|
227
270
|
while (result.length > 0 && result[result.length - 1]!.kind === "separator") result.pop();
|
|
228
271
|
return result;
|
|
@@ -302,6 +345,8 @@ type CodeRowsOptions = {
|
|
|
302
345
|
signs: boolean;
|
|
303
346
|
/** Dim note rendered below the code, such as a read truncation notice. */
|
|
304
347
|
footer?: string;
|
|
348
|
+
/** What each row starts with; the result rail unless a mode draws its own. */
|
|
349
|
+
rail?: string;
|
|
305
350
|
};
|
|
306
351
|
|
|
307
352
|
/** Render numbered, syntax-highlighted code rows shared by edit diffs and read/write file views. */
|
|
@@ -312,7 +357,7 @@ function renderCodeRows(lines: CodeDiffLine[], path: string, theme: Theme, optio
|
|
|
312
357
|
const shebangLine = first?.lineNumber === 1 && first.kind !== "separator" ? first.content : undefined;
|
|
313
358
|
const highlighted = highlightCodeLines(lines, resolveLanguage(path, shebangLine), theme);
|
|
314
359
|
const numberWidth = Math.max(1, ...lines.map((line) => String(line.lineNumber ?? "").length));
|
|
315
|
-
const prefix = paintChrome(theme, " \u2502 ");
|
|
360
|
+
const prefix = options.rail ?? paintChrome(theme, " \u2502 ");
|
|
316
361
|
const prefixWidth = visibleWidth(prefix);
|
|
317
362
|
const gutterWidth = numberWidth + (options.signs ? 3 : 2);
|
|
318
363
|
const addedTint = options.signs ? diffTintRgb(theme, "toolDiffAdded") : undefined;
|
|
@@ -362,13 +407,14 @@ export function renderCodeDiff(
|
|
|
362
407
|
const diff = parseCodeDiff(patch, displayDiff);
|
|
363
408
|
const lines = options.contextLines === undefined ? diff : trimDiffContext(diff, options.contextLines);
|
|
364
409
|
if (lines.length === 0) return undefined;
|
|
365
|
-
return renderCodeRows(lines, path, theme, { signs: true });
|
|
410
|
+
return renderCodeRows(lines, path, theme, { signs: true, rail: options.rail });
|
|
366
411
|
}
|
|
367
412
|
|
|
368
413
|
export type CodeViewOptions = {
|
|
369
414
|
/** File line number of the first code line, e.g. a read's `offset`. */
|
|
370
415
|
startLine?: number;
|
|
371
416
|
footer?: string;
|
|
417
|
+
rail?: string;
|
|
372
418
|
};
|
|
373
419
|
|
|
374
420
|
/** Render file contents with line numbers and syntax highlighting, matching the edit diff layout. */
|
|
@@ -382,20 +428,21 @@ export function renderCodeView(code: string, path: string, theme: Theme, options
|
|
|
382
428
|
content: content.replace(/\t/gu, " "),
|
|
383
429
|
hunk: 0,
|
|
384
430
|
}));
|
|
385
|
-
return renderCodeRows(lines, path, theme, { signs: false, footer: options.footer });
|
|
431
|
+
return renderCodeRows(lines, path, theme, { signs: false, footer: options.footer, rail: options.rail });
|
|
386
432
|
}
|
|
387
433
|
|
|
388
434
|
/** Limit a result preview by rendered rows while preserving the full source component for expansion. */
|
|
389
|
-
export function limitComponentLines(
|
|
435
|
+
export function limitComponentLines(
|
|
436
|
+
component: Component,
|
|
437
|
+
maximumLines: number,
|
|
438
|
+
theme: Theme,
|
|
439
|
+
footer: (omitted: number) => string = (omitted) => paintChrome(theme, " │ ")
|
|
440
|
+
+ theme.fg("toolOutput", `… ${omitted} more ${omitted === 1 ? "line" : "lines"}`),
|
|
441
|
+
): Component {
|
|
390
442
|
return new CachedComponent((width) => {
|
|
391
443
|
const lines = component.render(width);
|
|
392
444
|
if (lines.length <= maximumLines) return lines;
|
|
393
|
-
|
|
394
|
-
return [
|
|
395
|
-
...lines.slice(0, maximumLines),
|
|
396
|
-
paintChrome(theme, " │ ")
|
|
397
|
-
+ theme.fg("toolOutput", `… ${omitted} more ${omitted === 1 ? "line" : "lines"}`),
|
|
398
|
-
];
|
|
445
|
+
return [...lines.slice(0, maximumLines), footer(lines.length - maximumLines)];
|
|
399
446
|
}, () => component.invalidate?.());
|
|
400
447
|
}
|
|
401
448
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { indicatorGlyph, indicatorStrength, indicatorTone, type RowStatus } from "./compact-tools-core.ts";
|
|
2
3
|
import {
|
|
3
4
|
colorizeRgb,
|
|
4
5
|
interpolateRgb,
|
|
@@ -108,6 +109,15 @@ export function indicatorPulse(theme: Theme): ColorRamp | undefined {
|
|
|
108
109
|
return ramp(theme, PULSE_LOW, PULSE_HIGH);
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/** A row's status dot; a running one pulses between the theme's endpoints as frames advance. */
|
|
113
|
+
export function paintIndicator(theme: Theme, status: RowStatus, frame: number): string {
|
|
114
|
+
const glyph = indicatorGlyph(status, frame);
|
|
115
|
+
const pulse = status === "running" ? indicatorPulse(theme) : undefined;
|
|
116
|
+
return pulse
|
|
117
|
+
? colorizeRgb(theme, interpolateRgb(pulse.from, pulse.to, indicatorStrength(status, frame)), glyph)
|
|
118
|
+
: theme.fg(indicatorTone(status, frame), glyph);
|
|
119
|
+
}
|
|
120
|
+
|
|
111
121
|
/** How far the brightest activity dot moves from the level's color toward the label's crest. */
|
|
112
122
|
const ACTIVITY_CREST_MIX = 0.3;
|
|
113
123
|
/** Contrast against the background of an unlit dot: the level's hue, barely there. */
|
|
@@ -39,25 +39,27 @@ export function compareVersions(left: string, right: string): number {
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* The notes to show, newest first: every version with notes after the last one
|
|
42
|
-
* shown, up to the installed one.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* shown, up to the installed one. An empty list marks a quiet release, such as a
|
|
43
|
+
* documentation update, that shows nothing. With nothing shown before, only the
|
|
44
|
+
* newest notes up to the installed version appear: that is either a new install
|
|
45
|
+
* or an update from a version that predates these notices, and there is no
|
|
46
|
+
* telling which.
|
|
45
47
|
*/
|
|
46
48
|
export function releasesToShow(
|
|
47
49
|
installed: string,
|
|
48
50
|
notes: ReleaseNotes,
|
|
49
51
|
lastShown: string | undefined,
|
|
50
52
|
): Array<{ version: string; notes: readonly string[] }> {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
: Object.keys(notes).filter((version) =>
|
|
53
|
+
const withNotes = Object.keys(notes)
|
|
54
|
+
.filter((version) =>
|
|
54
55
|
VERSION.test(version)
|
|
55
|
-
&& compareVersions(version, lastShown) > 0
|
|
56
56
|
&& compareVersions(version, installed) <= 0
|
|
57
|
-
&& notes[version]!.length > 0)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.
|
|
57
|
+
&& notes[version]!.length > 0)
|
|
58
|
+
.sort((left, right) => compareVersions(right, left));
|
|
59
|
+
const versions = lastShown === undefined
|
|
60
|
+
? withNotes.slice(0, 1)
|
|
61
|
+
: withNotes.filter((version) => compareVersions(version, lastShown) > 0);
|
|
62
|
+
return versions.map((version) => ({ version, notes: notes[version]! }));
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
function lastShownVersion(directory: string): string | undefined {
|
|
@@ -38,6 +38,16 @@ export class ToolRuntime {
|
|
|
38
38
|
return this.configValue;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** The shared animation frame of running indicators, for rows drawn outside a tool renderer. */
|
|
42
|
+
get frame(): number {
|
|
43
|
+
return this.indicatorFrame;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** When a tool call started and, once it has, ended. */
|
|
47
|
+
timing(toolCallId: string): Readonly<ExecutionTiming> | undefined {
|
|
48
|
+
return this.executionTimings.get(toolCallId);
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
configure(config: CompactToolsConfig): void {
|
|
42
52
|
this.configValue = config;
|
|
43
53
|
this.configRevision = {};
|
|
@@ -59,7 +59,7 @@ function sharedState(): SilentState {
|
|
|
59
59
|
* an install with a second copy (a development checkout, for one) still builds
|
|
60
60
|
* the same classes, so a matching name identifies them there.
|
|
61
61
|
*/
|
|
62
|
-
function isKind(child: unknown, kind: abstract new (...args: any[]) => unknown): boolean {
|
|
62
|
+
export function isKind(child: unknown, kind: abstract new (...args: any[]) => unknown): boolean {
|
|
63
63
|
return child instanceof kind
|
|
64
64
|
|| (typeof child === "object" && child !== null && child.constructor?.name === kind.name);
|
|
65
65
|
}
|
|
@@ -159,10 +159,16 @@ type TuiLike = { children?: Array<{ children?: unknown[] }> };
|
|
|
159
159
|
* Pi mounts the transcript document first in both regular and fullscreen mode, and
|
|
160
160
|
* its chat is the document's last child, after the header and resource list.
|
|
161
161
|
*/
|
|
162
|
-
function
|
|
162
|
+
export function findChat(tui: unknown): { children: unknown[] } | undefined {
|
|
163
163
|
const documentChildren = (tui as TuiLike).children?.[0]?.children;
|
|
164
164
|
const chat = Array.isArray(documentChildren) ? documentChildren.at(-1) : undefined;
|
|
165
|
-
if (typeof chat !== "object" || chat === null || !Array.isArray((chat as { children?: unknown }).children)) return;
|
|
165
|
+
if (typeof chat !== "object" || chat === null || !Array.isArray((chat as { children?: unknown }).children)) return undefined;
|
|
166
|
+
return chat as { children: unknown[] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function hookChatNotices(tui: unknown): void {
|
|
170
|
+
const chat = findChat(tui);
|
|
171
|
+
if (!chat) return;
|
|
166
172
|
hookMethod(chat, "render", "silent.chatNotices", (self, args, original) => {
|
|
167
173
|
const state = sharedState();
|
|
168
174
|
if (!state.active || !state.enabled) return original.apply(self, args);
|
|
@@ -10,6 +10,9 @@ export type BuiltInDefinition = ToolDefinition<any, any, any>;
|
|
|
10
10
|
|
|
11
11
|
export const DISPLAY_MODES = ["normal", "silent"] as const;
|
|
12
12
|
export type DisplayMode = (typeof DISPLAY_MODES)[number];
|
|
13
|
+
/** How tool rows are drawn: compactly, the way Claude Code draws them, or by Pi itself. */
|
|
14
|
+
export const DISPLAY_STYLES = ["compact", "claude", "off"] as const;
|
|
15
|
+
export type DisplayStyle = (typeof DISPLAY_STYLES)[number];
|
|
13
16
|
|
|
14
17
|
export interface CustomToolsConfig {
|
|
15
18
|
/** Render tools registered by other extensions compactly as well. */
|
|
@@ -20,6 +23,7 @@ export interface CustomToolsConfig {
|
|
|
20
23
|
|
|
21
24
|
export interface CompactToolsConfig {
|
|
22
25
|
mode: DisplayMode;
|
|
26
|
+
style: DisplayStyle;
|
|
23
27
|
tools: CompactToolName[];
|
|
24
28
|
/** Built-ins always have an entry; custom tools may, and otherwise use custom_tools.auto_compact. */
|
|
25
29
|
auto_compact: Record<CompactToolName, boolean> & { [tool: string]: boolean | undefined };
|