mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 06:04:40 +00:00
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:
parent
3e5d91f287
commit
b4a05cbcab
7 changed files with 211 additions and 420 deletions
|
|
@ -1,27 +1,13 @@
|
|||
/**
|
||||
* Git Diff Extension
|
||||
*
|
||||
* 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
|
||||
* Git Diff Extension - Ctrl+F or /diff to view git changes
|
||||
*/
|
||||
|
||||
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 { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { DynamicBorder, generateDiffString, renderDiff } from "@mariozechner/pi-coding-agent";
|
||||
import { Container, Key, matchesKey, type SelectItem, SelectList, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
|
||||
interface GitFile {
|
||||
status: "M" | "A" | "D" | "R" | "C" | "U" | "?";
|
||||
|
|
@ -29,383 +15,148 @@ interface GitFile {
|
|||
staged: boolean;
|
||||
}
|
||||
|
||||
type FileStatus = GitFile["status"];
|
||||
|
||||
const STATUS_LABELS: Record<FileStatus, string> = {
|
||||
M: "modified",
|
||||
A: "added",
|
||||
D: "deleted",
|
||||
R: "renamed",
|
||||
C: "copied",
|
||||
U: "unmerged",
|
||||
"?": "untracked",
|
||||
const STATUS_LABELS: Record<GitFile["status"], 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[] {
|
||||
dbg(`=== parseGitStatus ===`);
|
||||
dbg(`Raw output: ${JSON.stringify(output)}`);
|
||||
const files: GitFile[] = [];
|
||||
const lines = output.trim().split("\n").filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length < 3) continue;
|
||||
|
||||
const indexStatus = line[0];
|
||||
const workTreeStatus = line[1];
|
||||
const path = line.slice(3);
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
for (const line of output.trim().split("\n").filter(Boolean)) {
|
||||
dbg(`Line: ${JSON.stringify(line)} (len=${line.length})`);
|
||||
if (line.length < 4) { dbg(" Skipped: too short"); continue; }
|
||||
const idx = line[0], wt = line[1], path = line.slice(3);
|
||||
dbg(` idx='${idx}' wt='${wt}' path='${path}'`);
|
||||
if (idx !== " " && idx !== "?") { dbg(` -> staged`); files.push({ status: idx as GitFile["status"], path, staged: true }); }
|
||||
if (wt !== " " && wt !== "?" && (idx === " " || idx !== wt)) { dbg(` -> unstaged`); files.push({ status: wt as GitFile["status"], path, staged: false }); }
|
||||
if (idx === "?" && wt === "?") { dbg(` -> untracked`); files.push({ status: "?", path, staged: false }); }
|
||||
}
|
||||
|
||||
dbg(`Files: ${JSON.stringify(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 {
|
||||
private lines: string[] = [];
|
||||
private scrollOffset = 0;
|
||||
private viewportHeight = 20;
|
||||
private filePath: string;
|
||||
|
||||
private lines: string[];
|
||||
private offset = 0;
|
||||
private height = 20;
|
||||
onClose?: () => void;
|
||||
|
||||
constructor(
|
||||
private theme: Theme,
|
||||
filePath: string,
|
||||
diffText: string,
|
||||
) {
|
||||
this.filePath = filePath;
|
||||
this.lines = renderUnifiedDiff(diffText, theme);
|
||||
constructor(private theme: Theme, private path: string, diff: string) {
|
||||
this.lines = renderDiff(diff).split("\n");
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const maxScroll = Math.max(0, this.lines.length - this.viewportHeight + 4);
|
||||
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
this.onClose?.();
|
||||
} else if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) {
|
||||
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
||||
} else if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) {
|
||||
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
||||
} 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;
|
||||
}
|
||||
const max = Math.max(0, this.lines.length - this.height + 4);
|
||||
if (matchesKey(data, Key.escape)) this.onClose?.();
|
||||
else if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) this.offset = Math.max(0, this.offset - 1);
|
||||
else if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) this.offset = Math.min(max, this.offset + 1);
|
||||
else if (matchesKey(data, Key.pageUp)) this.offset = Math.max(0, this.offset - this.height);
|
||||
else if (matchesKey(data, Key.pageDown)) this.offset = Math.min(max, this.offset + this.height);
|
||||
else if (matchesKey(data, "g")) this.offset = 0;
|
||||
else if (matchesKey(data, "shift+g")) this.offset = max;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const th = this.theme;
|
||||
const result: string[] = [];
|
||||
const innerWidth = width - 2;
|
||||
const th = this.theme, w = width - 2, out: string[] = [];
|
||||
const visible = this.lines.slice(this.offset, this.offset + this.height);
|
||||
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)
|
||||
this.viewportHeight = Math.max(5, 20);
|
||||
|
||||
const maxScroll = Math.max(0, this.lines.length - this.viewportHeight + 4);
|
||||
const visibleLines = this.lines.slice(this.scrollOffset, this.scrollOffset + this.viewportHeight);
|
||||
|
||||
// Helper to create bordered line
|
||||
const row = (content: string) => {
|
||||
const vis = visibleWidth(content);
|
||||
const padding = Math.max(0, innerWidth - vis);
|
||||
return th.fg("border", "│") + content + " ".repeat(padding) + th.fg("border", "│");
|
||||
};
|
||||
|
||||
// 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;
|
||||
out.push(th.fg("border", "╭" + "─".repeat(w) + "╮"));
|
||||
out.push(row(` ${th.fg("accent", th.bold(truncateToWidth(this.path, w - 2)))}`));
|
||||
out.push(row(""));
|
||||
for (const l of visible) out.push(row(" " + truncateToWidth(l, w - 2)));
|
||||
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`;
|
||||
out.push(row(""));
|
||||
out.push(row(` ${th.fg("dim", info)}`));
|
||||
out.push(row(` ${th.fg("dim", "↑↓/←→ scroll • PgUp/PgDn • g/G • Esc")}`));
|
||||
out.push(th.fg("border", "╰" + "─".repeat(w) + "╯"));
|
||||
return out;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the diff for a file
|
||||
*/
|
||||
async function showFileDiff(file: GitFile, ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
|
||||
// Get the diff
|
||||
let diffArgs: string[];
|
||||
async function getDiff(file: GitFile, ctx: ExtensionContext, pi: ExtensionAPI): Promise<{ diff: string } | { error: string }> {
|
||||
const abs = join(ctx.cwd, file.path);
|
||||
|
||||
if (file.status === "?") {
|
||||
// Untracked file: show full content as "added"
|
||||
const result = await pi.exec("cat", [file.path], { cwd: ctx.cwd });
|
||||
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;
|
||||
try { return { diff: generateDiffString("", await readFile(abs, "utf-8")).diff }; }
|
||||
catch (e) { return { error: `Read failed: ${e}` }; }
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
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}` }; }
|
||||
}
|
||||
|
||||
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);
|
||||
return { diff: generateDiffString(old, cur).diff };
|
||||
}
|
||||
|
||||
async function showDiffViewer(filePath: string, diffText: string, ctx: ExtensionContext): Promise<void> {
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const viewer = new DiffViewer(theme, filePath, diffText);
|
||||
viewer.onClose = () => done();
|
||||
|
||||
return {
|
||||
render: (w) => viewer.render(w),
|
||||
invalidate: () => viewer.invalidate(),
|
||||
handleInput: (data) => {
|
||||
viewer.handleInput(data);
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
async function showViewer(path: string, diff: string, ctx: ExtensionContext): Promise<void> {
|
||||
await ctx.ui.custom<void>((tui, theme, _, done) => {
|
||||
const v = new DiffViewer(theme, path, diff);
|
||||
v.onClose = () => done();
|
||||
return { render: (w) => v.render(w), invalidate: () => v.invalidate(), handleInput: (d) => { v.handleInput(d); tui.requestRender(); } };
|
||||
}, { overlay: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the file picker overlay
|
||||
*/
|
||||
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}`,
|
||||
};
|
||||
});
|
||||
async function showPicker(files: GitFile[], ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
|
||||
const items: SelectItem[] = files.map((f, i) => ({ value: i, label: f.path, description: `${STATUS_LABELS[f.status]}${f.staged ? " (staged)" : ""}` }));
|
||||
|
||||
const result = await ctx.ui.custom<GitFile | null>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
// Top border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
// Title
|
||||
const title = new Text(theme.fg("accent", theme.bold("Git Changes")) + theme.fg("dim", ` (${files.length} files)`), 1, 0);
|
||||
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),
|
||||
const idx = await ctx.ui.custom<number | null>((tui, theme, _, done) => {
|
||||
const c = new Container();
|
||||
c.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
c.addChild(new Text(`${theme.fg("accent", theme.bold("Git Changes"))} ${theme.fg("dim", `(${files.length})`)}`, 1, 0));
|
||||
const list = new SelectList(items, Math.min(items.length, 15), {
|
||||
selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t),
|
||||
description: (t) => theme.fg(t.startsWith("modified") ? "warning" : t.startsWith("added") ? "success" : t.startsWith("deleted") ? "error" : "muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t),
|
||||
});
|
||||
|
||||
selectList.onSelect = (item) => done(item.value as GitFile);
|
||||
selectList.onCancel = () => done(null);
|
||||
container.addChild(selectList);
|
||||
|
||||
// Help text
|
||||
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();
|
||||
},
|
||||
};
|
||||
list.onSelect = (item) => done(item.value as number);
|
||||
list.onCancel = () => done(null);
|
||||
c.addChild(list);
|
||||
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)));
|
||||
return { render: (w) => c.render(w), invalidate: () => c.invalidate(), handleInput: (d) => { list.handleInput(d); tui.requestRender(); } };
|
||||
}, { overlay: true });
|
||||
|
||||
if (result) {
|
||||
await showFileDiff(result, ctx, pi);
|
||||
// After viewing diff, show file picker again
|
||||
await showFilePicker(files, ctx, pi);
|
||||
if (idx !== null) {
|
||||
const file = files[idx];
|
||||
const r = await getDiff(file, 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main handler for showing the diff overlay
|
||||
*/
|
||||
async function showDiffOverlay(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
|
||||
// Check if we're in a git repo
|
||||
const gitCheck = await pi.exec("git", ["rev-parse", "--git-dir"], { cwd: ctx.cwd });
|
||||
if (gitCheck.code !== 0) {
|
||||
ctx.ui.notify("Not in a git repository", "error");
|
||||
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);
|
||||
async function run(ctx: ExtensionContext, pi: ExtensionAPI): Promise<void> {
|
||||
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 });
|
||||
if (s.code !== 0) { ctx.ui.notify(`Git failed: ${s.stderr}`, "error"); return; }
|
||||
if (!s.stdout.trim()) { ctx.ui.notify("No changes", "info"); return; }
|
||||
const files = parseGitStatus(s.stdout);
|
||||
if (!files.length) { ctx.ui.notify("No changes", "info"); return; }
|
||||
await showPicker(files, ctx, pi);
|
||||
}
|
||||
|
||||
export default function gitDiffExtension(pi: ExtensionAPI) {
|
||||
// Register Ctrl+F shortcut
|
||||
pi.registerShortcut(Key.ctrl("f"), {
|
||||
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);
|
||||
},
|
||||
});
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerShortcut(Key.ctrl("f"), { description: "Git diff overlay", handler: (ctx) => run(ctx, pi) });
|
||||
pi.registerCommand("diff", { description: "Show git changes", handler: (_, ctx) => run(ctx, pi) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,12 @@
|
|||
|
||||
### 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.
|
||||
- Extension example: `input-transform.ts` demonstrating input interception patterns (quick mode, instant commands, source routing)
|
||||
- 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) ([#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
|
||||
|
||||
|
|
|
|||
|
|
@ -256,7 +256,9 @@ pi starts
|
|||
▼
|
||||
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)
|
||||
├─► agent_start │
|
||||
│ │
|
||||
|
|
@ -579,25 +581,49 @@ pi.on("user_bash", (event, ctx) => {
|
|||
|
||||
#### 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
|
||||
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 "))
|
||||
return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` };
|
||||
|
||||
// Handle: respond without LLM (extension shows its own feedback)
|
||||
if (event.text === "ping") {
|
||||
ctx.ui.notify("pong", "info"); // Extension handles its own feedback
|
||||
return { action: "handled" }; // Skip LLM
|
||||
ctx.ui.notify("pong", "info");
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ With images:
|
|||
|
||||
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:
|
||||
```json
|
||||
|
|
@ -65,7 +65,7 @@ The `images` field is optional. Each image uses `ImageContent` format with base6
|
|||
|
||||
#### 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
|
||||
{"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
|
||||
|
||||
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
|
||||
{"type": "follow_up", "message": "After you're done, also do this"}
|
||||
|
|
@ -108,7 +108,7 @@ Response:
|
|||
|
||||
#### 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
|
||||
{"type": "new_session"}
|
||||
|
|
@ -124,7 +124,7 @@ Response:
|
|||
{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": false}}
|
||||
```
|
||||
|
||||
If a hook cancelled:
|
||||
If an extension cancelled:
|
||||
```json
|
||||
{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": true}}
|
||||
```
|
||||
|
|
@ -525,7 +525,7 @@ Response:
|
|||
|
||||
#### 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
|
||||
{"type": "switch_session", "sessionPath": "/path/to/session.jsonl"}
|
||||
|
|
@ -536,14 +536,14 @@ Response:
|
|||
{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": false}}
|
||||
```
|
||||
|
||||
If a hook cancelled the switch:
|
||||
If an extension cancelled the switch:
|
||||
```json
|
||||
{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}}
|
||||
```
|
||||
|
||||
#### 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
|
||||
{"type": "fork", "entryId": "abc123"}
|
||||
|
|
@ -559,7 +559,7 @@ Response:
|
|||
}
|
||||
```
|
||||
|
||||
If a hook cancelled the fork:
|
||||
If an extension cancelled the fork:
|
||||
```json
|
||||
{
|
||||
"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_retry_start` | Auto-retry begins (after transient error) |
|
||||
| `auto_retry_end` | Auto-retry completes (success or final failure) |
|
||||
| `hook_error` | Hook threw an error |
|
||||
| `extension_error` | Extension threw an error |
|
||||
|
||||
### 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
|
||||
{
|
||||
"type": "hook_error",
|
||||
"hookPath": "/path/to/hook.ts",
|
||||
"type": "extension_error",
|
||||
"extensionPath": "/path/to/extension.ts",
|
||||
"event": "tool_call",
|
||||
"error": "Error message..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
* Modes use this class and add their own I/O layer on top.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import type {
|
||||
Agent,
|
||||
AgentEvent,
|
||||
|
|
@ -25,6 +26,7 @@ import type { AssistantMessage, ImageContent, Message, Model, TextContent } from
|
|||
import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
|
||||
import { getAuthPath } from "../config.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 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 currentImages = options?.images;
|
||||
if (this._extensionRunner?.hasHandlers("input")) {
|
||||
|
|
@ -587,10 +589,12 @@ export class AgentSession {
|
|||
}
|
||||
}
|
||||
|
||||
// Expand file-based prompt templates if requested
|
||||
const expandedText = expandPromptTemplates
|
||||
? expandPromptTemplate(currentText, [...this._promptTemplates])
|
||||
: currentText;
|
||||
// Expand skill commands (/skill:name args) and prompt templates (/template args)
|
||||
let expandedText = currentText;
|
||||
if (expandPromptTemplates) {
|
||||
expandedText = this._expandSkillCommand(expandedText);
|
||||
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
|
||||
}
|
||||
|
||||
// If streaming, queue via steer() or followUp() based on option
|
||||
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.
|
||||
* 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
|
||||
*/
|
||||
async steer(text: string): Promise<void> {
|
||||
|
|
@ -730,8 +766,9 @@ export class AgentSession {
|
|||
this._throwIfExtensionCommand(text);
|
||||
}
|
||||
|
||||
// Expand file-based prompt templates
|
||||
const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
|
||||
// Expand skill commands and prompt templates
|
||||
let expandedText = this._expandSkillCommand(text);
|
||||
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
|
||||
|
||||
await this._queueSteer(expandedText);
|
||||
}
|
||||
|
|
@ -739,7 +776,7 @@ export class AgentSession {
|
|||
/**
|
||||
* Queue a follow-up message to be processed after the agent finishes.
|
||||
* 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
|
||||
*/
|
||||
async followUp(text: string): Promise<void> {
|
||||
|
|
@ -748,8 +785,9 @@ export class AgentSession {
|
|||
this._throwIfExtensionCommand(text);
|
||||
}
|
||||
|
||||
// Expand file-based prompt templates
|
||||
const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
|
||||
// Expand skill commands and prompt templates
|
||||
let expandedText = this._expandSkillCommand(text);
|
||||
expandedText = expandPromptTemplate(expandedText, [...this._promptTemplates]);
|
||||
|
||||
await this._queueFollowUp(expandedText);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ import type { TruncationResult } from "../../core/tools/truncate.js";
|
|||
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
|
||||
import { copyToClipboard } from "../../utils/clipboard.js";
|
||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
|
||||
import { stripFrontmatter } from "../../utils/frontmatter.js";
|
||||
import { ensureTool } from "../../utils/tools-manager.js";
|
||||
import { ArminComponent } from "./components/armin.js";
|
||||
import { AssistantMessageComponent } from "./components/assistant-message.js";
|
||||
|
|
@ -1508,20 +1507,6 @@ export class InteractiveMode {
|
|||
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)
|
||||
if (text.startsWith("!")) {
|
||||
const isExcluded = text.startsWith("!!");
|
||||
|
|
@ -3313,20 +3298,6 @@ export class InteractiveMode {
|
|||
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 {
|
||||
const changelogPath = getChangelogPath();
|
||||
const allEntries = parseChangelog(changelogPath);
|
||||
|
|
|
|||
1
test-new-file.ts
Normal file
1
test-new-file.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
// temporary test file
|
||||
Loading…
Add table
Add a link
Reference in a new issue