@xynogen/pix-optimizer 1.1.15 → 1.1.17
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 +1 -1
- package/package.json +1 -1
- package/src/capability.test.ts +42 -0
- package/src/capability.ts +16 -0
- package/src/caveman.ts +4 -13
- package/src/index.ts +2 -6
- package/src/json.test.ts +1 -3
- package/src/json.ts +8 -22
- package/src/opt.test.ts +1 -5
- package/src/opt.ts +8 -36
- package/src/ponytail.ts +4 -13
- package/src/rtk.test.ts +43 -41
- package/src/rtk.ts +12 -29
- package/src/status.test.ts +4 -20
- package/src/status.ts +6 -19
- package/src/tool-result-filter.test.ts +3 -9
- package/src/tool-result-filter.ts +1 -4
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ Switch the style live from the **`/optimizer` overlay** — a fifth `icons` row
|
|
|
44
44
|
at the bottom cycles `nerd → unicode → ascii` with `←→`; the choice persists to
|
|
45
45
|
`~/.pi/agent/optimizer.json`.
|
|
46
46
|
|
|
47
|
-
Icons follow the **global** `pix-pretty` mode — set via `/
|
|
47
|
+
Icons follow the **global** `pix-pretty` mode — set via `/pix` or `PRETTY_ICONS`
|
|
48
48
|
env var. The optimizer no longer has its own toggle.
|
|
49
49
|
|
|
50
50
|
## Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-optimizer",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.17",
|
|
4
4
|
"description": "Performance optimization suite for Pi Coding Agent - caveman mode + RTK tool rewriting + jq/TOON JSON compression + ponytail lazy-dev mode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { canExecute } from "./capability.ts";
|
|
4
|
+
|
|
5
|
+
function executor(impl: ExtensionAPI["exec"]): Pick<ExtensionAPI, "exec"> {
|
|
6
|
+
return { exec: impl };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("canExecute", () => {
|
|
10
|
+
it("runs the requested capability probe without a shell", async () => {
|
|
11
|
+
const exec = mock(async () => ({
|
|
12
|
+
stdout: "jq-1.7",
|
|
13
|
+
stderr: "",
|
|
14
|
+
code: 0,
|
|
15
|
+
killed: false,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
expect(await canExecute(executor(exec), "jq", ["--version"])).toBe(true);
|
|
19
|
+
expect(exec).toHaveBeenCalledWith("jq", ["--version"], { timeout: 3000 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("supports a custom timeout", async () => {
|
|
23
|
+
const exec = mock(async () => ({ stdout: "2.3.0", stderr: "", code: 0, killed: false }));
|
|
24
|
+
|
|
25
|
+
expect(await canExecute(executor(exec), "toon", ["--version"], 1000)).toBe(true);
|
|
26
|
+
expect(exec).toHaveBeenCalledWith("toon", ["--version"], { timeout: 1000 });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns false for a non-zero exit", async () => {
|
|
30
|
+
const exec = mock(async () => ({ stdout: "", stderr: "failed", code: 1, killed: false }));
|
|
31
|
+
|
|
32
|
+
expect(await canExecute(executor(exec), "jq", ["--version"])).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns false when the executable cannot be spawned", async () => {
|
|
36
|
+
const exec = mock(async () => {
|
|
37
|
+
throw new Error("ENOENT");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(await canExecute(executor(exec), "toon", ["--version"])).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Test whether a command can perform a harmless operation through Pi's executor. */
|
|
4
|
+
export async function canExecute(
|
|
5
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
6
|
+
command: string,
|
|
7
|
+
args: string[],
|
|
8
|
+
timeout = 3000,
|
|
9
|
+
): Promise<boolean> {
|
|
10
|
+
try {
|
|
11
|
+
const result = await pi.exec(command, args, { timeout });
|
|
12
|
+
return result.code === 0;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/caveman.ts
CHANGED
|
@@ -108,8 +108,7 @@ export function resolveLevel(arg: string): Level | null {
|
|
|
108
108
|
* Help text shown when /caveman is run with no argument.
|
|
109
109
|
*/
|
|
110
110
|
export function buildHelp(current: Level): string {
|
|
111
|
-
const statusLine =
|
|
112
|
-
current === "off" ? "off" : `${STATUS_LABELS[current]} (${current})`;
|
|
111
|
+
const statusLine = current === "off" ? "off" : `${STATUS_LABELS[current]} (${current})`;
|
|
113
112
|
return [
|
|
114
113
|
`Caveman mode: ${statusLine}`,
|
|
115
114
|
"",
|
|
@@ -133,10 +132,7 @@ export function toggleLevel(current: Level): Level {
|
|
|
133
132
|
|
|
134
133
|
// ── Pi extension ────────────────────────────────────────────────────────────
|
|
135
134
|
|
|
136
|
-
export function caveman(
|
|
137
|
-
pi: ExtensionAPI,
|
|
138
|
-
status: OptimizerStatus,
|
|
139
|
-
): OptimizerHandle {
|
|
135
|
+
export function caveman(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle {
|
|
140
136
|
let level: Level = "off";
|
|
141
137
|
|
|
142
138
|
// -- Status: report into the shared optimizer indicator. --
|
|
@@ -179,10 +175,7 @@ export function caveman(
|
|
|
179
175
|
|
|
180
176
|
// -- Overlay value handler (called by the /optimizer overlay) --
|
|
181
177
|
|
|
182
|
-
async function run(
|
|
183
|
-
value: string,
|
|
184
|
-
ctx: ExtensionCommandContext,
|
|
185
|
-
): Promise<void> {
|
|
178
|
+
async function run(value: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
186
179
|
const resolved = resolveLevel(value);
|
|
187
180
|
if (resolved === null) return;
|
|
188
181
|
level = resolved;
|
|
@@ -192,9 +185,7 @@ export function caveman(
|
|
|
192
185
|
syncStatus(ctx);
|
|
193
186
|
|
|
194
187
|
ctx.ui.notify(
|
|
195
|
-
level === "off"
|
|
196
|
-
? "Caveman mode off."
|
|
197
|
-
: `Caveman: ${STATUS_LABELS[level]}`,
|
|
188
|
+
level === "off" ? "Caveman mode off." : `Caveman: ${STATUS_LABELS[level]}`,
|
|
198
189
|
"info",
|
|
199
190
|
);
|
|
200
191
|
}
|
package/src/index.ts
CHANGED
|
@@ -19,15 +19,11 @@ import { json } from "./json.ts";
|
|
|
19
19
|
import { registerOptCommand } from "./opt.ts";
|
|
20
20
|
import { ponytail } from "./ponytail.ts";
|
|
21
21
|
import { rtk } from "./rtk.ts";
|
|
22
|
-
import {
|
|
23
|
-
type OptimizerHandle,
|
|
24
|
-
OptimizerStatus,
|
|
25
|
-
type OptimizerTool,
|
|
26
|
-
} from "./status.ts";
|
|
22
|
+
import { type OptimizerHandle, OptimizerStatus, type OptimizerTool } from "./status.ts";
|
|
27
23
|
import { filterModelWarnings } from "./tool-result-filter.ts";
|
|
28
24
|
|
|
29
25
|
export default function optimizer(pi: ExtensionAPI) {
|
|
30
|
-
// Icons follow the global pix-pretty mode (set via /
|
|
26
|
+
// Icons follow the global pix-pretty mode (set via /pix); no local state.
|
|
31
27
|
const status = new OptimizerStatus();
|
|
32
28
|
|
|
33
29
|
// Each module registers its own lifecycle hooks and returns a handle the
|
package/src/json.test.ts
CHANGED
|
@@ -71,9 +71,7 @@ describe("isUniformObjectArray", () => {
|
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
it("false for differing keys", () => {
|
|
74
|
-
expect(isUniformObjectArray([{ id: 1 }, { id: 2, extra: true }])).toBe(
|
|
75
|
-
false,
|
|
76
|
-
);
|
|
74
|
+
expect(isUniformObjectArray([{ id: 1 }, { id: 2, extra: true }])).toBe(false);
|
|
77
75
|
});
|
|
78
76
|
|
|
79
77
|
it("false when a value is itself an object", () => {
|
package/src/json.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
ExtensionCommandContext,
|
|
23
23
|
ExtensionContext,
|
|
24
24
|
} from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { canExecute } from "./capability.ts";
|
|
25
26
|
import { loadOptValue, saveOptValue } from "./persist.ts";
|
|
26
27
|
import type { OptimizerHandle, OptimizerStatus } from "./status.ts";
|
|
27
28
|
|
|
@@ -182,8 +183,7 @@ export function isUniformObjectArray(arr: unknown[]): boolean {
|
|
|
182
183
|
for (let i = 0; i < keys.length; i++) {
|
|
183
184
|
if (elKeys[i] !== keys[i]) return false;
|
|
184
185
|
// values must be primitive for a clean tabular row
|
|
185
|
-
if (isObjectLike((el as Record<string, unknown>)[keys[i] as string]))
|
|
186
|
-
return false;
|
|
186
|
+
if (isObjectLike((el as Record<string, unknown>)[keys[i] as string])) return false;
|
|
187
187
|
}
|
|
188
188
|
return true;
|
|
189
189
|
});
|
|
@@ -209,21 +209,14 @@ export function objectDepth(value: unknown): number {
|
|
|
209
209
|
|
|
210
210
|
// ── Pi extension ──────────────────────────────────────────────────────────────
|
|
211
211
|
|
|
212
|
-
export function json(
|
|
213
|
-
pi: ExtensionAPI,
|
|
214
|
-
status: OptimizerStatus,
|
|
215
|
-
): OptimizerHandle {
|
|
212
|
+
export function json(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle {
|
|
216
213
|
let enabled = true;
|
|
217
214
|
let jqAvailable: boolean | null = null;
|
|
218
215
|
let toonAvailable: boolean | null = null;
|
|
219
216
|
|
|
220
217
|
// Report into the shared optimizer indicator.
|
|
221
218
|
function syncStatus(ctx: Pick<ExtensionContext, "ui">) {
|
|
222
|
-
status.set(
|
|
223
|
-
"toon",
|
|
224
|
-
enabled && jqAvailable !== false && toonAvailable !== false,
|
|
225
|
-
ctx,
|
|
226
|
-
);
|
|
219
|
+
status.set("toon", enabled && jqAvailable !== false && toonAvailable !== false, ctx);
|
|
227
220
|
}
|
|
228
221
|
|
|
229
222
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -249,14 +242,10 @@ export function json(
|
|
|
249
242
|
|
|
250
243
|
// Probe once on first JSON-relevant prompt.
|
|
251
244
|
if (jqAvailable === null || toonAvailable === null) {
|
|
252
|
-
|
|
253
|
-
pi
|
|
254
|
-
pi
|
|
255
|
-
.exec("which", ["toon"], { timeout: 1000 })
|
|
256
|
-
.catch(() => ({ code: 1 })),
|
|
245
|
+
[jqAvailable, toonAvailable] = await Promise.all([
|
|
246
|
+
canExecute(pi, "jq", ["--version"]),
|
|
247
|
+
canExecute(pi, "toon", ["--version"]),
|
|
257
248
|
]);
|
|
258
|
-
jqAvailable = jqRes.code === 0;
|
|
259
|
-
toonAvailable = toonRes.code === 0;
|
|
260
249
|
if (!jqAvailable)
|
|
261
250
|
ctx.ui.notify(
|
|
262
251
|
"jq not found — JSON/TOON guidance disabled. Install: sudo apt install jq or brew install jq",
|
|
@@ -277,10 +266,7 @@ export function json(
|
|
|
277
266
|
|
|
278
267
|
// -- Overlay value handler (called by the /optimizer overlay) --
|
|
279
268
|
|
|
280
|
-
async function run(
|
|
281
|
-
value: string,
|
|
282
|
-
ctx: ExtensionCommandContext,
|
|
283
|
-
): Promise<void> {
|
|
269
|
+
async function run(value: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
284
270
|
enabled = value === "on";
|
|
285
271
|
saveOptValue("toon", enabled ? "on" : "off");
|
|
286
272
|
|
package/src/opt.test.ts
CHANGED
|
@@ -4,11 +4,7 @@ import type { OptimizerHandle, OptimizerTool } from "./status.ts";
|
|
|
4
4
|
|
|
5
5
|
/** Build a handle set with fixed current values + value lists. */
|
|
6
6
|
function fakeHandles(): Record<OptimizerTool, OptimizerHandle> {
|
|
7
|
-
const mk = (
|
|
8
|
-
name: OptimizerTool,
|
|
9
|
-
current: string,
|
|
10
|
-
values: string[],
|
|
11
|
-
): OptimizerHandle => ({
|
|
7
|
+
const mk = (name: OptimizerTool, current: string, values: string[]): OptimizerHandle => ({
|
|
12
8
|
name,
|
|
13
9
|
help: `${name} — ${name} help`,
|
|
14
10
|
values,
|
package/src/opt.ts
CHANGED
|
@@ -16,11 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { frameLines } from "@xynogen/pix-pretty/modal-frame";
|
|
19
|
-
import type {
|
|
20
|
-
OptimizerHandle,
|
|
21
|
-
OptimizerStatus,
|
|
22
|
-
OptimizerTool,
|
|
23
|
-
} from "./status.ts";
|
|
19
|
+
import type { OptimizerHandle, OptimizerStatus, OptimizerTool } from "./status.ts";
|
|
24
20
|
import { toolIcon } from "./status.ts";
|
|
25
21
|
|
|
26
22
|
/** Min/max content width for the overlay box (excludes 4 cols of chrome). */
|
|
@@ -28,12 +24,7 @@ const MIN_CONTENT = 28;
|
|
|
28
24
|
const MAX_CONTENT = 60;
|
|
29
25
|
|
|
30
26
|
/** Fixed render order — matches the status-bar cell. */
|
|
31
|
-
const TOOL_ORDER: readonly OptimizerTool[] = [
|
|
32
|
-
"caveman",
|
|
33
|
-
"rtk",
|
|
34
|
-
"toon",
|
|
35
|
-
"ponytail",
|
|
36
|
-
];
|
|
27
|
+
const TOOL_ORDER: readonly OptimizerTool[] = ["caveman", "rtk", "toon", "ponytail"];
|
|
37
28
|
|
|
38
29
|
/** Strip the leading "name [args] — " prefix from a handle's help string. */
|
|
39
30
|
function helpSummary(help: string): string {
|
|
@@ -64,9 +55,7 @@ export function levelBar(current: string, values: readonly string[]): string {
|
|
|
64
55
|
}
|
|
65
56
|
|
|
66
57
|
/** Plain-text status fallback when there's no custom-UI host (headless/tests). */
|
|
67
|
-
export function buildOptHelp(
|
|
68
|
-
handles: Record<OptimizerTool, OptimizerHandle>,
|
|
69
|
-
): string {
|
|
58
|
+
export function buildOptHelp(handles: Record<OptimizerTool, OptimizerHandle>): string {
|
|
70
59
|
const lines = TOOL_ORDER.map(
|
|
71
60
|
(n) => ` ${n}: ${handles[n].current()} — ${helpSummary(handles[n].help)}`,
|
|
72
61
|
);
|
|
@@ -140,8 +129,7 @@ export function registerOptCommand(
|
|
|
140
129
|
};
|
|
141
130
|
|
|
142
131
|
const move = (direction: -1 | 1) => {
|
|
143
|
-
selected =
|
|
144
|
-
(selected + direction + TOOL_ORDER.length) % TOOL_ORDER.length;
|
|
132
|
+
selected = (selected + direction + TOOL_ORDER.length) % TOOL_ORDER.length;
|
|
145
133
|
};
|
|
146
134
|
|
|
147
135
|
return {
|
|
@@ -156,14 +144,8 @@ export function registerOptCommand(
|
|
|
156
144
|
tool.padEnd(nameWidth),
|
|
157
145
|
);
|
|
158
146
|
const cur = handles[tool].current();
|
|
159
|
-
const value = theme.fg(
|
|
160
|
-
|
|
161
|
-
cur.padEnd(valueWidth),
|
|
162
|
-
);
|
|
163
|
-
const bar = theme.fg(
|
|
164
|
-
on ? "accent" : "dim",
|
|
165
|
-
levelBar(cur, handles[tool].values),
|
|
166
|
-
);
|
|
147
|
+
const value = theme.fg(on ? "success" : "dim", cur.padEnd(valueWidth));
|
|
148
|
+
const bar = theme.fg(on ? "accent" : "dim", levelBar(cur, handles[tool].values));
|
|
167
149
|
return `${cursor} ${glyph} ${name} ${value} ${bar}`;
|
|
168
150
|
});
|
|
169
151
|
const lines = [
|
|
@@ -171,12 +153,7 @@ export function registerOptCommand(
|
|
|
171
153
|
"",
|
|
172
154
|
...rows,
|
|
173
155
|
"",
|
|
174
|
-
theme.fg(
|
|
175
|
-
"dim",
|
|
176
|
-
helpSummary(
|
|
177
|
-
handles[TOOL_ORDER[selected] as OptimizerTool].help,
|
|
178
|
-
),
|
|
179
|
-
),
|
|
156
|
+
theme.fg("dim", helpSummary(handles[TOOL_ORDER[selected] as OptimizerTool].help)),
|
|
180
157
|
"",
|
|
181
158
|
theme.fg("dim", "←→ change · ↑↓ move · esc close"),
|
|
182
159
|
];
|
|
@@ -192,12 +169,7 @@ export function registerOptCommand(
|
|
|
192
169
|
if (data === "k" || data === "\u001b[A") move(-1);
|
|
193
170
|
else if (data === "j" || data === "\u001b[B") move(1);
|
|
194
171
|
else if (data === "h" || data === "\u001b[D") cycle(-1);
|
|
195
|
-
else if (
|
|
196
|
-
data === "l" ||
|
|
197
|
-
data === "\u001b[C" ||
|
|
198
|
-
data === " " ||
|
|
199
|
-
data === "\r"
|
|
200
|
-
)
|
|
172
|
+
else if (data === "l" || data === "\u001b[C" || data === " " || data === "\r")
|
|
201
173
|
cycle(1);
|
|
202
174
|
else if (data === "\u001b" || data === "q") {
|
|
203
175
|
done(null);
|
package/src/ponytail.ts
CHANGED
|
@@ -113,8 +113,7 @@ export function resolveLevel(arg: string): Level | null {
|
|
|
113
113
|
* Help text shown when /opt ponytail is run with no argument.
|
|
114
114
|
*/
|
|
115
115
|
export function buildHelp(current: Level): string {
|
|
116
|
-
const statusLine =
|
|
117
|
-
current === "off" ? "off" : `${STATUS_LABELS[current]} (${current})`;
|
|
116
|
+
const statusLine = current === "off" ? "off" : `${STATUS_LABELS[current]} (${current})`;
|
|
118
117
|
return [
|
|
119
118
|
`Ponytail mode: ${statusLine}`,
|
|
120
119
|
"",
|
|
@@ -137,10 +136,7 @@ export function toggleLevel(current: Level): Level {
|
|
|
137
136
|
|
|
138
137
|
// ── Pi extension ────────────────────────────────────────────────────────────
|
|
139
138
|
|
|
140
|
-
export function ponytail(
|
|
141
|
-
pi: ExtensionAPI,
|
|
142
|
-
status: OptimizerStatus,
|
|
143
|
-
): OptimizerHandle {
|
|
139
|
+
export function ponytail(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle {
|
|
144
140
|
let level: Level = "off";
|
|
145
141
|
|
|
146
142
|
// -- Status: report into the shared optimizer indicator. --
|
|
@@ -182,10 +178,7 @@ export function ponytail(
|
|
|
182
178
|
|
|
183
179
|
// -- Overlay value handler (called by the /optimizer overlay) --
|
|
184
180
|
|
|
185
|
-
async function run(
|
|
186
|
-
value: string,
|
|
187
|
-
ctx: ExtensionCommandContext,
|
|
188
|
-
): Promise<void> {
|
|
181
|
+
async function run(value: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
189
182
|
const resolved = resolveLevel(value);
|
|
190
183
|
if (resolved === null) return;
|
|
191
184
|
level = resolved;
|
|
@@ -195,9 +188,7 @@ export function ponytail(
|
|
|
195
188
|
syncStatus(ctx);
|
|
196
189
|
|
|
197
190
|
ctx.ui.notify(
|
|
198
|
-
level === "off"
|
|
199
|
-
? "Ponytail mode off."
|
|
200
|
-
: `Ponytail: ${STATUS_LABELS[level]}`,
|
|
191
|
+
level === "off" ? "Ponytail mode off." : `Ponytail: ${STATUS_LABELS[level]}`,
|
|
201
192
|
"info",
|
|
202
193
|
);
|
|
203
194
|
}
|
package/src/rtk.test.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import {
|
|
3
4
|
applyRtkRewrite,
|
|
4
5
|
type BashCallEvent,
|
|
5
6
|
buildSudoBlockReason,
|
|
6
7
|
detectSudoSegments,
|
|
8
|
+
probeRtkAvailability,
|
|
7
9
|
rewriteChain,
|
|
8
10
|
splitChain,
|
|
9
11
|
} from "./rtk.ts";
|
|
@@ -13,17 +15,41 @@ function bashEvent(command: string): BashCallEvent {
|
|
|
13
15
|
return { toolName: "bash", input: { command } };
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
describe("probeRtkAvailability", () => {
|
|
19
|
+
it("probes rtk directly and accepts a successful version check", async () => {
|
|
20
|
+
const exec = mock(async () => ({
|
|
21
|
+
stdout: "rtk 0.1.0",
|
|
22
|
+
stderr: "",
|
|
23
|
+
code: 0,
|
|
24
|
+
killed: false,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
expect(await probeRtkAvailability({ exec } as Pick<ExtensionAPI, "exec">)).toBe(true);
|
|
28
|
+
expect(exec).toHaveBeenCalledWith("rtk", ["--version"], { timeout: 3000 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects an unsuccessful version check", async () => {
|
|
32
|
+
const exec = mock(async () => ({ stdout: "", stderr: "missing", code: 1, killed: false }));
|
|
33
|
+
|
|
34
|
+
expect(await probeRtkAvailability({ exec } as Pick<ExtensionAPI, "exec">)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("treats a spawn failure as unavailable", async () => {
|
|
38
|
+
const exec = mock(async () => {
|
|
39
|
+
throw new Error("ENOENT");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(await probeRtkAvailability({ exec } as Pick<ExtensionAPI, "exec">)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
16
46
|
describe("splitChain", () => {
|
|
17
47
|
it("returns single segment for plain command", () => {
|
|
18
48
|
expect(splitChain("git status")).toEqual(["git status"]);
|
|
19
49
|
});
|
|
20
50
|
|
|
21
51
|
it("splits on && keeping operator", () => {
|
|
22
|
-
expect(splitChain("git add . && git push")).toEqual([
|
|
23
|
-
"git add . ",
|
|
24
|
-
"&&",
|
|
25
|
-
" git push",
|
|
26
|
-
]);
|
|
52
|
+
expect(splitChain("git add . && git push")).toEqual(["git add . ", "&&", " git push"]);
|
|
27
53
|
});
|
|
28
54
|
|
|
29
55
|
it("splits on ||, ;, |", () => {
|
|
@@ -33,9 +59,7 @@ describe("splitChain", () => {
|
|
|
33
59
|
});
|
|
34
60
|
|
|
35
61
|
it("ignores operators inside double quotes", () => {
|
|
36
|
-
expect(splitChain('git commit -m "a && b"')).toEqual([
|
|
37
|
-
'git commit -m "a && b"',
|
|
38
|
-
]);
|
|
62
|
+
expect(splitChain('git commit -m "a && b"')).toEqual(['git commit -m "a && b"']);
|
|
39
63
|
});
|
|
40
64
|
|
|
41
65
|
it("ignores operators inside single quotes", () => {
|
|
@@ -59,9 +83,7 @@ describe("rewriteChain", () => {
|
|
|
59
83
|
});
|
|
60
84
|
|
|
61
85
|
it("prefixes mixed known commands", () => {
|
|
62
|
-
expect(rewriteChain("cargo build && npm test")).toBe(
|
|
63
|
-
"rtk cargo build && rtk npm test",
|
|
64
|
-
);
|
|
86
|
+
expect(rewriteChain("cargo build && npm test")).toBe("rtk cargo build && rtk npm test");
|
|
65
87
|
});
|
|
66
88
|
|
|
67
89
|
it("leaves unknown commands alone", () => {
|
|
@@ -74,22 +96,16 @@ describe("rewriteChain", () => {
|
|
|
74
96
|
});
|
|
75
97
|
|
|
76
98
|
it("only prefixes known segments in a mixed chain", () => {
|
|
77
|
-
expect(rewriteChain("cd /tmp && git status")).toBe(
|
|
78
|
-
"cd /tmp && rtk git status",
|
|
79
|
-
);
|
|
99
|
+
expect(rewriteChain("cd /tmp && git status")).toBe("cd /tmp && rtk git status");
|
|
80
100
|
});
|
|
81
101
|
|
|
82
102
|
it("does not double-prefix already-rtk commands", () => {
|
|
83
103
|
expect(rewriteChain("rtk git status")).toBe("rtk git status");
|
|
84
|
-
expect(rewriteChain("rtk git add . && git push")).toBe(
|
|
85
|
-
"rtk git add . && rtk git push",
|
|
86
|
-
);
|
|
104
|
+
expect(rewriteChain("rtk git add . && git push")).toBe("rtk git add . && rtk git push");
|
|
87
105
|
});
|
|
88
106
|
|
|
89
107
|
it("does not touch operators inside quotes", () => {
|
|
90
|
-
expect(rewriteChain('git commit -m "a && b"')).toBe(
|
|
91
|
-
'rtk git commit -m "a && b"',
|
|
92
|
-
);
|
|
108
|
+
expect(rewriteChain('git commit -m "a && b"')).toBe('rtk git commit -m "a && b"');
|
|
93
109
|
});
|
|
94
110
|
|
|
95
111
|
it("returns original on unbalanced quotes", () => {
|
|
@@ -106,9 +122,7 @@ describe("rewriteChain", () => {
|
|
|
106
122
|
});
|
|
107
123
|
|
|
108
124
|
it("handles pipes between known commands", () => {
|
|
109
|
-
expect(rewriteChain("git log | grep fix")).toBe(
|
|
110
|
-
"rtk git log | rtk grep fix",
|
|
111
|
-
);
|
|
125
|
+
expect(rewriteChain("git log | grep fix")).toBe("rtk git log | rtk grep fix");
|
|
112
126
|
});
|
|
113
127
|
});
|
|
114
128
|
|
|
@@ -120,9 +134,7 @@ describe("detectSudoSegments", () => {
|
|
|
120
134
|
});
|
|
121
135
|
|
|
122
136
|
it("detects a plain sudo segment", () => {
|
|
123
|
-
expect(detectSudoSegments(["sudo apt-get install foo"])).toEqual([
|
|
124
|
-
"sudo apt-get install foo",
|
|
125
|
-
]);
|
|
137
|
+
expect(detectSudoSegments(["sudo apt-get install foo"])).toEqual(["sudo apt-get install foo"]);
|
|
126
138
|
});
|
|
127
139
|
|
|
128
140
|
it("detects sudo in a chain (operators excluded)", () => {
|
|
@@ -132,10 +144,7 @@ describe("detectSudoSegments", () => {
|
|
|
132
144
|
|
|
133
145
|
it("detects multiple sudo segments", () => {
|
|
134
146
|
const parts = ["sudo rm -rf /tmp ", ";", " sudo reboot"];
|
|
135
|
-
expect(detectSudoSegments(parts)).toEqual([
|
|
136
|
-
"sudo rm -rf /tmp",
|
|
137
|
-
"sudo reboot",
|
|
138
|
-
]);
|
|
147
|
+
expect(detectSudoSegments(parts)).toEqual(["sudo rm -rf /tmp", "sudo reboot"]);
|
|
139
148
|
});
|
|
140
149
|
|
|
141
150
|
it("does not match 'sudoer' or 'pseudo'", () => {
|
|
@@ -170,10 +179,7 @@ describe("buildSudoBlockReason", () => {
|
|
|
170
179
|
});
|
|
171
180
|
|
|
172
181
|
it("lists all blocked commands", () => {
|
|
173
|
-
const reason = buildSudoBlockReason(
|
|
174
|
-
["sudo rm -rf /tmp", "sudo reboot"],
|
|
175
|
-
true,
|
|
176
|
-
);
|
|
182
|
+
const reason = buildSudoBlockReason(["sudo rm -rf /tmp", "sudo reboot"], true);
|
|
177
183
|
expect(reason).toContain("sudo rm -rf /tmp");
|
|
178
184
|
expect(reason).toContain("sudo reboot");
|
|
179
185
|
});
|
|
@@ -254,13 +260,9 @@ describe("applyRtkRewrite (tool_call hook step)", () => {
|
|
|
254
260
|
|
|
255
261
|
it("handles missing / non-string command safely", () => {
|
|
256
262
|
const event: BashCallEvent = { toolName: "bash", input: {} };
|
|
257
|
-
expect(applyRtkRewrite(event, { enabled: true, rtkAvailable: true })).toBe(
|
|
258
|
-
false,
|
|
259
|
-
);
|
|
263
|
+
expect(applyRtkRewrite(event, { enabled: true, rtkAvailable: true })).toBe(false);
|
|
260
264
|
const event2: BashCallEvent = { toolName: "bash", input: { command: 123 } };
|
|
261
|
-
expect(applyRtkRewrite(event2, { enabled: true, rtkAvailable: true })).toBe(
|
|
262
|
-
false,
|
|
263
|
-
);
|
|
265
|
+
expect(applyRtkRewrite(event2, { enabled: true, rtkAvailable: true })).toBe(false);
|
|
264
266
|
});
|
|
265
267
|
|
|
266
268
|
it("leaves command unchanged on unbalanced quotes", () => {
|
package/src/rtk.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
ExtensionCommandContext,
|
|
12
12
|
ExtensionContext,
|
|
13
13
|
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { canExecute } from "./capability.ts";
|
|
14
15
|
import { loadOptValue, saveOptValue } from "./persist.ts";
|
|
15
16
|
import type { OptimizerHandle, OptimizerStatus } from "./status.ts";
|
|
16
17
|
|
|
@@ -51,10 +52,7 @@ export function detectSudoSegments(parts: string[]): string[] {
|
|
|
51
52
|
* intercepted. Directs the model to `sudo_run` when available, otherwise
|
|
52
53
|
* explains the restriction clearly.
|
|
53
54
|
*/
|
|
54
|
-
export function buildSudoBlockReason(
|
|
55
|
-
sudoCmds: string[],
|
|
56
|
-
hasSudoRunTool: boolean,
|
|
57
|
-
): string {
|
|
55
|
+
export function buildSudoBlockReason(sudoCmds: string[], hasSudoRunTool: boolean): string {
|
|
58
56
|
const list = sudoCmds.map((c) => ` - ${c}`).join("\n");
|
|
59
57
|
if (hasSudoRunTool) {
|
|
60
58
|
return (
|
|
@@ -139,7 +137,11 @@ const RTK_COMMANDS = new Set([
|
|
|
139
137
|
interface RtkStatus {
|
|
140
138
|
available: boolean;
|
|
141
139
|
checkedAt: number;
|
|
142
|
-
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Probe the command we actually use instead of relying on a platform-specific locator. */
|
|
143
|
+
export function probeRtkAvailability(pi: Pick<ExtensionAPI, "exec">): Promise<boolean> {
|
|
144
|
+
return canExecute(pi, "rtk", ["--version"]);
|
|
143
145
|
}
|
|
144
146
|
|
|
145
147
|
/**
|
|
@@ -224,10 +226,7 @@ export function rewriteChain(command: string): string {
|
|
|
224
226
|
return changed ? rewritten.join("") : command;
|
|
225
227
|
}
|
|
226
228
|
|
|
227
|
-
export function rtk(
|
|
228
|
-
pi: ExtensionAPI,
|
|
229
|
-
status: OptimizerStatus,
|
|
230
|
-
): OptimizerHandle {
|
|
229
|
+
export function rtk(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle {
|
|
231
230
|
let rtkStatus: RtkStatus | null = null;
|
|
232
231
|
let warnedMissing = false;
|
|
233
232
|
let enabled = true;
|
|
@@ -248,25 +247,12 @@ export function rtk(
|
|
|
248
247
|
return rtkStatus;
|
|
249
248
|
}
|
|
250
249
|
|
|
251
|
-
|
|
252
|
-
const result = await pi.exec("which", ["rtk"], { timeout: 1000 });
|
|
253
|
-
if (result.code === 0 && result.stdout?.trim()) {
|
|
254
|
-
rtkStatus = {
|
|
255
|
-
available: true,
|
|
256
|
-
checkedAt: Date.now(),
|
|
257
|
-
path: result.stdout.trim(),
|
|
258
|
-
};
|
|
259
|
-
warnedMissing = false;
|
|
260
|
-
return rtkStatus;
|
|
261
|
-
}
|
|
262
|
-
} catch (_error) {
|
|
263
|
-
// which command failed
|
|
264
|
-
}
|
|
265
|
-
|
|
250
|
+
const available = await probeRtkAvailability(pi);
|
|
266
251
|
rtkStatus = {
|
|
267
|
-
available
|
|
252
|
+
available,
|
|
268
253
|
checkedAt: Date.now(),
|
|
269
254
|
};
|
|
255
|
+
if (available) warnedMissing = false;
|
|
270
256
|
return rtkStatus;
|
|
271
257
|
};
|
|
272
258
|
|
|
@@ -306,10 +292,7 @@ export function rtk(
|
|
|
306
292
|
|
|
307
293
|
// -- Overlay value handler (called by the /optimizer overlay) --
|
|
308
294
|
|
|
309
|
-
async function run(
|
|
310
|
-
value: string,
|
|
311
|
-
ctx: ExtensionCommandContext,
|
|
312
|
-
): Promise<void> {
|
|
295
|
+
async function run(value: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
313
296
|
enabled = value === "on";
|
|
314
297
|
saveOptValue("rtk", enabled ? "on" : "off");
|
|
315
298
|
|
package/src/status.test.ts
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
OptimizerStatus,
|
|
4
|
-
renderStatus,
|
|
5
|
-
STATUS_KEY,
|
|
6
|
-
toolIcon,
|
|
7
|
-
} from "./status.ts";
|
|
2
|
+
import { OptimizerStatus, renderStatus, STATUS_KEY, toolIcon } from "./status.ts";
|
|
8
3
|
|
|
9
4
|
/** Tagging colorizer: <accent>X</accent> / <dim>X</dim> for assertions. */
|
|
10
5
|
const tag = (c: string, t: string) => `<${c}>${t}</${c}>`;
|
|
@@ -17,23 +12,13 @@ const PT = toolIcon("ponytail");
|
|
|
17
12
|
|
|
18
13
|
describe("renderStatus", () => {
|
|
19
14
|
it("shows ALL icons in order, accent when enabled", () => {
|
|
20
|
-
expect(
|
|
21
|
-
renderStatus(
|
|
22
|
-
{ caveman: true, rtk: true, toon: true, ponytail: true },
|
|
23
|
-
tag,
|
|
24
|
-
),
|
|
25
|
-
).toBe(
|
|
15
|
+
expect(renderStatus({ caveman: true, rtk: true, toon: true, ponytail: true }, tag)).toBe(
|
|
26
16
|
`<accent>${CV}</accent> <accent>${RK}</accent> <accent>${TN}</accent> <accent>${PT}</accent> `,
|
|
27
17
|
);
|
|
28
18
|
});
|
|
29
19
|
|
|
30
20
|
it("dims disabled tools but still shows them", () => {
|
|
31
|
-
expect(
|
|
32
|
-
renderStatus(
|
|
33
|
-
{ caveman: false, rtk: true, toon: true, ponytail: true },
|
|
34
|
-
tag,
|
|
35
|
-
),
|
|
36
|
-
).toBe(
|
|
21
|
+
expect(renderStatus({ caveman: false, rtk: true, toon: true, ponytail: true }, tag)).toBe(
|
|
37
22
|
`<dim>${CV}</dim> <accent>${RK}</accent> <accent>${TN}</accent> <accent>${PT}</accent> `,
|
|
38
23
|
);
|
|
39
24
|
});
|
|
@@ -63,8 +48,7 @@ describe("OptimizerStatus", () => {
|
|
|
63
48
|
return {
|
|
64
49
|
calls,
|
|
65
50
|
ui: {
|
|
66
|
-
setStatus: (key: string, text: string | undefined) =>
|
|
67
|
-
calls.push({ key, text: text ?? "" }),
|
|
51
|
+
setStatus: (key: string, text: string | undefined) => calls.push({ key, text: text ?? "" }),
|
|
68
52
|
theme: { fg: (c: string, t: string) => `<${c}>${t}</${c}>` },
|
|
69
53
|
},
|
|
70
54
|
} as const;
|
package/src/status.ts
CHANGED
|
@@ -17,11 +17,7 @@ import type {
|
|
|
17
17
|
ExtensionContext,
|
|
18
18
|
ThemeColor,
|
|
19
19
|
} from "@earendil-works/pi-coding-agent";
|
|
20
|
-
import {
|
|
21
|
-
type IconKey,
|
|
22
|
-
icon,
|
|
23
|
-
onIconModeChange,
|
|
24
|
-
} from "@xynogen/pix-pretty/icon-catalog";
|
|
20
|
+
import { type IconKey, icon, onIconModeChange } from "@xynogen/pix-pretty/icon-catalog";
|
|
25
21
|
|
|
26
22
|
/**
|
|
27
23
|
* Each optimizer tool (caveman/rtk/toon/ponytail) exposes a handle so the
|
|
@@ -49,7 +45,7 @@ export type OptimizerTool = "caveman" | "rtk" | "toon" | "ponytail";
|
|
|
49
45
|
|
|
50
46
|
/**
|
|
51
47
|
* Icons come from the shared pix-pretty catalog and follow the ONE global icon
|
|
52
|
-
* mode (set via /
|
|
48
|
+
* mode (set via /pix). The optimizer no longer owns a mode: each tool maps
|
|
53
49
|
* to a semantic catalog key, and the cell renders whatever the active mode
|
|
54
50
|
* resolves — nerd / unicode / ascii — with no per-package knob.
|
|
55
51
|
*/
|
|
@@ -66,12 +62,7 @@ export function toolIcon(tool: OptimizerTool): string {
|
|
|
66
62
|
}
|
|
67
63
|
|
|
68
64
|
/** Fixed left-to-right order of icons in the cell. */
|
|
69
|
-
const TOOL_ORDER: readonly OptimizerTool[] = [
|
|
70
|
-
"caveman",
|
|
71
|
-
"rtk",
|
|
72
|
-
"toon",
|
|
73
|
-
"ponytail",
|
|
74
|
-
];
|
|
65
|
+
const TOOL_ORDER: readonly OptimizerTool[] = ["caveman", "rtk", "toon", "ponytail"];
|
|
75
66
|
|
|
76
67
|
/** Theme color for enabled icons. */
|
|
77
68
|
const ENABLED_COLOR: ThemeColor = "accent";
|
|
@@ -85,7 +76,7 @@ export type Colorize = (color: ThemeColor, text: string) => string;
|
|
|
85
76
|
* Build the colored status string for a set of tool states. ALL tool icons are
|
|
86
77
|
* always shown, in TOOL_ORDER; each is accent-colored when its tool is enabled
|
|
87
78
|
* and dim when disabled. Glyphs resolve against the active global icon mode
|
|
88
|
-
* (see /
|
|
79
|
+
* (see /pix). `color` applies the theme color (e.g. theme.fg).
|
|
89
80
|
*
|
|
90
81
|
* Pure + exported for tests (pass a tagging colorizer to assert per-icon color).
|
|
91
82
|
* A trailing space separates the cell from the next status segment.
|
|
@@ -111,7 +102,7 @@ export class OptimizerStatus {
|
|
|
111
102
|
|
|
112
103
|
constructor() {
|
|
113
104
|
// The cell is a pushed status (setStatus), so it does NOT auto-refresh
|
|
114
|
-
// when /
|
|
105
|
+
// when /pix flips the global icon mode. Repaint it on mode change
|
|
115
106
|
// using the last-seen ctx (no-op until the cell has painted once).
|
|
116
107
|
onIconModeChange(() => {
|
|
117
108
|
if (this.lastCtx) this.paint(this.lastCtx);
|
|
@@ -124,11 +115,7 @@ export class OptimizerStatus {
|
|
|
124
115
|
}
|
|
125
116
|
|
|
126
117
|
/** Update one tool's enabled state and repaint the shared cell. */
|
|
127
|
-
set(
|
|
128
|
-
tool: OptimizerTool,
|
|
129
|
-
enabled: boolean,
|
|
130
|
-
ctx: Pick<ExtensionContext, "ui">,
|
|
131
|
-
): void {
|
|
118
|
+
set(tool: OptimizerTool, enabled: boolean, ctx: Pick<ExtensionContext, "ui">): void {
|
|
132
119
|
this.states[tool] = enabled;
|
|
133
120
|
this.paint(ctx);
|
|
134
121
|
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
filterModelWarnings,
|
|
4
|
-
stripModelWarningParagraphs,
|
|
5
|
-
} from "./tool-result-filter.ts";
|
|
2
|
+
import { filterModelWarnings, stripModelWarningParagraphs } from "./tool-result-filter.ts";
|
|
6
3
|
|
|
7
4
|
// ── stripModelWarningParagraphs ───────────────────────────────────────────────
|
|
8
5
|
|
|
@@ -29,8 +26,7 @@ describe("stripModelWarningParagraphs", () => {
|
|
|
29
26
|
});
|
|
30
27
|
|
|
31
28
|
it("strips multiple warning paragraphs", () => {
|
|
32
|
-
const text =
|
|
33
|
-
"output\n\n⚠ BLIND WRITE — foo\n\n🔴 THRASHING — bar\n\nmore output";
|
|
29
|
+
const text = "output\n\n⚠ BLIND WRITE — foo\n\n🔴 THRASHING — bar\n\nmore output";
|
|
34
30
|
const result = stripModelWarningParagraphs(text);
|
|
35
31
|
expect(result).not.toContain("BLIND WRITE");
|
|
36
32
|
expect(result).not.toContain("THRASHING");
|
|
@@ -75,9 +71,7 @@ describe("filterModelWarnings", () => {
|
|
|
75
71
|
expect(result).not.toBe(content);
|
|
76
72
|
expect(result[0]?.type).toBe("text");
|
|
77
73
|
if (result[0]?.type === "text") {
|
|
78
|
-
expect((result[0] as { type: string; text: string }).text).not.toContain(
|
|
79
|
-
"BLIND WRITE",
|
|
80
|
-
);
|
|
74
|
+
expect((result[0] as { type: string; text: string }).text).not.toContain("BLIND WRITE");
|
|
81
75
|
}
|
|
82
76
|
});
|
|
83
77
|
|
|
@@ -28,10 +28,7 @@ export function stripModelWarningParagraphs(text: string): string {
|
|
|
28
28
|
// then rejoin. Trim trailing whitespace from the result.
|
|
29
29
|
const paragraphs = text.split(/\n\n/);
|
|
30
30
|
const kept = paragraphs.filter(
|
|
31
|
-
(p) =>
|
|
32
|
-
!MODEL_WARNING_PREFIXES.some((prefix) =>
|
|
33
|
-
p.trimStart().startsWith(prefix),
|
|
34
|
-
),
|
|
31
|
+
(p) => !MODEL_WARNING_PREFIXES.some((prefix) => p.trimStart().startsWith(prefix)),
|
|
35
32
|
);
|
|
36
33
|
if (kept.length === paragraphs.length) return text;
|
|
37
34
|
return kept.join("\n\n").trimEnd();
|