pi-compact-tools 0.13.2 → 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 +48 -26
- 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 +115 -19
- package/extensions/compact-tools-palette.ts +6 -7
- package/extensions/compact-tools-release.ts +30 -7
- package/extensions/compact-tools-shell.ts +277 -0
- package/extensions/compact-tools-silent.ts +4 -1
- package/extensions/compact-tools-types.ts +9 -2
- package/extensions/compact-tools.ts +107 -34
- package/package.json +4 -2
- package/release-notes.json +14 -0
- package/schemas/compact-tools.schema.json +3 -3
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell commands as both summary styles read them: split into words the way the
|
|
3
|
+
* shell would, and checked for anything that writes or runs something else. A row
|
|
4
|
+
* that folds into a "looked around" group hides its command, so a command that
|
|
5
|
+
* changes the disk must never pass for one that only reads it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A redirection and where it points: `>`, `2>`, `&>`, `>&` with `1`, `<`. */
|
|
9
|
+
export type Redirect = { operator: string; target: string };
|
|
10
|
+
|
|
11
|
+
/** One command between connectors: its words, and the redirections written into it. */
|
|
12
|
+
export type ShellCommand = { words: string[]; redirects: Redirect[] };
|
|
13
|
+
|
|
14
|
+
export type ParsedShell = {
|
|
15
|
+
commands: ShellCommand[];
|
|
16
|
+
/** `$(…)`, backticks, `<(…)`, a subshell, or a background job: what runs there cannot be read off the words. */
|
|
17
|
+
opaque: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const CONNECTORS = ["&&", "||", "|&", "|", ";"];
|
|
21
|
+
const REDIRECTS = ["&>>", "&>", ">>", ">&", ">|", "<>", "<&", ">", "<"];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Split a command the way the shell does: quotes removed, a quoted `;` or newline
|
|
25
|
+
* kept as text, and connectors, newlines outside quotes, and redirections kept
|
|
26
|
+
* apart from words. Undefined when it cannot be split: an unterminated quote, or a
|
|
27
|
+
* here-document whose body would read as commands.
|
|
28
|
+
*/
|
|
29
|
+
export function parseShell(source: string): ParsedShell | undefined {
|
|
30
|
+
const commands: ShellCommand[] = [{ words: [], redirects: [] }];
|
|
31
|
+
let opaque = false;
|
|
32
|
+
let word = "";
|
|
33
|
+
let started = false;
|
|
34
|
+
let redirect: string | undefined;
|
|
35
|
+
const current = () => commands[commands.length - 1]!;
|
|
36
|
+
const flush = () => {
|
|
37
|
+
if (!started) return;
|
|
38
|
+
if (redirect !== undefined) {
|
|
39
|
+
current().redirects.push({ operator: redirect, target: word });
|
|
40
|
+
redirect = undefined;
|
|
41
|
+
} else {
|
|
42
|
+
current().words.push(word);
|
|
43
|
+
}
|
|
44
|
+
word = "";
|
|
45
|
+
started = false;
|
|
46
|
+
};
|
|
47
|
+
const connect = () => {
|
|
48
|
+
flush();
|
|
49
|
+
if (redirect !== undefined) return false;
|
|
50
|
+
if (current().words.length > 0 || current().redirects.length > 0) commands.push({ words: [], redirects: [] });
|
|
51
|
+
return true;
|
|
52
|
+
};
|
|
53
|
+
for (let index = 0; index < source.length; index++) {
|
|
54
|
+
const character = source[index]!;
|
|
55
|
+
if (character === "'") {
|
|
56
|
+
const end = source.indexOf("'", index + 1);
|
|
57
|
+
if (end < 0) return undefined;
|
|
58
|
+
word += source.slice(index + 1, end);
|
|
59
|
+
started = true;
|
|
60
|
+
index = end;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (character === "\"") {
|
|
64
|
+
let end = index + 1;
|
|
65
|
+
for (; end < source.length && source[end] !== "\""; end++) {
|
|
66
|
+
const inner = source[end]!;
|
|
67
|
+
if (inner === "\\" && end + 1 < source.length && "\"\\$`\n".includes(source[end + 1]!)) {
|
|
68
|
+
if (source[end + 1] !== "\n") word += source[end + 1];
|
|
69
|
+
end++;
|
|
70
|
+
} else {
|
|
71
|
+
if (inner === "`" || (inner === "$" && source[end + 1] === "(")) opaque = true;
|
|
72
|
+
word += inner;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (end >= source.length) return undefined;
|
|
76
|
+
started = true;
|
|
77
|
+
index = end;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (character === "\\") {
|
|
81
|
+
// A backslash before a newline joins the lines; before anything else it quotes it.
|
|
82
|
+
if (source[index + 1] !== "\n" && index + 1 < source.length) {
|
|
83
|
+
word += source[index + 1];
|
|
84
|
+
started = true;
|
|
85
|
+
}
|
|
86
|
+
index++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (character === " " || character === "\t") {
|
|
90
|
+
flush();
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (character === "\n") {
|
|
94
|
+
if (!connect()) return undefined;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (character === "#" && !started) {
|
|
98
|
+
const end = source.indexOf("\n", index);
|
|
99
|
+
index = end < 0 ? source.length : end - 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (character === "`" || (character === "$" && source[index + 1] === "(")) {
|
|
103
|
+
opaque = true;
|
|
104
|
+
word += character;
|
|
105
|
+
started = true;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const rest = source.slice(index);
|
|
109
|
+
if (rest.startsWith("<<")) return undefined;
|
|
110
|
+
if ((character === "<" || character === ">") && source[index + 1] === "(") {
|
|
111
|
+
opaque = true;
|
|
112
|
+
word += character;
|
|
113
|
+
started = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const redirection = REDIRECTS.find((operator) => rest.startsWith(operator));
|
|
117
|
+
if (redirection) {
|
|
118
|
+
// A word of digits right before it names the descriptor: `2>`.
|
|
119
|
+
const descriptor = started && /^\d+$/u.test(word) ? word : "";
|
|
120
|
+
if (descriptor) {
|
|
121
|
+
word = "";
|
|
122
|
+
started = false;
|
|
123
|
+
}
|
|
124
|
+
flush();
|
|
125
|
+
if (redirect !== undefined) return undefined;
|
|
126
|
+
redirect = descriptor + redirection;
|
|
127
|
+
index += redirection.length - 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const connector = CONNECTORS.find((operator) => rest.startsWith(operator));
|
|
131
|
+
if (connector) {
|
|
132
|
+
if (!connect()) return undefined;
|
|
133
|
+
index += connector.length - 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (character === "&" || character === "(" || character === ")") {
|
|
137
|
+
opaque = true;
|
|
138
|
+
if (!connect()) return undefined;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
word += character;
|
|
142
|
+
started = true;
|
|
143
|
+
}
|
|
144
|
+
flush();
|
|
145
|
+
if (redirect !== undefined) return undefined;
|
|
146
|
+
return { commands: commands.filter((command) => command.words.length > 0 || command.redirects.length > 0), opaque };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether a redirection only discards output or joins two streams, rather than reading or writing a file. */
|
|
150
|
+
export function isHarmlessRedirect({ operator, target }: Redirect): boolean {
|
|
151
|
+
if (/[>&]&$/u.test(operator) || operator.endsWith("<&")) return /^(?:\d+|-)$/u.test(target);
|
|
152
|
+
return operator.includes(">") && target === "/dev/null";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const FIND_ACTIONS = new Set(["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"]);
|
|
156
|
+
|
|
157
|
+
function shortFlag(arg: string, letter: string): boolean {
|
|
158
|
+
return /^-[^-]/u.test(arg) && arg.slice(1).includes(letter);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function operandCount(args: readonly string[]): number {
|
|
162
|
+
return args.filter((arg) => !arg.startsWith("-") || arg === "-").length;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* sed scripts given with -e, or the first operand when there is none. A short option
|
|
167
|
+
* takes its value from the rest of its word or the next one: `-e'w out'`, `-ne 'w out'`.
|
|
168
|
+
*/
|
|
169
|
+
function sedScripts(args: readonly string[]): string[] | undefined {
|
|
170
|
+
const scripts: string[] = [];
|
|
171
|
+
const operands: string[] = [];
|
|
172
|
+
for (let index = 0; index < args.length; index++) {
|
|
173
|
+
const arg = args[index]!;
|
|
174
|
+
if (arg === "--expression") scripts.push(args[++index] ?? "");
|
|
175
|
+
else if (arg.startsWith("--expression=")) scripts.push(arg.slice("--expression=".length));
|
|
176
|
+
// A script read from a file cannot be checked.
|
|
177
|
+
else if (arg === "--file" || arg.startsWith("--file=")) return undefined;
|
|
178
|
+
else if (/^-[^-]/u.test(arg)) {
|
|
179
|
+
// A cluster of single-letter options; e and f take what follows them.
|
|
180
|
+
for (let position = 1; position < arg.length; position++) {
|
|
181
|
+
const letter = arg[position];
|
|
182
|
+
if (letter === "f") return undefined;
|
|
183
|
+
if (letter !== "e") continue;
|
|
184
|
+
scripts.push(position + 1 < arg.length ? arg.slice(position + 1) : args[++index] ?? "");
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
} else if (!arg.startsWith("-")) operands.push(arg);
|
|
188
|
+
}
|
|
189
|
+
if (scripts.length === 0 && operands[0] !== undefined) scripts.push(operands[0]);
|
|
190
|
+
return scripts;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Whether one sed command writes a file or runs one: `w`, `W`, `e`, or an `s` with the w or e flag. */
|
|
194
|
+
function sedCommandWrites(command: string): boolean {
|
|
195
|
+
// Past the address: line numbers, `$`, `/regex/` ranges, and `!`.
|
|
196
|
+
const body = command.replace(/^\s*(?:(?:\d+|\$|\/(?:\\.|[^/])*\/)\s*(?:[,~]\s*(?:\d+|\$|\/(?:\\.|[^/])*\/))?\s*!?\s*)?/u, "");
|
|
197
|
+
const verb = body[0];
|
|
198
|
+
if (verb === "w" || verb === "W" || verb === "e") return true;
|
|
199
|
+
if (verb !== "s" || body.length < 2) return false;
|
|
200
|
+
const delimiter = body[1]!;
|
|
201
|
+
let seen = 0;
|
|
202
|
+
let index = 2;
|
|
203
|
+
for (; index < body.length && seen < 2; index++) {
|
|
204
|
+
if (body[index] === "\\") index++;
|
|
205
|
+
else if (body[index] === delimiter) seen++;
|
|
206
|
+
}
|
|
207
|
+
return /^[^;}\s]*[we]/u.test(body.slice(index));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sedWrites(args: readonly string[]): boolean {
|
|
211
|
+
// An i before any e or f in a cluster is the in-place flag; after one it belongs to the script.
|
|
212
|
+
const inPlace = (arg: string) => /^-[^-ef]*i/u.test(arg);
|
|
213
|
+
if (args.some((arg) => arg === "--in-place" || arg.startsWith("--in-place=") || inPlace(arg))) return true;
|
|
214
|
+
const scripts = sedScripts(args);
|
|
215
|
+
if (!scripts) return true;
|
|
216
|
+
return scripts.some((script) => script.split(/[;\n]/u).some(sedCommandWrites));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** awk can print into files and pipes and run commands; a program it reads from a file cannot be checked. */
|
|
220
|
+
function awkWrites(args: readonly string[]): boolean {
|
|
221
|
+
let program: string | undefined;
|
|
222
|
+
for (let index = 0; index < args.length; index++) {
|
|
223
|
+
const arg = args[index]!;
|
|
224
|
+
if (arg === "-f" || arg.startsWith("--file")) return true;
|
|
225
|
+
if (arg === "-v" || arg === "-F") index++;
|
|
226
|
+
else if (!arg.startsWith("-")) {
|
|
227
|
+
program = arg;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return program === undefined || /system\s*\(|[|>]/u.test(program);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** An option given alone or with its value attached: `--pager`, `--pager=less`. */
|
|
235
|
+
function hasOption(args: readonly string[], ...names: string[]): boolean {
|
|
236
|
+
return args.some((arg) => names.some((name) => arg === name || arg.startsWith(`${name}=`)));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Whether a command can change files or run another program, though the program it
|
|
241
|
+
* names usually only reads: `find -delete`, `fd -x`, `rg --pre`, a pager it is told
|
|
242
|
+
* to run, `sort -o`, an in-place or writing `sed`, an `awk` that prints to a file,
|
|
243
|
+
* and `xargs` and `tee`, which exist to run and to write.
|
|
244
|
+
*/
|
|
245
|
+
export function writesOrRuns(words: readonly string[]): boolean {
|
|
246
|
+
const [program, ...args] = words;
|
|
247
|
+
switch (program) {
|
|
248
|
+
case "git": {
|
|
249
|
+
// `git grep -O` opens its matches in a pager it runs; an option before the subcommand can set one.
|
|
250
|
+
const [sub, ...rest] = args;
|
|
251
|
+
if (sub !== "grep") return sub?.startsWith("-") === true;
|
|
252
|
+
return rest.some((arg) => /^-[^-]*O/u.test(arg)) || hasOption(rest, "--open-files-in-pager");
|
|
253
|
+
}
|
|
254
|
+
case "ag": case "ack": case "pt": case "bat": case "batcat":
|
|
255
|
+
return hasOption(args, "--pager");
|
|
256
|
+
case "xargs": case "tee":
|
|
257
|
+
return true;
|
|
258
|
+
case "find":
|
|
259
|
+
return args.some((arg) => FIND_ACTIONS.has(arg));
|
|
260
|
+
case "fd": case "fdfind":
|
|
261
|
+
return args.some((arg) => arg === "-x" || arg === "-X" || /^--exec(?:-batch)?(?:=|$)/u.test(arg));
|
|
262
|
+
case "rg":
|
|
263
|
+
return hasOption(args, "--pre", "--hostname-bin");
|
|
264
|
+
case "sort":
|
|
265
|
+
return args.some((arg) => arg.startsWith("--output") || arg.startsWith("--compress-program") || shortFlag(arg, "o"));
|
|
266
|
+
case "uniq":
|
|
267
|
+
return operandCount(args) >= 2;
|
|
268
|
+
case "tree":
|
|
269
|
+
return args.some((arg) => arg === "-o" || arg.startsWith("--output"));
|
|
270
|
+
case "sed":
|
|
271
|
+
return sedWrites(args);
|
|
272
|
+
case "awk": case "gawk": case "mawk": case "nawk":
|
|
273
|
+
return awkWrites(args);
|
|
274
|
+
default:
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -274,8 +274,11 @@ export class SilentModeController {
|
|
|
274
274
|
pi.registerCommand("silent", {
|
|
275
275
|
description: `(${SILENT_SHORTCUT}) Show only prompts and final answers`,
|
|
276
276
|
getArgumentCompletions: (prefix) => {
|
|
277
|
+
// Pi applies a shown completion on Enter instead of running the command, so a value
|
|
278
|
+
// already typed in full is not offered again: Enter then runs it the first time.
|
|
279
|
+
const typed = prefix.trim().toLowerCase();
|
|
277
280
|
const items = COMMAND_ARGUMENTS
|
|
278
|
-
.filter((value) => value.startsWith(
|
|
281
|
+
.filter((value) => value.startsWith(typed) && value !== typed)
|
|
279
282
|
.map((value) => ({ value, label: value }));
|
|
280
283
|
return items.length > 0 ? items : null;
|
|
281
284
|
},
|
|
@@ -10,8 +10,8 @@ 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;
|
|
13
|
+
/** How tool rows are drawn: compactly, the way Claude Code or Codex draws them, or by Pi itself. */
|
|
14
|
+
export const DISPLAY_STYLES = ["compact", "claude", "codex", "off"] as const;
|
|
15
15
|
export type DisplayStyle = (typeof DISPLAY_STYLES)[number];
|
|
16
16
|
|
|
17
17
|
export interface CustomToolsConfig {
|
|
@@ -51,6 +51,13 @@ export interface RowState {
|
|
|
51
51
|
lastResultError?: boolean;
|
|
52
52
|
lastResultConfigRevision?: object;
|
|
53
53
|
lastResultPalette?: object;
|
|
54
|
+
/** What a Codex row's head reads from its result: a command's exit code, an edit's counts. */
|
|
55
|
+
codex?: {
|
|
56
|
+
exitCode?: number;
|
|
57
|
+
changes?: { added: number; removed: number };
|
|
58
|
+
/** Whether the row draws opened at a width: Codex opens only a call whose preview leaves something out. */
|
|
59
|
+
opens?: (width: number) => boolean;
|
|
60
|
+
};
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
type BaseRenderContext = Parameters<NonNullable<BuiltInDefinition["renderCall"]>>[2];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
1
2
|
import type {
|
|
2
3
|
AgentToolResult,
|
|
3
4
|
ExtensionAPI,
|
|
@@ -15,12 +16,14 @@ import {
|
|
|
15
16
|
createPowerShellToolDefinition,
|
|
16
17
|
createReadToolDefinition,
|
|
17
18
|
createWriteToolDefinition,
|
|
19
|
+
ToolExecutionComponent,
|
|
18
20
|
} from "@earendil-works/pi-coding-agent";
|
|
19
21
|
import type { Component } from "@earendil-works/pi-tui";
|
|
20
22
|
import { classifyCallStatus, formatDurationMs, normalizeLineEndings, type RowStatus } from "./compact-tools-core.ts";
|
|
21
23
|
import { loadConfig } from "./compact-tools-config.ts";
|
|
22
24
|
import { paletteOf } from "./compact-tools-color.ts";
|
|
23
|
-
import {
|
|
25
|
+
import { registerCompactToolsCommand } from "./compact-tools-command.ts";
|
|
26
|
+
import { clearRowResolver, installToolRowPatch, setRowResolver, type RowRenderers, type ToolRow } from "./compact-tools-custom.ts";
|
|
24
27
|
import {
|
|
25
28
|
formatResultLineSummary,
|
|
26
29
|
countEditChanges,
|
|
@@ -41,7 +44,7 @@ import {
|
|
|
41
44
|
claudeRows,
|
|
42
45
|
LiveCallContainer,
|
|
43
46
|
limitComponentLines,
|
|
44
|
-
|
|
47
|
+
prefixedSentence,
|
|
45
48
|
prefixedText,
|
|
46
49
|
railComponent,
|
|
47
50
|
renderArguments,
|
|
@@ -50,14 +53,17 @@ import {
|
|
|
50
53
|
renderOutput,
|
|
51
54
|
renderToolCall,
|
|
52
55
|
styleMultiline,
|
|
56
|
+
WithinWidth,
|
|
53
57
|
} from "./compact-tools-layout.ts";
|
|
54
|
-
import { CLAUDE_DIFF_CONTEXT_LINES, CLAUDE_OUTPUT_ROWS, CLAUDE_WRITE_ROWS, claudeFailure, claudeOutcome, claudeTitle } from "./compact-tools-claude.ts";
|
|
58
|
+
import { CLAUDE_DIFF_CONTEXT_LINES, CLAUDE_INDENT, CLAUDE_OUTPUT_ROWS, CLAUDE_RESULT_PREFIX, CLAUDE_TITLE_INDENT, CLAUDE_WRITE_ROWS, claudeFailure, claudeOutcome, claudeTitle } from "./compact-tools-claude.ts";
|
|
59
|
+
import { CODEX_BULLET } from "./compact-tools-codex.ts";
|
|
60
|
+
import { renderCodexCall, renderCodexResult } from "./compact-tools-codex-rows.ts";
|
|
55
61
|
import { ToolGroupController } from "./compact-tools-grouping.ts";
|
|
56
62
|
import { chromePainter, paintIndicator } from "./compact-tools-palette.ts";
|
|
57
63
|
import { ProgressController } from "./compact-tools-progress.ts";
|
|
58
64
|
import { showReleaseNotice } from "./compact-tools-release.ts";
|
|
59
65
|
import { ToolRuntime } from "./compact-tools-runtime.ts";
|
|
60
|
-
import { SilentModeController } from "./compact-tools-silent.ts";
|
|
66
|
+
import { findChat, isKind, SilentModeController } from "./compact-tools-silent.ts";
|
|
61
67
|
import { ThinkingCycleController } from "./compact-tools-thinking.ts";
|
|
62
68
|
import { ViewportKeeper } from "./compact-tools-viewport.ts";
|
|
63
69
|
import {
|
|
@@ -71,13 +77,28 @@ import {
|
|
|
71
77
|
|
|
72
78
|
/** Context lines kept around each change while an edit result is collapsed. */
|
|
73
79
|
const PREVIEW_DIFF_CONTEXT_LINES = 1;
|
|
74
|
-
/** Claude Code sets what a call returned under the "⎿" rather than beside a rail. */
|
|
75
|
-
const CLAUDE_INDENT = " ";
|
|
76
80
|
|
|
77
81
|
const runtime = new ToolRuntime();
|
|
78
82
|
const RENDER_WIDGET_KEY = "compact-tools-render";
|
|
79
83
|
const registeredTools = new Set<CompactToolName>();
|
|
80
84
|
let registeredConfiguration: string | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Whether the last session found its directory trusted. A /reload rebuilds the
|
|
87
|
+
* transcript before session_start says so again, so the tools a trusted project
|
|
88
|
+
* adds are registered from the start rather than after its rows were made. Pi
|
|
89
|
+
* evaluates this module afresh on every load, so the answer is kept on globalThis.
|
|
90
|
+
*/
|
|
91
|
+
const TRUST_KEY = Symbol.for("pi-compact-tools.trust");
|
|
92
|
+
type TrustSlot = typeof globalThis & { [TRUST_KEY]?: { cwd: string; trusted: boolean } };
|
|
93
|
+
|
|
94
|
+
/** A directory as the file system names it, so `/var` and `/private/var` are one place. */
|
|
95
|
+
function realDirectory(path: string): string {
|
|
96
|
+
try {
|
|
97
|
+
return realpathSync(path);
|
|
98
|
+
} catch {
|
|
99
|
+
return path;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
81
102
|
|
|
82
103
|
/** How a tool's row is drawn: a file tool's result is code, a shell's is output, a custom tool's is its author's. */
|
|
83
104
|
type RowKind = "file" | "shell" | "custom";
|
|
@@ -91,6 +112,10 @@ function isClaude(): boolean {
|
|
|
91
112
|
return runtime.config.style === "claude";
|
|
92
113
|
}
|
|
93
114
|
|
|
115
|
+
function isCodex(): boolean {
|
|
116
|
+
return runtime.config.style === "codex";
|
|
117
|
+
}
|
|
118
|
+
|
|
94
119
|
function pathArgument(args: ToolArgs): string {
|
|
95
120
|
return typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : "";
|
|
96
121
|
}
|
|
@@ -108,13 +133,13 @@ function callStatus(ctx: RenderContext, state: RowState): RowStatus {
|
|
|
108
133
|
* result, so a dot painted with the call would miss the result that arrived with
|
|
109
134
|
* it; reading the row's state at draw time sees it. A settled dot never changes.
|
|
110
135
|
*/
|
|
111
|
-
function liveIndicator(theme: Theme, ctx: RenderContext, state: RowState): () => string {
|
|
136
|
+
function liveIndicator(theme: Theme, ctx: RenderContext, state: RowState, glyph?: string): () => string {
|
|
112
137
|
let settled: string | undefined;
|
|
113
138
|
return () => {
|
|
114
139
|
if (settled !== undefined) return settled;
|
|
115
140
|
const status = callStatus(ctx, state);
|
|
116
141
|
const indicator = paintIndicator(theme, status,
|
|
117
|
-
runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate()));
|
|
142
|
+
runtime.syncIndicator(ctx.toolCallId, status === "running", () => ctx.invalidate()), glyph);
|
|
118
143
|
if (status === "success" || status === "error") settled = indicator;
|
|
119
144
|
return indicator;
|
|
120
145
|
};
|
|
@@ -192,19 +217,22 @@ function renderRowCall(name: string, args: ToolArgs, theme: Theme, ctx: RenderCo
|
|
|
192
217
|
const state = runtime.syncRow(ctx);
|
|
193
218
|
runtime.syncExpansion(state, ctx.expanded, name);
|
|
194
219
|
const kind = rowKind(name);
|
|
195
|
-
const indicator = liveIndicator(theme, ctx, state);
|
|
220
|
+
const indicator = liveIndicator(theme, ctx, state, isCodex() ? CODEX_BULLET : undefined);
|
|
196
221
|
// Painted once now as well, so a row that starts running starts the animation clock.
|
|
197
222
|
indicator();
|
|
223
|
+
// Codex's head reads the call's outcome, so it is worded each time it is drawn.
|
|
224
|
+
if (isCodex()) return renderCodexCall(name, kind, args, theme, state, () => callStatus(ctx, state), indicator, ctx.toolCallId);
|
|
198
225
|
if (isClaude()) {
|
|
199
226
|
// Claude Code's call line: `⦁ Bash(npm test)`, `⦁ Update(src/app.ts)`.
|
|
200
227
|
const { label, argument } = claudeTitle(name, args, state.expanded === true);
|
|
201
228
|
const title = ` ${theme.fg("toolTitle", theme.bold(label))}`
|
|
202
229
|
+ (argument ? styleMultiline(`(${argument})`, (line) => theme.fg("toolOutput", line)) : "");
|
|
203
|
-
// The title wraps rather than being cut to the row; only a
|
|
204
|
-
|
|
230
|
+
// The title wraps under the tool's name rather than being cut to the row; only a
|
|
231
|
+
// very long command is shortened.
|
|
232
|
+
const container = new LiveCallContainer(indicator, (dot) => renderToolCall(dot + title, undefined, theme, CLAUDE_TITLE_INDENT));
|
|
205
233
|
// The title already lists plain arguments; only nested ones need the full view.
|
|
206
|
-
if (kind === "custom" && state.expanded && hasNestedArguments(args)) container.addChild(renderArguments(args, theme));
|
|
207
|
-
return container;
|
|
234
|
+
if (kind === "custom" && state.expanded && hasNestedArguments(args)) container.addChild(renderArguments(args, theme, CLAUDE_INDENT));
|
|
235
|
+
return new WithinWidth(container);
|
|
208
236
|
}
|
|
209
237
|
const title = ` ${theme.fg("toolTitle", theme.bold(name))}`;
|
|
210
238
|
let details: string | undefined;
|
|
@@ -220,10 +248,13 @@ function renderRowCall(name: string, args: ToolArgs, theme: Theme, ctx: RenderCo
|
|
|
220
248
|
const container = new LiveCallContainer(indicator, (dot) => renderToolCall(dot + title, details, theme));
|
|
221
249
|
const extra = kind === "file" ? getArgumentDetails(name, args) : kind === "custom" && state.expanded ? args : {};
|
|
222
250
|
if (Object.keys(extra).length > 0) container.addChild(renderArguments(extra, theme));
|
|
223
|
-
return container;
|
|
251
|
+
return new WithinWidth(container);
|
|
224
252
|
}
|
|
225
253
|
|
|
226
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* A result body as code where it is code: numbered file text, or an edit's diff.
|
|
256
|
+
* `rail` is what each row starts with; the compact result rail unless given.
|
|
257
|
+
*/
|
|
227
258
|
function renderFileBody(
|
|
228
259
|
name: string,
|
|
229
260
|
args: ToolArgs,
|
|
@@ -232,20 +263,22 @@ function renderFileBody(
|
|
|
232
263
|
expanded: boolean,
|
|
233
264
|
theme: Theme,
|
|
234
265
|
isError: boolean,
|
|
266
|
+
rail?: string,
|
|
235
267
|
): Component | undefined {
|
|
236
|
-
|
|
268
|
+
const fallback = () => renderOutput(output, theme, isError, rail);
|
|
269
|
+
if (isError) return fallback();
|
|
237
270
|
const path = pathArgument(args);
|
|
238
271
|
if (name === "edit" && getEditDiff(result)) {
|
|
239
272
|
return renderCodeDiff(getEditPatch(result), getEditDiff(result), path, theme,
|
|
240
|
-
expanded ? {} : { contextLines: PREVIEW_DIFF_CONTEXT_LINES }) ??
|
|
273
|
+
expanded ? { rail } : { contextLines: PREVIEW_DIFF_CONTEXT_LINES, rail }) ?? fallback();
|
|
241
274
|
}
|
|
242
275
|
if (name === "read" && isReadTextResult(result)) {
|
|
243
276
|
const { body, footer } = splitReadFooter(output);
|
|
244
277
|
const startLine = typeof args.offset === "number" ? args.offset : 1;
|
|
245
|
-
return renderCodeView(body, path, theme, { startLine, footer }) ??
|
|
278
|
+
return renderCodeView(body, path, theme, { startLine, footer, rail }) ?? fallback();
|
|
246
279
|
}
|
|
247
|
-
if (name === "write") return renderCodeView(output, path, theme) ??
|
|
248
|
-
return
|
|
280
|
+
if (name === "write") return renderCodeView(output, path, theme, { rail }) ?? fallback();
|
|
281
|
+
return fallback();
|
|
249
282
|
}
|
|
250
283
|
|
|
251
284
|
function renderRowBody(
|
|
@@ -257,17 +290,18 @@ function renderRowBody(
|
|
|
257
290
|
theme: Theme,
|
|
258
291
|
isError: boolean,
|
|
259
292
|
author: AuthorResultRenderer | undefined,
|
|
293
|
+
rail?: string,
|
|
260
294
|
): Component | undefined {
|
|
261
|
-
if (rowKind(name) !== "custom") return renderFileBody(name, args, result, output, expanded, theme, isError);
|
|
295
|
+
if (rowKind(name) !== "custom") return renderFileBody(name, args, result, output, expanded, theme, isError, rail);
|
|
262
296
|
if (author) {
|
|
263
297
|
try {
|
|
264
298
|
const component = author(expanded);
|
|
265
|
-
if (component) return railComponent(component, theme);
|
|
299
|
+
if (component) return railComponent(component, theme, rail);
|
|
266
300
|
} catch {
|
|
267
301
|
// A failing third-party renderer degrades to the plain text result.
|
|
268
302
|
}
|
|
269
303
|
}
|
|
270
|
-
return renderOutput(output, theme, isError);
|
|
304
|
+
return renderOutput(output, theme, isError, rail);
|
|
271
305
|
}
|
|
272
306
|
|
|
273
307
|
function claudeMoreLines(theme: Theme, hidden: number): string {
|
|
@@ -313,12 +347,12 @@ function claudeBody(
|
|
|
313
347
|
// A command's output is already the result line itself.
|
|
314
348
|
const kind = rowKind(name);
|
|
315
349
|
if (!expanded || kind === "shell") return undefined;
|
|
316
|
-
if (kind === "custom") return author ? renderRowBody(name, args, result, output, true, theme, false, author) : undefined;
|
|
317
|
-
return renderFileBody(name, args, result, output, true, theme, false);
|
|
350
|
+
if (kind === "custom") return author ? renderRowBody(name, args, result, output, true, theme, false, author, CLAUDE_INDENT) : undefined;
|
|
351
|
+
return renderFileBody(name, args, result, output, true, theme, false, CLAUDE_INDENT);
|
|
318
352
|
}
|
|
319
353
|
|
|
320
354
|
/**
|
|
321
|
-
* Claude Code's result block: a "
|
|
355
|
+
* Claude Code's result block: a "⎿" line saying what happened, or the output
|
|
322
356
|
* itself for commands and custom tools, then any diff or code beneath it.
|
|
323
357
|
*/
|
|
324
358
|
function renderClaudeResult(
|
|
@@ -335,7 +369,7 @@ function renderClaudeResult(
|
|
|
335
369
|
const chrome = chromePainter(theme);
|
|
336
370
|
const container = new CachedContainer();
|
|
337
371
|
// One line under the call, wrapped rather than cut to the row.
|
|
338
|
-
const line = (text: string) =>
|
|
372
|
+
const line = (text: string) => prefixedSentence(text, chrome(CLAUDE_RESULT_PREFIX), CLAUDE_INDENT);
|
|
339
373
|
if (options.isPartial) {
|
|
340
374
|
container.addChild(line(chrome("Running…")));
|
|
341
375
|
return container;
|
|
@@ -348,7 +382,7 @@ function renderClaudeResult(
|
|
|
348
382
|
container.addChild(line(theme.fg("error", failure.headline)));
|
|
349
383
|
container.addChild(claudeOutput(failure.detail, expanded, theme, true, "", CLAUDE_INDENT));
|
|
350
384
|
} else if (expanded && output.trim()) {
|
|
351
|
-
container.addChild(claudeOutput(`Error: ${output.trim()}`, true, theme, true, "", chrome(
|
|
385
|
+
container.addChild(claudeOutput(`Error: ${output.trim()}`, true, theme, true, "", chrome(CLAUDE_RESULT_PREFIX)));
|
|
352
386
|
} else {
|
|
353
387
|
container.addChild(line(theme.fg("error", failure.headline)));
|
|
354
388
|
}
|
|
@@ -360,7 +394,7 @@ function renderClaudeResult(
|
|
|
360
394
|
? formatResultLineSummary(name, args, result, output) ?? "Done"
|
|
361
395
|
: claudeOutcome(name, args, result, output, (value) => theme.bold(value));
|
|
362
396
|
if (outcome !== undefined) container.addChild(line(theme.fg("toolOutput", outcome)));
|
|
363
|
-
else container.addChild(claudeOutput(output, expanded, theme, false, rowKind(name) === "shell" ? "(No output)" : "(No content)", chrome(
|
|
397
|
+
else container.addChild(claudeOutput(output, expanded, theme, false, rowKind(name) === "shell" ? "(No output)" : "(No content)", chrome(CLAUDE_RESULT_PREFIX)));
|
|
364
398
|
if (body) container.addChild(body);
|
|
365
399
|
return container;
|
|
366
400
|
}
|
|
@@ -386,8 +420,13 @@ function renderRowResult(
|
|
|
386
420
|
const hasEditDiff = name === "edit" && getEditDiff(result).length > 0;
|
|
387
421
|
runtime.setResultAvailable(state, name, hasEditDiff || output.length > 0 || author !== undefined);
|
|
388
422
|
let component: Component;
|
|
389
|
-
if (
|
|
390
|
-
component =
|
|
423
|
+
if (isCodex()) {
|
|
424
|
+
component = renderCodexResult({
|
|
425
|
+
name, kind, args: ctx.args, result, output, theme, state, isError: ctx.isError,
|
|
426
|
+
isPartial: options.isPartial, expanded: state.expanded === true, toolCallId: ctx.toolCallId, author,
|
|
427
|
+
});
|
|
428
|
+
} else if (isClaude()) {
|
|
429
|
+
component = new WithinWidth(renderClaudeResult(name, ctx.args, result, options, theme, ctx.isError, state.expanded === true, output, author));
|
|
391
430
|
} else {
|
|
392
431
|
if (!options.isPartial && !state.resultLineSummaryComputed) {
|
|
393
432
|
// A custom tool with no text has nothing to count.
|
|
@@ -404,7 +443,7 @@ function renderRowResult(
|
|
|
404
443
|
const failureReason = ctx.isError && !state.expanded && !state.preview ? summarizeFailure(name, output) : undefined;
|
|
405
444
|
container.addChild(renderControls(theme, state, options.isPartial, ctx.isError, failureReason,
|
|
406
445
|
name === "edit" ? countEditChanges(result) : undefined));
|
|
407
|
-
component = container;
|
|
446
|
+
component = new WithinWidth(container);
|
|
408
447
|
}
|
|
409
448
|
rememberResult(state, result, options, ctx.isError, theme);
|
|
410
449
|
return component;
|
|
@@ -495,6 +534,28 @@ function configure(pi: ExtensionAPI, cwd?: string, projectTrusted = false): void
|
|
|
495
534
|
registeredConfiguration = signature;
|
|
496
535
|
}
|
|
497
536
|
|
|
537
|
+
/**
|
|
538
|
+
* Apply a style saved by /compact-tools without reloading Pi. The compact, Claude, and
|
|
539
|
+
* Codex styles share every tool registration and read the style as they draw, so the
|
|
540
|
+
* configuration is swapped and each tool row on screen is drawn again, a running one
|
|
541
|
+
* included. Anything else that changed, or a switch to or from off, needs a reload.
|
|
542
|
+
*/
|
|
543
|
+
function applyStyle(cwd: string, trusted: boolean): boolean {
|
|
544
|
+
const config = loadConfig(cwd, trusted);
|
|
545
|
+
const current = runtime.config;
|
|
546
|
+
if (config.style === "off" || current.style === "off") return false;
|
|
547
|
+
if (JSON.stringify({ ...config, style: current.style }) !== JSON.stringify(current)) return false;
|
|
548
|
+
runtime.configure(config);
|
|
549
|
+
registeredConfiguration = JSON.stringify([cwd, config]);
|
|
550
|
+
const chat = boundTui ? findChat(boundTui) : undefined;
|
|
551
|
+
for (const child of chat?.children ?? []) {
|
|
552
|
+
// Pi asks a row's renderers again whenever it redraws the row; this is that redraw.
|
|
553
|
+
if (isKind(child, ToolExecutionComponent)) (child as { updateDisplay?(): void }).updateDisplay?.();
|
|
554
|
+
}
|
|
555
|
+
boundTui?.requestRender();
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
|
|
498
559
|
/** Note the calls an assistant message makes, as Pi reads them to make their rows. */
|
|
499
560
|
function noteCalls(message: unknown): void {
|
|
500
561
|
// Only a run's own updates announce calls; the check is cheap enough for every update.
|
|
@@ -515,15 +576,20 @@ function madeByTool(event: object): boolean {
|
|
|
515
576
|
return typeof (event as { parentToolCallId?: unknown }).parentToolCallId === "string";
|
|
516
577
|
}
|
|
517
578
|
|
|
579
|
+
/** Pi's TUI while one is bound: where a style switched in place finds the rows to draw again. */
|
|
580
|
+
let boundTui: { requestRender(): void } | undefined;
|
|
581
|
+
|
|
518
582
|
/** Hand the animation Pi's frame request, reached through an invisible widget as the extension API allows. */
|
|
519
583
|
function bindRenderer(ctx: ExtensionContext): void {
|
|
520
584
|
ctx.ui.setWidget(RENDER_WIDGET_KEY, (tui) => {
|
|
585
|
+
boundTui = tui;
|
|
521
586
|
runtime.bindRenderer(() => tui.requestRender());
|
|
522
587
|
return { render: () => [], invalidate() {} };
|
|
523
588
|
}, { placement: "belowEditor" });
|
|
524
589
|
}
|
|
525
590
|
|
|
526
591
|
function unbindRenderer(ctx: ExtensionContext | undefined): void {
|
|
592
|
+
boundTui = undefined;
|
|
527
593
|
runtime.bindRenderer(undefined);
|
|
528
594
|
ctx?.ui.setWidget(RENDER_WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
529
595
|
}
|
|
@@ -537,6 +603,7 @@ export default function compactTools(pi: ExtensionAPI): void {
|
|
|
537
603
|
registeredConfiguration = undefined;
|
|
538
604
|
registeredTools.clear();
|
|
539
605
|
|
|
606
|
+
registerCompactToolsCommand(pi, () => runtime.config, applyStyle);
|
|
540
607
|
const thinkingCycle = new ThinkingCycleController(pi);
|
|
541
608
|
const progress = new ProgressController(pi);
|
|
542
609
|
const viewport = new ViewportKeeper();
|
|
@@ -548,12 +615,16 @@ export default function compactTools(pi: ExtensionAPI): void {
|
|
|
548
615
|
let renderContext: ExtensionContext | undefined;
|
|
549
616
|
// Register once while the extension runtime is being built. In particular, this
|
|
550
617
|
// makes the overrides available before Pi restores the active tool set on /reload.
|
|
551
|
-
|
|
618
|
+
const cwd = process.cwd();
|
|
619
|
+
const lastTrust = (globalThis as TrustSlot)[TRUST_KEY];
|
|
620
|
+
configure(pi, cwd, lastTrust?.cwd === realDirectory(cwd) && lastTrust.trusted);
|
|
552
621
|
pi.on("session_start", (event, ctx) => {
|
|
553
622
|
if (event.reason !== "reload") runtime.clearTimings();
|
|
554
623
|
// Rows restored from the session are drawn next; while idle none of them is running.
|
|
555
624
|
if (typeof ctx.isIdle === "function") runtime.setBusy(!ctx.isIdle());
|
|
556
|
-
|
|
625
|
+
const trusted = ctx.isProjectTrusted();
|
|
626
|
+
(globalThis as TrustSlot)[TRUST_KEY] = { cwd: realDirectory(ctx.cwd), trusted };
|
|
627
|
+
configure(pi, ctx.cwd, trusted);
|
|
557
628
|
unbindRenderer(renderContext);
|
|
558
629
|
renderContext = undefined;
|
|
559
630
|
if (ctx.mode === "tui" && runtime.config.style !== "off") {
|
|
@@ -601,5 +672,7 @@ export default function compactTools(pi: ExtensionAPI): void {
|
|
|
601
672
|
unbindRenderer(renderContext);
|
|
602
673
|
renderContext = undefined;
|
|
603
674
|
runtime.reset(event.reason !== "reload");
|
|
675
|
+
// A reload that keeps the extension sets it again before Pi rebuilds the transcript.
|
|
676
|
+
clearRowResolver(resolveCustomRow);
|
|
604
677
|
});
|
|
605
678
|
}
|