@xynogen/pix-optimizer 1.1.4 → 1.1.7
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/package.json +4 -1
- package/src/caveman.test.ts +0 -37
- package/src/caveman.ts +17 -252
- package/src/index.ts +4 -4
- package/src/json.ts +13 -26
- package/src/opt.test.ts +35 -78
- package/src/opt.ts +180 -73
- package/src/persist.test.ts +56 -0
- package/src/persist.ts +50 -0
- package/src/ponytail.test.ts +0 -41
- package/src/ponytail.ts +17 -244
- package/src/rtk.ts +11 -18
- package/src/status.ts +17 -16
package/src/opt.ts
CHANGED
|
@@ -1,108 +1,215 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* opt.ts — the single `/
|
|
2
|
+
* opt.ts — the single `/optimizer` command: an interactive overlay that fronts
|
|
3
|
+
* every optimizer tool.
|
|
3
4
|
*
|
|
4
|
-
* caveman / rtk /
|
|
5
|
-
* OptimizerHandle
|
|
6
|
-
*
|
|
5
|
+
* caveman / rtk / toon / ponytail each register their own lifecycle hooks but
|
|
6
|
+
* expose an OptimizerHandle (name · values · current() · run()). This overlay
|
|
7
|
+
* renders one SettingsList row per tool:
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* ↑↓ move between tools
|
|
10
|
+
* ←→ cycle the selected tool's value (off → level/on → …)
|
|
11
|
+
* esc close
|
|
12
|
+
*
|
|
13
|
+
* Selecting a value calls the handle's run(), which persists + repaints the
|
|
14
|
+
* shared status cell. There is no text-arg form — the overlay is the only UI.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
OptimizerHandle,
|
|
18
|
-
|
|
18
|
+
import { frameLines } from "@xynogen/pix-pretty/modal-frame";
|
|
19
|
+
import {
|
|
20
|
+
type OptimizerHandle,
|
|
21
|
+
type OptimizerStatus,
|
|
22
|
+
type OptimizerTool,
|
|
23
|
+
TOOL_ICONS,
|
|
19
24
|
} from "./status.ts";
|
|
20
25
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
/** Min/max content width for the overlay box (excludes 4 cols of chrome). */
|
|
27
|
+
const MIN_CONTENT = 28;
|
|
28
|
+
const MAX_CONTENT = 60;
|
|
29
|
+
|
|
30
|
+
/** Fixed render order — matches the status-bar cell. */
|
|
31
|
+
const TOOL_ORDER: readonly OptimizerTool[] = [
|
|
32
|
+
"caveman",
|
|
33
|
+
"rtk",
|
|
34
|
+
"toon",
|
|
35
|
+
"ponytail",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** Strip the leading "name [args] — " prefix from a handle's help string. */
|
|
39
|
+
function helpSummary(help: string): string {
|
|
40
|
+
const dash = help.indexOf("—");
|
|
41
|
+
return dash === -1 ? help : help.slice(dash + 1).trim();
|
|
31
42
|
}
|
|
32
43
|
|
|
44
|
+
/** Filled / empty bar segment glyphs. */
|
|
45
|
+
const BAR_FILLED = "▰";
|
|
46
|
+
const BAR_EMPTY = "▱";
|
|
47
|
+
|
|
33
48
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
49
|
+
* Render an intensity bar for a tool's current value. One segment per
|
|
50
|
+
* non-"off" step in `values`; segments up to and including the current value's
|
|
51
|
+
* position are filled, the rest empty. "off" → all empty.
|
|
52
|
+
*
|
|
53
|
+
* ponytail [off,lite,full,ultra]: off=▱▱▱ lite=▰▱▱ full=▰▰▱ ultra=▰▰▰
|
|
54
|
+
* rtk [off,on]: off=▱ on=▰
|
|
37
55
|
*
|
|
38
56
|
* Pure + exported for tests.
|
|
39
57
|
*/
|
|
40
|
-
export function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const prefix = args.trim().toLowerCase();
|
|
47
|
-
const names = (Object.keys(handles) as OptimizerTool[])
|
|
48
|
-
.filter((n) => n.startsWith(prefix))
|
|
49
|
-
.map((n) => ({
|
|
50
|
-
value: n,
|
|
51
|
-
label: n,
|
|
52
|
-
description: handles[n].help,
|
|
53
|
-
}));
|
|
54
|
-
return names.length > 0 ? names : null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Past the tool name → delegate to the tool's own completer.
|
|
58
|
-
const { name, rest } = parseInvocation(args);
|
|
59
|
-
const handle = handles[name as OptimizerTool];
|
|
60
|
-
if (!handle) return null;
|
|
61
|
-
return handle.complete(rest);
|
|
58
|
+
export function levelBar(current: string, values: readonly string[]): string {
|
|
59
|
+
const segments = values.length - 1; // exclude "off"
|
|
60
|
+
if (segments < 1) return "";
|
|
61
|
+
const idx = values.indexOf(current); // 0 = off → 0 filled
|
|
62
|
+
const filled = Math.max(0, idx);
|
|
63
|
+
return BAR_FILLED.repeat(filled) + BAR_EMPTY.repeat(segments - filled);
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
/**
|
|
66
|
+
/** Plain-text status fallback when there's no custom-UI host (headless/tests). */
|
|
65
67
|
export function buildOptHelp(
|
|
66
68
|
handles: Record<OptimizerTool, OptimizerHandle>,
|
|
67
69
|
): string {
|
|
68
|
-
const lines =
|
|
69
|
-
(n) => `
|
|
70
|
+
const lines = TOOL_ORDER.map(
|
|
71
|
+
(n) => ` ${n}: ${handles[n].current()} — ${helpSummary(handles[n].help)}`,
|
|
70
72
|
);
|
|
71
|
-
return [
|
|
72
|
-
"pix-optimizer — token tools",
|
|
73
|
-
"",
|
|
74
|
-
"Usage: /opt <tool> [args]",
|
|
75
|
-
...lines,
|
|
76
|
-
].join("\n");
|
|
73
|
+
return ["pix-optimizer — token tools", "", ...lines].join("\n");
|
|
77
74
|
}
|
|
78
75
|
|
|
79
76
|
export function registerOptCommand(
|
|
80
77
|
pi: ExtensionAPI,
|
|
81
78
|
handles: Record<OptimizerTool, OptimizerHandle>,
|
|
79
|
+
_status: OptimizerStatus,
|
|
82
80
|
): void {
|
|
83
|
-
pi.registerCommand("
|
|
81
|
+
pi.registerCommand("optimizer", {
|
|
84
82
|
description: "pix-optimizer: caveman / rtk / toon / ponytail tools",
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
handler: async (_args, ctx) => {
|
|
84
|
+
const ui = ctx.ui as unknown as {
|
|
85
|
+
theme: {
|
|
86
|
+
fg(c: string, t: string): string;
|
|
87
|
+
bg(c: string, t: string): string;
|
|
88
|
+
bold(t: string): string;
|
|
89
|
+
};
|
|
90
|
+
custom?: <T>(
|
|
91
|
+
f: unknown,
|
|
92
|
+
opts?: {
|
|
93
|
+
overlay?: boolean;
|
|
94
|
+
overlayOptions?: {
|
|
95
|
+
anchor?: string;
|
|
96
|
+
width?: number;
|
|
97
|
+
maxHeight?: string;
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
) => Promise<T>;
|
|
101
|
+
notify(m: string, t?: "info" | "warning" | "error"): void;
|
|
102
|
+
};
|
|
89
103
|
|
|
90
|
-
// No
|
|
91
|
-
if (
|
|
92
|
-
|
|
104
|
+
// No custom-UI host (headless/tests) → plain status text.
|
|
105
|
+
if (typeof ui.custom !== "function") {
|
|
106
|
+
ui.notify(buildOptHelp(handles), "info");
|
|
93
107
|
return;
|
|
94
108
|
}
|
|
95
109
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
const nameWidth = Math.max(...TOOL_ORDER.map((n) => n.length));
|
|
111
|
+
// Widest value across all tools, so value columns line up.
|
|
112
|
+
const valueWidth = Math.max(
|
|
113
|
+
...TOOL_ORDER.flatMap((t) => handles[t].values.map((v) => v.length)),
|
|
114
|
+
);
|
|
115
|
+
// Fixed content width: widest help summary (the box's longest line) vs
|
|
116
|
+
// title, clamped. Fixed so the box doesn't resize as values/selection
|
|
117
|
+
// change and so the overlay can be centered at exactly this width.
|
|
118
|
+
const widestHelp = Math.max(
|
|
119
|
+
...TOOL_ORDER.map((t) => helpSummary(handles[t].help).length),
|
|
120
|
+
" Optimizer".length,
|
|
121
|
+
);
|
|
122
|
+
const content = Math.min(MAX_CONTENT, Math.max(MIN_CONTENT, widestHelp));
|
|
123
|
+
const boxW = content + 4; // + chrome (2 border + 2 padding)
|
|
124
|
+
|
|
125
|
+
await ui.custom<null>(
|
|
126
|
+
(
|
|
127
|
+
tui: { requestRender(): void },
|
|
128
|
+
theme: typeof ui.theme,
|
|
129
|
+
_kb: unknown,
|
|
130
|
+
done: (v: null) => void,
|
|
131
|
+
) => {
|
|
132
|
+
let selected = 0;
|
|
133
|
+
|
|
134
|
+
const cycle = (direction: -1 | 1) => {
|
|
135
|
+
const tool = TOOL_ORDER[selected]!;
|
|
136
|
+
const values = handles[tool].values;
|
|
137
|
+
const cur = values.indexOf(handles[tool].current());
|
|
138
|
+
const next = (cur + direction + values.length) % values.length;
|
|
139
|
+
handles[tool].run(values[next]!, ctx);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const move = (direction: -1 | 1) => {
|
|
143
|
+
selected =
|
|
144
|
+
(selected + direction + TOOL_ORDER.length) % TOOL_ORDER.length;
|
|
145
|
+
};
|
|
104
146
|
|
|
105
|
-
|
|
147
|
+
return {
|
|
148
|
+
render: () => {
|
|
149
|
+
const rows = TOOL_ORDER.map((tool, i) => {
|
|
150
|
+
const on = handles[tool].current() !== "off";
|
|
151
|
+
const sel = i === selected;
|
|
152
|
+
const cursor = sel ? theme.fg("accent", "→") : " ";
|
|
153
|
+
const icon = theme.fg(on ? "accent" : "dim", TOOL_ICONS[tool]);
|
|
154
|
+
const name = theme.fg(
|
|
155
|
+
sel ? "accent" : on ? "text" : "muted",
|
|
156
|
+
tool.padEnd(nameWidth),
|
|
157
|
+
);
|
|
158
|
+
const cur = handles[tool].current();
|
|
159
|
+
const value = theme.fg(
|
|
160
|
+
on ? "success" : "dim",
|
|
161
|
+
cur.padEnd(valueWidth),
|
|
162
|
+
);
|
|
163
|
+
const bar = theme.fg(
|
|
164
|
+
on ? "accent" : "dim",
|
|
165
|
+
levelBar(cur, handles[tool].values),
|
|
166
|
+
);
|
|
167
|
+
return `${cursor} ${icon} ${name} ${value} ${bar}`;
|
|
168
|
+
});
|
|
169
|
+
const lines = [
|
|
170
|
+
theme.fg("accent", theme.bold(" Optimizer")),
|
|
171
|
+
"",
|
|
172
|
+
...rows,
|
|
173
|
+
"",
|
|
174
|
+
theme.fg(
|
|
175
|
+
"dim",
|
|
176
|
+
helpSummary(handles[TOOL_ORDER[selected]!].help),
|
|
177
|
+
),
|
|
178
|
+
"",
|
|
179
|
+
theme.fg("dim", "←→ change · ↑↓ move · esc close"),
|
|
180
|
+
];
|
|
181
|
+
return frameLines({
|
|
182
|
+
width: boxW,
|
|
183
|
+
lines,
|
|
184
|
+
color: (s) => theme.fg("accent", s),
|
|
185
|
+
bg: (s) => theme.bg("customMessageBg", s),
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
invalidate: () => {},
|
|
189
|
+
handleInput: (data: string) => {
|
|
190
|
+
if (data === "k" || data === "\u001b[A") move(-1);
|
|
191
|
+
else if (data === "j" || data === "\u001b[B") move(1);
|
|
192
|
+
else if (data === "h" || data === "\u001b[D") cycle(-1);
|
|
193
|
+
else if (
|
|
194
|
+
data === "l" ||
|
|
195
|
+
data === "\u001b[C" ||
|
|
196
|
+
data === " " ||
|
|
197
|
+
data === "\r"
|
|
198
|
+
)
|
|
199
|
+
cycle(1);
|
|
200
|
+
else if (data === "\u001b" || data === "q") {
|
|
201
|
+
done(null);
|
|
202
|
+
return;
|
|
203
|
+
} else return;
|
|
204
|
+
tui.requestRender();
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
overlay: true,
|
|
210
|
+
overlayOptions: { anchor: "center", width: boxW, maxHeight: "80%" },
|
|
211
|
+
},
|
|
212
|
+
);
|
|
106
213
|
},
|
|
107
214
|
});
|
|
108
215
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test for the cross-session reset bug: optimizer tool states
|
|
3
|
+
* (caveman/ponytail/rtk/toon) were lost on a full quit/restart because they
|
|
4
|
+
* only persisted to the session log. persist.ts adds disk persistence so a
|
|
5
|
+
* value written in one session is readable in the next.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { loadOptValue, saveOptValue } from "./persist.ts";
|
|
13
|
+
|
|
14
|
+
let tmpAgentDir: string;
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
tmpAgentDir = mkdtempSync(join(tmpdir(), "optimizer-persist-test-"));
|
|
18
|
+
process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
23
|
+
try {
|
|
24
|
+
rmSync(tmpAgentDir, { recursive: true });
|
|
25
|
+
} catch {
|
|
26
|
+
// temp dir may already be gone — safe to ignore
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("optimizer persistence", () => {
|
|
31
|
+
test("returns undefined before anything is saved", () => {
|
|
32
|
+
expect(loadOptValue("caveman")).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("round-trips a single tool value across save/load (new-session sim)", () => {
|
|
36
|
+
saveOptValue("caveman", "lite");
|
|
37
|
+
// A fresh load (as a new session would do) sees the persisted value.
|
|
38
|
+
expect(loadOptValue("caveman")).toBe("lite");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("persists each tool independently in one shared file", () => {
|
|
42
|
+
saveOptValue("ponytail", "full");
|
|
43
|
+
saveOptValue("rtk", "off");
|
|
44
|
+
saveOptValue("toon", "on");
|
|
45
|
+
expect(loadOptValue("caveman")).toBe("lite");
|
|
46
|
+
expect(loadOptValue("ponytail")).toBe("full");
|
|
47
|
+
expect(loadOptValue("rtk")).toBe("off");
|
|
48
|
+
expect(loadOptValue("toon")).toBe("on");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("overwriting one tool leaves the others intact", () => {
|
|
52
|
+
saveOptValue("caveman", "ultra");
|
|
53
|
+
expect(loadOptValue("caveman")).toBe("ultra");
|
|
54
|
+
expect(loadOptValue("ponytail")).toBe("full");
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/persist.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* persist.ts — disk-backed persistence for the /optimizer tool states.
|
|
3
|
+
*
|
|
4
|
+
* caveman/ponytail previously saved only to the session log (lost on a fresh
|
|
5
|
+
* session); rtk/toon never persisted at all. This stores every tool's current
|
|
6
|
+
* value in one file under the agent dir so the picker survives a full quit and
|
|
7
|
+
* restart. Each tool reads its value on session_start and writes on run().
|
|
8
|
+
*
|
|
9
|
+
* ~/.pi/agent/optimizer.json → { "caveman": "lite", "rtk": "on", ... }
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { OptimizerTool } from "./status.ts";
|
|
16
|
+
|
|
17
|
+
type OptimizerConfig = Partial<Record<OptimizerTool, string>>;
|
|
18
|
+
|
|
19
|
+
function getStatePath(): string {
|
|
20
|
+
return join(getAgentDir(), "optimizer.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readAll(): OptimizerConfig {
|
|
24
|
+
try {
|
|
25
|
+
const sp = getStatePath();
|
|
26
|
+
if (!existsSync(sp)) return {};
|
|
27
|
+
const raw = JSON.parse(readFileSync(sp, "utf-8")) as OptimizerConfig;
|
|
28
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
29
|
+
} catch {
|
|
30
|
+
// Missing, corrupt, or no getAgentDir (tests/headless) — start clean.
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Read a single tool's persisted value, or undefined if none. */
|
|
36
|
+
export function loadOptValue(tool: OptimizerTool): string | undefined {
|
|
37
|
+
return readAll()[tool];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Persist a single tool's value, merging into the shared config file. */
|
|
41
|
+
export function saveOptValue(tool: OptimizerTool, value: string): void {
|
|
42
|
+
try {
|
|
43
|
+
const sp = getStatePath();
|
|
44
|
+
mkdirSync(dirname(sp), { recursive: true });
|
|
45
|
+
const next = { ...readAll(), [tool]: value };
|
|
46
|
+
writeFileSync(sp, JSON.stringify(next, null, 2), "utf-8");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.warn(`optimizer: persist ${tool} failed:`, err);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/ponytail.test.ts
CHANGED
|
@@ -2,11 +2,9 @@ import { describe, expect, it } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
buildHelp,
|
|
4
4
|
buildPrompt,
|
|
5
|
-
DEFAULT_CONFIG,
|
|
6
5
|
LEVEL_NUMBERS,
|
|
7
6
|
LEVELS,
|
|
8
7
|
type Level,
|
|
9
|
-
parseConfig,
|
|
10
8
|
resolveLevel,
|
|
11
9
|
STATUS_LABELS,
|
|
12
10
|
STOP_ALIASES,
|
|
@@ -195,42 +193,3 @@ describe("buildPrompt", () => {
|
|
|
195
193
|
expect(lite).not.toContain("YAGNI extremist");
|
|
196
194
|
});
|
|
197
195
|
});
|
|
198
|
-
|
|
199
|
-
// ── parseConfig ───────────────────────────────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
describe("parseConfig", () => {
|
|
202
|
-
it("parses valid config", () => {
|
|
203
|
-
const cfg = parseConfig({ defaultLevel: "ultra", showStatus: false });
|
|
204
|
-
expect(cfg.defaultLevel).toBe("ultra");
|
|
205
|
-
expect(cfg.showStatus).toBe(false);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("falls back to defaults for invalid defaultLevel", () => {
|
|
209
|
-
const cfg = parseConfig({ defaultLevel: "invalid", showStatus: true });
|
|
210
|
-
expect(cfg.defaultLevel).toBe(DEFAULT_CONFIG.defaultLevel);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("default level is full", () => {
|
|
214
|
-
expect(DEFAULT_CONFIG.defaultLevel).toBe("full");
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it("falls back to defaults for missing fields", () => {
|
|
218
|
-
const cfg = parseConfig({});
|
|
219
|
-
expect(cfg.defaultLevel).toBe(DEFAULT_CONFIG.defaultLevel);
|
|
220
|
-
expect(cfg.showStatus).toBe(DEFAULT_CONFIG.showStatus);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("falls back to defaults for null/undefined", () => {
|
|
224
|
-
const cfg = parseConfig(null);
|
|
225
|
-
expect(cfg.defaultLevel).toBe(DEFAULT_CONFIG.defaultLevel);
|
|
226
|
-
expect(cfg.showStatus).toBe(DEFAULT_CONFIG.showStatus);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("accepts all valid levels as defaultLevel", () => {
|
|
230
|
-
for (const l of LEVELS) {
|
|
231
|
-
expect(
|
|
232
|
-
parseConfig({ defaultLevel: l, showStatus: true }).defaultLevel,
|
|
233
|
-
).toBe(l);
|
|
234
|
-
}
|
|
235
|
-
});
|
|
236
|
-
});
|