@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/src/opt.ts CHANGED
@@ -1,108 +1,215 @@
1
1
  /**
2
- * opt.ts — the single `/opt` command that fronts every optimizer tool.
2
+ * opt.ts — the single `/optimizer` command: an interactive overlay that fronts
3
+ * every optimizer tool.
3
4
  *
4
- * caveman / rtk / json each register their own lifecycle hooks but expose an
5
- * OptimizerHandle for command dispatch. This router wires them under one
6
- * command so there's exactly one entry point:
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
- * /opt → status + help
9
- * /opt caveman <level> → caveman subcommand
10
- * /opt rtk [on|off] → rtk subcommand
11
- * /opt toon [on|off] → toon subcommand
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 type {
16
- CompletionItem,
17
- OptimizerHandle,
18
- OptimizerTool,
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
- /** Split raw args into the subcommand name and the rest. */
22
- export function parseInvocation(args: string): { name: string; rest: string } {
23
- const trimmed = args.trim();
24
- if (!trimmed) return { name: "", rest: "" };
25
- const idx = trimmed.search(/\s/);
26
- if (idx === -1) return { name: trimmed.toLowerCase(), rest: "" };
27
- return {
28
- name: trimmed.slice(0, idx).toLowerCase(),
29
- rest: trimmed.slice(idx + 1),
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
- * Compute completions for `/opt ...`:
35
- * - first token → tool names (filtered by prefix)
36
- * - after a known tool name → delegate to that tool's complete()
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 completeInvocation(
41
- args: string,
42
- handles: Record<OptimizerTool, OptimizerHandle>,
43
- ): CompletionItem[] | null {
44
- // No space yet → still completing the tool name.
45
- if (!/\s/.test(args.trimStart())) {
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
- /** Build the `/opt` help text listing every tool. */
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 = (Object.keys(handles) as OptimizerTool[]).map(
69
- (n) => ` /opt ${handles[n].help}`,
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("opt", {
81
+ pi.registerCommand("optimizer", {
84
82
  description: "pix-optimizer: caveman / rtk / toon / ponytail tools",
85
- getArgumentCompletions: (prefix: string) =>
86
- completeInvocation(prefix, handles),
87
- handler: async (args, ctx) => {
88
- const { name, rest } = parseInvocation(args ?? "");
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 subcommand → show help.
91
- if (!name) {
92
- ctx.ui.notify(buildOptHelp(handles), "info");
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 handle = handles[name as OptimizerTool];
97
- if (!handle) {
98
- ctx.ui.notify(
99
- `Unknown tool: "${name}". Try: ${Object.keys(handles).join(", ")}`,
100
- "error",
101
- );
102
- return;
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
- await handle.run(rest, ctx);
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
+ }
@@ -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
- });