@andares/pi-toolkits 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -112
- package/README.zh-CN.md +337 -0
- package/package.json +6 -3
- package/src/features/autostart/autostart.test.ts +315 -0
- package/src/features/autostart/autostart.ts +108 -0
- package/src/features/autostart/index.ts +109 -0
- package/src/features/favorites/constants.ts +35 -5
- package/src/features/favorites/favorites.test.ts +23 -5
- package/src/features/favorites/index.ts +9 -5
- package/src/features/favorites/patch-selector.ts +7 -7
- package/src/index.ts +4 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autostart feature tests.
|
|
3
|
+
*
|
|
4
|
+
* Pure-logic coverage for parseAutostartConfig / commandNameOf, plus an
|
|
5
|
+
* integration pass over registerAutostart with a minimal mock of the pi
|
|
6
|
+
* extension surface (on/getCommands/sendUserMessage) and ExtensionContext
|
|
7
|
+
* (cwd/isProjectTrusted/ui.notify), using a temp directory for the config
|
|
8
|
+
* file.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import {
|
|
16
|
+
AUTOSTART_CONFIG_PATH,
|
|
17
|
+
AutostartParseError,
|
|
18
|
+
commandNameOf,
|
|
19
|
+
parseAutostartConfig,
|
|
20
|
+
RUNNABLE_REASONS,
|
|
21
|
+
} from "./autostart.js";
|
|
22
|
+
import { registerAutostart } from "./index.js";
|
|
23
|
+
|
|
24
|
+
describe("parseAutostartConfig", () => {
|
|
25
|
+
it("parses a full config into the ordered command list", () => {
|
|
26
|
+
const text = [
|
|
27
|
+
"# project autostart",
|
|
28
|
+
"version = 1",
|
|
29
|
+
"",
|
|
30
|
+
"[command]",
|
|
31
|
+
'run = ["/add-dir ../shop-frontend", "/add-dir ../shop-api"]',
|
|
32
|
+
].join("\n");
|
|
33
|
+
expect(parseAutostartConfig(text)).toEqual([
|
|
34
|
+
"/add-dir ../shop-frontend",
|
|
35
|
+
"/add-dir ../shop-api",
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accepts a config without version (defaults to 1)", () => {
|
|
40
|
+
expect(parseAutostartConfig('[command]\nrun = ["/ask on"]')).toEqual([
|
|
41
|
+
"/ask on",
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns empty for a config without [command] or run", () => {
|
|
46
|
+
expect(parseAutostartConfig("version = 1")).toEqual([]);
|
|
47
|
+
expect(parseAutostartConfig("[command]\n")).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("drops blank entries", () => {
|
|
51
|
+
expect(
|
|
52
|
+
parseAutostartConfig('[command]\nrun = ["/ask on", " "]'),
|
|
53
|
+
).toEqual(["/ask on"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects [[command]] array tables instead of ignoring them", () => {
|
|
57
|
+
expect(() =>
|
|
58
|
+
parseAutostartConfig('[[command]]\nrun = ["/ask on"]'),
|
|
59
|
+
).toThrow(AutostartParseError);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("normalizes whitespace out of commands", () => {
|
|
63
|
+
expect(
|
|
64
|
+
parseAutostartConfig('[command]\nrun = [" /add-dir ../x "]'),
|
|
65
|
+
).toEqual(["/add-dir ../x"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("rejects version != 1", () => {
|
|
69
|
+
expect(() =>
|
|
70
|
+
parseAutostartConfig("version = 2\n[command]\nrun = []"),
|
|
71
|
+
).toThrow(AutostartParseError);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects invalid TOML", () => {
|
|
75
|
+
expect(() => parseAutostartConfig("[command\nrun = [")).toThrow(
|
|
76
|
+
AutostartParseError,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects [command].run that is not a string array", () => {
|
|
81
|
+
expect(() => parseAutostartConfig('[command]\nrun = "/ask on"')).toThrow(
|
|
82
|
+
AutostartParseError,
|
|
83
|
+
);
|
|
84
|
+
expect(() => parseAutostartConfig("[command]\nrun = [1, 2]")).toThrow(
|
|
85
|
+
AutostartParseError,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("commandNameOf", () => {
|
|
91
|
+
it("extracts the command name from a slash command with args", () => {
|
|
92
|
+
expect(commandNameOf("/add-dir ../shop-frontend")).toBe("add-dir");
|
|
93
|
+
});
|
|
94
|
+
it("tolerates missing slash, extra whitespace, and bare command", () => {
|
|
95
|
+
expect(commandNameOf("add-dir ../x")).toBe("add-dir");
|
|
96
|
+
expect(commandNameOf(" /add-dir ../x ")).toBe("add-dir");
|
|
97
|
+
expect(commandNameOf("/ask")).toBe("ask");
|
|
98
|
+
});
|
|
99
|
+
it("returns empty for blank input", () => {
|
|
100
|
+
expect(commandNameOf(" ")).toBe("");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("RUNNABLE_REASONS", () => {
|
|
105
|
+
it("replays startup/new/fork and not resume/reload", () => {
|
|
106
|
+
expect(RUNNABLE_REASONS.has("startup")).toBe(true);
|
|
107
|
+
expect(RUNNABLE_REASONS.has("new")).toBe(true);
|
|
108
|
+
expect(RUNNABLE_REASONS.has("fork")).toBe(true);
|
|
109
|
+
expect(RUNNABLE_REASONS.has("resume")).toBe(false);
|
|
110
|
+
expect(RUNNABLE_REASONS.has("reload")).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/* ------------------------------------------------------------------ */
|
|
115
|
+
/* registerAutostart integration (mocked pi surface) */
|
|
116
|
+
/* ------------------------------------------------------------------ */
|
|
117
|
+
|
|
118
|
+
type SessionStartHandler = (
|
|
119
|
+
event: { reason: string },
|
|
120
|
+
ctx: {
|
|
121
|
+
cwd: string;
|
|
122
|
+
isProjectTrusted: () => boolean;
|
|
123
|
+
ui: { notify: (message: string, type?: string) => void };
|
|
124
|
+
},
|
|
125
|
+
) => Promise<void>;
|
|
126
|
+
|
|
127
|
+
interface Harness {
|
|
128
|
+
cwd: string;
|
|
129
|
+
handler: SessionStartHandler;
|
|
130
|
+
sent: Array<{ command: string; options?: { expandPromptTemplates?: boolean } }>;
|
|
131
|
+
notifications: Array<{ message: string; type?: string }>;
|
|
132
|
+
commands: Array<{ name: string; source: string }>;
|
|
133
|
+
failCommands: Set<string>;
|
|
134
|
+
setTrusted(trusted: boolean): void;
|
|
135
|
+
start(reason: string): Promise<void>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function makeHarness(
|
|
139
|
+
config: string | null,
|
|
140
|
+
configAsDir = false,
|
|
141
|
+
): Promise<Harness> {
|
|
142
|
+
const cwd = await mkdtemp(join(tmpdir(), "pi-autostart-test-"));
|
|
143
|
+
if (configAsDir) {
|
|
144
|
+
// EISDIR setup: the config path exists but is a directory.
|
|
145
|
+
await mkdir(join(cwd, ".pi"), { recursive: true });
|
|
146
|
+
await mkdir(join(cwd, AUTOSTART_CONFIG_PATH));
|
|
147
|
+
} else if (config !== null) {
|
|
148
|
+
await mkdir(join(cwd, ".pi"), { recursive: true });
|
|
149
|
+
await writeFile(join(cwd, AUTOSTART_CONFIG_PATH), config, "utf8");
|
|
150
|
+
}
|
|
151
|
+
let trusted = true;
|
|
152
|
+
let handler: SessionStartHandler = async () => {};
|
|
153
|
+
const sent: Harness["sent"] = [];
|
|
154
|
+
const notifications: Harness["notifications"] = [];
|
|
155
|
+
const commands: Harness["commands"] = [
|
|
156
|
+
{ name: "add-dir", source: "extension" },
|
|
157
|
+
{ name: "ask", source: "extension" },
|
|
158
|
+
];
|
|
159
|
+
const failCommands = new Set<string>();
|
|
160
|
+
const pi = {
|
|
161
|
+
on: (event: string, h: SessionStartHandler) => {
|
|
162
|
+
if (event === "session_start") handler = h;
|
|
163
|
+
},
|
|
164
|
+
getCommands: () => commands,
|
|
165
|
+
sendUserMessage: (
|
|
166
|
+
command: string,
|
|
167
|
+
options?: { expandPromptTemplates?: boolean },
|
|
168
|
+
) => {
|
|
169
|
+
sent.push({ command, options }); // records attempts, including failures
|
|
170
|
+
if (failCommands.has(command)) {
|
|
171
|
+
return Promise.reject(new Error("boom"));
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
} as unknown as ExtensionAPI;
|
|
175
|
+
registerAutostart(pi);
|
|
176
|
+
return {
|
|
177
|
+
handler,
|
|
178
|
+
sent,
|
|
179
|
+
notifications,
|
|
180
|
+
commands,
|
|
181
|
+
failCommands,
|
|
182
|
+
setTrusted(value: boolean) {
|
|
183
|
+
trusted = value;
|
|
184
|
+
},
|
|
185
|
+
async start(reason: string) {
|
|
186
|
+
await handler({ reason }, {
|
|
187
|
+
cwd,
|
|
188
|
+
isProjectTrusted: () => trusted,
|
|
189
|
+
ui: {
|
|
190
|
+
notify: (message: string, type?: string) => {
|
|
191
|
+
notifications.push({ message, type });
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
cwd,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let current: Harness | undefined;
|
|
201
|
+
|
|
202
|
+
afterEach(async () => {
|
|
203
|
+
if (current) {
|
|
204
|
+
await rm(current.cwd, { recursive: true, force: true });
|
|
205
|
+
current = undefined;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
async function harness(
|
|
210
|
+
config: string | null,
|
|
211
|
+
configAsDir = false,
|
|
212
|
+
): Promise<Harness> {
|
|
213
|
+
const h = await makeHarness(config, configAsDir);
|
|
214
|
+
current = h;
|
|
215
|
+
return h;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
describe("registerAutostart", () => {
|
|
219
|
+
const fullConfig =
|
|
220
|
+
'version = 1\n[command]\nrun = ["/add-dir ../frontend", "/add-dir ../api"]';
|
|
221
|
+
|
|
222
|
+
it("dispatches configured commands with expandPromptTemplates on startup", async () => {
|
|
223
|
+
const h = await harness(fullConfig);
|
|
224
|
+
await h.start("startup");
|
|
225
|
+
expect(h.sent).toEqual([
|
|
226
|
+
{ command: "/add-dir ../frontend", options: { expandPromptTemplates: true } },
|
|
227
|
+
{ command: "/add-dir ../api", options: { expandPromptTemplates: true } },
|
|
228
|
+
]);
|
|
229
|
+
expect(h.notifications).toEqual([]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("reports parse errors and dispatches nothing", async () => {
|
|
233
|
+
const h = await harness("/[command]\nrun = [");
|
|
234
|
+
await h.start("startup");
|
|
235
|
+
expect(h.sent).toEqual([]);
|
|
236
|
+
expect(h.notifications).toHaveLength(1);
|
|
237
|
+
expect(h.notifications[0]?.type).toBe("error");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("replays on new and fork", async () => {
|
|
241
|
+
const h = await harness('[command]\nrun = ["/ask on"]');
|
|
242
|
+
await h.start("new");
|
|
243
|
+
await h.start("fork");
|
|
244
|
+
expect(h.sent).toEqual([
|
|
245
|
+
{ command: "/ask on", options: { expandPromptTemplates: true } },
|
|
246
|
+
{ command: "/ask on", options: { expandPromptTemplates: true } },
|
|
247
|
+
]);
|
|
248
|
+
expect(h.notifications).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("does nothing on resume or reload", async () => {
|
|
252
|
+
const h = await harness(fullConfig);
|
|
253
|
+
await h.start("resume");
|
|
254
|
+
await h.start("reload");
|
|
255
|
+
expect(h.sent).toEqual([]);
|
|
256
|
+
expect(h.notifications).toEqual([]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("silently skips when no config file exists", async () => {
|
|
260
|
+
const h = await harness(null);
|
|
261
|
+
await h.start("startup");
|
|
262
|
+
expect(h.sent).toEqual([]);
|
|
263
|
+
expect(h.notifications).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("skips everything when the project is not trusted", async () => {
|
|
267
|
+
const h = await harness(fullConfig);
|
|
268
|
+
h.setTrusted(false);
|
|
269
|
+
await h.start("startup");
|
|
270
|
+
expect(h.sent).toEqual([]);
|
|
271
|
+
expect(h.notifications).toHaveLength(1);
|
|
272
|
+
expect(h.notifications[0]?.type).toBe("warning");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("skips unregistered commands instead of sending them to the model", async () => {
|
|
276
|
+
const h = await harness(
|
|
277
|
+
'[command]\nrun = ["/add-dir ../x", "/not-a-command y"]',
|
|
278
|
+
);
|
|
279
|
+
await h.start("startup");
|
|
280
|
+
expect(h.sent).toEqual([
|
|
281
|
+
{ command: "/add-dir ../x", options: { expandPromptTemplates: true } },
|
|
282
|
+
]);
|
|
283
|
+
expect(h.notifications).toHaveLength(1);
|
|
284
|
+
expect(h.notifications[0]?.type).toBe("warning");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("skips commands only registered as skill or prompt sources", async () => {
|
|
288
|
+
const h = await harness('[command]\nrun = ["/add-dir ../x"]');
|
|
289
|
+
h.commands.length = 0;
|
|
290
|
+
h.commands.push({ name: "add-dir", source: "skill" });
|
|
291
|
+
await h.start("startup");
|
|
292
|
+
expect(h.sent).toEqual([]);
|
|
293
|
+
expect(h.notifications).toHaveLength(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("warns when the config path cannot be read for a reason other than ENOENT", async () => {
|
|
297
|
+
const h = await harness(null, true); // .pi/autostart.toml is a directory → EISDIR
|
|
298
|
+
await h.start("startup");
|
|
299
|
+
expect(h.sent).toEqual([]);
|
|
300
|
+
expect(h.notifications).toHaveLength(1);
|
|
301
|
+
expect(h.notifications[0]?.type).toBe("warning");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("keeps dispatching after one command fails", async () => {
|
|
305
|
+
const h = await harness(fullConfig);
|
|
306
|
+
h.failCommands.add("/add-dir ../frontend");
|
|
307
|
+
await h.start("startup");
|
|
308
|
+
expect(h.sent.map((entry) => entry.command)).toEqual([
|
|
309
|
+
"/add-dir ../frontend",
|
|
310
|
+
"/add-dir ../api",
|
|
311
|
+
]);
|
|
312
|
+
expect(h.notifications).toHaveLength(1);
|
|
313
|
+
expect(h.notifications[0]?.type).toBe("error");
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autostart feature — pure config logic, no pi imports.
|
|
3
|
+
*
|
|
4
|
+
* Schema (`.pi/autostart.toml`, checked into the project):
|
|
5
|
+
*
|
|
6
|
+
* ```toml
|
|
7
|
+
* version = 1
|
|
8
|
+
*
|
|
9
|
+
* [command]
|
|
10
|
+
* run = [
|
|
11
|
+
* "/add-dir ../shop-frontend",
|
|
12
|
+
* "/add-dir ../shop-api",
|
|
13
|
+
* ]
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* `version` is optional (defaults to 1) but must be 1 when present, so a
|
|
17
|
+
* future schema change can fail loudly instead of silently misreading.
|
|
18
|
+
*/
|
|
19
|
+
import { parse as parseToml } from "smol-toml";
|
|
20
|
+
|
|
21
|
+
/** Project-relative location of the autostart config (`.pi/` is pi's
|
|
22
|
+
* project-config convention, precedent: `.pi/mcp.json`). */
|
|
23
|
+
export const AUTOSTART_CONFIG_PATH = ".pi/autostart.toml";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* session_start reasons that replay autostart commands:
|
|
27
|
+
* - startup / new: fresh session in the project — replay
|
|
28
|
+
* - fork: new branch of an earlier session — replay (the fork point may
|
|
29
|
+
* predate the config)
|
|
30
|
+
* - resume: the resumed branch already carries its /add-dir state —
|
|
31
|
+
* replaying would at best duplicate work (pi-add-dir is idempotent) and
|
|
32
|
+
* at worst re-add directories the user removed on that branch
|
|
33
|
+
* - reload: extensions are being rebuilt — re-running commands would
|
|
34
|
+
* duplicate side effects from the same session
|
|
35
|
+
*/
|
|
36
|
+
export const RUNNABLE_REASONS: ReadonlySet<string> = new Set([
|
|
37
|
+
"startup",
|
|
38
|
+
"new",
|
|
39
|
+
"fork",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** Parse/validation failure with a user-facing message. */
|
|
43
|
+
export class AutostartParseError extends Error {}
|
|
44
|
+
|
|
45
|
+
function errMessage(err: unknown): string {
|
|
46
|
+
return err instanceof Error ? err.message : String(err);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse autostart config text into the ordered command list.
|
|
51
|
+
* @throws AutostartParseError on invalid TOML, wrong shape, or version ≠ 1.
|
|
52
|
+
*/
|
|
53
|
+
export function parseAutostartConfig(text: string): string[] {
|
|
54
|
+
let doc: unknown;
|
|
55
|
+
try {
|
|
56
|
+
doc = parseToml(text);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new AutostartParseError(`invalid TOML: ${errMessage(err)}`);
|
|
59
|
+
}
|
|
60
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
61
|
+
throw new AutostartParseError("top-level value must be a table");
|
|
62
|
+
}
|
|
63
|
+
const { version, command } = doc as {
|
|
64
|
+
version?: unknown;
|
|
65
|
+
command?: unknown;
|
|
66
|
+
};
|
|
67
|
+
if (version !== undefined && version !== 1) {
|
|
68
|
+
throw new AutostartParseError(
|
|
69
|
+
`unsupported version ${String(version)} (expected 1)`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (command === undefined) return [];
|
|
73
|
+
if (Array.isArray(command)) {
|
|
74
|
+
throw new AutostartParseError(
|
|
75
|
+
"[[command]] array tables are not supported — use a single [command] table with a run array",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (command === null || typeof command !== "object") {
|
|
79
|
+
throw new AutostartParseError("[command] must be a table");
|
|
80
|
+
}
|
|
81
|
+
const run = (command as { run?: unknown }).run;
|
|
82
|
+
if (run === undefined) return [];
|
|
83
|
+
if (
|
|
84
|
+
!Array.isArray(run) ||
|
|
85
|
+
run.some((entry) => typeof entry !== "string")
|
|
86
|
+
) {
|
|
87
|
+
throw new AutostartParseError(
|
|
88
|
+
"[command].run must be an array of command strings",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
// Trim each entry: `prompt()` dispatches a command only when the text
|
|
92
|
+
// literally starts with "/", so a leading-space entry would silently fall
|
|
93
|
+
// through to the model as a plain message. Normalizing here keeps the
|
|
94
|
+
// "never sent to the model" guarantee (see index.ts).
|
|
95
|
+
return (run as string[])
|
|
96
|
+
.map((cmd) => cmd.trim())
|
|
97
|
+
.filter((cmd) => cmd !== "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Extract the command name a slash command dispatches to:
|
|
102
|
+
* `"/add-dir ../x"` → `"add-dir"`, tolerant of leading whitespace and an
|
|
103
|
+
* optional slash. Empty for blank input.
|
|
104
|
+
*/
|
|
105
|
+
export function commandNameOf(command: string): string {
|
|
106
|
+
const stripped = command.trim().replace(/^\//, "");
|
|
107
|
+
return stripped.split(/\s+/)[0] ?? "";
|
|
108
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autostart feature — run project-declared slash commands at session start.
|
|
3
|
+
*
|
|
4
|
+
* Config lives at `<project root>/.pi/autostart.toml` (see autostart.ts for
|
|
5
|
+
* the schema). Execution gates, in order:
|
|
6
|
+
*
|
|
7
|
+
* 1. reason filter — only startup/new/fork replay (see RUNNABLE_REASONS)
|
|
8
|
+
* 2. config file exists — a missing file is a silent opt-in skip
|
|
9
|
+
* 3. `ctx.isProjectTrusted()` — an untrusted project never auto-executes
|
|
10
|
+
* 4. per-command pre-validation against `pi.getCommands()`: only commands
|
|
11
|
+
* registered by an *extension* are dispatched. This is mandatory:
|
|
12
|
+
* sendUserMessage with an unhandled "/x" falls through prompt() to the
|
|
13
|
+
* model as a plain user message (pi agent-session.js), so an unknown
|
|
14
|
+
* command must be skipped here, never sent.
|
|
15
|
+
*
|
|
16
|
+
* Dispatch uses `sendUserMessage(cmd, { expandPromptTemplates: true })` —
|
|
17
|
+
* that flag is what routes "/cmd args" through _tryExecuteExtensionCommand
|
|
18
|
+
* instead of the model prompt path. The wrapper defaults it to false
|
|
19
|
+
* (verified against pi 1.0.4 agent-session.js; see AGENTS.md host-compat
|
|
20
|
+
* discipline for the version matrix).
|
|
21
|
+
*
|
|
22
|
+
* Only extension commands can run this way. Built-in commands (/compact,
|
|
23
|
+
* /reload, /resume, …) are dispatched at the TUI layer and cannot be
|
|
24
|
+
* triggered from the extension layer; skill commands (/skill:name) expand
|
|
25
|
+
* into prompts rather than execute. First-party use case: `/add-dir`
|
|
26
|
+
* (pi-add-dir), which is idempotent ("Already added" check).
|
|
27
|
+
*/
|
|
28
|
+
import { readFile } from "node:fs/promises";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import {
|
|
32
|
+
AUTOSTART_CONFIG_PATH,
|
|
33
|
+
AutostartParseError,
|
|
34
|
+
commandNameOf,
|
|
35
|
+
parseAutostartConfig,
|
|
36
|
+
RUNNABLE_REASONS,
|
|
37
|
+
} from "./autostart.js";
|
|
38
|
+
|
|
39
|
+
export function registerAutostart(pi: ExtensionAPI): void {
|
|
40
|
+
pi.on("session_start", async (event, ctx) => {
|
|
41
|
+
if (!RUNNABLE_REASONS.has(event.reason)) return;
|
|
42
|
+
|
|
43
|
+
let text: string;
|
|
44
|
+
try {
|
|
45
|
+
text = await readFile(join(ctx.cwd, AUTOSTART_CONFIG_PATH), "utf8");
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// Missing file = opt-in skip. Any other read failure (EACCES, EISDIR,
|
|
48
|
+
// …) is a misconfiguration the operator should hear about.
|
|
49
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
50
|
+
ctx.ui.notify(
|
|
51
|
+
`autostart: skipped — cannot read ${AUTOSTART_CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`,
|
|
52
|
+
"warning",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let commands: string[];
|
|
59
|
+
try {
|
|
60
|
+
commands = parseAutostartConfig(text);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const message = err instanceof AutostartParseError ? err.message : String(err);
|
|
63
|
+
ctx.ui.notify(
|
|
64
|
+
`autostart: skipped — ${message} (${AUTOSTART_CONFIG_PATH})`,
|
|
65
|
+
"error",
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (commands.length === 0) return;
|
|
70
|
+
|
|
71
|
+
if (!ctx.isProjectTrusted()) {
|
|
72
|
+
ctx.ui.notify(
|
|
73
|
+
`autostart: skipped — project not trusted (${AUTOSTART_CONFIG_PATH})`,
|
|
74
|
+
"warning",
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Only extension-registered commands are dispatchable; skill and
|
|
80
|
+
// prompt-template entries expand rather than execute.
|
|
81
|
+
const dispatchable = new Set(
|
|
82
|
+
pi
|
|
83
|
+
.getCommands()
|
|
84
|
+
.flatMap((cmd) =>
|
|
85
|
+
cmd.source === "extension" ? [cmd.name.replace(/^\//, "")] : [],
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
for (const command of commands) {
|
|
90
|
+
const name = commandNameOf(command);
|
|
91
|
+
if (!name || !dispatchable.has(name)) {
|
|
92
|
+
ctx.ui.notify(
|
|
93
|
+
`autostart: skipped "${command}" — not a registered extension command`,
|
|
94
|
+
"warning",
|
|
95
|
+
);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// One failing command must not cancel the rest of the list.
|
|
99
|
+
try {
|
|
100
|
+
await pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
ctx.ui.notify(
|
|
103
|
+
`autostart: "${command}" failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
104
|
+
"error",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -4,17 +4,47 @@
|
|
|
4
4
|
* Keybindings are only active INSIDE the /model model selector (the
|
|
5
5
|
* ModelSelectorComponent consumes input before the search box, see
|
|
6
6
|
* patch-selector.ts). Outside the selector the default pi bindings are
|
|
7
|
-
* untouched: ctrl+
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* untouched: ctrl+j stays "new line". ctrl+p/ctrl+shift+p keep their
|
|
8
|
+
* bindings — only the cycle semantics change (patch-cycle.ts), so no
|
|
9
|
+
* keybinding conflicts arise.
|
|
10
|
+
*
|
|
11
|
+
* Verified against pi 0.86.0 / 0.87.0 / 0.99.0 / 1.0.2 / 1.0.4 keybinding
|
|
12
|
+
* tables + pi-tui input dispatch on WSL2 + Windows Terminal; re-run
|
|
13
|
+
* `pnpm check:keybindings` after every host upgrade before trusting this.
|
|
10
14
|
*/
|
|
11
15
|
export const FAVORITES_STATUS_KEY = "favorites";
|
|
12
16
|
|
|
13
17
|
/** State file, written next to the auto-naming-session config convention. */
|
|
14
18
|
export const FAVORITES_FILE = "pi-toolkits-favorites.json";
|
|
15
19
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Selector-scoped keybindings (only honored while the model selector is open).
|
|
22
|
+
*
|
|
23
|
+
* Why ctrl+alt+f for the toggle (was ctrl+f through v0.6.0):
|
|
24
|
+
* - ctrl+f is consumed by the host in fullscreen mode, which became pi's
|
|
25
|
+
* default `tuiMode` in 1.0 (0.86–0.99 defaulted to `regular`, where the
|
|
26
|
+
* binding is inert). On Windows/WSL `tui.altScreen.search` resolves to
|
|
27
|
+
* ctrl+f, and `TuiAltScreen.handleViewportInput()` checks it before
|
|
28
|
+
* `shouldDeferViewportInputToOverlay()`, so the keystroke opens transcript
|
|
29
|
+
* search and the selector never sees it. Verified in pi-tui 1.0.4
|
|
30
|
+
* `dist/tui-alt-screen.js` (search check is the first key branch and
|
|
31
|
+
* returns `{ consume: true }` unconditionally).
|
|
32
|
+
* - alt+f is taken by `tui.editor.cursorWordRight` and `app.tree.unfoldOrDown`.
|
|
33
|
+
* - shift+f arrives as the literal character "F" without the kitty keyboard
|
|
34
|
+
* protocol, i.e. it would steal a typed character from the selector's
|
|
35
|
+
* search box.
|
|
36
|
+
* - ctrl+shift+f collapses to ctrl+f on Windows/WSL terminals (the reason
|
|
37
|
+
* pi itself binds search to ctrl+f there instead) and Windows Terminal
|
|
38
|
+
* binds it to its own Find action.
|
|
39
|
+
* - ctrl+alt+f is unclaimed across the whole 0.86–1.0 window (only
|
|
40
|
+
* `ctrl+alt+]` is taken from that family) and is byte-distinguishable from
|
|
41
|
+
* ctrl+f/alt+f without kitty (ESC + ctrl-f). Same family as the shipped
|
|
42
|
+
* stash key ctrl+alt+y, which is proven to reach pi on this terminal.
|
|
43
|
+
*
|
|
44
|
+
* ctrl+j is kept: no host layer consumes it before the selector, so it only
|
|
45
|
+
* shadows `tui.input.newLine` inside the search box.
|
|
46
|
+
*/
|
|
47
|
+
export const FAVORITE_TOGGLE_KEY = "ctrl+alt+f";
|
|
18
48
|
export const FAVORITES_ONLY_KEY = "ctrl+j";
|
|
19
49
|
|
|
20
50
|
/**
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* - cycle patch: favorites-only cycling (fallback to stock when disabled or
|
|
7
7
|
* no favorites; favorites-only pick; single-favorite jump; not-a-favorite
|
|
8
8
|
* → forward first / backward last)
|
|
9
|
-
* - selector patches: ctrl+j favorites-only filter, ctrl+f toggle, hint
|
|
10
|
-
* injection, idempotent apply
|
|
9
|
+
* - selector patches: ctrl+j favorites-only filter, ctrl+alt+f toggle, hint
|
|
10
|
+
* injection, idempotent apply, plain ctrl+f left to the host
|
|
11
11
|
*
|
|
12
12
|
* The patches read the shared store via getFavoritesStore(); each test gets an
|
|
13
13
|
* isolated store backed by a temp file via setSharedFavoritesStore(). The
|
|
@@ -408,18 +408,36 @@ describe("selector patches", () => {
|
|
|
408
408
|
expect(selector.filteredModels).toHaveLength(3);
|
|
409
409
|
});
|
|
410
410
|
|
|
411
|
-
it("ctrl+f toggles favorite for the selected model", () => {
|
|
411
|
+
it("ctrl+alt+f toggles favorite for the selected model", () => {
|
|
412
412
|
const selector = fakeSelector({
|
|
413
413
|
filteredModels: models.map(item),
|
|
414
414
|
selectedIndex: 1, // opus-4
|
|
415
415
|
activeModels: models.map(item),
|
|
416
416
|
});
|
|
417
|
-
selector.handleInput("\x06"); // ctrl+f
|
|
417
|
+
selector.handleInput("\x1b\x06"); // ctrl+alt+f (legacy ESC + ctrl-f)
|
|
418
418
|
expect(getFavoritesStore().has(models[1])).toBe(true);
|
|
419
|
-
selector.handleInput("\x06");
|
|
419
|
+
selector.handleInput("\x1b\x06");
|
|
420
420
|
expect(getFavoritesStore().has(models[1])).toBe(false);
|
|
421
421
|
});
|
|
422
422
|
|
|
423
|
+
it("does not intercept plain ctrl+f (host-owned in fullscreen mode)", () => {
|
|
424
|
+
const forwarded: string[] = [];
|
|
425
|
+
getFavoritesStore().toggle(models[1]);
|
|
426
|
+
const selector = fakeSelector({
|
|
427
|
+
filteredModels: models.map(item),
|
|
428
|
+
selectedIndex: 1,
|
|
429
|
+
activeModels: models.map(item),
|
|
430
|
+
searchInput: {
|
|
431
|
+
getValue: () => "",
|
|
432
|
+
handleInput: (d: string) => forwarded.push(d),
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
selector.handleInput("\x06"); // stock ctrl+f
|
|
436
|
+
// Reached the search box instead of our toggle, so the store is unchanged.
|
|
437
|
+
expect(forwarded).toEqual(["\x06"]);
|
|
438
|
+
expect(getFavoritesStore().has(models[1])).toBe(true);
|
|
439
|
+
});
|
|
440
|
+
|
|
423
441
|
it("injects the hint row once (children.length grows by exactly one)", () => {
|
|
424
442
|
const selector = fakeSelector({
|
|
425
443
|
children: [1, 2, 3, 4] as unknown[],
|
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
* What it does:
|
|
5
5
|
* - Inside the /model selector (opened via /model or ctrl+l):
|
|
6
6
|
* * a hint row at the top shows the keys and the favorites-only state
|
|
7
|
-
* * ctrl+f toggles favorite on the currently selected model
|
|
7
|
+
* * ctrl+alt+f toggles favorite on the currently selected model
|
|
8
8
|
* * ctrl+j toggles the "only favorites" filter
|
|
9
9
|
* * favorited model ids render in bold bright yellow
|
|
10
10
|
* The keys are consumed by the selector component only, so pi's editor
|
|
11
|
-
* bindings (ctrl+
|
|
11
|
+
* bindings (ctrl+j newline) are untouched elsewhere. (The toggle moved off
|
|
12
|
+
* ctrl+f after v0.6.0: pi >= 1.0 defaults to fullscreen, where ctrl+f is
|
|
13
|
+
* consumed by the alt-screen transcript search before the selector sees
|
|
14
|
+
* it — see constants.ts for the full key audit.)
|
|
12
15
|
* - ctrl+p / ctrl+shift+p keep their bindings but, once favorites exist
|
|
13
16
|
* (and cycleOnlyFavorites is not false), cycle only among favorited
|
|
14
17
|
* models instead of all models. ctrl+l / /model remain the escape hatch
|
|
@@ -19,7 +22,7 @@
|
|
|
19
22
|
* Implementation: pi's extension API has no hook to render inside the
|
|
20
23
|
* built-in model selector and cannot rebind the reserved ctrl+p actions, so
|
|
21
24
|
* the feature patches two built-in prototypes at runtime:
|
|
22
|
-
* - ModelSelectorComponent (hint row,
|
|
25
|
+
* - ModelSelectorComponent (hint row, favorites keys, styling, filter)
|
|
23
26
|
* - AgentSession.cycleModel (favorites-only cycling)
|
|
24
27
|
* Both patches are version-guarded (skip + warn if the API changed) and
|
|
25
28
|
* idempotent across /reload.
|
|
@@ -91,13 +94,14 @@ export function registerFavorites(pi: ExtensionAPI): void {
|
|
|
91
94
|
applyCyclePatch();
|
|
92
95
|
|
|
93
96
|
pi.registerCommand("favorites", {
|
|
94
|
-
description:
|
|
97
|
+
description:
|
|
98
|
+
"List favorited models (add/remove with ctrl+alt+F in /model)",
|
|
95
99
|
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
96
100
|
captureTheme(ctx);
|
|
97
101
|
const list = getFavoritesStore().list();
|
|
98
102
|
if (list.length === 0) {
|
|
99
103
|
ctx.ui.notify(
|
|
100
|
-
"No favorited models. Open /model and press ctrl+F on a model to favorite it.",
|
|
104
|
+
"No favorited models. Open /model and press ctrl+alt+F on a model to favorite it.",
|
|
101
105
|
"info",
|
|
102
106
|
);
|
|
103
107
|
return;
|