pi-compact-tools 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -26
- package/extensions/compact-tools-activity.ts +10 -2
- package/extensions/compact-tools-claude.ts +20 -26
- package/extensions/compact-tools-codex-rows.ts +465 -0
- package/extensions/compact-tools-codex.ts +453 -0
- package/extensions/compact-tools-command.ts +196 -0
- package/extensions/compact-tools-custom.ts +9 -0
- package/extensions/compact-tools-grouping.ts +130 -31
- package/extensions/compact-tools-invocation.ts +30 -1
- package/extensions/compact-tools-layout.ts +124 -27
- package/extensions/compact-tools-palette.ts +6 -7
- package/extensions/compact-tools-release.ts +30 -7
- package/extensions/compact-tools-runtime.ts +16 -0
- package/extensions/compact-tools-shell.ts +277 -0
- package/extensions/compact-tools-silent.ts +4 -1
- package/extensions/compact-tools-types.ts +10 -2
- package/extensions/compact-tools-viewport.ts +5 -7
- package/extensions/compact-tools.ts +115 -38
- package/package.json +10 -6
- package/release-notes.json +23 -0
- package/schemas/compact-tools.schema.json +3 -3
- package/scripts/check-resources.mjs +0 -82
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { normalizeLineEndings } from "./compact-tools-core.ts";
|
|
4
|
+
import { parseShell, writesOrRuns } from "./compact-tools-shell.ts";
|
|
5
|
+
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
6
|
+
|
|
7
|
+
/** Rows Codex shows of a call's output, its last ones, before offering the rest. */
|
|
8
|
+
export const CODEX_PREVIEW_ROWS = 3;
|
|
9
|
+
/**
|
|
10
|
+
* Codex 0.160.0's chrome: `• Ran …`, then ` └ ` under the bullet with the output two
|
|
11
|
+
* columns after it, and every later row four columns in. Pi draws every row one column
|
|
12
|
+
* in, so each prefix keeps that geometry one column further right.
|
|
13
|
+
*/
|
|
14
|
+
export const CODEX_RESULT_PREFIX = " └ ";
|
|
15
|
+
export const CODEX_INDENT = " ";
|
|
16
|
+
/** Where an opened command's wrapped lines continue, four columns past the `$`. */
|
|
17
|
+
export const CODEX_COMMAND_INDENT = " ";
|
|
18
|
+
/** The left margin every Codex row starts after. */
|
|
19
|
+
export const CODEX_MARGIN = " ";
|
|
20
|
+
export const CODEX_BULLET = "•";
|
|
21
|
+
/** Codex draws a tab as four columns. */
|
|
22
|
+
export const CODEX_TAB = " ";
|
|
23
|
+
/** Codex's key is ctrl+t; in Pi the key that opens a row is ctrl+o. */
|
|
24
|
+
export const CODEX_EXPAND_KEY = "ctrl+o";
|
|
25
|
+
|
|
26
|
+
/** How Codex prints how long a call took: `250ms`, `1.50s`, `1m 15s`. */
|
|
27
|
+
export function formatCodexDuration(milliseconds: number): string {
|
|
28
|
+
const millis = Math.max(0, Math.floor(milliseconds));
|
|
29
|
+
if (millis < 1000) return `${millis}ms`;
|
|
30
|
+
if (millis < 60_000) return `${(millis / 1000).toFixed(2)}s`;
|
|
31
|
+
const seconds = Math.floor((millis % 60_000) / 1000);
|
|
32
|
+
return `${Math.floor(millis / 60_000)}m ${String(seconds).padStart(2, "0")}s`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The control under a collapsed or opened call. Codex starts it at a quarter of the
|
|
37
|
+
* width, at most four columns in, and drops the key hint when the row cannot fit it.
|
|
38
|
+
*/
|
|
39
|
+
export function codexDisclosure(width: number, expanded: boolean, hiddenLines?: number): string {
|
|
40
|
+
const indent = CODEX_MARGIN + " ".repeat(Math.min(Math.floor(width / 4), 4));
|
|
41
|
+
if (expanded) return `${indent}− Show less`;
|
|
42
|
+
if (hiddenLines === undefined) return `${indent}+ Show details`;
|
|
43
|
+
const label = `+ ${hiddenLines} ${hiddenLines === 1 ? "line" : "lines"}`;
|
|
44
|
+
const hint = ` (${CODEX_EXPAND_KEY} to expand)`;
|
|
45
|
+
return visibleWidth(indent + label + hint) <= width ? indent + label + hint : indent + label;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The last path segment, as Codex names files in its exploration lines; `src`, `dist`,
|
|
50
|
+
* `build`, and `node_modules` say nothing about the file and are skipped.
|
|
51
|
+
*/
|
|
52
|
+
export function shortDisplayPath(path: string): string {
|
|
53
|
+
const trimmed = path.replace(/\\/gu, "/").replace(/\/+$/u, "");
|
|
54
|
+
const parts = trimmed.split("/").reverse()
|
|
55
|
+
.filter((part) => part && part !== "build" && part !== "dist" && part !== "node_modules" && part !== "src");
|
|
56
|
+
return parts[0] ?? trimmed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One thing an exploration call does, the way Codex parses it out of the command. */
|
|
60
|
+
export type ExploreAction =
|
|
61
|
+
| { kind: "read"; name: string }
|
|
62
|
+
| { kind: "search"; query?: string; path?: string; command: string }
|
|
63
|
+
| { kind: "list"; path?: string; command: string };
|
|
64
|
+
|
|
65
|
+
/** Operands, skipping flags and the values the given flags take. */
|
|
66
|
+
function operands(args: readonly string[], flagsWithValues: readonly string[] = []): string[] {
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
let afterDashes = false;
|
|
69
|
+
for (let index = 0; index < args.length; index++) {
|
|
70
|
+
const arg = args[index]!;
|
|
71
|
+
if (afterDashes) {
|
|
72
|
+
out.push(arg);
|
|
73
|
+
} else if (arg === "--") {
|
|
74
|
+
afterDashes = true;
|
|
75
|
+
} else if (arg.startsWith("--") && arg.includes("=")) {
|
|
76
|
+
continue;
|
|
77
|
+
} else if (flagsWithValues.includes(arg)) {
|
|
78
|
+
index++;
|
|
79
|
+
} else if (!arg.startsWith("-")) {
|
|
80
|
+
out.push(arg);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const digits = (value: string | undefined) => value !== undefined && /^\d+$/u.test(value);
|
|
87
|
+
const signedDigits = (value: string | undefined) => value !== undefined && /^\+?\d+$/u.test(value);
|
|
88
|
+
|
|
89
|
+
function isSedRange(value: string | undefined): boolean {
|
|
90
|
+
return value !== undefined && /^\d+(?:,\d+)?p$/u.test(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sedReadPath(args: readonly string[]): string | undefined {
|
|
94
|
+
if (args.some((arg) => arg === "-i" || arg.startsWith("-i") || arg === "--in-place") || !args.includes("-n")) return undefined;
|
|
95
|
+
let range = false;
|
|
96
|
+
for (let index = 0; index < args.length; index++) {
|
|
97
|
+
if (args[index] === "-e" || args[index] === "--expression") {
|
|
98
|
+
if (isSedRange(args[index + 1])) range = true;
|
|
99
|
+
index++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!range) range = args.some((arg) => !arg.startsWith("-") && isSedRange(arg));
|
|
103
|
+
if (!range) return undefined;
|
|
104
|
+
const rest = operands(args, ["-e", "-f", "--expression", "--file"]);
|
|
105
|
+
if (rest.length === 0) return undefined;
|
|
106
|
+
return isSedRange(rest[0]) ? rest[1] : rest[0];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Commands that only reshape another's output, which Codex leaves out of a pipeline's summary. */
|
|
110
|
+
function isFormatting(words: readonly string[]): boolean {
|
|
111
|
+
const [program, ...args] = words;
|
|
112
|
+
switch (program) {
|
|
113
|
+
// Codex counts xargs and tee as formatting too; they run and write, so here they are never left out.
|
|
114
|
+
case "wc": case "tr": case "cut": case "sort": case "uniq": case "column": case "yes": case "printf":
|
|
115
|
+
return true;
|
|
116
|
+
case "head":
|
|
117
|
+
return args.length === 0 || (args.length === 1 && args[0]!.startsWith("-"))
|
|
118
|
+
|| (args.length === 2 && (args[0] === "-n" || args[0] === "-c") && digits(args[1]));
|
|
119
|
+
case "tail":
|
|
120
|
+
return args.length === 0 || (args.length === 1 && args[0]!.startsWith("-"))
|
|
121
|
+
|| (args.length === 2 && (args[0] === "-n" || args[0] === "-c") && signedDigits(args[1]));
|
|
122
|
+
case "sed":
|
|
123
|
+
return !args.some((arg) => arg.startsWith("-i")) && sedReadPath(args) === undefined;
|
|
124
|
+
default:
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A head or tail of a file: `-n N file`, `-nN file`, or the file alone. */
|
|
130
|
+
function headTailPath(args: readonly string[], signed: boolean): string | undefined {
|
|
131
|
+
const count = signed ? signedDigits : digits;
|
|
132
|
+
const first = args[0];
|
|
133
|
+
const counted = (first === "-n" && count(args[1])) || (first?.startsWith("-n") === true && first.length > 2 && count(first.slice(2)));
|
|
134
|
+
if (counted) {
|
|
135
|
+
const rest = first === "-n" ? args.slice(2) : args.slice(1);
|
|
136
|
+
const path = rest.find((arg) => !arg.startsWith("-"));
|
|
137
|
+
if (path !== undefined) return path;
|
|
138
|
+
}
|
|
139
|
+
return args.length === 1 && !args[0]!.startsWith("-") ? args[0] : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function single(values: readonly string[]): string | undefined {
|
|
143
|
+
return values.length === 1 ? values[0] : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const read = (path: string | undefined): ExploreAction | undefined =>
|
|
147
|
+
path === undefined ? undefined : { kind: "read", name: shortDisplayPath(path) };
|
|
148
|
+
|
|
149
|
+
/** What one command does, or undefined when it does anything but look around. */
|
|
150
|
+
function summarize(words: readonly string[]): ExploreAction | undefined {
|
|
151
|
+
const [program, ...args] = words;
|
|
152
|
+
const command = words.join(" ");
|
|
153
|
+
const short = (path: string | undefined) => path === undefined ? undefined : shortDisplayPath(path);
|
|
154
|
+
switch (program) {
|
|
155
|
+
case "ls": case "eza": case "exa":
|
|
156
|
+
return { kind: "list", path: short(operands(args, ["-I", "-w", "--block-size", "--format", "--time-style", "--color", "--quoting-style", "--ignore-glob", "--sort", "--time"])[0]), command };
|
|
157
|
+
case "tree":
|
|
158
|
+
return { kind: "list", path: short(operands(args, ["-L", "-P", "-I", "--charset", "--filelimit", "--sort"])[0]), command };
|
|
159
|
+
case "du":
|
|
160
|
+
return { kind: "list", path: short(operands(args, ["-d", "--max-depth", "-B", "--block-size", "--exclude", "--time-style"])[0]), command };
|
|
161
|
+
case "rg": case "rga": case "ripgrep-all": {
|
|
162
|
+
const rest = operands(args, ["-g", "--glob", "--iglob", "-t", "--type", "--type-add", "--type-not", "-m", "--max-count", "-A", "-B", "-C", "--context", "--max-depth"]);
|
|
163
|
+
if (args.includes("--files")) return { kind: "list", path: short(rest[0]), command };
|
|
164
|
+
return { kind: "search", query: rest[0], path: short(rest[1]), command };
|
|
165
|
+
}
|
|
166
|
+
case "grep": case "egrep": case "fgrep": {
|
|
167
|
+
let pattern: string | undefined;
|
|
168
|
+
const rest: string[] = [];
|
|
169
|
+
for (let index = 0; index < args.length; index++) {
|
|
170
|
+
const arg = args[index]!;
|
|
171
|
+
if (arg === "-e" || arg === "--regexp" || arg === "-f" || arg === "--file") pattern ??= args[++index];
|
|
172
|
+
else if (["-m", "--max-count", "-C", "--context", "-A", "--after-context", "-B", "--before-context"].includes(arg)) index++;
|
|
173
|
+
else if (!arg.startsWith("-")) rest.push(arg);
|
|
174
|
+
}
|
|
175
|
+
if (pattern === undefined) pattern = rest.shift();
|
|
176
|
+
return { kind: "search", query: pattern, path: short(rest[0]), command };
|
|
177
|
+
}
|
|
178
|
+
case "ag": case "ack": case "pt": {
|
|
179
|
+
const rest = operands(args, ["-G", "-g", "--file-search-regex", "--ignore-dir", "--ignore-file", "--path-to-ignore"]);
|
|
180
|
+
return { kind: "search", query: rest[0], path: short(rest[1]), command };
|
|
181
|
+
}
|
|
182
|
+
case "fd": {
|
|
183
|
+
const rest = operands(args, ["-t", "--type", "-e", "--extension", "-E", "--exclude", "--search-path"]);
|
|
184
|
+
if (rest.length === 1) {
|
|
185
|
+
return /[/\\]|^\.\.?$/u.test(rest[0]!) ? { kind: "list", path: short(rest[0]), command } : { kind: "search", query: rest[0], command };
|
|
186
|
+
}
|
|
187
|
+
return rest.length === 0 ? { kind: "list", command } : { kind: "search", query: rest[0], path: short(rest[1]), command };
|
|
188
|
+
}
|
|
189
|
+
case "find": {
|
|
190
|
+
const root = args.find((arg) => !arg.startsWith("-") && arg !== "!" && arg !== "(" && arg !== ")");
|
|
191
|
+
const flag = args.findIndex((arg) => arg === "-name" || arg === "-iname" || arg === "-path" || arg === "-regex");
|
|
192
|
+
const query = flag >= 0 ? args[flag + 1] : undefined;
|
|
193
|
+
return query === undefined ? { kind: "list", path: short(root), command } : { kind: "search", query, path: short(root), command };
|
|
194
|
+
}
|
|
195
|
+
case "git": {
|
|
196
|
+
const [sub, ...rest] = args;
|
|
197
|
+
if (sub === "grep") {
|
|
198
|
+
const search = summarize(["grep", ...rest]);
|
|
199
|
+
return search && { ...search, command } as ExploreAction;
|
|
200
|
+
}
|
|
201
|
+
if (sub === "ls-files") return { kind: "list", path: short(operands(rest, ["--exclude", "--exclude-from", "--pathspec-from-file"])[0]), command };
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
case "cat": case "more":
|
|
205
|
+
return read(single(operands(args)));
|
|
206
|
+
case "bat": case "batcat":
|
|
207
|
+
return read(single(operands(args, ["--theme", "--language", "--style", "--terminal-width", "--tabs", "--line-range", "--map-syntax"])));
|
|
208
|
+
case "less":
|
|
209
|
+
return read(single(operands(args, ["-p", "-P", "-x", "-y", "-z", "-j", "--pattern", "--prompt", "--tabs", "--shift", "--jump-target"])));
|
|
210
|
+
case "head":
|
|
211
|
+
return read(headTailPath(args, false));
|
|
212
|
+
case "tail":
|
|
213
|
+
return read(headTailPath(args, true));
|
|
214
|
+
case "nl":
|
|
215
|
+
return read(operands(args, ["-s", "-w", "-v", "-i", "-b"])[0]);
|
|
216
|
+
case "sed":
|
|
217
|
+
return read(sedReadPath(args));
|
|
218
|
+
default:
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* What a shell command does, call by call, if all it does is read, search, and list;
|
|
225
|
+
* otherwise undefined, and Codex shows it as a command it ran. Formatting stages,
|
|
226
|
+
* a `cd`, a leading `echo`, and a `true` say nothing and are left out. Like Codex,
|
|
227
|
+
* only plain words are read: a redirection or a substitution is a command it ran.
|
|
228
|
+
* Unlike Codex, a command that can write or run something else, `xargs rm`, `tee`,
|
|
229
|
+
* `find -delete`, never passes for exploring, since the group would hide it.
|
|
230
|
+
*/
|
|
231
|
+
export function parseExploreCommand(command: string): ExploreAction[] | undefined {
|
|
232
|
+
const parsed = parseShell(normalizeLineEndings(command));
|
|
233
|
+
if (!parsed || parsed.opaque || parsed.commands.some((each) => each.redirects.length > 0)) return undefined;
|
|
234
|
+
let commands = parsed.commands.map((each) => each.words);
|
|
235
|
+
if (commands.some(writesOrRuns)) return undefined;
|
|
236
|
+
if (commands.length > 1) commands = commands.filter((each) => !isFormatting(each));
|
|
237
|
+
commands = commands.filter((each, index) => each[0] !== "cd" && !(each.length === 1 && each[0] === "true")
|
|
238
|
+
&& !(each[0] === "echo" && index === 0 && commands.length > 1));
|
|
239
|
+
if (commands.length === 0) return undefined;
|
|
240
|
+
const actions: ExploreAction[] = [];
|
|
241
|
+
for (const each of commands) {
|
|
242
|
+
const action = summarize(each);
|
|
243
|
+
if (!action) return undefined;
|
|
244
|
+
actions.push(action);
|
|
245
|
+
}
|
|
246
|
+
return actions;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function argument(args: ToolArgs, key: string): string | undefined {
|
|
250
|
+
const value = args[key];
|
|
251
|
+
return typeof value === "string" && value.trim() ? normalizeLineEndings(value) : undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A Pi tool's path argument, whichever name it uses. */
|
|
255
|
+
export function codexPath(args: ToolArgs): string {
|
|
256
|
+
return argument(args, "path") ?? argument(args, "file_path") ?? "";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* What a call does, as Codex's exploration lines name it. Pi's dedicated tools map to
|
|
261
|
+
* the verb Codex would give the command they stand for; a shell command is parsed.
|
|
262
|
+
*/
|
|
263
|
+
export function exploreActions(name: string, args: ToolArgs): ExploreAction[] | undefined {
|
|
264
|
+
const path = codexPath(args);
|
|
265
|
+
const short = path ? shortDisplayPath(path) : undefined;
|
|
266
|
+
switch (name) {
|
|
267
|
+
case "read": return path ? [{ kind: "read", name: shortDisplayPath(path) }] : undefined;
|
|
268
|
+
case "grep": case "find": {
|
|
269
|
+
const pattern = argument(args, "pattern");
|
|
270
|
+
return pattern ? [{ kind: "search", query: firstLine(pattern), path: short, command: name }] : undefined;
|
|
271
|
+
}
|
|
272
|
+
case "ls": return [{ kind: "list", path: short, command: "ls" }];
|
|
273
|
+
case "bash": case "powershell": {
|
|
274
|
+
const command = argument(args, "command");
|
|
275
|
+
return command ? parseExploreCommand(command) : undefined;
|
|
276
|
+
}
|
|
277
|
+
default: return undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function firstLine(text: string): string {
|
|
282
|
+
return normalizeLineEndings(stripTerminalSequences(text)).split("\n").find((line) => line.trim())?.trim() ?? "";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** One exploration line: its verb and the parts after it, each marked plain or dim. */
|
|
286
|
+
export type ExploreLine = { verb: "Read" | "Search" | "List"; parts: Array<{ text: string; dim?: boolean }>; failure?: { text: string; quiet: boolean } };
|
|
287
|
+
|
|
288
|
+
export type ExploreCall = { actions: ExploreAction[]; failure?: { text: string; quiet: boolean } };
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Codex's exploration lines for a run of calls: consecutive reads that succeeded
|
|
292
|
+
* merge into one line of distinct names; every other action is a line of its own, and
|
|
293
|
+
* a failed call's last line ends with why.
|
|
294
|
+
*/
|
|
295
|
+
export function exploreLines(calls: readonly ExploreCall[]): ExploreLine[] {
|
|
296
|
+
const lines: ExploreLine[] = [];
|
|
297
|
+
for (let index = 0; index < calls.length;) {
|
|
298
|
+
const call = calls[index]!;
|
|
299
|
+
const readsOnly = call.actions.every((action) => action.kind === "read");
|
|
300
|
+
if (readsOnly && !call.failure) {
|
|
301
|
+
const names: string[] = [];
|
|
302
|
+
while (index < calls.length && !calls[index]!.failure && calls[index]!.actions.every((action) => action.kind === "read")) {
|
|
303
|
+
for (const action of calls[index]!.actions) {
|
|
304
|
+
const name = (action as { name: string }).name;
|
|
305
|
+
if (!names.includes(name)) names.push(name);
|
|
306
|
+
}
|
|
307
|
+
index++;
|
|
308
|
+
}
|
|
309
|
+
const parts = names.flatMap((name, position) => position === 0 ? [{ text: name }] : [{ text: ", ", dim: true }, { text: name }]);
|
|
310
|
+
lines.push({ verb: "Read", parts });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
call.actions.forEach((action, position) => {
|
|
314
|
+
const line = actionLine(action);
|
|
315
|
+
if (call.failure && position === call.actions.length - 1) line.failure = call.failure;
|
|
316
|
+
lines.push(line);
|
|
317
|
+
});
|
|
318
|
+
index++;
|
|
319
|
+
}
|
|
320
|
+
return lines;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Text that spans lines, such as a quoted multi-line pattern, shows its first line and that more follows. */
|
|
324
|
+
function oneLine(text: string): string {
|
|
325
|
+
const lines = text.split("\n");
|
|
326
|
+
return lines.length > 1 ? `${lines[0]} …` : text;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function actionLine(action: ExploreAction): ExploreLine {
|
|
330
|
+
if (action.kind === "read") return { verb: "Read", parts: [{ text: oneLine(action.name) }] };
|
|
331
|
+
if (action.kind === "list") return { verb: "List", parts: [{ text: oneLine(action.path ?? action.command) }] };
|
|
332
|
+
if (action.query === undefined) return { verb: "Search", parts: [{ text: oneLine(action.command) }] };
|
|
333
|
+
const query = oneLine(action.query);
|
|
334
|
+
return { verb: "Search", parts: action.path ? [{ text: query }, { text: " in ", dim: true }, { text: oneLine(action.path) }] : [{ text: query }] };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const SHELL_STATUS = /^Command (?:exited with code (\d+)|(timed out.*|aborted|terminated.*))$/iu;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A command's outcome as Pi reports it: the exit code from the line Pi ends a failed
|
|
341
|
+
* run with, and the output without that line. A run that timed out or was aborted
|
|
342
|
+
* keeps its line, the only place that says why.
|
|
343
|
+
*/
|
|
344
|
+
export function shellOutcome(output: string): { exitCode?: number; output: string } {
|
|
345
|
+
const lines = normalizeLineEndings(output).trimEnd().split("\n");
|
|
346
|
+
const status = lines.at(-1)?.trim().match(SHELL_STATUS);
|
|
347
|
+
if (status?.[1] === undefined) return { output: lines.join("\n") };
|
|
348
|
+
return { exitCode: Number(status[1]), output: lines.slice(0, -1).join("\n").trimEnd() };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Pi's bash reports a run that printed nothing as "(no output)"; Codex has its own words for it. */
|
|
352
|
+
export function isEmptyOutput(output: string): boolean {
|
|
353
|
+
const trimmed = normalizeLineEndings(output).trim();
|
|
354
|
+
return !trimmed || trimmed === "(no output)";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A failed exploration call's note: the exit code a command gave, or that a tool
|
|
359
|
+
* failed. A compound command has one exit code for all it did, so Codex says so.
|
|
360
|
+
*/
|
|
361
|
+
export function exploreFailure(name: string, output: string, isError: boolean, actions: readonly ExploreAction[]): ExploreCall["failure"] {
|
|
362
|
+
if (!isError) return undefined;
|
|
363
|
+
if (name !== "bash" && name !== "powershell") return { text: " (failed)", quiet: false };
|
|
364
|
+
const { exitCode } = shellOutcome(output);
|
|
365
|
+
if (exitCode === undefined) return { text: " (failed)", quiet: false };
|
|
366
|
+
// A search's exit 1 can mean it found nothing; Codex reports the code without calling it a failure.
|
|
367
|
+
const quiet = exitCode === 1 && actions.some((action) => action.kind === "search");
|
|
368
|
+
return { text: actions.length > 1 ? ` (command exit ${exitCode})` : ` (exit ${exitCode})`, quiet };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The calls of every exploration opened this frame. Codex lists an opened group's
|
|
373
|
+
* calls with one "− Show less" beneath them all, so a call listed there leaves out
|
|
374
|
+
* its own. Refilled as the chat is drawn, before any row in it is.
|
|
375
|
+
*/
|
|
376
|
+
export const openExplorations = new Set<string>();
|
|
377
|
+
|
|
378
|
+
/** The last lines of output, the way Codex previews a call. */
|
|
379
|
+
export function tailLines(text: string, count = CODEX_PREVIEW_ROWS): string[] {
|
|
380
|
+
const lines = normalizeLineEndings(text).replace(/\n+$/u, "").split("\n");
|
|
381
|
+
return lines.slice(Math.max(0, lines.length - count));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export type ThemeColor = Parameters<Theme["fg"]>[0];
|
|
385
|
+
/** A run of text and the color it is painted in. Text is wrapped as text and painted after. */
|
|
386
|
+
export type Segment = { text: string; color?: ThemeColor };
|
|
387
|
+
|
|
388
|
+
export function paintSegments(segments: readonly Segment[], theme: Theme): string {
|
|
389
|
+
return segments.map((segment) => segment.color ? theme.fg(segment.color, segment.text) : segment.text).join("");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Plain text as lines of one color each. */
|
|
393
|
+
export function textLines(text: string, color?: ThemeColor): Segment[][] {
|
|
394
|
+
return normalizeLineEndings(text).split("\n").map((line) => line ? [{ text: line, color }] : []);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Where each row of word-wrapped text starts and ends, in graphemes: a row breaks
|
|
399
|
+
* at its last space when it can and mid-word when it cannot, and the spaces it
|
|
400
|
+
* breaks at begin no row. The first row has `first` columns and every later one `rest`.
|
|
401
|
+
*/
|
|
402
|
+
function wrapRanges(characters: readonly string[], first: number, rest: number): Array<[number, number]> {
|
|
403
|
+
const rows: Array<[number, number]> = [];
|
|
404
|
+
let start = 0;
|
|
405
|
+
while (start < characters.length) {
|
|
406
|
+
if (rows.length > 0) while (characters[start] === " ") start++;
|
|
407
|
+
if (start >= characters.length) break;
|
|
408
|
+
const limit = Math.max(1, rows.length === 0 ? first : rest);
|
|
409
|
+
let end = start;
|
|
410
|
+
let used = 0;
|
|
411
|
+
while (end < characters.length) {
|
|
412
|
+
const columns = visibleWidth(characters[end]!);
|
|
413
|
+
if (used + columns > limit && end > start) break;
|
|
414
|
+
used += columns;
|
|
415
|
+
end++;
|
|
416
|
+
}
|
|
417
|
+
if (end < characters.length && characters[end] !== " ") {
|
|
418
|
+
const space = characters.lastIndexOf(" ", end - 1);
|
|
419
|
+
if (space > start) end = space;
|
|
420
|
+
}
|
|
421
|
+
let trimmed = end;
|
|
422
|
+
while (trimmed > start && characters[trimmed - 1] === " ") trimmed--;
|
|
423
|
+
rows.push([start, trimmed > start ? trimmed : end]);
|
|
424
|
+
start = end;
|
|
425
|
+
}
|
|
426
|
+
return rows;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
430
|
+
|
|
431
|
+
/** Segments word-wrapped into rows, each piece keeping its color. */
|
|
432
|
+
export function wrapSegments(segments: readonly Segment[], first: number, rest: number): Segment[][] {
|
|
433
|
+
const owners: number[] = [];
|
|
434
|
+
const characters: string[] = [];
|
|
435
|
+
segments.forEach((segment, index) => {
|
|
436
|
+
// A grapheme, not a code point: an emoji joined from several stays whole on one row.
|
|
437
|
+
for (const { segment: character } of graphemes.segment(segment.text)) {
|
|
438
|
+
characters.push(character);
|
|
439
|
+
owners.push(index);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
if (characters.length === 0) return [[]];
|
|
443
|
+
return wrapRanges(characters, first, rest).map(([start, end]) => {
|
|
444
|
+
const row: Segment[] = [];
|
|
445
|
+
for (let position = start; position < end; position++) {
|
|
446
|
+
const color = segments[owners[position]!]!.color;
|
|
447
|
+
const last = row.at(-1);
|
|
448
|
+
if (last && owners[position] === owners[position - 1] && position > start) last.text += characters[position];
|
|
449
|
+
else row.push({ text: characters[position]!, color });
|
|
450
|
+
}
|
|
451
|
+
return row;
|
|
452
|
+
});
|
|
453
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { DISPLAY_STYLES, type CompactToolsConfig, type DisplayStyle } from "./compact-tools-types.ts";
|
|
6
|
+
import { loadConfig } from "./compact-tools-config.ts";
|
|
7
|
+
|
|
8
|
+
type Scope = "global" | "project";
|
|
9
|
+
const SCOPES: readonly Scope[] = ["global", "project"];
|
|
10
|
+
const HELP = "Usage: /compact-tools [compact|claude|codex|off] [global|project]\nWithout a scope the style is saved globally, or to this project when its settings choose the style.\n/compact-tools status · /compact-tools help\nCtrl+O expands rows; /silent toggles quiet mode. Styles switch at once, even while tools run; off reloads Pi.";
|
|
11
|
+
const BUSY = "Wait for the current response to finish before changing tool styles.";
|
|
12
|
+
const CHOICES: Record<DisplayStyle, string> = {
|
|
13
|
+
compact: "Compact — one expandable row per tool call",
|
|
14
|
+
claude: "Claude — group reads and searches",
|
|
15
|
+
codex: "Codex — explored groups and output tails, like Codex CLI",
|
|
16
|
+
off: "Off — restore Pi's default tool rows",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function configPath(cwd: string, scope: Scope): string {
|
|
20
|
+
return scope === "global" ? join(getAgentDir(), "compact-tools.json")
|
|
21
|
+
: join(cwd, CONFIG_DIR_NAME, "compact-tools.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Preserve unrelated and future settings. Never overwrite a malformed file. */
|
|
25
|
+
export function saveStyle(path: string, style: DisplayStyle): void {
|
|
26
|
+
let linked = false;
|
|
27
|
+
try {
|
|
28
|
+
linked = lstatSync(path).isSymbolicLink();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
31
|
+
}
|
|
32
|
+
// Update dotfiles-managed targets without replacing the link. A dangling link
|
|
33
|
+
// is an error, not permission to silently disconnect it.
|
|
34
|
+
if (linked) path = realpathSync(path);
|
|
35
|
+
let data: Record<string, unknown> = {};
|
|
36
|
+
let mode = 0o600;
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
39
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
40
|
+
throw new Error("expected a JSON object");
|
|
41
|
+
}
|
|
42
|
+
data = parsed as Record<string, unknown>;
|
|
43
|
+
mode = statSync(path).mode & 0o777;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
46
|
+
}
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
48
|
+
// Beside the target so the rename stays atomic, and short so a long target name still fits.
|
|
49
|
+
const temporary = join(dirname(path), `.compact-tools-${randomUUID()}.tmp`);
|
|
50
|
+
try {
|
|
51
|
+
writeFileSync(temporary, `${JSON.stringify({ ...data, style }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
52
|
+
// The creation mode passes through the umask; set the original mode exactly.
|
|
53
|
+
chmodSync(temporary, mode);
|
|
54
|
+
renameSync(temporary, path);
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(temporary, { force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether a trusted project's own settings choose the style, which then outranks the global one. */
|
|
61
|
+
function projectSetsStyle(cwd: string, trusted: boolean): boolean {
|
|
62
|
+
if (!trusted) return false;
|
|
63
|
+
try {
|
|
64
|
+
const parsed: unknown = JSON.parse(readFileSync(configPath(cwd, "project"), "utf8"));
|
|
65
|
+
return !!parsed && typeof parsed === "object" && !Array.isArray(parsed) && "style" in parsed;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function describePath(path: string): string {
|
|
72
|
+
return `${path} (${existsSync(path) ? "exists" : "not created"})`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function errorMessage(error: unknown): string {
|
|
76
|
+
return error instanceof Error ? error.message : String(error);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Draw the saved style in place, without a reload, and say whether it could: a switch
|
|
81
|
+
* between the compact, Claude, and Codex styles keeps every tool's registration, so
|
|
82
|
+
* it applies at once, even while a response runs. Turning rows off or back on cannot.
|
|
83
|
+
*/
|
|
84
|
+
export type ApplyStyle = (cwd: string, trusted: boolean) => boolean;
|
|
85
|
+
|
|
86
|
+
/** Whether a switch needs Pi to reload: only off hands tools to or takes them from Pi's own renderers. */
|
|
87
|
+
function needsReload(from: DisplayStyle, to: DisplayStyle): boolean {
|
|
88
|
+
return from === "off" || to === "off";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function registerCompactToolsCommand(pi: ExtensionAPI, getConfig: () => CompactToolsConfig, applyStyle?: ApplyStyle): void {
|
|
92
|
+
pi.registerCommand("compact-tools", {
|
|
93
|
+
description: "Choose compact/Claude/Codex/default tool rows, or show settings and controls",
|
|
94
|
+
getArgumentCompletions: (prefix) => {
|
|
95
|
+
const typed = prefix.trimStart().toLowerCase();
|
|
96
|
+
const parts = typed.split(/\s+/);
|
|
97
|
+
const scoped = parts.length === 2 && DISPLAY_STYLES.includes(parts[0] as DisplayStyle);
|
|
98
|
+
const values = scoped ? SCOPES.map((scope) => `${parts[0]} ${scope}`) : [...DISPLAY_STYLES, "status", "help"];
|
|
99
|
+
// Pi applies a shown completion on Enter instead of running the command, so a value already
|
|
100
|
+
// typed in full is not offered again: Enter then runs it the first time.
|
|
101
|
+
const items = values.filter((value) => value.startsWith(typed) && value !== typed.trimEnd()).map((value) => ({ value, label: value }));
|
|
102
|
+
return items.length ? items : null;
|
|
103
|
+
},
|
|
104
|
+
handler: async (args, ctx) => {
|
|
105
|
+
const parts = args.trim() ? args.trim().toLowerCase().split(/\s+/) : [];
|
|
106
|
+
if (parts.length === 1 && parts[0] === "help") {
|
|
107
|
+
ctx.ui.notify(HELP, "info");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (parts.length === 1 && parts[0] === "status") {
|
|
111
|
+
const config = getConfig();
|
|
112
|
+
ctx.ui.notify([
|
|
113
|
+
`Style: ${config.style} · configured mode: ${config.mode} · preview: ${config.previewLines} lines`,
|
|
114
|
+
`Global: ${describePath(configPath(ctx.cwd, "global"))}`,
|
|
115
|
+
`Project: ${describePath(configPath(ctx.cwd, "project"))} (${ctx.isProjectTrusted() ? "trusted; overrides global" : "untrusted; ignored"})`,
|
|
116
|
+
"/silent is a session toggle; Ctrl+O expands rows.",
|
|
117
|
+
].join("\n"), "info");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (parts.length > 2 || (parts.length > 0 && !DISPLAY_STYLES.includes(parts[0] as DisplayStyle))
|
|
121
|
+
|| (parts[1] !== undefined && !SCOPES.includes(parts[1] as Scope))) {
|
|
122
|
+
ctx.ui.notify(HELP, "warning");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// A switch between drawn styles applies in place, so only one that reloads waits for the response.
|
|
126
|
+
const named = parts[0] as DisplayStyle | undefined;
|
|
127
|
+
if (named && !ctx.isIdle() && (needsReload(getConfig().style, named) || !applyStyle)) {
|
|
128
|
+
ctx.ui.notify(BUSY, "warning");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
let style = named;
|
|
132
|
+
// Unnamed, the style is saved where it is decided: a trusted project that sets one would
|
|
133
|
+
// otherwise outrank a global save, and the switch would seem to do nothing.
|
|
134
|
+
let scope: Scope = (parts[1] as Scope | undefined) ?? (projectSetsStyle(ctx.cwd, ctx.isProjectTrusted()) ? "project" : "global");
|
|
135
|
+
if (!style) {
|
|
136
|
+
if (ctx.mode !== "tui") {
|
|
137
|
+
ctx.ui.notify(HELP, "info");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const selected = await ctx.ui.select(`Tool style (current: ${getConfig().style})`, Object.values(CHOICES));
|
|
141
|
+
style = DISPLAY_STYLES.find((value) => CHOICES[value] === selected);
|
|
142
|
+
if (!style) return;
|
|
143
|
+
// A project that sets its own style outranks a global save, so it is offered first and the global
|
|
144
|
+
// choice says it will not show here.
|
|
145
|
+
const projectDecides = projectSetsStyle(ctx.cwd, ctx.isProjectTrusted());
|
|
146
|
+
const scopes = projectDecides
|
|
147
|
+
? ["Project — this trusted project only", "Global — all projects (this project keeps its own style)"]
|
|
148
|
+
: ["Global — all projects", ...(ctx.isProjectTrusted() ? ["Project — this trusted project only"] : [])];
|
|
149
|
+
const selectedScope = await ctx.ui.select("Save style where?", scopes);
|
|
150
|
+
if (!selectedScope) return;
|
|
151
|
+
scope = selectedScope.startsWith("Project") ? "project" : "global";
|
|
152
|
+
}
|
|
153
|
+
if (scope === "project" && !ctx.isProjectTrusted()) {
|
|
154
|
+
ctx.ui.notify("Project settings require a trusted project. No file was changed.", "warning");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// The agent may have started while the user was choosing in a dialog.
|
|
158
|
+
const reloads = needsReload(getConfig().style, style) || !applyStyle;
|
|
159
|
+
if (reloads && !ctx.isIdle()) {
|
|
160
|
+
ctx.ui.notify(BUSY, "warning");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const path = configPath(ctx.cwd, scope);
|
|
164
|
+
try {
|
|
165
|
+
saveStyle(path, style);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
ctx.ui.notify(`Could not save ${path}: ${errorMessage(error)}. No settings were replaced.`, "error");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const effective = loadConfig(ctx.cwd, ctx.isProjectTrusted()).style;
|
|
171
|
+
const where = scope === "project" ? "this project" : "global settings";
|
|
172
|
+
// Paths stay out of these lines, which Pi keeps in the chat; /compact-tools status shows them.
|
|
173
|
+
if (scope === "global" && effective !== style) {
|
|
174
|
+
// Saved, but this project's own style still decides what is drawn here: nothing switches.
|
|
175
|
+
ctx.ui.notify(`Saved ${style} to global settings, but this project keeps its own ${effective} style. Run /compact-tools ${style} to switch it here.`, "warning");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!reloads && applyStyle!(ctx.cwd, ctx.isProjectTrusted())) {
|
|
179
|
+
ctx.ui.notify(`Switched to ${style} style (saved to ${where}).`, "info");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!ctx.isIdle()) {
|
|
183
|
+
ctx.ui.notify(`Saved ${style} style to ${where}. It applies once the response finishes; run /reload then.`, "warning");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
ctx.ui.notify(`Switching to ${style} style (saved to ${where}). Reloading…`, "info");
|
|
187
|
+
// Reloading makes this ctx stale, so a failure is reported through the UI held from before.
|
|
188
|
+
const ui = ctx.ui;
|
|
189
|
+
try {
|
|
190
|
+
await ctx.reload();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
ui.notify(`Style was saved, but reload failed: ${errorMessage(error)}. Run /reload to apply it.`, "error");
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -29,6 +29,15 @@ export function setRowResolver(resolver: RowResolver | undefined): void {
|
|
|
29
29
|
(globalThis as ResolverSlot)[RESOLVER_KEY] = resolver;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Stop answering for rows, if `resolver` is still the one answering. The patch on
|
|
34
|
+
* Pi's rows outlives the extension, so a /reload that leaves it out must find no
|
|
35
|
+
* resolver, and Pi's own renderers draw every row made afterwards.
|
|
36
|
+
*/
|
|
37
|
+
export function clearRowResolver(resolver: RowResolver): void {
|
|
38
|
+
if ((globalThis as ResolverSlot)[RESOLVER_KEY] === resolver) setRowResolver(undefined);
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
/**
|
|
33
42
|
* Pi routes every renderer lookup on a tool row through these four methods, so
|
|
34
43
|
* answering them is enough to give any tool, whichever extension registered it,
|