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.
@@ -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
- if (kept[index]) {
220
- result.push(line);
221
- return;
222
- }
223
- if (result.length > 0 && result[result.length - 1]!.kind !== "separator") {
224
- result.push({ kind: "separator", content: DIFF_SEPARATOR, hunk: line.hunk });
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(component: Component, maximumLines: number, theme: Theme): Component {
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
- const omitted = lines.length - maximumLines;
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. With nothing shown before, only the installed
43
- * version's notes appear: that is either a new install or an update from a
44
- * version that predates these notices, and there is no telling which.
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 versions = lastShown === undefined
52
- ? (notes[installed]?.length ? [installed] : [])
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
- return versions
59
- .sort((left, right) => compareVersions(right, left))
60
- .map((version) => ({ version, notes: notes[version]! }));
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 hookChatNotices(tui: unknown): void {
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 };