Move skill command handling to AgentSession, update docs

- Skill commands (/skill:name) now expanded in AgentSession instead of
  interactive mode, enabling them in RPC and print modes
- Input event can now intercept /skill:name before expansion
- Updated extensions.md with clearer input event docs and processing order
- Updated rpc.md: hook -> extension terminology, added skill expansion mentions
- Added PR attribution to changelog entries for #761
This commit is contained in:
Mario Zechner 2026-01-16 03:01:08 +01:00
parent 3e5d91f287
commit b4a05cbcab
7 changed files with 211 additions and 420 deletions

View file

@ -1,27 +1,13 @@
/** /**
* Git Diff Extension * Git Diff Extension - Ctrl+F or /diff to view git changes
*
* Shows modified/added/removed files in the git worktree and displays their diffs.
*
* Usage:
* - Press Ctrl+F or type /diff to open the file picker
* - Select a file to view its diff
* - Use Up/Down or Left/Right to scroll the diff
* - Press Escape to close
*/ */
import { appendFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
import { DynamicBorder } from "@mariozechner/pi-coding-agent"; import { DynamicBorder, generateDiffString, renderDiff } from "@mariozechner/pi-coding-agent";
import { import { Container, Key, matchesKey, type SelectItem, SelectList, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
Container,
Key,
matchesKey,
type SelectItem,
SelectList,
Text,
truncateToWidth,
visibleWidth,
} from "@mariozechner/pi-tui";
interface GitFile { interface GitFile {
status: "M" | "A" | "D" | "R" | "C" | "U" | "?"; status: "M" | "A" | "D" | "R" | "C" | "U" | "?";
@ -29,383 +15,148 @@ interface GitFile {
staged: boolean; staged: boolean;
} }
type FileStatus = GitFile["status"]; const STATUS_LABELS: Record<GitFile["status"], string> = {
M: "modified", A: "added", D: "deleted", R: "renamed", C: "copied", U: "unmerged", "?": "untracked",
const STATUS_LABELS: Record<FileStatus, string> = {
M: "modified",
A: "added",
D: "deleted",
R: "renamed",
C: "copied",
U: "unmerged",
"?": "untracked",
}; };
const dbg = (msg: string) => appendFileSync("/tmp/git-diff-debug.log", msg + "\n");
const STATUS_COLORS: Record<FileStatus, "warning" | "success" | "error" | "muted"> = {
M: "warning",
A: "success",
D: "error",
R: "warning",
C: "warning",
U: "error",
"?": "muted",
};
/**
* Parse git status --porcelain output into file list
*/
function parseGitStatus(output: string): GitFile[] { function parseGitStatus(output: string): GitFile[] {
dbg(`=== parseGitStatus ===`);
dbg(`Raw output: ${JSON.stringify(output)}`);
const files: GitFile[] = []; const files: GitFile[] = [];
const lines = output.trim().split("\n").filter(Boolean); for (const line of output.trim().split("\n").filter(Boolean)) {
dbg(`Line: ${JSON.stringify(line)} (len=${line.length})`);
for (const line of lines) { if (line.length < 4) { dbg(" Skipped: too short"); continue; }
if (line.length < 3) continue; const idx = line[0], wt = line[1], path = line.slice(3);
dbg(` idx='${idx}' wt='${wt}' path='${path}'`);
const indexStatus = line[0]; if (idx !== " " && idx !== "?") { dbg(` -> staged`); files.push({ status: idx as GitFile["status"], path, staged: true }); }
const workTreeStatus = line[1]; if (wt !== " " && wt !== "?" && (idx === " " || idx !== wt)) { dbg(` -> unstaged`); files.push({ status: wt as GitFile["status"], path, staged: false }); }
const path = line.slice(3); if (idx === "?" && wt === "?") { dbg(` -> untracked`); files.push({ status: "?", path, staged: false }); }
// Staged changes (index has status, worktree is space or has same status)
if (indexStatus !== " " && indexStatus !== "?") {
files.push({
status: indexStatus as FileStatus,
path,
staged: true,
});
}
// Unstaged changes (worktree has status different from index)
if (workTreeStatus !== " " && workTreeStatus !== "?") {
// Don't duplicate if same status in both
if (indexStatus === " " || indexStatus !== workTreeStatus) {
files.push({
status: workTreeStatus as FileStatus,
path,
staged: false,
});
}
}
// Untracked files
if (indexStatus === "?" && workTreeStatus === "?") {
files.push({
status: "?",
path,
staged: false,
});
}
} }
dbg(`Files: ${JSON.stringify(files)}`);
return files; return files;
} }
/**
* Render a unified diff with colors
*/
function renderUnifiedDiff(diffText: string, theme: Theme): string[] {
const lines = diffText.split("\n");
const result: string[] = [];
for (const line of lines) {
if (line.startsWith("+++") || line.startsWith("---")) {
// File headers
result.push(theme.fg("muted", line));
} else if (line.startsWith("@@")) {
// Hunk headers
result.push(theme.fg("accent", line));
} else if (line.startsWith("+")) {
result.push(theme.fg("toolDiffAdded", line));
} else if (line.startsWith("-")) {
result.push(theme.fg("toolDiffRemoved", line));
} else if (line.startsWith("diff --git")) {
result.push(theme.fg("dim", line));
} else if (line.startsWith("index ") || line.startsWith("new file") || line.startsWith("deleted file")) {
result.push(theme.fg("dim", line));
} else {
result.push(theme.fg("toolDiffContext", line));
}
}
return result;
}
/**
* Scrollable diff viewer component
*/
class DiffViewer { class DiffViewer {
private lines: string[] = []; private lines: string[];
private scrollOffset = 0; private offset = 0;
private viewportHeight = 20; private height = 20;
private filePath: string;
onClose?: () => void; onClose?: () => void;
constructor( constructor(private theme: Theme, private path: string, diff: string) {
private theme: Theme, this.lines = renderDiff(diff).split("\n");
filePath: string,
diffText: string,
) {
this.filePath = filePath;
this.lines = renderUnifiedDiff(diffText, theme);
} }
handleInput(data: string): void { handleInput(data: string): void {
const maxScroll = Math.max(0, this.lines.length - this.viewportHeight + 4); const max = Math.max(0, this.lines.length - this.height + 4);
if (matchesKey(data, Key.escape)) this.onClose?.();
if (matchesKey(data, Key.escape)) { else if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) this.offset = Math.max(0, this.offset - 1);
this.onClose?.(); else if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) this.offset = Math.min(max, this.offset + 1);
} else if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) { else if (matchesKey(data, Key.pageUp)) this.offset = Math.max(0, this.offset - this.height);
this.scrollOffset = Math.max(0, this.scrollOffset - 1); else if (matchesKey(data, Key.pageDown)) this.offset = Math.min(max, this.offset + this.height);
} else if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) { else if (matchesKey(data, "g")) this.offset = 0;
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1); else if (matchesKey(data, "shift+g")) this.offset = max;
} else if (matchesKey(data, Key.pageUp) || matchesKey(data, "shift+up")) {
this.scrollOffset = Math.max(0, this.scrollOffset - this.viewportHeight);
} else if (matchesKey(data, Key.pageDown) || matchesKey(data, "shift+down")) {
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + this.viewportHeight);
} else if (matchesKey(data, Key.home) || matchesKey(data, "g")) {
this.scrollOffset = 0;
} else if (matchesKey(data, Key.end) || matchesKey(data, "shift+g")) {
this.scrollOffset = maxScroll;
}
} }
render(width: number): string[] { render(width: number): string[] {
const th = this.theme; const th = this.theme, w = width - 2, out: string[] = [];
const result: string[] = []; const visible = this.lines.slice(this.offset, this.offset + this.height);
const innerWidth = width - 2; const row = (c: string) => th.fg("border", "│") + c + " ".repeat(Math.max(0, w - visibleWidth(c))) + th.fg("border", "│");
// Calculate viewport (leave room for header, footer, borders) out.push(th.fg("border", "╭" + "─".repeat(w) + "╮"));
this.viewportHeight = Math.max(5, 20); out.push(row(` ${th.fg("accent", th.bold(truncateToWidth(this.path, w - 2)))}`));
out.push(row(""));
const maxScroll = Math.max(0, this.lines.length - this.viewportHeight + 4); for (const l of visible) out.push(row(" " + truncateToWidth(l, w - 2)));
const visibleLines = this.lines.slice(this.scrollOffset, this.scrollOffset + this.viewportHeight); for (let i = visible.length; i < this.height; i++) out.push(row(""));
const info = this.lines.length > this.height ? `${this.offset + 1}-${Math.min(this.offset + this.height, this.lines.length)} of ${this.lines.length}` : `${this.lines.length} lines`;
// Helper to create bordered line out.push(row(""));
const row = (content: string) => { out.push(row(` ${th.fg("dim", info)}`));
const vis = visibleWidth(content); out.push(row(` ${th.fg("dim", "↑↓/←→ scroll • PgUp/PgDn • g/G • Esc")}`));
const padding = Math.max(0, innerWidth - vis); out.push(th.fg("border", "╰" + "─".repeat(w) + "╯"));
return th.fg("border", "│") + content + " ".repeat(padding) + th.fg("border", "│"); return out;
};
// Top border
result.push(th.fg("border", "╭" + "─".repeat(innerWidth) + "╮"));
// Header with file path
const header = ` ${th.fg("accent", th.bold(truncateToWidth(this.filePath, innerWidth - 2)))}`;
result.push(row(header));
result.push(row(""));
// Diff content
for (const line of visibleLines) {
result.push(row(" " + truncateToWidth(line, innerWidth - 2)));
}
// Pad if fewer lines than viewport
const paddingNeeded = this.viewportHeight - visibleLines.length;
for (let i = 0; i < paddingNeeded; i++) {
result.push(row(""));
}
// Scroll indicator
const scrollInfo =
this.lines.length > this.viewportHeight
? `${this.scrollOffset + 1}-${Math.min(this.scrollOffset + this.viewportHeight, this.lines.length)} of ${this.lines.length}`
: `${this.lines.length} lines`;
result.push(row(""));
result.push(row(` ${th.fg("dim", scrollInfo)}`));
// Footer with help
result.push(row(` ${th.fg("dim", "↑↓/←→ scroll • PgUp/PgDn page • g/G start/end • Esc close")}`));
// Bottom border
result.push(th.fg("border", "╰" + "─".repeat(innerWidth) + "╯"));
return result;
} }
invalidate(): void {} invalidate(): void {}
} }
/** async function getDiff(file: GitFile, ctx: ExtensionContext, pi: ExtensionAPI): Promise<{ diff: string } | { error: string }> {
* Show the diff for a file const abs = join(ctx.cwd, file.path);
*/
async function showFileDiff(file: GitFile, ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
// Get the diff
let diffArgs: string[];
if (file.status === "?") { if (file.status === "?") {
// Untracked file: show full content as "added" try { return { diff: generateDiffString("", await readFile(abs, "utf-8")).diff }; }
const result = await pi.exec("cat", [file.path], { cwd: ctx.cwd }); catch (e) { return { error: `Read failed: ${e}` }; }
if (result.code !== 0) {
ctx.ui.notify(`Failed to read ${file.path}`, "error");
return;
}
// Create a fake diff showing all lines as added
const lines = result.stdout.split("\n");
const diffText = [
`diff --git a/${file.path} b/${file.path}`,
"new file",
`--- /dev/null`,
`+++ b/${file.path}`,
`@@ -0,0 +1,${lines.length} @@`,
...lines.map((l) => "+" + l),
].join("\n");
await showDiffViewer(file.path, diffText, ctx);
return;
} }
if (file.status === "D") {
const r = await pi.exec("git", ["show", `HEAD:${file.path}`], { cwd: ctx.cwd });
return r.code === 0 ? { diff: generateDiffString(r.stdout, "").diff } : { error: `Git show failed: ${r.stderr}` };
}
let old = "", cur = "";
if (file.staged) { if (file.staged) {
diffArgs = ["diff", "--cached", "--", file.path]; const h = await pi.exec("git", ["show", `HEAD:${file.path}`], { cwd: ctx.cwd });
old = h.code === 0 ? h.stdout : "";
const i = await pi.exec("git", ["show", `:${file.path}`], { cwd: ctx.cwd });
if (i.code !== 0) return { error: `Staged content failed for '${file.path}': ${i.stderr}` };
cur = i.stdout;
} else { } else {
diffArgs = ["diff", "--", file.path]; const i = await pi.exec("git", ["show", `:${file.path}`], { cwd: ctx.cwd });
old = i.code === 0 ? i.stdout : (await pi.exec("git", ["show", `HEAD:${file.path}`], { cwd: ctx.cwd })).stdout || "";
try { cur = await readFile(abs, "utf-8"); }
catch (e) { return { error: `Read failed: ${e}` }; }
} }
return { diff: generateDiffString(old, cur).diff };
const result = await pi.exec("git", diffArgs, { cwd: ctx.cwd });
if (result.code !== 0) {
ctx.ui.notify(`Failed to get diff for ${file.path}: ${result.stderr}`, "error");
return;
}
if (!result.stdout.trim()) {
ctx.ui.notify(`No diff available for ${file.path}`, "info");
return;
}
await showDiffViewer(file.path, result.stdout, ctx);
} }
async function showDiffViewer(filePath: string, diffText: string, ctx: ExtensionContext): Promise<void> { async function showViewer(path: string, diff: string, ctx: ExtensionContext): Promise<void> {
await ctx.ui.custom<void>((tui, theme, _kb, done) => { await ctx.ui.custom<void>((tui, theme, _, done) => {
const viewer = new DiffViewer(theme, filePath, diffText); const v = new DiffViewer(theme, path, diff);
viewer.onClose = () => done(); v.onClose = () => done();
return { render: (w) => v.render(w), invalidate: () => v.invalidate(), handleInput: (d) => { v.handleInput(d); tui.requestRender(); } };
return {
render: (w) => viewer.render(w),
invalidate: () => viewer.invalidate(),
handleInput: (data) => {
viewer.handleInput(data);
tui.requestRender();
},
};
}, { overlay: true }); }, { overlay: true });
} }
/** async function showPicker(files: GitFile[], ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
* Show the file picker overlay const items: SelectItem[] = files.map((f, i) => ({ value: i, label: f.path, description: `${STATUS_LABELS[f.status]}${f.staged ? " (staged)" : ""}` }));
*/
async function showFilePicker(files: GitFile[], ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
// Group files by status
const items: SelectItem[] = files.map((file) => {
const statusLabel = STATUS_LABELS[file.status];
const stagedLabel = file.staged ? " (staged)" : "";
return {
value: file,
label: file.path,
description: `${statusLabel}${stagedLabel}`,
};
});
const result = await ctx.ui.custom<GitFile | null>((tui, theme, _kb, done) => { const idx = await ctx.ui.custom<number | null>((tui, theme, _, done) => {
const container = new Container(); const c = new Container();
c.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
// Top border c.addChild(new Text(`${theme.fg("accent", theme.bold("Git Changes"))} ${theme.fg("dim", `(${files.length})`)}`, 1, 0));
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); const list = new SelectList(items, Math.min(items.length, 15), {
selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t),
// Title description: (t) => theme.fg(t.startsWith("modified") ? "warning" : t.startsWith("added") ? "success" : t.startsWith("deleted") ? "error" : "muted", t),
const title = new Text(theme.fg("accent", theme.bold("Git Changes")) + theme.fg("dim", ` (${files.length} files)`), 1, 0); scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t),
container.addChild(title);
// File list
const selectList = new SelectList(items, Math.min(items.length, 15), {
selectedPrefix: (t) => theme.fg("accent", t),
selectedText: (t) => theme.fg("accent", t),
description: (t) => {
// Color description based on status keyword
if (t.startsWith("modified")) return theme.fg("warning", t);
if (t.startsWith("added")) return theme.fg("success", t);
if (t.startsWith("deleted")) return theme.fg("error", t);
if (t.startsWith("untracked")) return theme.fg("muted", t);
if (t.startsWith("renamed") || t.startsWith("copied")) return theme.fg("warning", t);
if (t.startsWith("unmerged")) return theme.fg("error", t);
return theme.fg("muted", t);
},
scrollInfo: (t) => theme.fg("dim", t),
noMatch: (t) => theme.fg("warning", t),
}); });
list.onSelect = (item) => done(item.value as number);
selectList.onSelect = (item) => done(item.value as GitFile); list.onCancel = () => done(null);
selectList.onCancel = () => done(null); c.addChild(list);
container.addChild(selectList); c.addChild(new Text(theme.fg("dim", "↑↓ nav • enter select • type filter • esc close"), 1, 0));
c.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
// Help text return { render: (w) => c.render(w), invalidate: () => c.invalidate(), handleInput: (d) => { list.handleInput(d); tui.requestRender(); } };
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • type to filter • esc close"), 1, 0));
// Bottom border
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
return {
render: (w) => container.render(w),
invalidate: () => container.invalidate(),
handleInput: (data) => {
selectList.handleInput(data);
tui.requestRender();
},
};
}, { overlay: true }); }, { overlay: true });
if (result) { if (idx !== null) {
await showFileDiff(result, ctx, pi); const file = files[idx];
// After viewing diff, show file picker again const r = await getDiff(file, ctx, pi);
await showFilePicker(files, ctx, pi); if ("error" in r) ctx.ui.notify(r.error, "error");
else if (!r.diff.trim()) ctx.ui.notify(`No changes in ${file.path}`, "info");
else await showViewer(file.path, r.diff, ctx);
await showPicker(files, ctx, pi);
} }
} }
/** async function run(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
* Main handler for showing the diff overlay if ((await pi.exec("git", ["rev-parse", "--git-dir"], { cwd: ctx.cwd })).code !== 0) { ctx.ui.notify("Not a git repo", "error"); return; }
*/ const s = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
async function showDiffOverlay(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> { if (s.code !== 0) { ctx.ui.notify(`Git failed: ${s.stderr}`, "error"); return; }
// Check if we're in a git repo if (!s.stdout.trim()) { ctx.ui.notify("No changes", "info"); return; }
const gitCheck = await pi.exec("git", ["rev-parse", "--git-dir"], { cwd: ctx.cwd }); const files = parseGitStatus(s.stdout);
if (gitCheck.code !== 0) { if (!files.length) { ctx.ui.notify("No changes", "info"); return; }
ctx.ui.notify("Not in a git repository", "error"); await showPicker(files, ctx, pi);
return;
}
// Get changed files
const statusResult = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
if (statusResult.code !== 0) {
ctx.ui.notify(`Git status failed: ${statusResult.stderr}`, "error");
return;
}
if (!statusResult.stdout.trim()) {
ctx.ui.notify("No changes in working tree", "info");
return;
}
const files = parseGitStatus(statusResult.stdout);
if (files.length === 0) {
ctx.ui.notify("No changes in working tree", "info");
return;
}
await showFilePicker(files, ctx, pi);
} }
export default function gitDiffExtension(pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
// Register Ctrl+F shortcut pi.registerShortcut(Key.ctrl("f"), { description: "Git diff overlay", handler: (ctx) => run(ctx, pi) });
pi.registerShortcut(Key.ctrl("f"), { pi.registerCommand("diff", { description: "Show git changes", handler: (_, ctx) => run(ctx, pi) });
description: "Show git diff overlay",
handler: async (ctx) => {
await showDiffOverlay(ctx, pi);
},
});
// Register /diff command
pi.registerCommand("diff", {
description: "Show modified files and their diffs",
handler: async (_args, ctx) => {
await showDiffOverlay(ctx, pi);
},
});
} }

View file

@ -4,8 +4,12 @@
### Added ### Added
- New `input` event in extension system for intercepting, transforming, or handling user input before the agent processes it. Supports three result types: `continue` (pass through), `transform` (modify text/images), `handled` (respond without LLM). Handlers chain transforms and short-circuit on handled. - New `input` event in extension system for intercepting, transforming, or handling user input before the agent processes it. Supports three result types: `continue` (pass through), `transform` (modify text/images), `handled` (respond without LLM). Handlers chain transforms and short-circuit on handled. ([#761](https://github.com/badlogic/pi-mono/pull/761) by [@nicobailon](https://github.com/nicobailon))
- Extension example: `input-transform.ts` demonstrating input interception patterns (quick mode, instant commands, source routing) - Extension example: `input-transform.ts` demonstrating input interception patterns (quick mode, instant commands, source routing) ([#761](https://github.com/badlogic/pi-mono/pull/761) by [@nicobailon](https://github.com/nicobailon))
### Changed
- Skill commands (`/skill:name`) are now expanded in AgentSession instead of interactive mode. This enables skill commands in RPC and print modes, and allows the `input` event to intercept `/skill:name` before expansion.
### Fixed ### Fixed

View file

@ -256,7 +256,9 @@ pi starts
user sends prompt ─────────────────────────────────────────┐ user sends prompt ─────────────────────────────────────────┐
│ │ │ │
├─► input (can transform or handle completely) │ ├─► (extension commands checked first, bypass if found) │
├─► input (can intercept, transform, or handle) │
├─► (skill/template expansion if not handled) │
├─► before_agent_start (can inject message, modify system prompt) ├─► before_agent_start (can inject message, modify system prompt)
├─► agent_start │ ├─► agent_start │
│ │ │ │
@ -579,25 +581,49 @@ pi.on("user_bash", (event, ctx) => {
#### input #### input
Fired when user input is received, before agent processing. Can transform or handle completely. Fired when user input is received, after extension commands are checked but before skill and template expansion. The event sees the raw input text, so `/skill:foo` and `/template` are not yet expanded.
**Processing order:**
1. Extension commands (`/cmd`) checked first - if found, handler runs and input event is skipped
2. `input` event fires - can intercept, transform, or handle
3. If not handled: skill commands (`/skill:name`) expanded to skill content
4. If not handled: prompt templates (`/template`) expanded to template content
5. Agent processing begins (`before_agent_start`, etc.)
```typescript ```typescript
pi.on("input", async (event, ctx) => { pi.on("input", async (event, ctx) => {
// event.text, event.images, event.source ("interactive" | "rpc" | "extension") // event.text - raw input (before skill/template expansion)
// event.images - attached images, if any
// event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage)
// Transform: rewrite input before expansion
if (event.text.startsWith("?quick ")) if (event.text.startsWith("?quick "))
return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` }; return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` };
// Handle: respond without LLM (extension shows its own feedback)
if (event.text === "ping") { if (event.text === "ping") {
ctx.ui.notify("pong", "info"); // Extension handles its own feedback ctx.ui.notify("pong", "info");
return { action: "handled" }; // Skip LLM return { action: "handled" };
} }
return { action: "continue" }; // Default: pass through // Route by source: skip processing for extension-injected messages
if (event.source === "extension") return { action: "continue" };
// Intercept skill commands before expansion
if (event.text.startsWith("/skill:")) {
// Could transform, block, or let pass through
}
return { action: "continue" }; // Default: pass through to expansion
}); });
``` ```
**Results:** `continue` (pass through), `transform` (modify text/images), `handled` (skip LLM). Transforms chain; first "handled" wins. See [input-transform.ts](../examples/extensions/input-transform.ts). **Results:**
- `continue` - pass through unchanged (default if handler returns nothing)
- `transform` - modify text/images, then continue to expansion
- `handled` - skip agent entirely (first handler to return this wins)
Transforms chain across handlers. See [input-transform.ts](../examples/extensions/input-transform.ts).
## ExtensionContext ## ExtensionContext

View file

@ -52,9 +52,9 @@ With images:
If the agent is streaming and no `streamingBehavior` is specified, the command returns an error. If the agent is streaming and no `streamingBehavior` is specified, the command returns an error.
**Extension commands**: If the message is a hook command (e.g., `/mycommand`), it executes immediately even during streaming. Extension commands manage their own LLM interaction via `pi.sendMessage()`. **Extension commands**: If the message is an extension command (e.g., `/mycommand`), it executes immediately even during streaming. Extension commands manage their own LLM interaction via `pi.sendMessage()`.
**Prompt templates**: File-based prompt templates (from `.md` files) are expanded before sending/queueing. **Input expansion**: Skill commands (`/skill:name`) and prompt templates (`/template`) are expanded before sending/queueing.
Response: Response:
```json ```json
@ -65,7 +65,7 @@ The `images` field is optional. Each image uses `ImageContent` format with base6
#### steer #### steer
Queue a steering message to interrupt the agent mid-run. Delivered after current tool execution, remaining tools are skipped. File-based prompt templates are expanded. Extension commands are not allowed (use `prompt` instead). Queue a steering message to interrupt the agent mid-run. Delivered after current tool execution, remaining tools are skipped. Skill commands and prompt templates are expanded. Extension commands are not allowed (use `prompt` instead).
```json ```json
{"type": "steer", "message": "Stop and do this instead"} {"type": "steer", "message": "Stop and do this instead"}
@ -80,7 +80,7 @@ See [set_steering_mode](#set_steering_mode) for controlling how steering message
#### follow_up #### follow_up
Queue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. File-based prompt templates are expanded. Extension commands are not allowed (use `prompt` instead). Queue a follow-up message to be processed after the agent finishes. Delivered only when agent has no more tool calls or steering messages. Skill commands and prompt templates are expanded. Extension commands are not allowed (use `prompt` instead).
```json ```json
{"type": "follow_up", "message": "After you're done, also do this"} {"type": "follow_up", "message": "After you're done, also do this"}
@ -108,7 +108,7 @@ Response:
#### new_session #### new_session
Start a fresh session. Can be cancelled by a `session_before_switch` hook. Start a fresh session. Can be cancelled by a `session_before_switch` extension event handler.
```json ```json
{"type": "new_session"} {"type": "new_session"}
@ -124,7 +124,7 @@ Response:
{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": false}} {"type": "response", "command": "new_session", "success": true, "data": {"cancelled": false}}
``` ```
If a hook cancelled: If an extension cancelled:
```json ```json
{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": true}} {"type": "response", "command": "new_session", "success": true, "data": {"cancelled": true}}
``` ```
@ -525,7 +525,7 @@ Response:
#### switch_session #### switch_session
Load a different session file. Can be cancelled by a `before_switch` hook. Load a different session file. Can be cancelled by a `session_before_switch` extension event handler.
```json ```json
{"type": "switch_session", "sessionPath": "/path/to/session.jsonl"} {"type": "switch_session", "sessionPath": "/path/to/session.jsonl"}
@ -536,14 +536,14 @@ Response:
{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": false}} {"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": false}}
``` ```
If a hook cancelled the switch: If an extension cancelled the switch:
```json ```json
{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}} {"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}}
``` ```
#### fork #### fork
Create a new fork from a previous user message. Can be cancelled by a `before_fork` hook. Returns the text of the message being forked from. Create a new fork from a previous user message. Can be cancelled by a `session_before_fork` extension event handler. Returns the text of the message being forked from.
```json ```json
{"type": "fork", "entryId": "abc123"} {"type": "fork", "entryId": "abc123"}
@ -559,7 +559,7 @@ Response:
} }
``` ```
If a hook cancelled the fork: If an extension cancelled the fork:
```json ```json
{ {
"type": "response", "type": "response",
@ -634,7 +634,7 @@ Events are streamed to stdout as JSON lines during agent operation. Events do NO
| `auto_compaction_end` | Auto-compaction completes | | `auto_compaction_end` | Auto-compaction completes |
| `auto_retry_start` | Auto-retry begins (after transient error) | | `auto_retry_start` | Auto-retry begins (after transient error) |
| `auto_retry_end` | Auto-retry completes (success or final failure) | | `auto_retry_end` | Auto-retry completes (success or final failure) |
| `hook_error` | Hook threw an error | | `extension_error` | Extension threw an error |
### agent_start ### agent_start
@ -827,14 +827,14 @@ On final failure (max retries exceeded):
} }
``` ```
### hook_error ### extension_error
Emitted when a hook throws an error. Emitted when an extension throws an error.
```json ```json
{ {
"type": "hook_error", "type": "extension_error",
"hookPath": "/path/to/hook.ts", "extensionPath": "/path/to/extension.ts",
"event": "tool_call", "event": "tool_call",
"error": "Error message..." "error": "Error message..."
} }

View file

@ -13,6 +13,7 @@
* Modes use this class and add their own I/O layer on top. * Modes use this class and add their own I/O layer on top.
*/ */
import { readFileSync } from "node:fs";
import type { import type {
Agent, Agent,
AgentEvent, AgentEvent,
@ -25,6 +26,7 @@ import type { AssistantMessage, ImageContent, Message, Model, TextContent } from
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
import { getAuthPath } from "../config.js"; import { getAuthPath } from "../config.js";
import { theme } from "../modes/interactive/theme/theme.js"; import { theme } from "../modes/interactive/theme/theme.js";
import { stripFrontmatter } from "../utils/frontmatter.js";
import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor.js"; import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor.js";
import { import {
type CompactionResult, type CompactionResult,
@ -569,7 +571,7 @@ export class AgentSession {
} }
} }
// Emit input event for extension interception (before template expansion) // Emit input event for extension interception (before skill/template expansion)
let currentText = text; let currentText = text;
let currentImages = options?.images; let currentImages = options?.images;
if (this._extensionRunner?.hasHandlers("input")) { if (this._extensionRunner?.hasHandlers("input")) {
@ -587,10 +589,12 @@ export class AgentSession {
} }
} }
// Expand file-based prompt templates if requested // Expand skill commands (/skill:name args) and prompt templates (/template args)
const expandedText = expandPromptTemplates let expandedText = currentText;
? expandPromptTemplate(currentText, [...this._promptTemplates]) if (expandPromptTemplates) {
: currentText; expandedText = this._expandSkillCommand(expandedText);
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
}
// If streaming, queue via steer() or followUp() based on option // If streaming, queue via steer() or followUp() based on option
if (this.isStreaming) { if (this.isStreaming) {
@ -718,10 +722,42 @@ export class AgentSession {
} }
} }
/**
* Expand skill commands (/skill:name args) to their full content.
* Returns the expanded text, or the original text if not a skill command or skill not found.
* Emits errors via extension runner if file read fails.
*/
private _expandSkillCommand(text: string): string {
if (!text.startsWith("/skill:")) return text;
const spaceIndex = text.indexOf(" ");
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
const skill = this._skills.find((s) => s.name === skillName);
if (!skill) return text; // Unknown skill, pass through
try {
const content = readFileSync(skill.filePath, "utf-8");
const body = stripFrontmatter(content).trim();
const header = `Skill location: ${skill.filePath}\nReferences are relative to ${skill.baseDir}.`;
const skillMessage = `${header}\n\n${body}`;
return args ? `${skillMessage}\n\n---\n\nUser: ${args}` : skillMessage;
} catch (err) {
// Emit error like extension commands do
this._extensionRunner?.emitError({
extensionPath: skill.filePath,
event: "skill_expansion",
error: err instanceof Error ? err.message : String(err),
});
return text; // Return original on error
}
}
/** /**
* Queue a steering message to interrupt the agent mid-run. * Queue a steering message to interrupt the agent mid-run.
* Delivered after current tool execution, skips remaining tools. * Delivered after current tool execution, skips remaining tools.
* Expands file-based prompt templates. Errors on extension commands. * Expands skill commands and prompt templates. Errors on extension commands.
* @throws Error if text is an extension command * @throws Error if text is an extension command
*/ */
async steer(text: string): Promise<void> { async steer(text: string): Promise<void> {
@ -730,8 +766,9 @@ export class AgentSession {
this._throwIfExtensionCommand(text); this._throwIfExtensionCommand(text);
} }
// Expand file-based prompt templates // Expand skill commands and prompt templates
const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); let expandedText = this._expandSkillCommand(text);
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
await this._queueSteer(expandedText); await this._queueSteer(expandedText);
} }
@ -739,7 +776,7 @@ export class AgentSession {
/** /**
* Queue a follow-up message to be processed after the agent finishes. * Queue a follow-up message to be processed after the agent finishes.
* Delivered only when agent has no more tool calls or steering messages. * Delivered only when agent has no more tool calls or steering messages.
* Expands file-based prompt templates. Errors on extension commands. * Expands skill commands and prompt templates. Errors on extension commands.
* @throws Error if text is an extension command * @throws Error if text is an extension command
*/ */
async followUp(text: string): Promise<void> { async followUp(text: string): Promise<void> {
@ -748,8 +785,9 @@ export class AgentSession {
this._throwIfExtensionCommand(text); this._throwIfExtensionCommand(text);
} }
// Expand file-based prompt templates // Expand skill commands and prompt templates
const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); let expandedText = this._expandSkillCommand(text);
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
await this._queueFollowUp(expandedText); await this._queueFollowUp(expandedText);
} }

View file

@ -60,7 +60,6 @@ import type { TruncationResult } from "../../core/tools/truncate.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js"; import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js"; import { copyToClipboard } from "../../utils/clipboard.js";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
import { stripFrontmatter } from "../../utils/frontmatter.js";
import { ensureTool } from "../../utils/tools-manager.js"; import { ensureTool } from "../../utils/tools-manager.js";
import { ArminComponent } from "./components/armin.js"; import { ArminComponent } from "./components/armin.js";
import { AssistantMessageComponent } from "./components/assistant-message.js"; import { AssistantMessageComponent } from "./components/assistant-message.js";
@ -1508,20 +1507,6 @@ export class InteractiveMode {
return; return;
} }
// Handle skill commands (/skill:name [args])
if (text.startsWith("/skill:")) {
const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
const skillPath = this.skillCommands.get(commandName);
if (skillPath) {
this.editor.addToHistory?.(text);
this.editor.setText("");
await this.handleSkillCommand(skillPath, args);
return;
}
}
// Handle bash command (! for normal, !! for excluded from context) // Handle bash command (! for normal, !! for excluded from context)
if (text.startsWith("!")) { if (text.startsWith("!")) {
const isExcluded = text.startsWith("!!"); const isExcluded = text.startsWith("!!");
@ -3313,20 +3298,6 @@ export class InteractiveMode {
this.ui.requestRender(); this.ui.requestRender();
} }
private async handleSkillCommand(skillPath: string, args: string): Promise<void> {
try {
const content = fs.readFileSync(skillPath, "utf-8");
const body = stripFrontmatter(content).trim();
const skillDir = path.dirname(skillPath);
const header = `Skill location: ${skillPath}\nReferences are relative to ${skillDir}.`;
const skillMessage = `${header}\n\n${body}`;
const message = args ? `${skillMessage}\n\n---\n\nUser: ${args}` : skillMessage;
await this.session.prompt(message);
} catch (err) {
this.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
}
}
private handleChangelogCommand(): void { private handleChangelogCommand(): void {
const changelogPath = getChangelogPath(); const changelogPath = getChangelogPath();
const allEntries = parseChangelog(changelogPath); const allEntries = parseChangelog(changelogPath);

1
test-new-file.ts Normal file
View file

@ -0,0 +1 @@
// temporary test file