@hheei/omp-optimizer 0.1.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/package.json +30 -0
- package/src/capability.test.ts +42 -0
- package/src/capability.ts +16 -0
- package/src/caveman.test.ts +203 -0
- package/src/caveman.ts +140 -0
- package/src/edit-guard.test.ts +114 -0
- package/src/edit-guard.ts +137 -0
- package/src/index.ts +50 -0
- package/src/mode.ts +115 -0
- package/src/opt-keys.test.ts +165 -0
- package/src/opt.test.ts +34 -0
- package/src/opt.ts +161 -0
- package/src/persist.test.ts +52 -0
- package/src/persist.ts +58 -0
- package/src/ponytail.test.ts +195 -0
- package/src/ponytail.ts +145 -0
- package/src/rtk.test.ts +277 -0
- package/src/rtk.ts +355 -0
- package/src/status.test.ts +84 -0
- package/src/status.ts +61 -0
- package/src/t2s.test.ts +15 -0
- package/src/t2s.ts +122 -0
- package/src/tool-result-filter.test.ts +96 -0
- package/src/tool-result-filter.ts +53 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
2
|
+
import type { OptimizerHandle, OptimizerStatus } from "./status.ts";
|
|
3
|
+
import { loadOptValue, saveOptValue } from "./persist.ts";
|
|
4
|
+
|
|
5
|
+
export type EditGuardLevel = "on" | "off";
|
|
6
|
+
export const EDIT_GUARD_LEVELS: readonly EditGuardLevel[] = ["on", "off"];
|
|
7
|
+
|
|
8
|
+
const EDIT_GUARD_TOOL = "edit-guard" as const;
|
|
9
|
+
const APPLY_PATCH_COMMAND = /(?:^|[\n;&|()])\s*(?:command\s+)?apply_patch\s/;
|
|
10
|
+
const HASHLINE_FILE_HEADER = /^\[[^\]\r\n#]+#[0-9a-f]{4}\]/im;
|
|
11
|
+
const HASHLINE_HUNK_HEADER = /^@@/m;
|
|
12
|
+
const HASHLINE_GUARD_MESSAGE =
|
|
13
|
+
"Hashline edit was aborted: `@@` is unified-diff syntax, not hashline syntax. Re-read the file, then retry with `[path#HASH]` plus `PUT`, `CUT`, `REM`, or `MV`. Do not retry the same payload.";
|
|
14
|
+
const NO_EDIT_TOOL_GUIDANCE =
|
|
15
|
+
"`apply_patch` is unavailable; the call was aborted. No supported file-editing tool is active. Enable `edit`, or `write` before retrying.";
|
|
16
|
+
const APPLY_PATCH_RETRY_WARNING = "Do not retry `apply_patch`.";
|
|
17
|
+
|
|
18
|
+
declare global {
|
|
19
|
+
var __ompOptimizerEditGuardRegistrations: WeakMap<object, symbol> | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function currentRegistration(pi: ExtensionAPI): () => boolean {
|
|
23
|
+
const identity: object = typeof pi.events === "object" && pi.events !== null ? pi.events : pi;
|
|
24
|
+
let registrations = globalThis.__ompOptimizerEditGuardRegistrations;
|
|
25
|
+
if (registrations === undefined) {
|
|
26
|
+
registrations = new WeakMap();
|
|
27
|
+
globalThis.__ompOptimizerEditGuardRegistrations = registrations;
|
|
28
|
+
}
|
|
29
|
+
const token = Symbol("omp-optimizer-edit-guard");
|
|
30
|
+
registrations.set(identity, token);
|
|
31
|
+
return () => registrations.get(identity) === token;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Detects a streamed shell attempt to invoke apply_patch, excluding plain mentions. */
|
|
35
|
+
export function hasStreamingApplyPatchCommand(command: string): boolean {
|
|
36
|
+
return APPLY_PATCH_COMMAND.test(command);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Detects top-level unified-diff hunk syntax inside a hashline edit payload. */
|
|
40
|
+
export function hasMalformedHashlineInput(input: string): boolean {
|
|
41
|
+
return HASHLINE_FILE_HEADER.test(input) && HASHLINE_HUNK_HEADER.test(input);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function blockReason(activeTools: readonly string[]): string {
|
|
45
|
+
const alternatives = ["edit", "write"].filter(name => activeTools.includes(name));
|
|
46
|
+
if (alternatives.length === 0) return NO_EDIT_TOOL_GUIDANCE;
|
|
47
|
+
const names = alternatives.map(name => `\`${name}\``);
|
|
48
|
+
const action = names.length === 1 ? names[0] : `${names[0]} or ${names[1]}`;
|
|
49
|
+
return `\`apply_patch\` is unavailable; the call was aborted. Continue with ${action}. ${APPLY_PATCH_RETRY_WARNING}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Aborts malformed hashline edits and unavailable shell apply_patch calls. */
|
|
53
|
+
export function editGuard(pi: ExtensionAPI, status: OptimizerStatus): OptimizerHandle {
|
|
54
|
+
let level: EditGuardLevel = "on";
|
|
55
|
+
const isCurrent = currentRegistration(pi);
|
|
56
|
+
let interrupted = false;
|
|
57
|
+
let pendingReason: string | undefined;
|
|
58
|
+
|
|
59
|
+
function syncStatus(ctx: Pick<ExtensionContext, "ui">): void {
|
|
60
|
+
status.set(EDIT_GUARD_TOOL, level === "on", ctx);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
pi.on("before_agent_start", () => {
|
|
64
|
+
if (!isCurrent()) return;
|
|
65
|
+
interrupted = false;
|
|
66
|
+
});
|
|
67
|
+
pi.on("session_start", (_event, ctx) => {
|
|
68
|
+
if (!isCurrent()) return;
|
|
69
|
+
interrupted = false;
|
|
70
|
+
pendingReason = undefined;
|
|
71
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
72
|
+
if (entry.type !== "custom" || entry.customType !== "edit-guard-level") continue;
|
|
73
|
+
const saved = entry.data && typeof entry.data === "object" && "level" in entry.data ? entry.data.level : undefined;
|
|
74
|
+
if (saved === "on" || saved === "off") level = saved;
|
|
75
|
+
}
|
|
76
|
+
const saved = loadOptValue(EDIT_GUARD_TOOL);
|
|
77
|
+
if (saved === "on" || saved === "off") level = saved;
|
|
78
|
+
syncStatus(ctx);
|
|
79
|
+
});
|
|
80
|
+
pi.on("agent_start", async (_event, ctx) => syncStatus(ctx));
|
|
81
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
82
|
+
if (!isCurrent()) return;
|
|
83
|
+
syncStatus(ctx);
|
|
84
|
+
if (event.willContinue || pendingReason === undefined) return;
|
|
85
|
+
const reason = pendingReason;
|
|
86
|
+
pendingReason = undefined;
|
|
87
|
+
pi.sendMessage(
|
|
88
|
+
{
|
|
89
|
+
customType: "edit-guard",
|
|
90
|
+
content: reason,
|
|
91
|
+
display: true,
|
|
92
|
+
},
|
|
93
|
+
{ triggerTurn: true },
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
pi.on("session_shutdown", () => {
|
|
97
|
+
if (!isCurrent()) return;
|
|
98
|
+
pendingReason = undefined;
|
|
99
|
+
});
|
|
100
|
+
pi.on("message_update", (event, context) => {
|
|
101
|
+
if (level !== "on" || interrupted || !isCurrent() || event.assistantMessageEvent.type !== "toolcall_delta") return;
|
|
102
|
+
const update = event.assistantMessageEvent;
|
|
103
|
+
const content = update.partial.content[update.contentIndex];
|
|
104
|
+
if (content?.type !== "toolCall") return;
|
|
105
|
+
|
|
106
|
+
let reason: string | undefined;
|
|
107
|
+
if (content.name === "edit") {
|
|
108
|
+
const input = content.arguments.input;
|
|
109
|
+
if (typeof input === "string" && hasMalformedHashlineInput(input)) reason = HASHLINE_GUARD_MESSAGE;
|
|
110
|
+
} else if (content.name === "bash") {
|
|
111
|
+
if (pi.getActiveTools().includes("apply_patch")) return;
|
|
112
|
+
const command = content.arguments.command;
|
|
113
|
+
if (typeof command === "string" && hasStreamingApplyPatchCommand(command)) reason = blockReason(pi.getActiveTools());
|
|
114
|
+
}
|
|
115
|
+
if (reason === undefined) return;
|
|
116
|
+
interrupted = true;
|
|
117
|
+
pendingReason = reason;
|
|
118
|
+
context.abort();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
async function run(value: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
122
|
+
if (value !== "on" && value !== "off") return;
|
|
123
|
+
level = value;
|
|
124
|
+
pi.appendEntry("edit-guard-level", { level });
|
|
125
|
+
saveOptValue(EDIT_GUARD_TOOL, level);
|
|
126
|
+
syncStatus(ctx);
|
|
127
|
+
ctx.ui.notify(`Edit Guard ${level === "on" ? "enabled" : "disabled"}`, "info");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
name: EDIT_GUARD_TOOL,
|
|
132
|
+
help: "Edit Guard — block malformed apply_patch syntax in hashline mode and unavailable shell apply_patch calls",
|
|
133
|
+
values: EDIT_GUARD_LEVELS,
|
|
134
|
+
current: () => level,
|
|
135
|
+
run,
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* omp-optimizer — token-optimization suite for Oh My Pi.
|
|
3
|
+
*
|
|
4
|
+
* Five tools, combined into one extension + one command:
|
|
5
|
+
* - caveman: terse-output system prompt
|
|
6
|
+
* - rtk: prefixes shell commands with `rtk` + injects RTK prompt
|
|
7
|
+
* - ponytail: lazy-senior-dev system prompt (minimal code, YAGNI)
|
|
8
|
+
* - t2s: converts interactive Traditional Chinese input to Simplified Chinese
|
|
9
|
+
* - edit-guard: blocks unified-diff syntax in hashline edits
|
|
10
|
+
*
|
|
11
|
+
* They share ONE status-bar cell (all five icons always shown — dimmed when
|
|
12
|
+
* off, accented when on) and ONE command (/optimizer — an interactive overlay).
|
|
13
|
+
* index.ts wires lifecycle hooks via each module, then registers the overlay command.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
17
|
+
import { editGuard } from "./edit-guard.ts";
|
|
18
|
+
import { caveman } from "./caveman.ts";
|
|
19
|
+
import { registerOptCommand } from "./opt.ts";
|
|
20
|
+
import { ponytail } from "./ponytail.ts";
|
|
21
|
+
import { rtk } from "./rtk.ts";
|
|
22
|
+
import { t2s } from "./t2s.ts";
|
|
23
|
+
import { type OptimizerHandle, OptimizerStatus, type OptimizerTool } from "./status.ts";
|
|
24
|
+
import { filterModelWarnings } from "./tool-result-filter.ts";
|
|
25
|
+
|
|
26
|
+
export default function optimizer(pi: ExtensionAPI) {
|
|
27
|
+
const status = new OptimizerStatus();
|
|
28
|
+
|
|
29
|
+
// Each module registers its own lifecycle hooks and returns a handle the
|
|
30
|
+
// /optimizer overlay renders + drives.
|
|
31
|
+
const handles: Record<OptimizerTool, OptimizerHandle> = {
|
|
32
|
+
caveman: caveman(pi, status),
|
|
33
|
+
rtk: rtk(pi, status),
|
|
34
|
+
ponytail: ponytail(pi, status),
|
|
35
|
+
t2s: t2s(pi, status),
|
|
36
|
+
"edit-guard": editGuard(pi, status),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
registerOptCommand(pi, handles, status);
|
|
40
|
+
|
|
41
|
+
// Strip model-guidance warnings injected by pi-lens into tool_result content.
|
|
42
|
+
// These strings (BLIND WRITE, THRASHING) are directives for the LLM, not
|
|
43
|
+
// information for the user — filtering them here hides them from the TUI
|
|
44
|
+
// without affecting the model (it already acted on the write/edit result).
|
|
45
|
+
pi.on("tool_result", async (event) => {
|
|
46
|
+
const filtered = filterModelWarnings(event.content);
|
|
47
|
+
if (filtered === event.content) return undefined;
|
|
48
|
+
return { content: filtered };
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/mode.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mode.ts — shared plumbing for prompt-injection optimizer modes.
|
|
3
|
+
*
|
|
4
|
+
* caveman.ts and ponytail.ts are the same *kind* of tool: a level enum, a
|
|
5
|
+
* system-prompt fragment injected via before_agent_start, and identical
|
|
6
|
+
* persistence/status wiring. Only the prompt/help/label *content* differs —
|
|
7
|
+
* that stays in each module. This file owns the generic level resolution and
|
|
8
|
+
* the extension factory both call.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ExtensionAPI,
|
|
13
|
+
ExtensionCommandContext,
|
|
14
|
+
ExtensionContext,
|
|
15
|
+
} from "@oh-my-pi/pi-coding-agent";
|
|
16
|
+
import { loadOptValue, saveOptValue } from "./persist.ts";
|
|
17
|
+
import type { OptimizerHandle, OptimizerStatus, OptimizerTool } from "./status.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a raw command arg to a level, or null if unrecognised.
|
|
21
|
+
* Handles stop aliases (stop/quit → "off"), numeric shortcuts, and level names.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveLevel<L extends string>(
|
|
24
|
+
arg: string,
|
|
25
|
+
levels: readonly L[],
|
|
26
|
+
levelNumbers: Record<string, L>,
|
|
27
|
+
stopAliases: ReadonlySet<string>,
|
|
28
|
+
): L | null {
|
|
29
|
+
const a = arg.trim().toLowerCase();
|
|
30
|
+
if (stopAliases.has(a)) return "off" as L;
|
|
31
|
+
if (levelNumbers[a]) return levelNumbers[a];
|
|
32
|
+
if (levels.includes(a as L)) return a as L;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Toggle: off → full, anything else → off. */
|
|
37
|
+
export function toggleLevel<L extends string>(current: L): L {
|
|
38
|
+
return (current === "off" ? "full" : "off") as L;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ModeConfig<L extends string> {
|
|
42
|
+
/** Status-registry key + persistence key + `${name}-level` custom entry. */
|
|
43
|
+
name: OptimizerTool;
|
|
44
|
+
/** One-line overlay summary. */
|
|
45
|
+
help: string;
|
|
46
|
+
/** Cyclable values in display order. */
|
|
47
|
+
levels: readonly L[];
|
|
48
|
+
/** Build the system-prompt fragment for a level ("" when off). */
|
|
49
|
+
buildPrompt(level: L): string;
|
|
50
|
+
/** Resolve an overlay/command value to a level (each module supplies its enum). */
|
|
51
|
+
resolve(value: string): L | null;
|
|
52
|
+
/** Notification shown after a successful change. */
|
|
53
|
+
notify(level: L): string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the shared extension: prompt injection + session restore + status +
|
|
58
|
+
* overlay handle. Identical across every prompt-mode tool.
|
|
59
|
+
*/
|
|
60
|
+
export function createMode<L extends string>(
|
|
61
|
+
pi: ExtensionAPI,
|
|
62
|
+
status: OptimizerStatus,
|
|
63
|
+
config: ModeConfig<L>,
|
|
64
|
+
): OptimizerHandle {
|
|
65
|
+
const { name, levels, buildPrompt } = config;
|
|
66
|
+
const customType = `${name}-level`;
|
|
67
|
+
let level = "off" as L;
|
|
68
|
+
|
|
69
|
+
function syncStatus(ctx: Pick<ExtensionContext, "ui">) {
|
|
70
|
+
status.set(name, level !== "off", ctx);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pi.on("before_agent_start", async (event, _ctx) => {
|
|
74
|
+
const prompt = buildPrompt(level);
|
|
75
|
+
if (!prompt) return undefined;
|
|
76
|
+
return { systemPrompt: [prompt, ...event.systemPrompt] };
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
80
|
+
// Session log first (survives in-session branch nav), then disk (survives
|
|
81
|
+
// a full quit/restart). Disk wins when present.
|
|
82
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
83
|
+
if (entry.type === "custom" && entry.customType === customType) {
|
|
84
|
+
level = ((entry.data as { level: L })?.level ?? level) as L;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const saved = loadOptValue(name);
|
|
88
|
+
if (saved && levels.includes(saved as L)) level = saved as L;
|
|
89
|
+
syncStatus(ctx);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
pi.on("agent_start", async (_event, ctx) => syncStatus(ctx));
|
|
93
|
+
pi.on("agent_end", async (_event, ctx) => syncStatus(ctx));
|
|
94
|
+
pi.on("session_shutdown", async () => {});
|
|
95
|
+
|
|
96
|
+
async function run(value: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
97
|
+
const resolved = config.resolve(value);
|
|
98
|
+
if (resolved === null) return;
|
|
99
|
+
level = resolved;
|
|
100
|
+
|
|
101
|
+
pi.appendEntry(customType, { level });
|
|
102
|
+
saveOptValue(name, level);
|
|
103
|
+
syncStatus(ctx);
|
|
104
|
+
|
|
105
|
+
ctx.ui.notify(config.notify(level), "info");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
name,
|
|
110
|
+
help: config.help,
|
|
111
|
+
values: levels,
|
|
112
|
+
current: () => level,
|
|
113
|
+
run,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/** OMP-native SettingsList interaction tests for the /optimizer overlay. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@oh-my-pi/pi-coding-agent";
|
|
5
|
+
import { registerOptCommand } from "./opt.ts";
|
|
6
|
+
import type { OptimizerHandle, OptimizerStatus, OptimizerTool } from "./status.ts";
|
|
7
|
+
|
|
8
|
+
const KEYS = {
|
|
9
|
+
up: { legacy: "\u001b[A", kitty: "\u001b[1;1A" },
|
|
10
|
+
down: { legacy: "\u001b[B", kitty: "\u001b[1;1B" },
|
|
11
|
+
enter: { legacy: "\r", kitty: "\u001b[13u" },
|
|
12
|
+
space: { legacy: " ", kitty: "\u001b[32u" },
|
|
13
|
+
escape: { legacy: "\u001b", kitty: "\u001b[27u" },
|
|
14
|
+
q: { legacy: "q", kitty: "\u001b[113u" },
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
const ENCODINGS = ["legacy", "kitty"] as const;
|
|
18
|
+
|
|
19
|
+
interface Overlay {
|
|
20
|
+
render(width: number): readonly string[];
|
|
21
|
+
handleInput(data: string): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Driver {
|
|
25
|
+
feed(data: string): void;
|
|
26
|
+
runs(): Array<{ tool: string; value: string }>;
|
|
27
|
+
rows(): readonly string[];
|
|
28
|
+
closed(): boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function openOverlay(): Promise<Driver> {
|
|
32
|
+
const runs: Array<{ tool: string; value: string }> = [];
|
|
33
|
+
const makeHandle = (name: OptimizerTool, current: string, values: string[]): OptimizerHandle => {
|
|
34
|
+
let value = current;
|
|
35
|
+
return {
|
|
36
|
+
name,
|
|
37
|
+
help: `${name} — help`,
|
|
38
|
+
values,
|
|
39
|
+
current: () => value,
|
|
40
|
+
run: async next => {
|
|
41
|
+
value = next;
|
|
42
|
+
runs.push({ tool: name, value: next });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
const handles: Record<OptimizerTool, OptimizerHandle> = {
|
|
47
|
+
caveman: makeHandle("caveman", "off", ["off", "lite", "full", "ultra", "micro"]),
|
|
48
|
+
rtk: makeHandle("rtk", "off", ["off", "on"]),
|
|
49
|
+
ponytail: makeHandle("ponytail", "off", ["off", "lite", "full", "ultra"]),
|
|
50
|
+
t2s: makeHandle("t2s", "on", ["on", "off"]),
|
|
51
|
+
"edit-guard": makeHandle("edit-guard", "on", ["on", "off"]),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let overlay: Overlay | undefined;
|
|
55
|
+
let closed = false;
|
|
56
|
+
let commandHandler: ((args: string, ctx: ExtensionCommandContext) => Promise<void>) | undefined;
|
|
57
|
+
const pi = {
|
|
58
|
+
registerCommand: (_name: string, spec: { handler: typeof commandHandler }) => {
|
|
59
|
+
commandHandler = spec.handler;
|
|
60
|
+
},
|
|
61
|
+
} as unknown as ExtensionAPI;
|
|
62
|
+
|
|
63
|
+
registerOptCommand(pi, handles, {} as OptimizerStatus);
|
|
64
|
+
if (!commandHandler) throw new Error("/optimizer did not register");
|
|
65
|
+
|
|
66
|
+
const ctx = {
|
|
67
|
+
hasUI: true,
|
|
68
|
+
ui: {
|
|
69
|
+
notify: () => {},
|
|
70
|
+
custom: async <T>(
|
|
71
|
+
factory: (tui: { requestRender(): void }, theme: unknown, keybindings: unknown, done: (value: T) => void) => Overlay,
|
|
72
|
+
): Promise<T | undefined> => {
|
|
73
|
+
overlay = factory(
|
|
74
|
+
{ requestRender: () => {} },
|
|
75
|
+
{
|
|
76
|
+
bold: (text: string) => text,
|
|
77
|
+
fg: (_color: string, text: string) => text,
|
|
78
|
+
boxRound: {
|
|
79
|
+
topLeft: "╭",
|
|
80
|
+
topRight: "╮",
|
|
81
|
+
bottomLeft: "╰",
|
|
82
|
+
bottomRight: "╯",
|
|
83
|
+
horizontal: "─",
|
|
84
|
+
vertical: "│",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
undefined,
|
|
88
|
+
() => {
|
|
89
|
+
closed = true;
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
return undefined;
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
} as unknown as ExtensionCommandContext;
|
|
96
|
+
|
|
97
|
+
await commandHandler("", ctx);
|
|
98
|
+
if (!overlay) throw new Error("overlay was not constructed");
|
|
99
|
+
const component = overlay;
|
|
100
|
+
return {
|
|
101
|
+
feed: data => component.handleInput(data),
|
|
102
|
+
runs: () => runs,
|
|
103
|
+
closed: () => closed,
|
|
104
|
+
rows: () => component.render(80),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("/optimizer layout", () => {
|
|
109
|
+
it("uses switch-style titled chrome with aligned rows", async () => {
|
|
110
|
+
const rows = (await openOverlay()).rows();
|
|
111
|
+
expect(rows).toHaveLength(11);
|
|
112
|
+
expect(rows[0]).toMatch(/^╭─ .*Optimizer /);
|
|
113
|
+
expect(rows[1]).toContain("Caveman");
|
|
114
|
+
expect(rows[5]).toContain("Edit Guard");
|
|
115
|
+
expect(rows[6]).toMatch(/^│\s+│$/);
|
|
116
|
+
expect(rows[7]).toContain("help");
|
|
117
|
+
expect(rows[8]).toMatch(/^│\s+│$/);
|
|
118
|
+
expect(rows[9]).toContain("Esc close");
|
|
119
|
+
expect(rows[10]).toMatch(/^╰─+╯$/);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
for (const encoding of ENCODINGS) {
|
|
124
|
+
describe(`/optimizer SettingsList keys (${encoding})`, () => {
|
|
125
|
+
it("moves with the OMP down binding and changes the selected tool", async () => {
|
|
126
|
+
const driver = await openOverlay();
|
|
127
|
+
driver.feed(KEYS.down[encoding]);
|
|
128
|
+
driver.feed(KEYS.space[encoding]);
|
|
129
|
+
expect(driver.runs()).toEqual([{ tool: "rtk", value: "on" }]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("wraps upward and changes the last tool", async () => {
|
|
133
|
+
const driver = await openOverlay();
|
|
134
|
+
driver.feed(KEYS.up[encoding]);
|
|
135
|
+
driver.feed(KEYS.space[encoding]);
|
|
136
|
+
expect(driver.runs()).toEqual([{ tool: "edit-guard", value: "off" }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("uses Enter and Space to cycle the selected setting", async () => {
|
|
140
|
+
const driver = await openOverlay();
|
|
141
|
+
driver.feed(KEYS.space[encoding]);
|
|
142
|
+
driver.feed(KEYS.enter[encoding]);
|
|
143
|
+
expect(driver.runs()).toEqual([
|
|
144
|
+
{ tool: "caveman", value: "lite" },
|
|
145
|
+
{ tool: "caveman", value: "full" },
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("closes on OMP cancel or the optional q shortcut", async () => {
|
|
150
|
+
const driver = await openOverlay();
|
|
151
|
+
driver.feed(KEYS.escape[encoding]);
|
|
152
|
+
expect(driver.closed()).toBe(true);
|
|
153
|
+
|
|
154
|
+
const second = await openOverlay();
|
|
155
|
+
second.feed(KEYS.q[encoding]);
|
|
156
|
+
expect(second.closed()).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("ignores an unbound key without changing optimizer state", async () => {
|
|
160
|
+
const driver = await openOverlay();
|
|
161
|
+
driver.feed("x");
|
|
162
|
+
expect(driver.runs()).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
package/src/opt.test.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { buildOptHelp } from "./opt.ts";
|
|
3
|
+
import type { OptimizerHandle, OptimizerTool } from "./status.ts";
|
|
4
|
+
|
|
5
|
+
/** Build a handle set with fixed current values + value lists. */
|
|
6
|
+
function fakeHandles(): Record<OptimizerTool, OptimizerHandle> {
|
|
7
|
+
const mk = (name: OptimizerTool, current: string, values: string[]): OptimizerHandle => ({
|
|
8
|
+
name,
|
|
9
|
+
help: `${name} — ${name} help`,
|
|
10
|
+
values,
|
|
11
|
+
current: () => current,
|
|
12
|
+
run: () => {},
|
|
13
|
+
});
|
|
14
|
+
return {
|
|
15
|
+
caveman: mk("caveman", "full", ["off", "lite", "full", "ultra", "micro"]),
|
|
16
|
+
rtk: mk("rtk", "on", ["off", "on"]),
|
|
17
|
+
ponytail: mk("ponytail", "off", ["off", "lite", "full", "ultra"]),
|
|
18
|
+
t2s: mk("t2s", "on", ["on", "off"]),
|
|
19
|
+
"edit-guard": mk("edit-guard", "on", ["on", "off"]),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
describe("buildOptHelp", () => {
|
|
25
|
+
it("lists every tool with its current value", () => {
|
|
26
|
+
const help = buildOptHelp(fakeHandles());
|
|
27
|
+
expect(help).toContain("caveman: full");
|
|
28
|
+
expect(help).toContain("rtk: on");
|
|
29
|
+
expect(help).not.toContain("toon");
|
|
30
|
+
expect(help).toContain("ponytail: off");
|
|
31
|
+
expect(help).toContain("edit-guard: on");
|
|
32
|
+
expect(help).toContain("caveman help");
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/opt.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/** Interactive `/optimizer` command using OMP's native settings components. */
|
|
2
|
+
|
|
3
|
+
import { getSettingsListTheme } from "@oh-my-pi/pi-coding-agent";
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@oh-my-pi/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
type Component,
|
|
7
|
+
matchesKey,
|
|
8
|
+
padding,
|
|
9
|
+
SettingsList,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
type SettingItem,
|
|
12
|
+
type TUI,
|
|
13
|
+
visibleWidth,
|
|
14
|
+
} from "@oh-my-pi/pi-tui";
|
|
15
|
+
import type { OptimizerHandle, OptimizerStatus, OptimizerTool } from "./status.ts";
|
|
16
|
+
import { OPTIMIZER_ICON, toolIcon } from "./status.ts";
|
|
17
|
+
|
|
18
|
+
const TOOL_ORDER: readonly OptimizerTool[] = ["caveman", "rtk", "ponytail", "t2s", "edit-guard"];
|
|
19
|
+
|
|
20
|
+
function helpSummary(help: string): string {
|
|
21
|
+
const dash = help.indexOf("—");
|
|
22
|
+
return dash === -1 ? help : help.slice(dash + 1).trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildOptHelp(handles: Record<OptimizerTool, OptimizerHandle>): string {
|
|
26
|
+
const lines = TOOL_ORDER.map(tool => ` ${tool}: ${handles[tool].current()} — ${helpSummary(handles[tool].help)}`);
|
|
27
|
+
return ["omp-optimizer — token tools", "", ...lines].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function displayName(tool: OptimizerTool): string {
|
|
31
|
+
return tool.split("-").map(part => `${part[0]?.toUpperCase()}${part.slice(1)}`).join(" ");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildItems(handles: Record<OptimizerTool, OptimizerHandle>): SettingItem[] {
|
|
35
|
+
return TOOL_ORDER.map(tool => ({
|
|
36
|
+
id: tool,
|
|
37
|
+
label: `${toolIcon(tool)} ${displayName(tool)}`,
|
|
38
|
+
description: helpSummary(handles[tool].help),
|
|
39
|
+
currentValue: handles[tool].current(),
|
|
40
|
+
values: [...handles[tool].values],
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeSettingsKey(data: string): string {
|
|
45
|
+
if (matchesKey(data, "up")) return "\u001b[A";
|
|
46
|
+
if (matchesKey(data, "down")) return "\u001b[B";
|
|
47
|
+
if (matchesKey(data, "escape")) return "\u001b";
|
|
48
|
+
if (matchesKey(data, "space")) return " ";
|
|
49
|
+
if (matchesKey(data, "enter")) return "\n";
|
|
50
|
+
return data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function trimSettingsListPadding(rows: readonly string[]): string[] {
|
|
54
|
+
const footerIndex = rows.findLastIndex(row => row !== "");
|
|
55
|
+
if (footerIndex < 0) return [...rows];
|
|
56
|
+
const content = rows.slice(0, footerIndex);
|
|
57
|
+
while (content.at(-1) === "") content.pop();
|
|
58
|
+
return [...content, "", rows[footerIndex]!];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fitLine(text: string, width: number): string {
|
|
62
|
+
const innerWidth = Math.max(0, width - 4);
|
|
63
|
+
const clipped = truncateToWidth(text, innerWidth);
|
|
64
|
+
return `${clipped}${padding(Math.max(0, innerWidth - visibleWidth(clipped)))}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function frameRow(text: string, width: number, theme: Theme): string {
|
|
68
|
+
const border = (value: string) => theme.fg("border", value);
|
|
69
|
+
return `${border(theme.boxRound.vertical)} ${fitLine(text, width)} ${border(theme.boxRound.vertical)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function topBorder(width: number, title: string, theme: Theme): string {
|
|
73
|
+
const border = (value: string) => theme.fg("border", value);
|
|
74
|
+
const inner = Math.max(0, width - 2);
|
|
75
|
+
const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
|
|
76
|
+
const fillWidth = Math.max(0, inner - 1 - visibleWidth(shown));
|
|
77
|
+
return `${border(theme.boxRound.topLeft + theme.boxRound.horizontal)}${theme.bold(theme.fg("accent", shown))}${border(theme.boxRound.horizontal.repeat(fillWidth) + theme.boxRound.topRight)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function bottomBorder(width: number, theme: Theme): string {
|
|
81
|
+
const border = (value: string) => theme.fg("border", value);
|
|
82
|
+
return border(
|
|
83
|
+
theme.boxRound.bottomLeft + theme.boxRound.horizontal.repeat(Math.max(0, width - 2)) + theme.boxRound.bottomRight,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class OptimizerPanel implements Component {
|
|
88
|
+
#list: SettingsList;
|
|
89
|
+
#tui: TUI;
|
|
90
|
+
#done: (result: null) => void;
|
|
91
|
+
#theme: Theme;
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
tui: TUI,
|
|
95
|
+
theme: Theme,
|
|
96
|
+
handles: Record<OptimizerTool, OptimizerHandle>,
|
|
97
|
+
ctx: ExtensionCommandContext,
|
|
98
|
+
done: (result: null) => void,
|
|
99
|
+
) {
|
|
100
|
+
this.#theme = theme;
|
|
101
|
+
this.#tui = tui;
|
|
102
|
+
this.#done = done;
|
|
103
|
+
this.#list = new SettingsList(
|
|
104
|
+
buildItems(handles),
|
|
105
|
+
Math.min(TOOL_ORDER.length, 8),
|
|
106
|
+
getSettingsListTheme(),
|
|
107
|
+
(id, value) => {
|
|
108
|
+
void handles[id as OptimizerTool]?.run(value, ctx);
|
|
109
|
+
},
|
|
110
|
+
() => this.#done(null),
|
|
111
|
+
{ typeToSearch: false, hint: "↑↓ move · Enter/Space change · Esc close" },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
render(width: number): readonly string[] {
|
|
116
|
+
const rows = trimSettingsListPadding(this.#list.render(width));
|
|
117
|
+
return [
|
|
118
|
+
topBorder(width, `${OPTIMIZER_ICON} Optimizer`, this.#theme),
|
|
119
|
+
...rows.map(row => frameRow(row, width, this.#theme)),
|
|
120
|
+
bottomBorder(width, this.#theme),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
handleInput(data: string): void {
|
|
125
|
+
if (matchesKey(data, "q")) {
|
|
126
|
+
this.#done(null);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.#list.handleInput(normalizeSettingsKey(data));
|
|
130
|
+
this.#tui.requestRender();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
invalidate(): void {
|
|
134
|
+
this.#list.invalidate();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function registerOptCommand(
|
|
139
|
+
pi: ExtensionAPI,
|
|
140
|
+
handles: Record<OptimizerTool, OptimizerHandle>,
|
|
141
|
+
_status: OptimizerStatus,
|
|
142
|
+
): void {
|
|
143
|
+
pi.registerCommand("optimizer", {
|
|
144
|
+
description: "Enable or disable optimizer modes",
|
|
145
|
+
handler: async (_args, ctx) => {
|
|
146
|
+
if (!ctx.hasUI) {
|
|
147
|
+
ctx.ui.notify(buildOptHelp(handles), "info");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await ctx.ui.custom<null>(
|
|
151
|
+
(tui, theme, _keybindings, done) => new OptimizerPanel(tui, theme, handles, ctx, done),
|
|
152
|
+
{
|
|
153
|
+
overlay: true,
|
|
154
|
+
// ponytail: default custom overlays are bottom-centered, leaving the
|
|
155
|
+
// transcript-sized prompt area blank above this short panel.
|
|
156
|
+
overlayOptions: { anchor: "top-center", offsetY: 5, width: "100%", maxHeight: "100%", margin: 0 },
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|