Compare commits
10 Commits
c11502c864
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 214a765cf3 | |||
| fbfc3c33ca | |||
|
|
52cca2148f | ||
| c69180d37b | |||
| 6663bd01a5 | |||
|
|
9bad10d2fd | ||
| 567a2a3ffc | |||
| 6a82a3c5d2 | |||
| cb1f893e3d | |||
| 28b6e89f62 |
@@ -152,7 +152,7 @@ set --export BUN_INSTALL "$HOME/.bun"
|
|||||||
set --export PATH $BUN_INSTALL/bin $PATH
|
set --export PATH $BUN_INSTALL/bin $PATH
|
||||||
|
|
||||||
# opencode
|
# opencode
|
||||||
fish_add_path /home/thomasgl/.opencode/bin
|
fish_add_path ~/.opencode/bin
|
||||||
|
|
||||||
if test (uname) = Darwin
|
if test (uname) = Darwin
|
||||||
if test -f ~/.safe-chain/scripts/init-fish.fish
|
if test -f ~/.safe-chain/scripts/init-fish.fish
|
||||||
@@ -165,4 +165,8 @@ if test (uname) = Darwin
|
|||||||
end
|
end
|
||||||
|
|
||||||
set -gx PATH /Users/thomasglopes/.local/bin /Users/thomasglopes/google-cloud-sdk/bin $PATH
|
set -gx PATH /Users/thomasglopes/.local/bin /Users/thomasglopes/google-cloud-sdk/bin $PATH
|
||||||
|
|
||||||
|
# opencode
|
||||||
|
fish_add_path /Users/thomasglopes/.opencode/bin
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ map kitty_mod+,
|
|||||||
#: kitty has very powerful font management. You can configure
|
#: kitty has very powerful font management. You can configure
|
||||||
#: individual font faces and even specify special fonts for particular
|
#: individual font faces and even specify special fonts for particular
|
||||||
#: characters.
|
#: characters.
|
||||||
font_family family="FantasqueSansM Nerd Font"
|
font_family family="IosevkaTermSlab Nerd Font"
|
||||||
bold_font auto
|
bold_font auto
|
||||||
italic_font auto
|
italic_font auto
|
||||||
bold_italic_font auto
|
bold_italic_font auto
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ mkdir -p "$HOME/.cache"
|
|||||||
{
|
{
|
||||||
ssh mac-attio "mkdir -p ~/.pi/agent/themes"
|
ssh mac-attio "mkdir -p ~/.pi/agent/themes"
|
||||||
scp "$HOME/.pi/agent/themes/matugen.json" \
|
scp "$HOME/.pi/agent/themes/matugen.json" \
|
||||||
mac-attio:~/.pi/agent/themes/
|
mac-attio:~/.pi/agent/themes/matugen.json.tmp
|
||||||
|
ssh mac-attio "mv ~/.pi/agent/themes/matugen.json.tmp ~/.pi/agent/themes/matugen.json"
|
||||||
} >>"$log_file" 2>&1
|
} >>"$log_file" 2>&1
|
||||||
|
|||||||
@@ -212,9 +212,9 @@ window-rule {
|
|||||||
// Block out sensitive components from screencasts
|
// Block out sensitive components from screencasts
|
||||||
layer-rule {
|
layer-rule {
|
||||||
match namespace="^dms:clipboard$"
|
match namespace="^dms:clipboard$"
|
||||||
match namespace="^dms:notification$"
|
match namespace="^dms:notification"
|
||||||
|
|
||||||
block-out-from "screencast"
|
block-out-from "screen-capture"
|
||||||
}
|
}
|
||||||
|
|
||||||
window-rule {
|
window-rule {
|
||||||
@@ -489,7 +489,7 @@ binds {
|
|||||||
// Mod+Space { switch-layout "next"; }
|
// Mod+Space { switch-layout "next"; }
|
||||||
// Mod+Shift+Space { switch-layout "prev"; }
|
// Mod+Shift+Space { switch-layout "prev"; }
|
||||||
|
|
||||||
Mod+P { screenshot; }
|
Mod+P { spawn "sh" "-c" "niri msg action screenshot && ~/.config/niri/copy-latest-screenshot.sh"; }
|
||||||
Mod+Ctrl+P { screenshot-screen; }
|
Mod+Ctrl+P { screenshot-screen; }
|
||||||
Mod+Alt+P { screenshot-window; }
|
Mod+Alt+P { screenshot-window; }
|
||||||
|
|
||||||
|
|||||||
29
niri/files/copy-latest-screenshot.sh
Executable file
29
niri/files/copy-latest-screenshot.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
screenshot_dir="$HOME/Pictures/Screenshots"
|
||||||
|
remote_target="mac-attio:~/screenshot.png"
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(id -u)/bus" \
|
||||||
|
XDG_RUNTIME_DIR="/run/user/$(id -u)" \
|
||||||
|
notify-send "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
files=("$screenshot_dir"/*.png)
|
||||||
|
latest_file=""
|
||||||
|
if (( ${#files[@]} )); then
|
||||||
|
latest_file=$(ls -1t "${files[@]}" | head -n 1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${latest_file:-}" ]]; then
|
||||||
|
notify "Screenshot upload" "No screenshots found in $screenshot_dir"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if scp -q "$latest_file" "$remote_target"; then
|
||||||
|
notify "Screenshot upload" "Uploaded $(basename "$latest_file")"
|
||||||
|
else
|
||||||
|
notify "Screenshot upload" "Upload failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -73,10 +73,10 @@ return {
|
|||||||
mode = "location",
|
mode = "location",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
on_attach = function(_, bufnr)
|
-- on_attach = function(_, bufnr)
|
||||||
vim.api.nvim_create_autocmd("BufWritePre", {
|
-- vim.api.nvim_create_autocmd("BufWritePre", {
|
||||||
buffer = bufnr,
|
-- buffer = bufnr,
|
||||||
command = "EslintFixAll",
|
-- command = "EslintFixAll",
|
||||||
})
|
-- })
|
||||||
end,
|
-- end,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ end, "Organize Imports")
|
|||||||
-- end, "Lint file")
|
-- end, "Lint file")
|
||||||
|
|
||||||
map("<leader>esf", function()
|
map("<leader>esf", function()
|
||||||
vim.cmd("EslintFixAll")
|
-- vim.cmd("EslintFixAll")
|
||||||
end, "Fix ESLint issues")
|
end, "Fix ESLint issues")
|
||||||
|
|
||||||
-- Window management
|
-- Window management
|
||||||
|
|||||||
59
pi/files/agent/extensions/confirm-destructive.ts
Normal file
59
pi/files/agent/extensions/confirm-destructive.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Confirm Destructive Actions Extension
|
||||||
|
*
|
||||||
|
* Prompts for confirmation before destructive session actions (clear, switch, branch).
|
||||||
|
* Demonstrates how to cancel session events using the before_* events.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
|
||||||
|
if (event.reason === "new") {
|
||||||
|
const confirmed = await ctx.ui.confirm(
|
||||||
|
"Clear session?",
|
||||||
|
"This will delete all messages in the current session.",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
ctx.ui.notify("Clear cancelled", "info");
|
||||||
|
return { cancel: true };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// reason === "resume" - check if there are unsaved changes (messages since last assistant response)
|
||||||
|
const entries = ctx.sessionManager.getEntries();
|
||||||
|
const hasUnsavedWork = entries.some(
|
||||||
|
(e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasUnsavedWork) {
|
||||||
|
const confirmed = await ctx.ui.confirm(
|
||||||
|
"Switch session?",
|
||||||
|
"You have messages in the current session. Switch anyway?",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
ctx.ui.notify("Switch cancelled", "info");
|
||||||
|
return { cancel: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_before_fork", async (event, ctx) => {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
|
||||||
|
const choice = await ctx.ui.select(`Fork from entry ${event.entryId.slice(0, 8)}?`, [
|
||||||
|
"Yes, create fork",
|
||||||
|
"No, stay in current session",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (choice !== "Yes, create fork") {
|
||||||
|
ctx.ui.notify("Fork cancelled", "info");
|
||||||
|
return { cancel: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
64
pi/files/agent/extensions/custom-footer.ts
Normal file
64
pi/files/agent/extensions/custom-footer.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Custom Footer Extension - demonstrates ctx.ui.setFooter()
|
||||||
|
*
|
||||||
|
* footerData exposes data not otherwise accessible:
|
||||||
|
* - getGitBranch(): current git branch
|
||||||
|
* - getExtensionStatuses(): texts from ctx.ui.setStatus()
|
||||||
|
*
|
||||||
|
* Token stats come from ctx.sessionManager/ctx.model (already accessible).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let enabled = false;
|
||||||
|
|
||||||
|
pi.registerCommand("footer", {
|
||||||
|
description: "Toggle custom footer",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
enabled = !enabled;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
||||||
|
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispose: unsub,
|
||||||
|
invalidate() {},
|
||||||
|
render(width: number): string[] {
|
||||||
|
// Compute tokens from ctx (already accessible to extensions)
|
||||||
|
let input = 0,
|
||||||
|
output = 0,
|
||||||
|
cost = 0;
|
||||||
|
for (const e of ctx.sessionManager.getBranch()) {
|
||||||
|
if (e.type === "message" && e.message.role === "assistant") {
|
||||||
|
const m = e.message as AssistantMessage;
|
||||||
|
input += m.usage.input;
|
||||||
|
output += m.usage.output;
|
||||||
|
cost += m.usage.cost.total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get git branch (not otherwise accessible)
|
||||||
|
const branch = footerData.getGitBranch();
|
||||||
|
const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);
|
||||||
|
|
||||||
|
const left = theme.fg("dim", `↑${fmt(input)} ↓${fmt(output)} $${cost.toFixed(3)}`);
|
||||||
|
const branchStr = branch ? ` (${branch})` : "";
|
||||||
|
const right = theme.fg("dim", `${ctx.model?.id || "no-model"}${branchStr}`);
|
||||||
|
|
||||||
|
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
|
||||||
|
return [truncateToWidth(left + pad + right, width)];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
ctx.ui.notify("Custom footer enabled", "info");
|
||||||
|
} else {
|
||||||
|
ctx.ui.setFooter(undefined);
|
||||||
|
ctx.ui.notify("Default footer restored", "info");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
1
pi/files/agent/extensions/custom-provider-anthropic/.gitignore
vendored
Normal file
1
pi/files/agent/extensions/custom-provider-anthropic/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
604
pi/files/agent/extensions/custom-provider-anthropic/index.ts
Normal file
604
pi/files/agent/extensions/custom-provider-anthropic/index.ts
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
/**
|
||||||
|
* Custom Provider Example
|
||||||
|
*
|
||||||
|
* Demonstrates registering a custom provider with:
|
||||||
|
* - Custom API identifier ("custom-anthropic-api")
|
||||||
|
* - Custom streamSimple implementation
|
||||||
|
* - OAuth support for /login
|
||||||
|
* - API key support via environment variable
|
||||||
|
* - Two model definitions
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* # First install dependencies
|
||||||
|
* cd packages/coding-agent/examples/extensions/custom-provider && npm install
|
||||||
|
*
|
||||||
|
* # With OAuth (run /login custom-anthropic first)
|
||||||
|
* pi -e ./packages/coding-agent/examples/extensions/custom-provider
|
||||||
|
*
|
||||||
|
* # With API key
|
||||||
|
* CUSTOM_ANTHROPIC_API_KEY=sk-ant-... pi -e ./packages/coding-agent/examples/extensions/custom-provider
|
||||||
|
*
|
||||||
|
* Then use /model to select custom-anthropic/claude-sonnet-4-5
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Anthropic from "@anthropic-ai/sdk";
|
||||||
|
import type { ContentBlockParam, MessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/messages.js";
|
||||||
|
import {
|
||||||
|
type Api,
|
||||||
|
type AssistantMessage,
|
||||||
|
type AssistantMessageEventStream,
|
||||||
|
type Context,
|
||||||
|
calculateCost,
|
||||||
|
createAssistantMessageEventStream,
|
||||||
|
type ImageContent,
|
||||||
|
type Message,
|
||||||
|
type Model,
|
||||||
|
type OAuthCredentials,
|
||||||
|
type OAuthLoginCallbacks,
|
||||||
|
type SimpleStreamOptions,
|
||||||
|
type StopReason,
|
||||||
|
type TextContent,
|
||||||
|
type ThinkingContent,
|
||||||
|
type Tool,
|
||||||
|
type ToolCall,
|
||||||
|
type ToolResultMessage,
|
||||||
|
} from "@mariozechner/pi-ai";
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// OAuth Implementation (copied from packages/ai/src/utils/oauth/anthropic.ts)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const decode = (s: string) => atob(s);
|
||||||
|
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
||||||
|
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||||
|
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
|
||||||
|
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
|
||||||
|
const SCOPES = "org:create_api_key user:profile user:inference";
|
||||||
|
|
||||||
|
async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
|
||||||
|
const array = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
const verifier = btoa(String.fromCharCode(...array))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(verifier);
|
||||||
|
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||||
|
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
|
||||||
|
return { verifier, challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAnthropic(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||||
|
const { verifier, challenge } = await generatePKCE();
|
||||||
|
|
||||||
|
const authParams = new URLSearchParams({
|
||||||
|
code: "true",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
scope: SCOPES,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
state: verifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
callbacks.onAuth({ url: `${AUTHORIZE_URL}?${authParams.toString()}` });
|
||||||
|
|
||||||
|
const authCode = await callbacks.onPrompt({ message: "Paste the authorization code:" });
|
||||||
|
const [code, state] = authCode.split("#");
|
||||||
|
|
||||||
|
const tokenResponse = await fetch(TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
code,
|
||||||
|
state,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
code_verifier: verifier,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) {
|
||||||
|
throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await tokenResponse.json()) as {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
refresh: data.refresh_token,
|
||||||
|
access: data.access_token,
|
||||||
|
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAnthropicToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
||||||
|
const response = await fetch(TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
refresh_token: credentials.refresh,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Token refresh failed: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
refresh: data.refresh_token,
|
||||||
|
access: data.access_token,
|
||||||
|
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Streaming Implementation (simplified from packages/ai/src/providers/anthropic.ts)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Claude Code tool names for OAuth stealth mode
|
||||||
|
const claudeCodeTools = [
|
||||||
|
"Read",
|
||||||
|
"Write",
|
||||||
|
"Edit",
|
||||||
|
"Bash",
|
||||||
|
"Grep",
|
||||||
|
"Glob",
|
||||||
|
"AskUserQuestion",
|
||||||
|
"TodoWrite",
|
||||||
|
"WebFetch",
|
||||||
|
"WebSearch",
|
||||||
|
];
|
||||||
|
const ccToolLookup = new Map(claudeCodeTools.map((t) => [t.toLowerCase(), t]));
|
||||||
|
const toClaudeCodeName = (name: string) => ccToolLookup.get(name.toLowerCase()) ?? name;
|
||||||
|
const fromClaudeCodeName = (name: string, tools?: Tool[]) => {
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
const matched = tools?.find((t) => t.name.toLowerCase() === lowerName);
|
||||||
|
return matched?.name ?? name;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isOAuthToken(apiKey: string): boolean {
|
||||||
|
return apiKey.includes("sk-ant-oat");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSurrogates(text: string): string {
|
||||||
|
return text.replace(/[\uD800-\uDFFF]/g, "\uFFFD");
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertContentBlocks(
|
||||||
|
content: (TextContent | ImageContent)[],
|
||||||
|
): string | Array<{ type: "text"; text: string } | { type: "image"; source: any }> {
|
||||||
|
const hasImages = content.some((c) => c.type === "image");
|
||||||
|
if (!hasImages) {
|
||||||
|
return sanitizeSurrogates(content.map((c) => (c as TextContent).text).join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = content.map((block) => {
|
||||||
|
if (block.type === "text") {
|
||||||
|
return { type: "text" as const, text: sanitizeSurrogates(block.text) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "image" as const,
|
||||||
|
source: {
|
||||||
|
type: "base64" as const,
|
||||||
|
media_type: block.mimeType,
|
||||||
|
data: block.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blocks.some((b) => b.type === "text")) {
|
||||||
|
blocks.unshift({ type: "text" as const, text: "(see attached image)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertMessages(messages: Message[], isOAuth: boolean, _tools?: Tool[]): any[] {
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const msg = messages[i];
|
||||||
|
|
||||||
|
if (msg.role === "user") {
|
||||||
|
if (typeof msg.content === "string") {
|
||||||
|
if (msg.content.trim()) {
|
||||||
|
params.push({ role: "user", content: sanitizeSurrogates(msg.content) });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const blocks: ContentBlockParam[] = msg.content.map((item) =>
|
||||||
|
item.type === "text"
|
||||||
|
? { type: "text" as const, text: sanitizeSurrogates(item.text) }
|
||||||
|
: {
|
||||||
|
type: "image" as const,
|
||||||
|
source: { type: "base64" as const, media_type: item.mimeType as any, data: item.data },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (blocks.length > 0) {
|
||||||
|
params.push({ role: "user", content: blocks });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.role === "assistant") {
|
||||||
|
const blocks: ContentBlockParam[] = [];
|
||||||
|
for (const block of msg.content) {
|
||||||
|
if (block.type === "text" && block.text.trim()) {
|
||||||
|
blocks.push({ type: "text", text: sanitizeSurrogates(block.text) });
|
||||||
|
} else if (block.type === "thinking" && block.thinking.trim()) {
|
||||||
|
if ((block as ThinkingContent).thinkingSignature) {
|
||||||
|
blocks.push({
|
||||||
|
type: "thinking" as any,
|
||||||
|
thinking: sanitizeSurrogates(block.thinking),
|
||||||
|
signature: (block as ThinkingContent).thinkingSignature!,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
blocks.push({ type: "text", text: sanitizeSurrogates(block.thinking) });
|
||||||
|
}
|
||||||
|
} else if (block.type === "toolCall") {
|
||||||
|
blocks.push({
|
||||||
|
type: "tool_use",
|
||||||
|
id: block.id,
|
||||||
|
name: isOAuth ? toClaudeCodeName(block.name) : block.name,
|
||||||
|
input: block.arguments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (blocks.length > 0) {
|
||||||
|
params.push({ role: "assistant", content: blocks });
|
||||||
|
}
|
||||||
|
} else if (msg.role === "toolResult") {
|
||||||
|
const toolResults: any[] = [];
|
||||||
|
toolResults.push({
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: msg.toolCallId,
|
||||||
|
content: convertContentBlocks(msg.content),
|
||||||
|
is_error: msg.isError,
|
||||||
|
});
|
||||||
|
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < messages.length && messages[j].role === "toolResult") {
|
||||||
|
const nextMsg = messages[j] as ToolResultMessage;
|
||||||
|
toolResults.push({
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: nextMsg.toolCallId,
|
||||||
|
content: convertContentBlocks(nextMsg.content),
|
||||||
|
is_error: nextMsg.isError,
|
||||||
|
});
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
i = j - 1;
|
||||||
|
params.push({ role: "user", content: toolResults });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cache control to last user message
|
||||||
|
if (params.length > 0) {
|
||||||
|
const last = params[params.length - 1];
|
||||||
|
if (last.role === "user" && Array.isArray(last.content)) {
|
||||||
|
const lastBlock = last.content[last.content.length - 1];
|
||||||
|
if (lastBlock) {
|
||||||
|
lastBlock.cache_control = { type: "ephemeral" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertTools(tools: Tool[], isOAuth: boolean): any[] {
|
||||||
|
return tools.map((tool) => ({
|
||||||
|
name: isOAuth ? toClaudeCodeName(tool.name) : tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: (tool.parameters as any).properties || {},
|
||||||
|
required: (tool.parameters as any).required || [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStopReason(reason: string): StopReason {
|
||||||
|
switch (reason) {
|
||||||
|
case "end_turn":
|
||||||
|
case "pause_turn":
|
||||||
|
case "stop_sequence":
|
||||||
|
return "stop";
|
||||||
|
case "max_tokens":
|
||||||
|
return "length";
|
||||||
|
case "tool_use":
|
||||||
|
return "toolUse";
|
||||||
|
default:
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamCustomAnthropic(
|
||||||
|
model: Model<Api>,
|
||||||
|
context: Context,
|
||||||
|
options?: SimpleStreamOptions,
|
||||||
|
): AssistantMessageEventStream {
|
||||||
|
const stream = createAssistantMessageEventStream();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const output: AssistantMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [],
|
||||||
|
api: model.api,
|
||||||
|
provider: model.provider,
|
||||||
|
model: model.id,
|
||||||
|
usage: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
},
|
||||||
|
stopReason: "stop",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiKey = options?.apiKey ?? "";
|
||||||
|
const isOAuth = isOAuthToken(apiKey);
|
||||||
|
|
||||||
|
// Configure client based on auth type
|
||||||
|
const betaFeatures = ["fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14"];
|
||||||
|
const clientOptions: any = {
|
||||||
|
baseURL: model.baseUrl,
|
||||||
|
dangerouslyAllowBrowser: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOAuth) {
|
||||||
|
clientOptions.apiKey = null;
|
||||||
|
clientOptions.authToken = apiKey;
|
||||||
|
clientOptions.defaultHeaders = {
|
||||||
|
accept: "application/json",
|
||||||
|
"anthropic-dangerous-direct-browser-access": "true",
|
||||||
|
"anthropic-beta": `claude-code-20250219,oauth-2025-04-20,${betaFeatures.join(",")}`,
|
||||||
|
"user-agent": "claude-cli/2.1.2 (external, cli)",
|
||||||
|
"x-app": "cli",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
clientOptions.apiKey = apiKey;
|
||||||
|
clientOptions.defaultHeaders = {
|
||||||
|
accept: "application/json",
|
||||||
|
"anthropic-dangerous-direct-browser-access": "true",
|
||||||
|
"anthropic-beta": betaFeatures.join(","),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Anthropic(clientOptions);
|
||||||
|
|
||||||
|
// Build request params
|
||||||
|
const params: MessageCreateParamsStreaming = {
|
||||||
|
model: model.id,
|
||||||
|
messages: convertMessages(context.messages, isOAuth, context.tools),
|
||||||
|
max_tokens: options?.maxTokens || Math.floor(model.maxTokens / 3),
|
||||||
|
stream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// System prompt with Claude Code identity for OAuth
|
||||||
|
if (isOAuth) {
|
||||||
|
params.system = [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
||||||
|
cache_control: { type: "ephemeral" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (context.systemPrompt) {
|
||||||
|
params.system.push({
|
||||||
|
type: "text",
|
||||||
|
text: sanitizeSurrogates(context.systemPrompt),
|
||||||
|
cache_control: { type: "ephemeral" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (context.systemPrompt) {
|
||||||
|
params.system = [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: sanitizeSurrogates(context.systemPrompt),
|
||||||
|
cache_control: { type: "ephemeral" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.tools) {
|
||||||
|
params.tools = convertTools(context.tools, isOAuth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle thinking/reasoning
|
||||||
|
if (options?.reasoning && model.reasoning) {
|
||||||
|
const defaultBudgets: Record<string, number> = {
|
||||||
|
minimal: 1024,
|
||||||
|
low: 4096,
|
||||||
|
medium: 10240,
|
||||||
|
high: 20480,
|
||||||
|
};
|
||||||
|
const customBudget = options.thinkingBudgets?.[options.reasoning as keyof typeof options.thinkingBudgets];
|
||||||
|
params.thinking = {
|
||||||
|
type: "enabled",
|
||||||
|
budget_tokens: customBudget ?? defaultBudgets[options.reasoning] ?? 10240,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const anthropicStream = client.messages.stream({ ...params }, { signal: options?.signal });
|
||||||
|
stream.push({ type: "start", partial: output });
|
||||||
|
|
||||||
|
type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { index: number };
|
||||||
|
const blocks = output.content as Block[];
|
||||||
|
|
||||||
|
for await (const event of anthropicStream) {
|
||||||
|
if (event.type === "message_start") {
|
||||||
|
output.usage.input = event.message.usage.input_tokens || 0;
|
||||||
|
output.usage.output = event.message.usage.output_tokens || 0;
|
||||||
|
output.usage.cacheRead = (event.message.usage as any).cache_read_input_tokens || 0;
|
||||||
|
output.usage.cacheWrite = (event.message.usage as any).cache_creation_input_tokens || 0;
|
||||||
|
output.usage.totalTokens =
|
||||||
|
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
||||||
|
calculateCost(model, output.usage);
|
||||||
|
} else if (event.type === "content_block_start") {
|
||||||
|
if (event.content_block.type === "text") {
|
||||||
|
output.content.push({ type: "text", text: "", index: event.index } as any);
|
||||||
|
stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });
|
||||||
|
} else if (event.content_block.type === "thinking") {
|
||||||
|
output.content.push({
|
||||||
|
type: "thinking",
|
||||||
|
thinking: "",
|
||||||
|
thinkingSignature: "",
|
||||||
|
index: event.index,
|
||||||
|
} as any);
|
||||||
|
stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output });
|
||||||
|
} else if (event.content_block.type === "tool_use") {
|
||||||
|
output.content.push({
|
||||||
|
type: "toolCall",
|
||||||
|
id: event.content_block.id,
|
||||||
|
name: isOAuth
|
||||||
|
? fromClaudeCodeName(event.content_block.name, context.tools)
|
||||||
|
: event.content_block.name,
|
||||||
|
arguments: {},
|
||||||
|
partialJson: "",
|
||||||
|
index: event.index,
|
||||||
|
} as any);
|
||||||
|
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
|
||||||
|
}
|
||||||
|
} else if (event.type === "content_block_delta") {
|
||||||
|
const index = blocks.findIndex((b) => b.index === event.index);
|
||||||
|
const block = blocks[index];
|
||||||
|
if (!block) continue;
|
||||||
|
|
||||||
|
if (event.delta.type === "text_delta" && block.type === "text") {
|
||||||
|
block.text += event.delta.text;
|
||||||
|
stream.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: output });
|
||||||
|
} else if (event.delta.type === "thinking_delta" && block.type === "thinking") {
|
||||||
|
block.thinking += event.delta.thinking;
|
||||||
|
stream.push({
|
||||||
|
type: "thinking_delta",
|
||||||
|
contentIndex: index,
|
||||||
|
delta: event.delta.thinking,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
} else if (event.delta.type === "input_json_delta" && block.type === "toolCall") {
|
||||||
|
(block as any).partialJson += event.delta.partial_json;
|
||||||
|
try {
|
||||||
|
block.arguments = JSON.parse((block as any).partialJson);
|
||||||
|
} catch {}
|
||||||
|
stream.push({
|
||||||
|
type: "toolcall_delta",
|
||||||
|
contentIndex: index,
|
||||||
|
delta: event.delta.partial_json,
|
||||||
|
partial: output,
|
||||||
|
});
|
||||||
|
} else if (event.delta.type === "signature_delta" && block.type === "thinking") {
|
||||||
|
block.thinkingSignature = (block.thinkingSignature || "") + (event.delta as any).signature;
|
||||||
|
}
|
||||||
|
} else if (event.type === "content_block_stop") {
|
||||||
|
const index = blocks.findIndex((b) => b.index === event.index);
|
||||||
|
const block = blocks[index];
|
||||||
|
if (!block) continue;
|
||||||
|
|
||||||
|
delete (block as any).index;
|
||||||
|
if (block.type === "text") {
|
||||||
|
stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output });
|
||||||
|
} else if (block.type === "thinking") {
|
||||||
|
stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output });
|
||||||
|
} else if (block.type === "toolCall") {
|
||||||
|
try {
|
||||||
|
block.arguments = JSON.parse((block as any).partialJson);
|
||||||
|
} catch {}
|
||||||
|
delete (block as any).partialJson;
|
||||||
|
stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output });
|
||||||
|
}
|
||||||
|
} else if (event.type === "message_delta") {
|
||||||
|
if ((event.delta as any).stop_reason) {
|
||||||
|
output.stopReason = mapStopReason((event.delta as any).stop_reason);
|
||||||
|
}
|
||||||
|
output.usage.input = (event.usage as any).input_tokens || 0;
|
||||||
|
output.usage.output = (event.usage as any).output_tokens || 0;
|
||||||
|
output.usage.cacheRead = (event.usage as any).cache_read_input_tokens || 0;
|
||||||
|
output.usage.cacheWrite = (event.usage as any).cache_creation_input_tokens || 0;
|
||||||
|
output.usage.totalTokens =
|
||||||
|
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
||||||
|
calculateCost(model, output.usage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
throw new Error("Request was aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
|
||||||
|
stream.end();
|
||||||
|
} catch (error) {
|
||||||
|
for (const block of output.content) delete (block as any).index;
|
||||||
|
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||||
|
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
||||||
|
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||||
|
stream.end();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Extension Entry Point
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerProvider("custom-anthropic", {
|
||||||
|
baseUrl: "https://api.anthropic.com",
|
||||||
|
apiKey: "CUSTOM_ANTHROPIC_API_KEY",
|
||||||
|
api: "custom-anthropic-api",
|
||||||
|
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "claude-opus-4-5",
|
||||||
|
name: "Claude Opus 4.5 (Custom)",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text", "image"],
|
||||||
|
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 64000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4-5",
|
||||||
|
name: "Claude Sonnet 4.5 (Custom)",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text", "image"],
|
||||||
|
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 64000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
oauth: {
|
||||||
|
name: "Custom Anthropic (Claude Pro/Max)",
|
||||||
|
login: loginAnthropic,
|
||||||
|
refreshToken: refreshAnthropicToken,
|
||||||
|
getApiKey: (cred) => cred.access,
|
||||||
|
},
|
||||||
|
|
||||||
|
streamSimple: streamCustomAnthropic,
|
||||||
|
});
|
||||||
|
}
|
||||||
24
pi/files/agent/extensions/custom-provider-anthropic/package-lock.json
generated
Normal file
24
pi/files/agent/extensions/custom-provider-anthropic/package-lock.json
generated
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-extension-custom-provider",
|
||||||
|
"version": "1.5.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "pi-extension-custom-provider",
|
||||||
|
"version": "1.5.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.52.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.52.0.tgz",
|
||||||
|
"integrity": "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-extension-custom-provider-anthropic",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.5.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "echo 'nothing to clean'",
|
||||||
|
"build": "echo 'nothing to build'",
|
||||||
|
"check": "echo 'nothing to check'"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.52.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
56
pi/files/agent/extensions/dirty-repo-guard.ts
Normal file
56
pi/files/agent/extensions/dirty-repo-guard.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Dirty Repo Guard Extension
|
||||||
|
*
|
||||||
|
* Prevents session changes when there are uncommitted git changes.
|
||||||
|
* Useful to ensure work is committed before switching context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
async function checkDirtyRepo(
|
||||||
|
pi: ExtensionAPI,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
action: string,
|
||||||
|
): Promise<{ cancel: boolean } | undefined> {
|
||||||
|
// Check for uncommitted changes
|
||||||
|
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
// Not a git repo, allow the action
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChanges = stdout.trim().length > 0;
|
||||||
|
if (!hasChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
// In non-interactive mode, block by default
|
||||||
|
return { cancel: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count changed files
|
||||||
|
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
|
||||||
|
|
||||||
|
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
|
||||||
|
"Yes, proceed anyway",
|
||||||
|
"No, let me commit first",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (choice !== "Yes, proceed anyway") {
|
||||||
|
ctx.ui.notify("Commit your changes first", "warning");
|
||||||
|
return { cancel: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.on("session_before_switch", async (event, ctx) => {
|
||||||
|
const action = event.reason === "new" ? "new session" : "switch session";
|
||||||
|
return checkDirtyRepo(pi, ctx, action);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_before_fork", async (_event, ctx) => {
|
||||||
|
return checkDirtyRepo(pi, ctx, "fork");
|
||||||
|
});
|
||||||
|
}
|
||||||
53
pi/files/agent/extensions/git-checkpoint.ts
Normal file
53
pi/files/agent/extensions/git-checkpoint.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Git Checkpoint Extension
|
||||||
|
*
|
||||||
|
* Creates git stash checkpoints at each turn so /fork can restore code state.
|
||||||
|
* When forking, offers to restore code to that point in history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
const checkpoints = new Map<string, string>();
|
||||||
|
let currentEntryId: string | undefined;
|
||||||
|
|
||||||
|
// Track the current entry ID when user messages are saved
|
||||||
|
pi.on("tool_result", async (_event, ctx) => {
|
||||||
|
const leaf = ctx.sessionManager.getLeafEntry();
|
||||||
|
if (leaf) currentEntryId = leaf.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_start", async () => {
|
||||||
|
// Create a git stash entry before LLM makes changes
|
||||||
|
const { stdout } = await pi.exec("git", ["stash", "create"]);
|
||||||
|
const ref = stdout.trim();
|
||||||
|
if (ref && currentEntryId) {
|
||||||
|
checkpoints.set(currentEntryId, ref);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_before_fork", async (event, ctx) => {
|
||||||
|
const ref = checkpoints.get(event.entryId);
|
||||||
|
if (!ref) return;
|
||||||
|
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
// In non-interactive mode, don't restore automatically
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = await ctx.ui.select("Restore code state?", [
|
||||||
|
"Yes, restore code to that point",
|
||||||
|
"No, keep current code",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (choice?.startsWith("Yes")) {
|
||||||
|
await pi.exec("git", ["stash", "apply", ref]);
|
||||||
|
ctx.ui.notify("Code restored to checkpoint", "info");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_end", async () => {
|
||||||
|
// Clear checkpoints after agent completes
|
||||||
|
checkpoints.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
150
pi/files/agent/extensions/handoff.ts
Normal file
150
pi/files/agent/extensions/handoff.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Handoff extension - transfer context to a new focused session
|
||||||
|
*
|
||||||
|
* Instead of compacting (which is lossy), handoff extracts what matters
|
||||||
|
* for your next task and creates a new session with a generated prompt.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* /handoff now implement this for teams as well
|
||||||
|
* /handoff execute phase one of the plan
|
||||||
|
* /handoff check other places that need this fix
|
||||||
|
*
|
||||||
|
* The generated prompt appears as a draft in the editor for review/editing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { complete, type Message } from "@mariozechner/pi-ai";
|
||||||
|
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
|
||||||
|
|
||||||
|
1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
|
||||||
|
2. Lists any relevant files that were discussed or modified
|
||||||
|
3. Clearly states the next task based on the user's goal
|
||||||
|
4. Is self-contained - the new thread should be able to proceed without the old conversation
|
||||||
|
|
||||||
|
Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
|
||||||
|
|
||||||
|
Example output format:
|
||||||
|
## Context
|
||||||
|
We've been working on X. Key decisions:
|
||||||
|
- Decision 1
|
||||||
|
- Decision 2
|
||||||
|
|
||||||
|
Files involved:
|
||||||
|
- path/to/file1.ts
|
||||||
|
- path/to/file2.ts
|
||||||
|
|
||||||
|
## Task
|
||||||
|
[Clear description of what to do next based on user's goal]`;
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerCommand("handoff", {
|
||||||
|
description: "Transfer context to a new focused session",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
ctx.ui.notify("handoff requires interactive mode", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.model) {
|
||||||
|
ctx.ui.notify("No model selected", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const goal = args.trim();
|
||||||
|
if (!goal) {
|
||||||
|
ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather conversation context from current branch
|
||||||
|
const branch = ctx.sessionManager.getBranch();
|
||||||
|
const messages = branch
|
||||||
|
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
|
||||||
|
.map((entry) => entry.message);
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
ctx.ui.notify("No conversation to hand off", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to LLM format and serialize
|
||||||
|
const llmMessages = convertToLlm(messages);
|
||||||
|
const conversationText = serializeConversation(llmMessages);
|
||||||
|
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
||||||
|
|
||||||
|
// Generate the handoff prompt with loader UI
|
||||||
|
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
||||||
|
const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
|
||||||
|
loader.onAbort = () => done(null);
|
||||||
|
|
||||||
|
const doGenerate = async () => {
|
||||||
|
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
|
||||||
|
|
||||||
|
const userMessage: Message = {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await complete(
|
||||||
|
ctx.model!,
|
||||||
|
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||||
|
{ apiKey, signal: loader.signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.stopReason === "aborted") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.content
|
||||||
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||||
|
.map((c) => c.text)
|
||||||
|
.join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
doGenerate()
|
||||||
|
.then(done)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Handoff generation failed:", err);
|
||||||
|
done(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return loader;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
ctx.ui.notify("Cancelled", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let user edit the generated prompt
|
||||||
|
const editedPrompt = await ctx.ui.editor("Edit handoff prompt", result);
|
||||||
|
|
||||||
|
if (editedPrompt === undefined) {
|
||||||
|
ctx.ui.notify("Cancelled", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new session with parent tracking
|
||||||
|
const newSessionResult = await ctx.newSession({
|
||||||
|
parentSession: currentSessionFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newSessionResult.cancelled) {
|
||||||
|
ctx.ui.notify("New session cancelled", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the edited prompt in the main editor for submission
|
||||||
|
ctx.ui.setEditorText(editedPrompt);
|
||||||
|
ctx.ui.notify("Handoff ready. Submit when ready.", "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
1
pi/files/agent/extensions/matugen-theme-watch/.gitignore
vendored
Normal file
1
pi/files/agent/extensions/matugen-theme-watch/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||||
|
import tsParser from "@typescript-eslint/parser";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": tseslint,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...tseslint.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -96,7 +96,7 @@ async function loadThemeFromPath(filePath: string): Promise<PiTheme> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function matugenThemeWatch(pi: ExtensionAPI) {
|
||||||
let watcher: fs.StatWatcher | null = null;
|
let watcher: fs.StatWatcher | null = null;
|
||||||
let debounce: NodeJS.Timeout | null = null;
|
let debounce: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
@@ -113,14 +113,14 @@ export default function (pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
debounce = setTimeout(async () => {
|
debounce = setTimeout(async () => {
|
||||||
try {
|
// i wish this fucking worked
|
||||||
const theme = await loadThemeFromPath(THEME_PATH);
|
// pi.sendUserMessage("/reload", { deliverAs: "followUp" });
|
||||||
const result = ctx.ui.setTheme(theme);
|
const theme = await loadThemeFromPath(THEME_PATH);
|
||||||
if (!result.success && result.error) {
|
const res = ctx.ui.setTheme(theme);
|
||||||
ctx.ui.notify(`Theme reload failed: ${result.error}`, "error");
|
if (res.success) {
|
||||||
}
|
ctx.ui.notify("Background changed. Colors: ", "info");
|
||||||
} catch (error) {
|
} else {
|
||||||
ctx.ui.notify(`Theme reload failed: ${String(error)}`, "error");
|
ctx.ui.notify("Theme update failed, fuck", "error");
|
||||||
}
|
}
|
||||||
}, 150);
|
}, 150);
|
||||||
},
|
},
|
||||||
@@ -132,7 +132,8 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
pi.on("session_shutdown", () => {
|
pi.on("session_shutdown", () => {
|
||||||
if (watcher) {
|
if (watcher) {
|
||||||
fs.unwatchFile(THEME_PATH, watcher);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- weird types
|
||||||
|
fs.unwatchFile(THEME_PATH, watcher as any);
|
||||||
watcher = null;
|
watcher = null;
|
||||||
}
|
}
|
||||||
if (debounce) {
|
if (debounce) {
|
||||||
13
pi/files/agent/extensions/matugen-theme-watch/package.json
Normal file
13
pi/files/agent/extensions/matugen-theme-watch/package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-extensions",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@mariozechner/pi-coding-agent": "^0.54.0",
|
||||||
|
"@sinclair/typebox": "^0.34.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"@typescript-eslint/parser": "^8.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3427
pi/files/agent/extensions/matugen-theme-watch/pnpm-lock.yaml
generated
Normal file
3427
pi/files/agent/extensions/matugen-theme-watch/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
pi/files/agent/extensions/matugen-theme-watch/tsconfig.json
Normal file
12
pi/files/agent/extensions/matugen-theme-watch/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts"]
|
||||||
|
}
|
||||||
34
pi/files/agent/extensions/permission-gate.ts
Normal file
34
pi/files/agent/extensions/permission-gate.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Permission Gate Extension
|
||||||
|
*
|
||||||
|
* Prompts for confirmation before running potentially dangerous bash commands.
|
||||||
|
* Patterns checked: rm -rf, sudo, chmod/chown 777
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
||||||
|
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
if (event.toolName !== "bash") return undefined;
|
||||||
|
|
||||||
|
const command = event.input.command as string;
|
||||||
|
const isDangerous = dangerousPatterns.some((p) => p.test(command));
|
||||||
|
|
||||||
|
if (isDangerous) {
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
// In non-interactive mode, block by default
|
||||||
|
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
|
||||||
|
|
||||||
|
if (choice !== "Yes") {
|
||||||
|
return { block: true, reason: "Blocked by user" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
30
pi/files/agent/extensions/protected-paths.ts
Normal file
30
pi/files/agent/extensions/protected-paths.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Protected Paths Extension
|
||||||
|
*
|
||||||
|
* Blocks write and edit operations to protected paths.
|
||||||
|
* Useful for preventing accidental modifications to sensitive files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
const protectedPaths = [".env", ".git/", "node_modules/"];
|
||||||
|
|
||||||
|
pi.on("tool_call", async (event, ctx) => {
|
||||||
|
if (event.toolName !== "write" && event.toolName !== "edit") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = event.input.path as string;
|
||||||
|
const isProtected = protectedPaths.some((p) => path.includes(p));
|
||||||
|
|
||||||
|
if (isProtected) {
|
||||||
|
if (ctx.hasUI) {
|
||||||
|
ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
|
||||||
|
}
|
||||||
|
return { block: true, reason: `Path "${path}" is protected` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
264
pi/files/agent/extensions/question.ts
Normal file
264
pi/files/agent/extensions/question.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* Question Tool - Single question with options
|
||||||
|
* Full custom UI: options list + inline editor for "Type something..."
|
||||||
|
* Escape in editor returns to options, Escape in options cancels
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
interface OptionWithDesc {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisplayOption = OptionWithDesc & { isOther?: boolean };
|
||||||
|
|
||||||
|
interface QuestionDetails {
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
answer: string | null;
|
||||||
|
wasCustom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options with labels and optional descriptions
|
||||||
|
const OptionSchema = Type.Object({
|
||||||
|
label: Type.String({ description: "Display label for the option" }),
|
||||||
|
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const QuestionParams = Type.Object({
|
||||||
|
question: Type.String({ description: "The question to ask the user" }),
|
||||||
|
options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function question(pi: ExtensionAPI) {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "question",
|
||||||
|
label: "Question",
|
||||||
|
description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.",
|
||||||
|
parameters: QuestionParams,
|
||||||
|
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
|
||||||
|
details: {
|
||||||
|
question: params.question,
|
||||||
|
options: params.options.map((o) => o.label),
|
||||||
|
answer: null,
|
||||||
|
} as QuestionDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.options.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "Error: No options provided" }],
|
||||||
|
details: { question: params.question, options: [], answer: null } as QuestionDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allOptions: DisplayOption[] = [...params.options, { label: "Type something.", isOther: true }];
|
||||||
|
|
||||||
|
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>(
|
||||||
|
(tui, theme, _kb, done) => {
|
||||||
|
let optionIndex = 0;
|
||||||
|
let editMode = false;
|
||||||
|
let cachedLines: string[] | undefined;
|
||||||
|
|
||||||
|
const editorTheme: EditorTheme = {
|
||||||
|
borderColor: (s) => theme.fg("accent", s),
|
||||||
|
selectList: {
|
||||||
|
selectedPrefix: (t) => theme.fg("accent", t),
|
||||||
|
selectedText: (t) => theme.fg("accent", t),
|
||||||
|
description: (t) => theme.fg("muted", t),
|
||||||
|
scrollInfo: (t) => theme.fg("dim", t),
|
||||||
|
noMatch: (t) => theme.fg("warning", t),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const editor = new Editor(tui, editorTheme);
|
||||||
|
|
||||||
|
editor.onSubmit = (value) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
done({ answer: trimmed, wasCustom: true });
|
||||||
|
} else {
|
||||||
|
editMode = false;
|
||||||
|
editor.setText("");
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
cachedLines = undefined;
|
||||||
|
tui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(data: string) {
|
||||||
|
if (editMode) {
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
editMode = false;
|
||||||
|
editor.setText("");
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.handleInput(data);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.up)) {
|
||||||
|
optionIndex = Math.max(0, optionIndex - 1);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.down)) {
|
||||||
|
optionIndex = Math.min(allOptions.length - 1, optionIndex + 1);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.enter)) {
|
||||||
|
const selected = allOptions[optionIndex];
|
||||||
|
if (selected.isOther) {
|
||||||
|
editMode = true;
|
||||||
|
refresh();
|
||||||
|
} else {
|
||||||
|
done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
done(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(width: number): string[] {
|
||||||
|
if (cachedLines) return cachedLines;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
||||||
|
|
||||||
|
add(theme.fg("accent", "─".repeat(width)));
|
||||||
|
add(theme.fg("text", ` ${params.question}`));
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
for (let i = 0; i < allOptions.length; i++) {
|
||||||
|
const opt = allOptions[i];
|
||||||
|
const selected = i === optionIndex;
|
||||||
|
const isOther = opt.isOther === true;
|
||||||
|
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
||||||
|
|
||||||
|
if (isOther && editMode) {
|
||||||
|
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
|
||||||
|
} else if (selected) {
|
||||||
|
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
|
||||||
|
} else {
|
||||||
|
add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show description if present
|
||||||
|
if (opt.description) {
|
||||||
|
add(` ${theme.fg("muted", opt.description)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editMode) {
|
||||||
|
lines.push("");
|
||||||
|
add(theme.fg("muted", " Your answer:"));
|
||||||
|
for (const line of editor.render(width - 2)) {
|
||||||
|
add(` ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
if (editMode) {
|
||||||
|
add(theme.fg("dim", " Enter to submit • Esc to go back"));
|
||||||
|
} else {
|
||||||
|
add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel"));
|
||||||
|
}
|
||||||
|
add(theme.fg("accent", "─".repeat(width)));
|
||||||
|
|
||||||
|
cachedLines = lines;
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
render,
|
||||||
|
invalidate: () => {
|
||||||
|
cachedLines = undefined;
|
||||||
|
},
|
||||||
|
handleInput,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build simple options list for details
|
||||||
|
const simpleOptions = params.options.map((o) => o.label);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "User cancelled the selection" }],
|
||||||
|
details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.wasCustom) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `User wrote: ${result.answer}` }],
|
||||||
|
details: {
|
||||||
|
question: params.question,
|
||||||
|
options: simpleOptions,
|
||||||
|
answer: result.answer,
|
||||||
|
wasCustom: true,
|
||||||
|
} as QuestionDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `User selected: ${result.index}. ${result.answer}` }],
|
||||||
|
details: {
|
||||||
|
question: params.question,
|
||||||
|
options: simpleOptions,
|
||||||
|
answer: result.answer,
|
||||||
|
wasCustom: false,
|
||||||
|
} as QuestionDetails,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderCall(args, theme) {
|
||||||
|
let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
|
||||||
|
const opts = Array.isArray(args.options) ? args.options : [];
|
||||||
|
if (opts.length) {
|
||||||
|
const labels = opts.map((o: OptionWithDesc) => o.label);
|
||||||
|
const numbered = [...labels, "Type something."].map((o, i) => `${i + 1}. ${o}`);
|
||||||
|
text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
|
||||||
|
}
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderResult(result, _options, theme) {
|
||||||
|
const details = result.details as QuestionDetails | undefined;
|
||||||
|
if (!details) {
|
||||||
|
const text = result.content[0];
|
||||||
|
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.answer === null) {
|
||||||
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.wasCustom) {
|
||||||
|
return new Text(
|
||||||
|
theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const idx = details.options.indexOf(details.answer) + 1;
|
||||||
|
const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer;
|
||||||
|
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
427
pi/files/agent/extensions/questionnaire.ts
Normal file
427
pi/files/agent/extensions/questionnaire.ts
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
/**
|
||||||
|
* Questionnaire Tool - Unified tool for asking single or multiple questions
|
||||||
|
*
|
||||||
|
* Single question: simple options list
|
||||||
|
* Multiple questions: tab bar navigation between questions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface QuestionOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderOption = QuestionOption & { isOther?: boolean };
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
prompt: string;
|
||||||
|
options: QuestionOption[];
|
||||||
|
allowOther: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Answer {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
wasCustom: boolean;
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuestionnaireResult {
|
||||||
|
questions: Question[];
|
||||||
|
answers: Answer[];
|
||||||
|
cancelled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema
|
||||||
|
const QuestionOptionSchema = Type.Object({
|
||||||
|
value: Type.String({ description: "The value returned when selected" }),
|
||||||
|
label: Type.String({ description: "Display label for the option" }),
|
||||||
|
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const QuestionSchema = Type.Object({
|
||||||
|
id: Type.String({ description: "Unique identifier for this question" }),
|
||||||
|
label: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description: "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
prompt: Type.String({ description: "The full question text to display" }),
|
||||||
|
options: Type.Array(QuestionOptionSchema, { description: "Available options to choose from" }),
|
||||||
|
allowOther: Type.Optional(Type.Boolean({ description: "Allow 'Type something' option (default: true)" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const QuestionnaireParams = Type.Object({
|
||||||
|
questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function errorResult(
|
||||||
|
message: string,
|
||||||
|
questions: Question[] = [],
|
||||||
|
): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: message }],
|
||||||
|
details: { questions, answers: [], cancelled: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function questionnaire(pi: ExtensionAPI) {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "questionnaire",
|
||||||
|
label: "Questionnaire",
|
||||||
|
description:
|
||||||
|
"Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.",
|
||||||
|
parameters: QuestionnaireParams,
|
||||||
|
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
if (!ctx.hasUI) {
|
||||||
|
return errorResult("Error: UI not available (running in non-interactive mode)");
|
||||||
|
}
|
||||||
|
if (params.questions.length === 0) {
|
||||||
|
return errorResult("Error: No questions provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize questions with defaults
|
||||||
|
const questions: Question[] = params.questions.map((q, i) => ({
|
||||||
|
...q,
|
||||||
|
label: q.label || `Q${i + 1}`,
|
||||||
|
allowOther: q.allowOther !== false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isMulti = questions.length > 1;
|
||||||
|
const totalTabs = questions.length + 1; // questions + Submit
|
||||||
|
|
||||||
|
const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
|
||||||
|
// State
|
||||||
|
let currentTab = 0;
|
||||||
|
let optionIndex = 0;
|
||||||
|
let inputMode = false;
|
||||||
|
let inputQuestionId: string | null = null;
|
||||||
|
let cachedLines: string[] | undefined;
|
||||||
|
const answers = new Map<string, Answer>();
|
||||||
|
|
||||||
|
// Editor for "Type something" option
|
||||||
|
const editorTheme: EditorTheme = {
|
||||||
|
borderColor: (s) => theme.fg("accent", s),
|
||||||
|
selectList: {
|
||||||
|
selectedPrefix: (t) => theme.fg("accent", t),
|
||||||
|
selectedText: (t) => theme.fg("accent", t),
|
||||||
|
description: (t) => theme.fg("muted", t),
|
||||||
|
scrollInfo: (t) => theme.fg("dim", t),
|
||||||
|
noMatch: (t) => theme.fg("warning", t),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const editor = new Editor(tui, editorTheme);
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function refresh() {
|
||||||
|
cachedLines = undefined;
|
||||||
|
tui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(cancelled: boolean) {
|
||||||
|
done({ questions, answers: Array.from(answers.values()), cancelled });
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentQuestion(): Question | undefined {
|
||||||
|
return questions[currentTab];
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentOptions(): RenderOption[] {
|
||||||
|
const q = currentQuestion();
|
||||||
|
if (!q) return [];
|
||||||
|
const opts: RenderOption[] = [...q.options];
|
||||||
|
if (q.allowOther) {
|
||||||
|
opts.push({ value: "__other__", label: "Type something.", isOther: true });
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allAnswered(): boolean {
|
||||||
|
return questions.every((q) => answers.has(q.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceAfterAnswer() {
|
||||||
|
if (!isMulti) {
|
||||||
|
submit(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentTab < questions.length - 1) {
|
||||||
|
currentTab++;
|
||||||
|
} else {
|
||||||
|
currentTab = questions.length; // Submit tab
|
||||||
|
}
|
||||||
|
optionIndex = 0;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) {
|
||||||
|
answers.set(questionId, { id: questionId, value, label, wasCustom, index });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editor submit callback
|
||||||
|
editor.onSubmit = (value) => {
|
||||||
|
if (!inputQuestionId) return;
|
||||||
|
const trimmed = value.trim() || "(no response)";
|
||||||
|
saveAnswer(inputQuestionId, trimmed, trimmed, true);
|
||||||
|
inputMode = false;
|
||||||
|
inputQuestionId = null;
|
||||||
|
editor.setText("");
|
||||||
|
advanceAfterAnswer();
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleInput(data: string) {
|
||||||
|
// Input mode: route to editor
|
||||||
|
if (inputMode) {
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
inputMode = false;
|
||||||
|
inputQuestionId = null;
|
||||||
|
editor.setText("");
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.handleInput(data);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = currentQuestion();
|
||||||
|
const opts = currentOptions();
|
||||||
|
|
||||||
|
// Tab navigation (multi-question only)
|
||||||
|
if (isMulti) {
|
||||||
|
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
||||||
|
currentTab = (currentTab + 1) % totalTabs;
|
||||||
|
optionIndex = 0;
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
||||||
|
currentTab = (currentTab - 1 + totalTabs) % totalTabs;
|
||||||
|
optionIndex = 0;
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit tab
|
||||||
|
if (currentTab === questions.length) {
|
||||||
|
if (matchesKey(data, Key.enter) && allAnswered()) {
|
||||||
|
submit(false);
|
||||||
|
} else if (matchesKey(data, Key.escape)) {
|
||||||
|
submit(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option navigation
|
||||||
|
if (matchesKey(data, Key.up)) {
|
||||||
|
optionIndex = Math.max(0, optionIndex - 1);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matchesKey(data, Key.down)) {
|
||||||
|
optionIndex = Math.min(opts.length - 1, optionIndex + 1);
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select option
|
||||||
|
if (matchesKey(data, Key.enter) && q) {
|
||||||
|
const opt = opts[optionIndex];
|
||||||
|
if (opt.isOther) {
|
||||||
|
inputMode = true;
|
||||||
|
inputQuestionId = q.id;
|
||||||
|
editor.setText("");
|
||||||
|
refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
|
||||||
|
advanceAfterAnswer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel
|
||||||
|
if (matchesKey(data, Key.escape)) {
|
||||||
|
submit(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(width: number): string[] {
|
||||||
|
if (cachedLines) return cachedLines;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
const q = currentQuestion();
|
||||||
|
const opts = currentOptions();
|
||||||
|
|
||||||
|
// Helper to add truncated line
|
||||||
|
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
||||||
|
|
||||||
|
add(theme.fg("accent", "─".repeat(width)));
|
||||||
|
|
||||||
|
// Tab bar (multi-question only)
|
||||||
|
if (isMulti) {
|
||||||
|
const tabs: string[] = ["← "];
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const isActive = i === currentTab;
|
||||||
|
const isAnswered = answers.has(questions[i].id);
|
||||||
|
const lbl = questions[i].label;
|
||||||
|
const box = isAnswered ? "■" : "□";
|
||||||
|
const color = isAnswered ? "success" : "muted";
|
||||||
|
const text = ` ${box} ${lbl} `;
|
||||||
|
const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text);
|
||||||
|
tabs.push(`${styled} `);
|
||||||
|
}
|
||||||
|
const canSubmit = allAnswered();
|
||||||
|
const isSubmitTab = currentTab === questions.length;
|
||||||
|
const submitText = " ✓ Submit ";
|
||||||
|
const submitStyled = isSubmitTab
|
||||||
|
? theme.bg("selectedBg", theme.fg("text", submitText))
|
||||||
|
: theme.fg(canSubmit ? "success" : "dim", submitText);
|
||||||
|
tabs.push(`${submitStyled} →`);
|
||||||
|
add(` ${tabs.join("")}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to render options list
|
||||||
|
function renderOptions() {
|
||||||
|
for (let i = 0; i < opts.length; i++) {
|
||||||
|
const opt = opts[i];
|
||||||
|
const selected = i === optionIndex;
|
||||||
|
const isOther = opt.isOther === true;
|
||||||
|
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
||||||
|
const color = selected ? "accent" : "text";
|
||||||
|
// Mark "Type something" differently when in input mode
|
||||||
|
if (isOther && inputMode) {
|
||||||
|
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
|
||||||
|
} else {
|
||||||
|
add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
|
||||||
|
}
|
||||||
|
if (opt.description) {
|
||||||
|
add(` ${theme.fg("muted", opt.description)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if (inputMode && q) {
|
||||||
|
add(theme.fg("text", ` ${q.prompt}`));
|
||||||
|
lines.push("");
|
||||||
|
// Show options for reference
|
||||||
|
renderOptions();
|
||||||
|
lines.push("");
|
||||||
|
add(theme.fg("muted", " Your answer:"));
|
||||||
|
for (const line of editor.render(width - 2)) {
|
||||||
|
add(` ${line}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
add(theme.fg("dim", " Enter to submit • Esc to cancel"));
|
||||||
|
} else if (currentTab === questions.length) {
|
||||||
|
add(theme.fg("accent", theme.bold(" Ready to submit")));
|
||||||
|
lines.push("");
|
||||||
|
for (const question of questions) {
|
||||||
|
const answer = answers.get(question.id);
|
||||||
|
if (answer) {
|
||||||
|
const prefix = answer.wasCustom ? "(wrote) " : "";
|
||||||
|
add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
if (allAnswered()) {
|
||||||
|
add(theme.fg("success", " Press Enter to submit"));
|
||||||
|
} else {
|
||||||
|
const missing = questions
|
||||||
|
.filter((q) => !answers.has(q.id))
|
||||||
|
.map((q) => q.label)
|
||||||
|
.join(", ");
|
||||||
|
add(theme.fg("warning", ` Unanswered: ${missing}`));
|
||||||
|
}
|
||||||
|
} else if (q) {
|
||||||
|
add(theme.fg("text", ` ${q.prompt}`));
|
||||||
|
lines.push("");
|
||||||
|
renderOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
if (!inputMode) {
|
||||||
|
const help = isMulti
|
||||||
|
? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
|
||||||
|
: " ↑↓ navigate • Enter select • Esc cancel";
|
||||||
|
add(theme.fg("dim", help));
|
||||||
|
}
|
||||||
|
add(theme.fg("accent", "─".repeat(width)));
|
||||||
|
|
||||||
|
cachedLines = lines;
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
render,
|
||||||
|
invalidate: () => {
|
||||||
|
cachedLines = undefined;
|
||||||
|
},
|
||||||
|
handleInput,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.cancelled) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "User cancelled the questionnaire" }],
|
||||||
|
details: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerLines = result.answers.map((a) => {
|
||||||
|
const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
|
||||||
|
if (a.wasCustom) {
|
||||||
|
return `${qLabel}: user wrote: ${a.label}`;
|
||||||
|
}
|
||||||
|
return `${qLabel}: user selected: ${a.index}. ${a.label}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: answerLines.join("\n") }],
|
||||||
|
details: result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderCall(args, theme) {
|
||||||
|
const qs = (args.questions as Question[]) || [];
|
||||||
|
const count = qs.length;
|
||||||
|
const labels = qs.map((q) => q.label || q.id).join(", ");
|
||||||
|
let text = theme.fg("toolTitle", theme.bold("questionnaire "));
|
||||||
|
text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
|
||||||
|
if (labels) {
|
||||||
|
text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
|
||||||
|
}
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderResult(result, _options, theme) {
|
||||||
|
const details = result.details as QuestionnaireResult | undefined;
|
||||||
|
if (!details) {
|
||||||
|
const text = result.content[0];
|
||||||
|
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||||
|
}
|
||||||
|
if (details.cancelled) {
|
||||||
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||||
|
}
|
||||||
|
const lines = details.answers.map((a) => {
|
||||||
|
if (a.wasCustom) {
|
||||||
|
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`;
|
||||||
|
}
|
||||||
|
const display = a.index ? `${a.index}. ${a.label}` : a.label;
|
||||||
|
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`;
|
||||||
|
});
|
||||||
|
return new Text(lines.join("\n"), 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
27
pi/files/agent/extensions/session-name.ts
Normal file
27
pi/files/agent/extensions/session-name.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Session naming example.
|
||||||
|
*
|
||||||
|
* Shows setSessionName/getSessionName to give sessions friendly names
|
||||||
|
* that appear in the session selector instead of the first message.
|
||||||
|
*
|
||||||
|
* Usage: /session-name [name] - set or show session name
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerCommand("session-name", {
|
||||||
|
description: "Set or show session name (usage: /session-name [new name])",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const name = args.trim();
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
pi.setSessionName(name);
|
||||||
|
ctx.ui.notify(`Session named: ${name}`, "info");
|
||||||
|
} else {
|
||||||
|
const current = pi.getSessionName();
|
||||||
|
ctx.ui.notify(current ? `Session: ${current}` : "No session name set", "info");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
69
pi/files/agent/extensions/shot.ts
Normal file
69
pi/files/agent/extensions/shot.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
|
||||||
|
function getLatestScreenshotPath(): { path?: string; error?: string } {
|
||||||
|
const home = os.homedir();
|
||||||
|
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
const macPath = path.join(home, "screenshot.png");
|
||||||
|
if (fs.existsSync(macPath)) {
|
||||||
|
return { path: macPath };
|
||||||
|
}
|
||||||
|
return { error: `No screenshot found at ${macPath}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenshotsDir = path.join(home, "Pictures", "Screenshots");
|
||||||
|
if (!fs.existsSync(screenshotsDir)) {
|
||||||
|
return { error: `Screenshots directory not found: ${screenshotsDir}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(screenshotsDir)
|
||||||
|
.filter((file) => file.toLowerCase().endsWith(".png"))
|
||||||
|
.map((file) => path.join(screenshotsDir, file));
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return { error: `No screenshots found in ${screenshotsDir}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||||
|
return { path: files[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerCommand("shot", {
|
||||||
|
description: "Insert the latest screenshot path into the editor",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const result = getLatestScreenshotPath();
|
||||||
|
if (result.error) {
|
||||||
|
ctx.ui.notify(result.error, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.ui.setEditorText(result.path!);
|
||||||
|
ctx.ui.notify("Inserted latest screenshot path", "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "shot_path",
|
||||||
|
label: "Latest screenshot path",
|
||||||
|
description: "Get the latest screenshot path for this machine",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute() {
|
||||||
|
const result = getLatestScreenshotPath();
|
||||||
|
if (result.error) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: result.error }],
|
||||||
|
details: { error: result.error },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: result.path! }],
|
||||||
|
details: { path: result.path },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
40
pi/files/agent/extensions/status-line.ts
Normal file
40
pi/files/agent/extensions/status-line.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Status Line Extension
|
||||||
|
*
|
||||||
|
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
|
||||||
|
* Shows turn progress with themed colors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let turnCount = 0;
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
const theme = ctx.ui.theme;
|
||||||
|
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_start", async (_event, ctx) => {
|
||||||
|
turnCount++;
|
||||||
|
const theme = ctx.ui.theme;
|
||||||
|
const spinner = theme.fg("accent", "●");
|
||||||
|
const text = theme.fg("dim", ` Turn ${turnCount}...`);
|
||||||
|
ctx.ui.setStatus("status-demo", spinner + text);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("turn_end", async (_event, ctx) => {
|
||||||
|
const theme = ctx.ui.theme;
|
||||||
|
const check = theme.fg("success", "✓");
|
||||||
|
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
|
||||||
|
ctx.ui.setStatus("status-demo", check + text);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_switch", async (event, ctx) => {
|
||||||
|
if (event.reason === "new") {
|
||||||
|
turnCount = 0;
|
||||||
|
const theme = ctx.ui.theme;
|
||||||
|
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
58
pi/files/agent/extensions/titlebar-spinner.ts
Normal file
58
pi/files/agent/extensions/titlebar-spinner.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Titlebar Spinner Extension
|
||||||
|
*
|
||||||
|
* Shows a braille spinner animation in the terminal title while the agent is working.
|
||||||
|
* Uses `ctx.ui.setTitle()` to update the terminal title via the extension API.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pi --extension examples/extensions/titlebar-spinner.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
||||||
|
function getBaseTitle(pi: ExtensionAPI): string {
|
||||||
|
const cwd = path.basename(process.cwd());
|
||||||
|
const session = pi.getSessionName();
|
||||||
|
return session ? `π - ${session} - ${cwd}` : `π - ${cwd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let frameIndex = 0;
|
||||||
|
|
||||||
|
function stopAnimation(ctx: ExtensionContext) {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
frameIndex = 0;
|
||||||
|
ctx.ui.setTitle(getBaseTitle(pi));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAnimation(ctx: ExtensionContext) {
|
||||||
|
stopAnimation(ctx);
|
||||||
|
timer = setInterval(() => {
|
||||||
|
const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length];
|
||||||
|
const cwd = path.basename(process.cwd());
|
||||||
|
const session = pi.getSessionName();
|
||||||
|
const title = session ? `${frame} π - ${session} - ${cwd}` : `${frame} π - ${cwd}`;
|
||||||
|
ctx.ui.setTitle(title);
|
||||||
|
frameIndex++;
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("agent_start", async (_event, ctx) => {
|
||||||
|
startAnimation(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("agent_end", async (_event, ctx) => {
|
||||||
|
stopAnimation(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", async (_event, ctx) => {
|
||||||
|
stopAnimation(ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerCommand("tools", {
|
|
||||||
description: "List available and active tools",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
const allTools = pi.getAllTools();
|
|
||||||
const activeTools = new Set(pi.getActiveTools());
|
|
||||||
const lines = allTools
|
|
||||||
.map((tool) => {
|
|
||||||
const status = activeTools.has(tool.name) ? "active" : "inactive";
|
|
||||||
return `- ${tool.name} (${status})${tool.description ? `: ${tool.description}` : ""}`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
ctx.ui.notify(lines || "No tools registered.", "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
146
pi/files/agent/extensions/tools.ts
Normal file
146
pi/files/agent/extensions/tools.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Tools Extension
|
||||||
|
*
|
||||||
|
* Provides a /tools command to enable/disable tools interactively.
|
||||||
|
* Tool selection persists across session reloads and respects branch navigation.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
|
||||||
|
* 2. Use /tools to open the tool selector
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI, ExtensionContext, ToolInfo } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Container, type SettingItem, SettingsList } from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
|
// State persisted to session
|
||||||
|
interface ToolsState {
|
||||||
|
enabledTools: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function toolsExtension(pi: ExtensionAPI) {
|
||||||
|
// Track enabled tools
|
||||||
|
let enabledTools: Set<string> = new Set();
|
||||||
|
let allTools: ToolInfo[] = [];
|
||||||
|
|
||||||
|
// Persist current state
|
||||||
|
function persistState() {
|
||||||
|
pi.appendEntry<ToolsState>("tools-config", {
|
||||||
|
enabledTools: Array.from(enabledTools),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply current tool selection
|
||||||
|
function applyTools() {
|
||||||
|
pi.setActiveTools(Array.from(enabledTools));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the last tools-config entry in the current branch
|
||||||
|
function restoreFromBranch(ctx: ExtensionContext) {
|
||||||
|
allTools = pi.getAllTools();
|
||||||
|
|
||||||
|
// Get entries in current branch only
|
||||||
|
const branchEntries = ctx.sessionManager.getBranch();
|
||||||
|
let savedTools: string[] | undefined;
|
||||||
|
|
||||||
|
for (const entry of branchEntries) {
|
||||||
|
if (entry.type === "custom" && entry.customType === "tools-config") {
|
||||||
|
const data = entry.data as ToolsState | undefined;
|
||||||
|
if (data?.enabledTools) {
|
||||||
|
savedTools = data.enabledTools;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedTools) {
|
||||||
|
// Restore saved tool selection (filter to only tools that still exist)
|
||||||
|
const allToolNames = allTools.map((t) => t.name);
|
||||||
|
enabledTools = new Set(savedTools.filter((t: string) => allToolNames.includes(t)));
|
||||||
|
applyTools();
|
||||||
|
} else {
|
||||||
|
// No saved state - sync with currently active tools
|
||||||
|
enabledTools = new Set(pi.getActiveTools());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register /tools command
|
||||||
|
pi.registerCommand("tools", {
|
||||||
|
description: "Enable/disable tools",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
// Refresh tool list
|
||||||
|
allTools = pi.getAllTools();
|
||||||
|
|
||||||
|
await ctx.ui.custom((tui, theme, _kb, done) => {
|
||||||
|
// Build settings items for each tool
|
||||||
|
const items: SettingItem[] = allTools.map((tool) => ({
|
||||||
|
id: tool.name,
|
||||||
|
label: tool.name,
|
||||||
|
currentValue: enabledTools.has(tool.name) ? "enabled" : "disabled",
|
||||||
|
values: ["enabled", "disabled"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = new Container();
|
||||||
|
container.addChild(
|
||||||
|
new (class {
|
||||||
|
render(_width: number) {
|
||||||
|
return [theme.fg("accent", theme.bold("Tool Configuration")), ""];
|
||||||
|
}
|
||||||
|
invalidate() {}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const settingsList = new SettingsList(
|
||||||
|
items,
|
||||||
|
Math.min(items.length + 2, 15),
|
||||||
|
getSettingsListTheme(),
|
||||||
|
(id, newValue) => {
|
||||||
|
// Update enabled state and apply immediately
|
||||||
|
if (newValue === "enabled") {
|
||||||
|
enabledTools.add(id);
|
||||||
|
} else {
|
||||||
|
enabledTools.delete(id);
|
||||||
|
}
|
||||||
|
applyTools();
|
||||||
|
persistState();
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Close dialog
|
||||||
|
done(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
container.addChild(settingsList);
|
||||||
|
|
||||||
|
const component = {
|
||||||
|
render(width: number) {
|
||||||
|
return container.render(width);
|
||||||
|
},
|
||||||
|
invalidate() {
|
||||||
|
container.invalidate();
|
||||||
|
},
|
||||||
|
handleInput(data: string) {
|
||||||
|
settingsList.handleInput?.(data);
|
||||||
|
tui.requestRender();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return component;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore state on session start
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
restoreFromBranch(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore state when navigating the session tree
|
||||||
|
pi.on("session_tree", async (_event, ctx) => {
|
||||||
|
restoreFromBranch(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore state after forking
|
||||||
|
pi.on("session_fork", async (_event, ctx) => {
|
||||||
|
restoreFromBranch(ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
192
pi/files/agent/extensions/truncated-tool.ts
Normal file
192
pi/files/agent/extensions/truncated-tool.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* Truncated Tool Example - Demonstrates proper output truncation for custom tools
|
||||||
|
*
|
||||||
|
* Custom tools MUST truncate their output to avoid overwhelming the LLM context.
|
||||||
|
* The built-in limit is 50KB (~10k tokens) and 2000 lines, whichever is hit first.
|
||||||
|
*
|
||||||
|
* This example shows how to:
|
||||||
|
* 1. Use the built-in truncation utilities
|
||||||
|
* 2. Write full output to a temp file when truncated
|
||||||
|
* 3. Inform the LLM where to find the complete output
|
||||||
|
* 4. Custom rendering of tool calls and results
|
||||||
|
*
|
||||||
|
* The `rg` tool here wraps ripgrep with proper truncation. Compare this to the
|
||||||
|
* built-in `grep` tool in src/core/tools/grep.ts for a more complete implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import {
|
||||||
|
DEFAULT_MAX_BYTES,
|
||||||
|
DEFAULT_MAX_LINES,
|
||||||
|
formatSize,
|
||||||
|
type TruncationResult,
|
||||||
|
truncateHead,
|
||||||
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
import { Text } from "@mariozechner/pi-tui";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import { mkdtempSync, writeFileSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const RgParams = Type.Object({
|
||||||
|
pattern: Type.String({ description: "Search pattern (regex)" }),
|
||||||
|
path: Type.Optional(Type.String({ description: "Directory to search (default: current directory)" })),
|
||||||
|
glob: Type.Optional(Type.String({ description: "File glob pattern, e.g. '*.ts'" })),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RgDetails {
|
||||||
|
pattern: string;
|
||||||
|
path?: string;
|
||||||
|
glob?: string;
|
||||||
|
matchCount: number;
|
||||||
|
truncation?: TruncationResult;
|
||||||
|
fullOutputPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerTool({
|
||||||
|
name: "rg",
|
||||||
|
label: "ripgrep",
|
||||||
|
// Document the truncation limits in the tool description so the LLM knows
|
||||||
|
description: `Search file contents using ripgrep. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)} (whichever is hit first). If truncated, full output is saved to a temp file.`,
|
||||||
|
parameters: RgParams,
|
||||||
|
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const { pattern, path: searchPath, glob } = params;
|
||||||
|
|
||||||
|
// Build the ripgrep command
|
||||||
|
const args = ["rg", "--line-number", "--color=never"];
|
||||||
|
if (glob) args.push("--glob", glob);
|
||||||
|
args.push(pattern);
|
||||||
|
args.push(searchPath || ".");
|
||||||
|
|
||||||
|
let output: string;
|
||||||
|
try {
|
||||||
|
output = execSync(args.join(" "), {
|
||||||
|
cwd: ctx.cwd,
|
||||||
|
encoding: "utf-8",
|
||||||
|
maxBuffer: 100 * 1024 * 1024, // 100MB buffer to capture full output
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
// ripgrep exits with 1 when no matches found
|
||||||
|
if (err.status === 1) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "No matches found" }],
|
||||||
|
details: { pattern, path: searchPath, glob, matchCount: 0 } as RgDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`ripgrep failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!output.trim()) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: "No matches found" }],
|
||||||
|
details: { pattern, path: searchPath, glob, matchCount: 0 } as RgDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply truncation using built-in utilities
|
||||||
|
// truncateHead keeps the first N lines/bytes (good for search results)
|
||||||
|
// truncateTail keeps the last N lines/bytes (good for logs/command output)
|
||||||
|
const truncation = truncateHead(output, {
|
||||||
|
maxLines: DEFAULT_MAX_LINES,
|
||||||
|
maxBytes: DEFAULT_MAX_BYTES,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count matches (each non-empty line with a match)
|
||||||
|
const matchCount = output.split("\n").filter((line) => line.trim()).length;
|
||||||
|
|
||||||
|
const details: RgDetails = {
|
||||||
|
pattern,
|
||||||
|
path: searchPath,
|
||||||
|
glob,
|
||||||
|
matchCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resultText = truncation.content;
|
||||||
|
|
||||||
|
if (truncation.truncated) {
|
||||||
|
// Save full output to a temp file so LLM can access it if needed
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-rg-"));
|
||||||
|
const tempFile = join(tempDir, "output.txt");
|
||||||
|
writeFileSync(tempFile, output);
|
||||||
|
|
||||||
|
details.truncation = truncation;
|
||||||
|
details.fullOutputPath = tempFile;
|
||||||
|
|
||||||
|
// Add truncation notice - this helps the LLM understand the output is incomplete
|
||||||
|
const truncatedLines = truncation.totalLines - truncation.outputLines;
|
||||||
|
const truncatedBytes = truncation.totalBytes - truncation.outputBytes;
|
||||||
|
|
||||||
|
resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
||||||
|
resultText += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
||||||
|
resultText += ` ${truncatedLines} lines (${formatSize(truncatedBytes)}) omitted.`;
|
||||||
|
resultText += ` Full output saved to: ${tempFile}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: resultText }],
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Custom rendering of the tool call (shown before/during execution)
|
||||||
|
renderCall(args, theme) {
|
||||||
|
let text = theme.fg("toolTitle", theme.bold("rg "));
|
||||||
|
text += theme.fg("accent", `"${args.pattern}"`);
|
||||||
|
if (args.path) {
|
||||||
|
text += theme.fg("muted", ` in ${args.path}`);
|
||||||
|
}
|
||||||
|
if (args.glob) {
|
||||||
|
text += theme.fg("dim", ` --glob ${args.glob}`);
|
||||||
|
}
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Custom rendering of the tool result
|
||||||
|
renderResult(result, { expanded, isPartial }, theme) {
|
||||||
|
const details = result.details as RgDetails | undefined;
|
||||||
|
|
||||||
|
// Handle streaming/partial results
|
||||||
|
if (isPartial) {
|
||||||
|
return new Text(theme.fg("warning", "Searching..."), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No matches
|
||||||
|
if (!details || details.matchCount === 0) {
|
||||||
|
return new Text(theme.fg("dim", "No matches found"), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result display
|
||||||
|
let text = theme.fg("success", `${details.matchCount} matches`);
|
||||||
|
|
||||||
|
// Show truncation warning if applicable
|
||||||
|
if (details.truncation?.truncated) {
|
||||||
|
text += theme.fg("warning", " (truncated)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// In expanded view, show the actual matches
|
||||||
|
if (expanded) {
|
||||||
|
const content = result.content[0];
|
||||||
|
if (content?.type === "text") {
|
||||||
|
// Show first 20 lines in expanded view, or all if fewer
|
||||||
|
const lines = content.text.split("\n").slice(0, 20);
|
||||||
|
for (const line of lines) {
|
||||||
|
text += `\n${theme.fg("dim", line)}`;
|
||||||
|
}
|
||||||
|
if (content.text.split("\n").length > 20) {
|
||||||
|
text += `\n${theme.fg("muted", "... (use read tool to see full output)")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show temp file path if truncated
|
||||||
|
if (details.fullOutputPath) {
|
||||||
|
text += `\n${theme.fg("dim", `Full output: ${details.fullOutputPath}`)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"lastChangelogVersion": "0.54.0",
|
"lastChangelogVersion": "0.54.0",
|
||||||
"defaultProvider": "openrouter",
|
"defaultProvider": "openrouter",
|
||||||
"defaultModel": "openai/gpt-5.2-codex",
|
"defaultModel": "z-ai/glm-5",
|
||||||
"defaultThinkingLevel": "minimal",
|
"defaultThinkingLevel": "high",
|
||||||
"theme": "matugen"
|
"theme": "matugen"
|
||||||
}
|
}
|
||||||
90
pi/files/agent/skills/github-prs/SKILL.md
Normal file
90
pi/files/agent/skills/github-prs/SKILL.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: github-prs
|
||||||
|
description: Find, inspect, and create GitHub pull requests for a branch using the GitHub CLI and git. Use when you need to locate the PR tied to a branch or open/update a PR.
|
||||||
|
---
|
||||||
|
|
||||||
|
# GitHub PRs (branch workflows)
|
||||||
|
|
||||||
|
This skill documents common GitHub CLI workflows for finding a PR for a branch, inspecting it, and creating one if needed.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Repository has a GitHub remote (usually `origin`).
|
||||||
|
- GitHub CLI (`gh`) is installed and authenticated (`gh auth status`).
|
||||||
|
|
||||||
|
## Find the PR for the current branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show current branch
|
||||||
|
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
# Find PR for the current branch (if one exists)
|
||||||
|
gh pr list --head "$branch" --state all
|
||||||
|
|
||||||
|
# View the PR details by branch
|
||||||
|
pr_number=$(gh pr list --head "$branch" --state all --json number --jq '.[0].number')
|
||||||
|
if [ -n "$pr_number" ]; then
|
||||||
|
gh pr view "$pr_number" --web
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Find PRs by branch name (explicit)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
branch="feature/your-branch"
|
||||||
|
gh pr list --head "$branch" --state all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Find the PR for the current branch (alternative via git)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If upstream is set, this is handy for branch -> PR lookup
|
||||||
|
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name @{u})
|
||||||
|
if [ -n "$upstream" ]; then
|
||||||
|
gh pr list --head "${upstream#origin/}" --state all
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create a PR from the current branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
# Push branch if needed
|
||||||
|
if ! git rev-parse --symbolic-full-name "${branch}@{u}" >/dev/null 2>&1; then
|
||||||
|
git push -u origin "$branch"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create PR (edit title/body as needed)
|
||||||
|
gh pr create \
|
||||||
|
--title "[ISSUE-123] Short summary" \
|
||||||
|
--body $'[](https://awac.attio.com/redirect/developer-preview/<preview-hash>)\n\n\n\n- Summary bullet 1\n- Summary bullet 2\n'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update an existing PR body
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pr_number=<number>
|
||||||
|
|
||||||
|
# Edit in editor
|
||||||
|
EDITOR=vim gh pr edit "$pr_number" --body-file -
|
||||||
|
|
||||||
|
# Or overwrite directly
|
||||||
|
gh pr edit "$pr_number" --body $'...'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Confirm gh auth and repo
|
||||||
|
|
||||||
|
gh auth status
|
||||||
|
|
||||||
|
gh repo view --web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If `gh pr list --head` returns nothing, the PR may not exist yet, or the branch name differs.
|
||||||
|
- Use `git branch -r | rg <branch-name>` to confirm the remote branch name.
|
||||||
|
- Ensure you're in the correct repo (`git remote -v`).
|
||||||
Reference in New Issue
Block a user