@firstpick/pi-extension-bang-command-autocomplete 0.1.1 → 0.1.3

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.
Files changed (3) hide show
  1. package/README.md +16 -27
  2. package/index.ts +298 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,15 +2,16 @@
2
2
 
3
3
  Autocomplete for `!<command>` in Pi.
4
4
 
5
- ## Goal
5
+ ## What it does
6
6
 
7
- Make shell-style `!` execution faster and less error-prone by suggesting command names while typing.
8
-
9
- ## Why it works this way
10
-
11
- - Ships with a curated common-command list, so suggestions work immediately.
12
- - Can optionally include your shell history for personalized suggestions.
13
- - Keeps scope intentionally small: command-name completion only (no argument prediction), which makes behavior predictable and lightweight.
7
+ - Suggests command names while typing `!<command>`.
8
+ - Uses a built-in common-command index out of the box.
9
+ - Learns commands you run via `!`/`!!` and persists them across Pi sessions.
10
+ - Learns full bang command lines (e.g. `!git add .`) and suggests them directly.
11
+ - Learns flags used with those commands (e.g. `!rg -n`) and suggests them when you type `!<command> ` or `!<command> -...`.
12
+ - Also suggests learned command+flag combos directly while typing `!<command>`.
13
+ - Optionally adds commands from shell history for personalized suggestions.
14
+ - Keeps scope intentionally narrow (command + flag completion only; no positional-argument prediction).
14
15
 
15
16
  ## Install
16
17
 
@@ -18,33 +19,21 @@ Make shell-style `!` execution faster and less error-prone by suggesting command
18
19
  pi install npm:@firstpick/pi-extension-bang-command-autocomplete
19
20
  ```
20
21
 
21
- Local testing:
22
-
23
- ```bash
24
- pi install /absolute/path/to/pi-extension-bang-command-autocomplete
25
- ```
26
-
27
22
  ## Configuration
28
23
 
29
24
  - `PI_BANG_AUTOCOMPLETE_INCLUDE_HISTORY`
30
25
  - `1|true|yes|on`: include commands from `~/.bash_history` and fish history.
31
- - unset/other: use built-in common commands only (default).
26
+ - unset/other: use built-in command list only (default).
27
+ - `PI_BANG_AUTOCOMPLETE_RUNTIME_STORE_PATH`
28
+ - optional absolute/relative file path for persisted learned commands.
29
+ - default: `~/.pi/agent/state/bang-command-autocomplete-runtime.json`.
30
+ - stores learned command names, learned full command lines, and per-command learned flags.
32
31
 
33
32
  ## Commands
34
33
 
35
- - `/bang-refresh` — rebuilds the autocomplete index (use after changing history/config).
36
- - `/bang-status` — shows how many commands are indexed and whether history is enabled.
34
+ - `/bang-refresh` — rebuild autocomplete index.
35
+ - `/bang-status` — show indexed command count, history-index status, runtime-learned command/line counts, and learned flag count.
37
36
 
38
37
  ## Tools
39
38
 
40
39
  None.
41
-
42
- ## Publish
43
-
44
- ```bash
45
- bun publish --access public
46
- ```
47
-
48
- ```bash
49
- npm publish --access public
50
- ```
package/index.ts CHANGED
@@ -3,7 +3,15 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
5
 
6
- type CommandSource = "common" | "history";
6
+ type CommandSource = "common" | "history" | "runtime";
7
+
8
+ const DEFAULT_RUNTIME_STORE_PATH = path.join(
9
+ os.homedir(),
10
+ ".pi",
11
+ "agent",
12
+ "state",
13
+ "bang-command-autocomplete-runtime.json",
14
+ );
7
15
 
8
16
  function envFlag(name: string, fallback: boolean): boolean {
9
17
  const raw = process.env[name]?.trim().toLowerCase();
@@ -57,19 +65,33 @@ const COMMON_COMMANDS = [
57
65
  "btop",
58
66
  ] as const;
59
67
 
60
- function extractExecutable(commandLine: string): string | undefined {
68
+ function parseCommandLine(commandLine: string): { executable?: string; flags: string[] } {
61
69
  const trimmed = commandLine.trim();
62
- if (!trimmed || trimmed.startsWith("#")) return undefined;
70
+ if (!trimmed || trimmed.startsWith("#")) return { flags: [] };
63
71
 
64
72
  const tokens = trimmed.split(/\s+/).filter(Boolean);
65
- if (tokens.length === 0) return undefined;
73
+ if (tokens.length === 0) return { flags: [] };
74
+
75
+ let startIndex = 0;
76
+ let executable = tokens[startIndex] ?? "";
77
+
78
+ if (executable === "sudo") {
79
+ startIndex += 1;
80
+ executable = tokens[startIndex] ?? "";
81
+ }
66
82
 
67
- let executable = tokens[0] ?? "";
68
- if (executable === "sudo") executable = tokens[1] ?? "";
69
83
  if (executable.startsWith("!")) executable = executable.slice(1);
84
+ if (!executable) return { flags: [] };
70
85
 
71
- if (!executable) return undefined;
72
- return executable;
86
+ const flags = tokens
87
+ .slice(startIndex + 1)
88
+ .filter((token) => token.startsWith("-") && token !== "-");
89
+
90
+ return { executable, flags };
91
+ }
92
+
93
+ function extractExecutable(commandLine: string): string | undefined {
94
+ return parseCommandLine(commandLine).executable;
73
95
  }
74
96
 
75
97
  function readFishHistoryExecutables(): string[] {
@@ -102,6 +124,97 @@ function readBashHistoryExecutables(): string[] {
102
124
  .filter((v): v is string => Boolean(v));
103
125
  }
104
126
 
127
+ function getRuntimeStorePath(): string {
128
+ const configured = process.env.PI_BANG_AUTOCOMPLETE_RUNTIME_STORE_PATH?.trim();
129
+ return configured ? path.resolve(configured) : DEFAULT_RUNTIME_STORE_PATH;
130
+ }
131
+
132
+ type RuntimeStoreData = {
133
+ commands: Set<string>;
134
+ flagsByCommand: Map<string, Set<string>>;
135
+ lines: Set<string>;
136
+ };
137
+
138
+ function readRuntimeData(storePath: string): RuntimeStoreData {
139
+ const empty: RuntimeStoreData = { commands: new Set<string>(), flagsByCommand: new Map<string, Set<string>>(), lines: new Set<string>() };
140
+ if (!fs.existsSync(storePath)) return empty;
141
+
142
+ try {
143
+ const parsed = JSON.parse(fs.readFileSync(storePath, "utf8")) as unknown;
144
+
145
+ // Backward compatibility with older format: string[]
146
+ if (Array.isArray(parsed)) {
147
+ for (const item of parsed) {
148
+ if (typeof item === "string" && item.trim()) {
149
+ const normalized = item.trim();
150
+ empty.commands.add(normalized);
151
+ empty.lines.add(normalized);
152
+ }
153
+ }
154
+ return empty;
155
+ }
156
+
157
+ if (!parsed || typeof parsed !== "object") return empty;
158
+
159
+ const commandsRaw = (parsed as { commands?: unknown }).commands;
160
+ if (Array.isArray(commandsRaw)) {
161
+ for (const item of commandsRaw) {
162
+ if (typeof item === "string" && item.trim()) {
163
+ empty.commands.add(item.trim());
164
+ }
165
+ }
166
+ }
167
+
168
+ const flagsRaw = (parsed as { flags?: unknown }).flags;
169
+ if (flagsRaw && typeof flagsRaw === "object") {
170
+ for (const [command, flags] of Object.entries(flagsRaw)) {
171
+ if (!command.trim() || !Array.isArray(flags)) continue;
172
+ const normalizedFlags = flags
173
+ .map((flag) => (typeof flag === "string" ? flag.trim() : ""))
174
+ .filter(Boolean);
175
+ if (normalizedFlags.length === 0) continue;
176
+ empty.flagsByCommand.set(command.trim(), new Set(normalizedFlags));
177
+ }
178
+ }
179
+
180
+ const linesRaw = (parsed as { lines?: unknown }).lines;
181
+ if (Array.isArray(linesRaw)) {
182
+ for (const item of linesRaw) {
183
+ if (typeof item === "string" && item.trim()) {
184
+ empty.lines.add(item.trim());
185
+ }
186
+ }
187
+ }
188
+
189
+ return empty;
190
+ } catch {
191
+ return empty;
192
+ }
193
+ }
194
+
195
+ function writeRuntimeData(storePath: string, data: RuntimeStoreData): void {
196
+ try {
197
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
198
+
199
+ const commands = Array.from(data.commands).sort((a, b) => a.localeCompare(b));
200
+ const flags: Record<string, string[]> = {};
201
+ const sortedCommands = Array.from(data.flagsByCommand.keys()).sort((a, b) => a.localeCompare(b));
202
+
203
+ for (const command of sortedCommands) {
204
+ const commandFlags = Array.from(data.flagsByCommand.get(command) ?? []).sort((a, b) => a.localeCompare(b));
205
+ if (commandFlags.length > 0) {
206
+ flags[command] = commandFlags;
207
+ }
208
+ }
209
+
210
+ const lines = Array.from(data.lines).sort((a, b) => a.localeCompare(b));
211
+
212
+ fs.writeFileSync(storePath, `${JSON.stringify({ commands, flags, lines }, null, 2)}\n`, "utf8");
213
+ } catch {
214
+ // Ignore persistence errors; autocomplete should still work in-memory.
215
+ }
216
+ }
217
+
105
218
  function buildCommandIndex(includeHistory: boolean): Array<{ command: string; source: CommandSource }> {
106
219
  const merged = new Map<string, CommandSource>();
107
220
 
@@ -125,6 +238,28 @@ function buildCommandIndex(includeHistory: boolean): Array<{ command: string; so
125
238
  return Array.from(merged.entries()).map(([command, source]) => ({ command, source }));
126
239
  }
127
240
 
241
+ function addRuntimeCommand(
242
+ index: Array<{ command: string; source: CommandSource }>,
243
+ command: string,
244
+ ): Array<{ command: string; source: CommandSource }> {
245
+ const normalized = command.trim();
246
+ if (!normalized) return index;
247
+
248
+ const existingIndex = index.findIndex((entry) => entry.command === normalized);
249
+ if (existingIndex === -1) {
250
+ return [...index, { command: normalized, source: "runtime" }];
251
+ }
252
+
253
+ const existing = index[existingIndex];
254
+ if (existing?.source === "runtime") {
255
+ return index;
256
+ }
257
+
258
+ const next = [...index];
259
+ next[existingIndex] = { command: normalized, source: "runtime" };
260
+ return next;
261
+ }
262
+
128
263
  function rankCommands(commands: Array<{ command: string; source: CommandSource }>, query: string) {
129
264
  const q = query.toLowerCase();
130
265
 
@@ -136,12 +271,79 @@ function rankCommands(commands: Array<{ command: string; source: CommandSource }
136
271
  return [...startsWith, ...includes].slice(0, 24);
137
272
  }
138
273
 
274
+ function rankFlags(flags: string[], query: string): string[] {
275
+ const q = query.toLowerCase();
276
+ const startsWith = flags.filter((flag) => flag.toLowerCase().startsWith(q));
277
+ const includes = flags.filter((flag) => !flag.toLowerCase().startsWith(q) && flag.toLowerCase().includes(q));
278
+ return [...startsWith, ...includes].slice(0, 24);
279
+ }
280
+
281
+ function rankLineCandidates(lines: string[], query: string): string[] {
282
+ const q = query.toLowerCase();
283
+ const startsWith = lines.filter((line) => line.toLowerCase().startsWith(q));
284
+ const includes = lines.filter((line) => !line.toLowerCase().startsWith(q) && line.toLowerCase().includes(q));
285
+ return [...startsWith, ...includes].slice(0, 24);
286
+ }
287
+
139
288
  export default function bangCommandAutocomplete(pi: ExtensionAPI) {
140
289
  const includeHistory = envFlag("PI_BANG_AUTOCOMPLETE_INCLUDE_HISTORY", false);
290
+ const runtimeStorePath = getRuntimeStorePath();
291
+ const runtimeData = readRuntimeData(runtimeStorePath);
292
+ const runtimeLearned = runtimeData.commands;
293
+ const runtimeFlagsByCommand = runtimeData.flagsByCommand;
294
+ const runtimeLearnedLines = runtimeData.lines;
141
295
  let commandIndex = buildCommandIndex(includeHistory);
142
296
 
297
+ const learnFromCommandLine = (commandLine: string | undefined) => {
298
+ if (!commandLine) return;
299
+ const normalizedLine = commandLine.trim().replace(/^!+/, "");
300
+ if (!normalizedLine) return;
301
+
302
+ const parsed = parseCommandLine(commandLine);
303
+ const executable = parsed.executable;
304
+ if (!executable) return;
305
+
306
+ let changed = false;
307
+
308
+ const beforeSize = runtimeLearned.size;
309
+ runtimeLearned.add(executable);
310
+ commandIndex = addRuntimeCommand(commandIndex, executable);
311
+ if (runtimeLearned.size !== beforeSize) {
312
+ changed = true;
313
+ }
314
+
315
+ const beforeLines = runtimeLearnedLines.size;
316
+ runtimeLearnedLines.add(normalizedLine);
317
+ if (runtimeLearnedLines.size !== beforeLines) {
318
+ changed = true;
319
+ }
320
+
321
+ if (parsed.flags.length > 0) {
322
+ const flagSet = runtimeFlagsByCommand.get(executable) ?? new Set<string>();
323
+ const beforeFlags = flagSet.size;
324
+ for (const flag of parsed.flags) {
325
+ flagSet.add(flag);
326
+ }
327
+ if (flagSet.size !== beforeFlags) {
328
+ runtimeFlagsByCommand.set(executable, flagSet);
329
+ changed = true;
330
+ }
331
+ }
332
+
333
+ if (changed) {
334
+ writeRuntimeData(runtimeStorePath, {
335
+ commands: runtimeLearned,
336
+ flagsByCommand: runtimeFlagsByCommand,
337
+ lines: runtimeLearnedLines,
338
+ });
339
+ }
340
+ };
341
+
143
342
  const refreshIndex = () => {
144
343
  commandIndex = buildCommandIndex(includeHistory);
344
+ for (const command of runtimeLearned) {
345
+ commandIndex = addRuntimeCommand(commandIndex, command);
346
+ }
145
347
  };
146
348
 
147
349
  pi.on("session_start", (_event, ctx) => {
@@ -152,6 +354,46 @@ export default function bangCommandAutocomplete(pi: ExtensionAPI) {
152
354
  const line = lines[cursorLine] ?? "";
153
355
  const beforeCursor = line.slice(0, cursorCol);
154
356
 
357
+ const flagMatch = beforeCursor.match(/(?:^|[ \t])!([^\s!]+)\s+([^\s]*)$/);
358
+ if (flagMatch) {
359
+ const command = flagMatch[1] ?? "";
360
+ const partialFlag = flagMatch[2] ?? "";
361
+
362
+ if (partialFlag === "" || partialFlag.startsWith("-")) {
363
+ const knownFlags = Array.from(runtimeFlagsByCommand.get(command) ?? []);
364
+ const rankedFlags = rankFlags(knownFlags, partialFlag);
365
+
366
+ if (rankedFlags.length > 0) {
367
+ return {
368
+ prefix: partialFlag,
369
+ items: rankedFlags.map((flag) => ({
370
+ value: flag,
371
+ label: flag,
372
+ description: `learned for ${command}`,
373
+ })),
374
+ };
375
+ }
376
+ }
377
+ }
378
+
379
+ const fullLineMatch = beforeCursor.match(/(?:^|[ \t])!(.*)$/);
380
+ if (fullLineMatch) {
381
+ const partialLine = fullLineMatch[1] ?? "";
382
+ if (partialLine.includes(" ")) {
383
+ const rankedLines = rankLineCandidates(Array.from(runtimeLearnedLines), partialLine);
384
+ if (rankedLines.length > 0) {
385
+ return {
386
+ prefix: `!${partialLine}`,
387
+ items: rankedLines.map((lineCandidate) => ({
388
+ value: `!${lineCandidate}`,
389
+ label: `!${lineCandidate}`,
390
+ description: "learned full line",
391
+ })),
392
+ };
393
+ }
394
+ }
395
+ }
396
+
155
397
  // Trigger on `!<command>` in the current token.
156
398
  const match = beforeCursor.match(/(?:^|[ \t])!([^\s!]*)$/);
157
399
  if (!match) {
@@ -161,13 +403,37 @@ export default function bangCommandAutocomplete(pi: ExtensionAPI) {
161
403
  const partial = match[1] ?? "";
162
404
  const ranked = rankCommands(commandIndex, partial);
163
405
 
406
+ const commandItems = ranked.map((entry) => ({
407
+ value: `!${entry.command}`,
408
+ label: `!${entry.command}`,
409
+ description:
410
+ entry.source === "history"
411
+ ? "shell history"
412
+ : entry.source === "runtime"
413
+ ? "current session"
414
+ : "common command",
415
+ }));
416
+
417
+ const commandWithFlagItems = ranked.flatMap((entry) => {
418
+ const knownFlags = Array.from(runtimeFlagsByCommand.get(entry.command) ?? []);
419
+ return knownFlags.slice(0, 3).map((flag) => ({
420
+ value: `!${entry.command} ${flag}`,
421
+ label: `!${entry.command} ${flag}`,
422
+ description: "learned command + flag",
423
+ }));
424
+ });
425
+
426
+ const fullLineItems = rankLineCandidates(Array.from(runtimeLearnedLines), partial)
427
+ .map((lineCandidate) => ({
428
+ value: `!${lineCandidate}`,
429
+ label: `!${lineCandidate}`,
430
+ description: "learned full line",
431
+ }))
432
+ .filter((item) => item.value.startsWith(`!${partial}`));
433
+
164
434
  return {
165
435
  prefix: `!${partial}`,
166
- items: ranked.map((entry) => ({
167
- value: `!${entry.command}`,
168
- label: `!${entry.command}`,
169
- description: entry.source === "history" ? "shell history" : "common command",
170
- })),
436
+ items: [...fullLineItems, ...commandItems, ...commandWithFlagItems].slice(0, 24),
171
437
  };
172
438
  },
173
439
 
@@ -181,7 +447,10 @@ export default function bangCommandAutocomplete(pi: ExtensionAPI) {
181
447
 
182
448
  // Allow Tab-forced autocomplete for bang commands (editor reserves auto-popups
183
449
  // for /, @, # contexts by default).
184
- if (beforeCursor.match(/(?:^|[ \t])![^\s!]*$/)) {
450
+ if (
451
+ beforeCursor.match(/(?:^|[ \t])![^\s!]*$/) ||
452
+ beforeCursor.match(/(?:^|[ \t])![^\s!]+\s+[^\s]*$/)
453
+ ) {
185
454
  return true;
186
455
  }
187
456
 
@@ -190,12 +459,25 @@ export default function bangCommandAutocomplete(pi: ExtensionAPI) {
190
459
  }));
191
460
  });
192
461
 
462
+ pi.on("user_bash", (event) => {
463
+ learnFromCommandLine(event.command);
464
+ });
465
+
466
+ // Compatibility with extensions that intercept user_bash and short-circuit
467
+ // subsequent handlers (e.g. fish-user-bash).
468
+ pi.events.on("fish-user-bash:executed", (payload: unknown) => {
469
+ if (!payload || typeof payload !== "object") return;
470
+ const command = (payload as { command?: unknown }).command;
471
+ if (typeof command !== "string") return;
472
+ learnFromCommandLine(command);
473
+ });
474
+
193
475
  pi.registerCommand("bang-refresh", {
194
476
  description: "Refresh !command autocomplete index",
195
477
  handler: async (_args, ctx) => {
196
478
  refreshIndex();
197
479
  ctx.ui.notify(
198
- `Bang autocomplete refreshed (${commandIndex.length} commands, history ${includeHistory ? "enabled" : "disabled"})`,
480
+ `Bang autocomplete refreshed (${commandIndex.length} commands, history ${includeHistory ? "enabled" : "disabled"}, runtime learned commands ${runtimeLearned.size}, learned lines ${runtimeLearnedLines.size}, learned flags ${Array.from(runtimeFlagsByCommand.values()).reduce((acc, set) => acc + set.size, 0)})`,
199
481
  "info",
200
482
  );
201
483
  },
@@ -205,7 +487,7 @@ export default function bangCommandAutocomplete(pi: ExtensionAPI) {
205
487
  description: "Show !command autocomplete configuration",
206
488
  handler: async (_args, ctx) => {
207
489
  ctx.ui.notify(
208
- `Bang autocomplete: ${commandIndex.length} commands · history ${includeHistory ? "enabled" : "disabled"} (${includeHistory ? "PI_BANG_AUTOCOMPLETE_INCLUDE_HISTORY=1" : "set PI_BANG_AUTOCOMPLETE_INCLUDE_HISTORY=1 to enable"})`,
490
+ `Bang autocomplete: ${commandIndex.length} commands · history ${includeHistory ? "enabled" : "disabled"} · runtime learned commands ${runtimeLearned.size} · learned lines ${runtimeLearnedLines.size} · learned flags ${Array.from(runtimeFlagsByCommand.values()).reduce((acc, set) => acc + set.size, 0)} (${includeHistory ? "PI_BANG_AUTOCOMPLETE_INCLUDE_HISTORY=1" : "set PI_BANG_AUTOCOMPLETE_INCLUDE_HISTORY=1 to enable"}) · store ${runtimeStorePath}`,
209
491
  "info",
210
492
  );
211
493
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-extension-bang-command-autocomplete",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Autocomplete for !<command> in Pi, with optional shell-history indexing.",
5
5
  "license": "MIT",
6
6
  "keywords": [