diff --git a/CHANGELOG.md b/CHANGELOG.md index 7227913..9252f5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Expand queued prompt templates and Agent Skills at delivery, with arguments, images, short aliases such as `/bro`, and full-batch restoration if expansion fails. - Add command rows: `/compact [instructions]` and `/reload` queue in FIFO position and execute only once the agent is idle, so rows behind them wait — e.g. a queued `continue` delivers after compaction completes. - Queue a mid-run `Enter` on `/reload` instead of surfacing Pi's built-in "wait until the agent finishes" warning; mid-run `Enter` on `/compact` keeps Pi's built-in behaviour. - Restore rows queued behind a `/reload` after the runtime swap. diff --git a/README.md b/README.md index b01b6b1..5871bdf 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,12 @@ The extension keeps Pi’s 2 delivery classes: The extension hands messages back to Pi’s native queues only when their delivery boundary arrives. They remain visible and editable before that point. Pi records delivered rows as normal user messages. +## Prompt templates and Agent Skills + +Queued `/do-less this code`, `/skill:bro` and `/bro` rows stay short and editable, then expand when delivered. `/bro` is shorthand for `/skill:bro` unless a built-in, prompt or extension already uses that name. Template arguments and images are preserved; unknown slash input remains ordinary text. + +Pi cannot invoke arbitrary commands through its public extension API. `/compact` and `/reload` are the supported built-ins. A queued extension command pauses delivery until you edit or remove it. + ## Command rows Rows whose text is exactly `/compact`, `/compact ` or `/reload` are command rows. They execute the Pi command instead of becoming an LLM message: @@ -124,9 +130,9 @@ npm run ci pi -e ./index.ts ``` -The automated suite covers both lanes, queue modes, delivery boundaries, stable edits, rollback, removal marks, lane toggles, command-row parsing and batch cuts, abort recovery, image preservation, failed handoffs, editor-frame extraction and editor composition. Check TUI changes in a real interactive Pi session as well. +The automated suite covers delivery, editing, command rows, resource expansion, recovery, images and editor composition. Check TUI changes in a real Pi session as well. -Tested with Pi 0.80.9. +Automated against Pi 0.80.9 and smoke-tested interactively with Pi 0.84.1. ## Security diff --git a/index.ts b/index.ts index fde1e18..dabcaaa 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ import { } from "@earendil-works/pi-coding-agent"; import { matchesKey, truncateToWidth, visibleWidth, type Component, type EditorComponent } from "@earendil-works/pi-tui"; import { extractInlineEditorLines } from "./editor-render.ts"; +import { expandQueuedInput } from "./queued-input.ts"; import { DeliveryQueue, parseQueuedCommand, @@ -252,6 +253,15 @@ export default function queueSteerExtension(pi: ExtensionAPI) { followUp: settingsManager?.getFollowUpMode() ?? "one-at-a-time", }); + const pauseAfterPreparationFailure = (ctx: ExtensionContext, lane: QueueLane, error: unknown): void => { + paused = true; + renderQueue(ctx); + ctx.ui.notify( + `Could not prepare queued ${laneLabel(lane)}; queue paused: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + }; + const laneIsHeld = (lane: QueueLane): boolean => { if (!editSession) return false; const mode = queueModes()[lane]; @@ -341,10 +351,19 @@ export default function queueSteerExtension(pi: ExtensionAPI) { items: QueuedMessage[], ): Promise => { if (items.length === 0) return false; + let prepared: QueuedMessage[]; + try { + const commands = pi.getCommands(); + prepared = items.map((item) => ({ ...item, text: expandQueuedInput(item.text, commands) })); + } catch (error) { + queue.prependMany(items); + pauseAfterPreparationFailure(ctx, lane, error); + return false; + } const pendingBefore = ctx.hasPendingMessages(); renderQueue(ctx); try { - for (const item of items) { + for (const item of prepared) { pi.sendUserMessage(userContent(item), { deliverAs: lane }); } // sendUserMessage is fire-and-forget. Keep the awaited boundary @@ -426,6 +445,33 @@ export default function queueSteerExtension(pi: ExtensionAPI) { return true; }; + const sendHeadMessage = (ctx: ExtensionContext, lane: QueueLane, deliverAs?: QueueLane): boolean => { + const head = queue.peek(lane); + if (!head) return false; + let prepared: QueuedMessage; + try { + prepared = { ...head, text: expandQueuedInput(head.text, pi.getCommands()) }; + } catch (error) { + pauseAfterPreparationFailure(ctx, lane, error); + return false; + } + queue.shift(lane); + paused = false; + renderQueue(ctx); + try { + pi.sendUserMessage(userContent(prepared), deliverAs ? { deliverAs } : undefined); + return true; + } catch (error) { + queue.prepend(head); + renderQueue(ctx); + ctx.ui.notify( + `Could not send queued ${laneLabel(lane)}: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + return false; + } + }; + const dispatchFromIdle = (ctx: ExtensionContext): boolean => { activeContext = ctx; if (commandRunning) { @@ -443,22 +489,7 @@ export default function queueSteerExtension(pi: ExtensionAPI) { } const head = queue.peek(lane); if (head && parseQueuedCommand(head.text)) return executeCommandRow(ctx, lane); - const next = queue.shift(lane); - if (!next) return false; - paused = false; - renderQueue(ctx); - try { - pi.sendUserMessage(userContent(next)); - return true; - } catch (error) { - queue.prepend(next); - renderQueue(ctx); - ctx.ui.notify( - `Could not send queued ${laneLabel(lane)}: ${error instanceof Error ? error.message : String(error)}`, - "error", - ); - return false; - } + return sendHeadMessage(ctx, lane); }; const sendFollowUpNow = (ctx: ExtensionContext): boolean => { @@ -472,21 +503,7 @@ export default function queueSteerExtension(pi: ExtensionAPI) { } return executeCommandRow(ctx, "followUp"); } - const next = queue.shift("followUp"); - if (!next) return false; - renderQueue(ctx); - try { - pi.sendUserMessage(userContent(next), ctx.isIdle() ? undefined : { deliverAs: "steer" }); - return true; - } catch (error) { - queue.prepend(next); - renderQueue(ctx); - ctx.ui.notify( - `Could not send queued follow-up: ${error instanceof Error ? error.message : String(error)}`, - "error", - ); - return false; - } + return sendHeadMessage(ctx, "followUp", ctx.isIdle() ? undefined : "steer"); }; const finishEditing = ( diff --git a/package.json b/package.json index bbc98a9..02b19bf 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "index.ts", "editor-render.ts", "queue-state.ts", + "queued-input.ts", "README.md", "LICENSE", "CHANGELOG.md", diff --git a/queued-input.ts b/queued-input.ts new file mode 100644 index 0000000..0e766ec --- /dev/null +++ b/queued-input.ts @@ -0,0 +1,86 @@ +import { readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { + parseFrontmatter, + stripFrontmatter, + type SlashCommandInfo, +} from "@earendil-works/pi-coding-agent"; + +// getCommands() omits built-ins, which still take precedence over skill aliases. +const PI_BUILTIN_COMMANDS = new Set([ + "settings", "model", "scoped-models", "export", "import", "share", "copy", "name", "session", + "changelog", "hotkeys", "fork", "clone", "tree", "trust", "login", "logout", "new", "compact", + "resume", "reload", "quit", +]); + +// Pi does not export its prompt argument parser or substitution helper. +function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: "\"" | "'" | undefined; + for (const character of argsString) { + if (inQuote) { + if (character === inQuote) inQuote = undefined; + else current += character; + } else if (character === "\"" || character === "'") { + inQuote = character; + } else if (/\s/.test(character)) { + if (current) { + args.push(current); + current = ""; + } + } else { + current += character; + } + } + if (current) args.push(current); + return args; +} + +function substituteArgs(content: string, args: readonly string[]): string { + const allArgs = args.join(" "); + return content.replace( + /\$\{(\d+):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, + (_match, defaultTarget, defaultValue, sliceStart, sliceLength, simple: string | undefined) => { + if (defaultTarget) { + return args[Number.parseInt(defaultTarget, 10) - 1] || defaultValue; + } + if (sliceStart) { + const start = Math.max(0, Number.parseInt(sliceStart, 10) - 1); + if (sliceLength) { + return args.slice(start, start + Number.parseInt(sliceLength, 10)).join(" "); + } + return args.slice(start).join(" "); + } + if (simple === "ARGUMENTS" || simple === "@") return allArgs; + return args[Number.parseInt(simple ?? "", 10) - 1] ?? ""; + }, + ); +} + +export function expandQueuedInput(text: string, commands: readonly SlashCommandInfo[]): string { + const invocation = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); + const name = invocation?.[1]; + if (!name || PI_BUILTIN_COMMANDS.has(name)) return text; + + const command = commands.find((candidate) => candidate.name === name) + ?? commands.find((candidate) => candidate.source === "skill" && candidate.name === `skill:${name}`); + if (!command) return text; + if (command.source === "extension") { + throw new Error(`/${name} is an extension command and cannot be run from the queue`); + } + + const source = readFileSync(command.sourceInfo.path, "utf8"); + const args = invocation[2] ?? ""; + if (command.source === "prompt") { + const { body } = parseFrontmatter(source); + return substituteArgs(body, parseCommandArgs(args)); + } + + const skillName = command.name.slice("skill:".length); + const baseDir = dirname(command.sourceInfo.path); + const body = stripFrontmatter(source).trim(); + const skillBlock = `\nReferences are relative to ${baseDir}.\n\n${body}\n`; + const skillArgs = args.trim(); + return skillArgs ? `${skillBlock}\n\n${skillArgs}` : skillBlock; +} diff --git a/test/queue-state.test.ts b/test/queue-state.test.ts index 51e4ca9..6adb468 100644 --- a/test/queue-state.test.ts +++ b/test/queue-state.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import type { SlashCommandInfo } from "@earendil-works/pi-coding-agent"; import { visibleWidth } from "@earendil-works/pi-tui"; import queueSteerExtension from "../index.ts"; import { DeliveryQueue, QueueEditSession, type QueueLane } from "../queue-state.ts"; @@ -156,7 +157,11 @@ class MockEditor { invalidate(): void {} } -function createHarness(options: { cwd?: string; projectTrusted?: boolean } = {}) { +function createHarness(options: { + cwd?: string; + projectTrusted?: boolean; + commands?: SlashCommandInfo[]; +} = {}) { type Handler = (event: any, context: any) => any; const handlers = new Map(); const sent: Array<{ content: unknown; options: any }> = []; @@ -218,6 +223,7 @@ function createHarness(options: { cwd?: string; projectTrusted?: boolean } = {}) sent.push({ content, options }); if (options) pending = true; }, + getCommands: () => options.commands ?? [], }; queueSteerExtension(pi as any); @@ -638,3 +644,82 @@ test("recomposes after another extension installs editor chrome on a later tick" harness.editor.handleInput("alt-up"); assert.equal(harness.editor.getText(), "original"); }); + +test("expands queued prompt templates and short Agent Skill commands at delivery", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-queue-resources-")); + const promptPath = join(dir, "do-less.md"); + const skillPath = join(dir, "SKILL.md"); + writeFileSync(promptPath, "---\ndescription: Do less\n---\nReview $1 and simplify it."); + writeFileSync(skillPath, "---\nname: bro\ndescription: Speak plainly\n---\nSpeak plainly."); + const sourceInfo = (path: string) => ({ + path, + source: "test", + scope: "temporary" as const, + origin: "top-level" as const, + }); + const harness = createHarness({ + commands: [ + { name: "do-less", source: "prompt", sourceInfo: sourceInfo(promptPath) }, + { name: "skill:bro", source: "skill", sourceInfo: sourceInfo(skillPath) }, + ], + }); + const image = { type: "image", source: { type: "base64", mediaType: "image/png", data: "AA==" } }; + try { + await harness.emit("session_start"); + await harness.emit("input", { + source: "interactive", + text: "/do-less this", + images: [image], + streamingBehavior: "followUp", + }); + await enqueue(harness, "steer", "/bro make this clearer"); + + await harness.emit("turn_end", { message: { role: "assistant", stopReason: "toolUse" } }); + assert.match(String(harness.sent[0]?.content), / { + const cwd = mkdtempSync(join(tmpdir(), "pi-queue-expansion-failure-")); + mkdirSync(join(cwd, ".pi")); + writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({ followUpMode: "all" })); + const missingPath = join(cwd, "missing.md"); + const harness = createHarness({ + cwd, + projectTrusted: true, + commands: [{ + name: "missing", + source: "prompt", + sourceInfo: { + path: missingPath, + source: "test", + scope: "temporary", + origin: "top-level", + }, + }], + }); + try { + await harness.emit("session_start"); + await enqueue(harness, "followUp", "sendable first"); + await enqueue(harness, "followUp", "/missing"); + + await harness.emit("agent_end"); + assert.equal(harness.sent.length, 0); + assert.match(renderWidget(harness), /sendable first/); + assert.match(renderWidget(harness), /\/missing/); + assert.match(renderWidget(harness), /paused/); + assert.match(harness.notifications.at(-1)?.message ?? "", /Could not prepare queued follow-up; queue paused/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/test/queued-input.test.ts b/test/queued-input.test.ts new file mode 100644 index 0000000..cc70dee --- /dev/null +++ b/test/queued-input.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { SlashCommandInfo } from "@earendil-works/pi-coding-agent"; +import { expandQueuedInput } from "../queued-input.ts"; + +function command(name: string, source: SlashCommandInfo["source"], path: string): SlashCommandInfo { + return { + name, + source, + sourceInfo: { path, source: "test", scope: "temporary", origin: "top-level" }, + }; +} + +test("expands prompt templates with Pi-compatible arguments", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-queue-prompt-")); + const path = join(dir, "review.md"); + writeFileSync(path, [ + "---", + "description: Test prompt", + "---", + "$1|$2|$@|${3:-fallback}|${@:2:1}", + ].join("\n")); + try { + const review = command("review", "prompt", path); + const expected = "first|two words|first two words|fallback|two words"; + assert.equal(expandQueuedInput('/review first "two words"', [review]), expected); + assert.equal(expandQueuedInput('/review first\n"two words"', [review]), expected); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("expands native and short Agent Skill invocations", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-queue-skill-")); + const path = join(dir, "SKILL.md"); + writeFileSync(path, "---\nname: bro\ndescription: Speak plainly\n---\nSpeak plainly."); + const skill = command("skill:bro", "skill", path); + const block = `\nReferences are relative to ${dir}.\n\nSpeak plainly.\n`; + try { + assert.equal(expandQueuedInput("/skill:bro", [skill]), block); + assert.equal(expandQueuedInput("/bro simplify this", [skill]), `${block}\n\nsimplify this`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("prompt templates take precedence over short skill aliases", () => { + const dir = mkdtempSync(join(tmpdir(), "pi-queue-collision-")); + const promptPath = join(dir, "bro.md"); + const skillPath = join(dir, "SKILL.md"); + writeFileSync(promptPath, "Prompt wins: $@"); + writeFileSync(skillPath, "---\nname: bro\ndescription: Skill\n---\nSkill body"); + try { + assert.equal(expandQueuedInput("/bro now", [ + command("bro", "prompt", promptPath), + command("skill:bro", "skill", skillPath), + ]), "Prompt wins: now"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("does not let resources or short skill aliases shadow Pi built-ins", () => { + const commands = [ + command("model", "prompt", "/missing/model.md"), + command("skill:model", "skill", "/missing/SKILL.md"), + ]; + assert.equal(expandQueuedInput("/model", commands), "/model"); +}); + +test("leaves messages and unknown slash input unchanged", () => { + assert.equal(expandQueuedInput("continue", []), "continue"); + assert.equal(expandQueuedInput("/unknown with args", []), "/unknown with args"); +}); + +test("rejects discovered extension commands", () => { + const extension = command("deploy", "extension", "/extension.ts"); + assert.throws( + () => expandQueuedInput("/deploy prod", [extension]), + /extension command.*cannot be run from the queue/, + ); +});