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
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
import { closeSync, mkdirSync, openSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { DynamicBorder, getAgentDir, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { DynamicBorder, getAgentDir, getMarkdownTheme, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
5
5
|
|
|
6
6
|
/** One marker file per version shown, so two Pi processes can't both show it. */
|
|
7
7
|
const NOTICE_DIRECTORY = "compact-tools-notices";
|
|
8
8
|
const WIDGET_KEY = "compact-tools-release-notes";
|
|
9
9
|
const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** The headings a version's notes are grouped under, in the order they are shown. */
|
|
12
|
+
export const RELEASE_SECTIONS = ["New", "Fix", "Deprecated"] as const;
|
|
13
|
+
export type ReleaseSection = (typeof RELEASE_SECTIONS)[number];
|
|
14
|
+
/** A version's notes: grouped under New, Fix, and Deprecated, or, as older versions have them, a plain list. */
|
|
15
|
+
export type ReleaseEntry = readonly string[] | Partial<Record<ReleaseSection, readonly string[]>>;
|
|
16
|
+
export type ReleaseNotes = Record<string, ReleaseEntry>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A version's notes as Markdown, the way Pi's own changelog reads: a `### New`
|
|
20
|
+
* heading over each group's `- ` items, or the items alone for a plain list.
|
|
21
|
+
*/
|
|
22
|
+
export function releaseMarkdown(entry: ReleaseEntry): string {
|
|
23
|
+
return releaseSections(entry)
|
|
24
|
+
.map(({ title, notes }) => [...(title ? [`### ${title}`, ""] : []), ...notes.map((note) => `- ${note}`)].join("\n"))
|
|
25
|
+
.join("\n\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A version's notes as the groups they are shown in: a heading for each section with notes, none for a plain list. */
|
|
29
|
+
export function releaseSections(entry: ReleaseEntry): Array<{ title?: ReleaseSection; notes: readonly string[] }> {
|
|
30
|
+
if (Array.isArray(entry)) return entry.length > 0 ? [{ notes: entry }] : [];
|
|
31
|
+
const sections = entry as Partial<Record<ReleaseSection, readonly string[]>>;
|
|
32
|
+
return RELEASE_SECTIONS.flatMap((title) => sections[title]?.length ? [{ title, notes: sections[title]! }] : []);
|
|
33
|
+
}
|
|
12
34
|
|
|
13
35
|
type Chat = { children: unknown[]; addChild(component: unknown): void };
|
|
14
36
|
|
|
@@ -49,12 +71,12 @@ export function releasesToShow(
|
|
|
49
71
|
installed: string,
|
|
50
72
|
notes: ReleaseNotes,
|
|
51
73
|
lastShown: string | undefined,
|
|
52
|
-
): Array<{ version: string; notes:
|
|
74
|
+
): Array<{ version: string; notes: ReleaseEntry }> {
|
|
53
75
|
const withNotes = Object.keys(notes)
|
|
54
76
|
.filter((version) =>
|
|
55
77
|
VERSION.test(version)
|
|
56
78
|
&& compareVersions(version, installed) <= 0
|
|
57
|
-
&& notes[version]
|
|
79
|
+
&& releaseSections(notes[version]!).length > 0)
|
|
58
80
|
.sort((left, right) => compareVersions(right, left));
|
|
59
81
|
const versions = lastShown === undefined
|
|
60
82
|
? withNotes.slice(0, 1)
|
|
@@ -88,7 +110,8 @@ function addReleaseBlock(chat: Chat, theme: Theme, releases: ReturnType<typeof r
|
|
|
88
110
|
if (index > 0) chat.addChild(new Spacer(1));
|
|
89
111
|
chat.addChild(new Text(theme.bold(theme.fg("accent", `What's new in pi-compact-tools v${version}`)), 1, 0));
|
|
90
112
|
chat.addChild(new Spacer(1));
|
|
91
|
-
|
|
113
|
+
// Drawn as Pi draws its own changelog, so the headings and items look the same.
|
|
114
|
+
chat.addChild(new Markdown(releaseMarkdown(notes), 1, 0, getMarkdownTheme()));
|
|
92
115
|
});
|
|
93
116
|
chat.addChild(new DynamicBorder(border));
|
|
94
117
|
}
|
|
@@ -131,7 +154,7 @@ export function showReleaseNotice(
|
|
|
131
154
|
ctx.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
132
155
|
if (!shown) {
|
|
133
156
|
const text = releases.map(({ version: shownVersion, notes: shownNotes }) =>
|
|
134
|
-
`pi-compact-tools v${shownVersion}\n${shownNotes
|
|
157
|
+
`pi-compact-tools v${shownVersion}\n\n${releaseMarkdown(shownNotes)}`).join("\n\n");
|
|
135
158
|
ctx.ui.notify(text, "info");
|
|
136
159
|
}
|
|
137
160
|
return true;
|
|
@@ -207,12 +207,14 @@ export class ToolRuntime {
|
|
|
207
207
|
*/
|
|
208
208
|
noteExecutionStart(toolCallId: string): void {
|
|
209
209
|
if (!this.executionTimings.has(toolCallId)) this.executionTimings.set(toolCallId, { startedAt: Date.now() });
|
|
210
|
+
this.trimTimings();
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
noteExecutionEnd(toolCallId: string): void {
|
|
213
214
|
const timing = this.executionTimings.get(toolCallId) ?? { startedAt: Date.now() };
|
|
214
215
|
timing.endedAt ??= Date.now();
|
|
215
216
|
this.executionTimings.set(toolCallId, timing);
|
|
217
|
+
this.trimTimings();
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
createTimedExecute(definition: BuiltInDefinition): TimedExecute {
|
|
@@ -222,6 +224,7 @@ export class ToolRuntime {
|
|
|
222
224
|
const startedAt = (toolCallId ? this.executionTimings.get(toolCallId)?.startedAt : undefined) ?? Date.now();
|
|
223
225
|
if (toolCallId && !this.executionTimings.has(toolCallId)) {
|
|
224
226
|
this.executionTimings.set(toolCallId, { startedAt });
|
|
227
|
+
this.trimTimings();
|
|
225
228
|
}
|
|
226
229
|
try {
|
|
227
230
|
return await execute.apply(definition, args);
|
|
@@ -295,7 +298,20 @@ export class ToolRuntime {
|
|
|
295
298
|
timing.startedAt = state.startedAt;
|
|
296
299
|
if (state.endedAt !== undefined) timing.endedAt = state.endedAt;
|
|
297
300
|
this.executionTimings.set(toolCallId, timing);
|
|
301
|
+
this.trimTimings();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Bound every insertion, including calls never drawn (for example in silent mode). */
|
|
305
|
+
private trimTimings(): void {
|
|
298
306
|
if (this.executionTimings.size <= MAX_TRACKED_ROWS) return;
|
|
307
|
+
// Prefer settled calls so a long-running sibling keeps its start time. If all
|
|
308
|
+
// 2,001 calls are active, retain the original FIFO fallback to stay bounded.
|
|
309
|
+
for (const [id, timing] of this.executionTimings) {
|
|
310
|
+
if (timing.endedAt !== undefined) {
|
|
311
|
+
this.executionTimings.delete(id);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
299
315
|
const oldest = this.executionTimings.keys().next().value;
|
|
300
316
|
if (oldest !== undefined) this.executionTimings.delete(oldest);
|
|
301
317
|
}
|
|
@@ -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 {
|
|
@@ -50,6 +50,14 @@ export interface RowState {
|
|
|
50
50
|
lastResultPreview?: boolean;
|
|
51
51
|
lastResultError?: boolean;
|
|
52
52
|
lastResultConfigRevision?: object;
|
|
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
|
+
};
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
type BaseRenderContext = Parameters<NonNullable<BuiltInDefinition["renderCall"]>>[2];
|
|
@@ -89,17 +89,15 @@ export function findAnchorTop(
|
|
|
89
89
|
else starts.set(line, [index]);
|
|
90
90
|
});
|
|
91
91
|
const bottom = Math.min(before.length, top + Math.max(1, height));
|
|
92
|
-
|
|
93
|
-
for (let
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const window = before.slice(row, row + ANCHOR_LINES);
|
|
97
|
-
if (window.length < ANCHOR_LINES || !window.some(hasText)) continue;
|
|
92
|
+
// Visit the viewport first, then preceding rows, without copying candidate windows.
|
|
93
|
+
for (let candidate = 0; candidate < bottom; candidate++) {
|
|
94
|
+
const row = candidate < bottom - top ? top + candidate : bottom - candidate - 1;
|
|
95
|
+
if (row + ANCHOR_LINES > before.length || (!hasText(before[row]) && !hasText(before[row + 1]))) continue;
|
|
98
96
|
// Several places can match a short window; the one that keeps matching for
|
|
99
97
|
// longest is the same text, and distance only breaks a remaining tie.
|
|
100
98
|
let best: number | undefined;
|
|
101
99
|
let bestRun = 0;
|
|
102
|
-
for (const start of starts.get(
|
|
100
|
+
for (const start of starts.get(before[row]!) ?? []) {
|
|
103
101
|
const run = matchingRun(before, row, after, start, TIE_BREAK_LINES);
|
|
104
102
|
if (run < ANCHOR_LINES) continue;
|
|
105
103
|
if (best === undefined || run > bestRun
|