Compare commits

...

10 Commits

Author SHA1 Message Date
214a765cf3 modal editor is a bit bad 2026-02-21 13:00:57 +00:00
fbfc3c33ca extensions! 2026-02-21 12:56:20 +00:00
Thomas G. Lopes
52cca2148f github prs skill 2026-02-21 12:43:00 +00:00
c69180d37b glm 5 2026-02-21 12:42:46 +00:00
6663bd01a5 shot extension 2026-02-20 17:50:19 +00:00
Thomas G. Lopes
9bad10d2fd fish changes 2026-02-20 17:42:37 +00:00
567a2a3ffc Add screenshot sync to Mac via niri 2026-02-20 17:37:53 +00:00
6a82a3c5d2 several changes 2026-02-20 15:36:45 +00:00
cb1f893e3d shut up eslint 2026-02-20 14:41:07 +00:00
28b6e89f62 fix notifications 2026-02-20 12:24:36 +00:00
35 changed files with 5941 additions and 43 deletions

View File

@@ -152,7 +152,7 @@ set --export BUN_INSTALL "$HOME/.bun"
set --export PATH $BUN_INSTALL/bin $PATH
# opencode
fish_add_path /home/thomasgl/.opencode/bin
fish_add_path ~/.opencode/bin
if test (uname) = Darwin
if test -f ~/.safe-chain/scripts/init-fish.fish
@@ -165,4 +165,8 @@ if test (uname) = Darwin
end
set -gx PATH /Users/thomasglopes/.local/bin /Users/thomasglopes/google-cloud-sdk/bin $PATH
# opencode
fish_add_path /Users/thomasglopes/.opencode/bin
end

View File

@@ -29,7 +29,7 @@ map kitty_mod+,
#: kitty has very powerful font management. You can configure
#: individual font faces and even specify special fonts for particular
#: characters.
font_family family="FantasqueSansM Nerd Font"
font_family family="IosevkaTermSlab Nerd Font"
bold_font auto
italic_font auto
bold_italic_font auto

View File

@@ -8,5 +8,6 @@ mkdir -p "$HOME/.cache"
{
ssh mac-attio "mkdir -p ~/.pi/agent/themes"
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

View File

@@ -212,9 +212,9 @@ window-rule {
// Block out sensitive components from screencasts
layer-rule {
match namespace="^dms:clipboard$"
match namespace="^dms:notification$"
match namespace="^dms:notification"
block-out-from "screencast"
block-out-from "screen-capture"
}
window-rule {
@@ -489,7 +489,7 @@ binds {
// Mod+Space { switch-layout "next"; }
// 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+Alt+P { screenshot-window; }

View 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

View File

@@ -73,10 +73,10 @@ return {
mode = "location",
},
},
on_attach = function(_, bufnr)
vim.api.nvim_create_autocmd("BufWritePre", {
buffer = bufnr,
command = "EslintFixAll",
})
end,
-- on_attach = function(_, bufnr)
-- vim.api.nvim_create_autocmd("BufWritePre", {
-- buffer = bufnr,
-- command = "EslintFixAll",
-- })
-- end,
}

View File

@@ -67,7 +67,7 @@ end, "Organize Imports")
-- end, "Lint file")
map("<leader>esf", function()
vim.cmd("EslintFixAll")
-- vim.cmd("EslintFixAll")
end, "Fix ESLint issues")
-- Window management

View 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 };
}
});
}

View 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");
}
},
});
}

View File

@@ -0,0 +1 @@
node_modules/

View 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,
});
}

View 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"
}
}
}
}

View File

@@ -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"
}
}

View 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");
});
}

View 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();
});
}

View 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");
},
});
}

View File

@@ -0,0 +1 @@
node_modules

View File

@@ -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,
},
},
];

View File

@@ -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 debounce: NodeJS.Timeout | null = null;
@@ -113,14 +113,14 @@ export default function (pi: ExtensionAPI) {
}
debounce = setTimeout(async () => {
try {
const theme = await loadThemeFromPath(THEME_PATH);
const result = ctx.ui.setTheme(theme);
if (!result.success && result.error) {
ctx.ui.notify(`Theme reload failed: ${result.error}`, "error");
}
} catch (error) {
ctx.ui.notify(`Theme reload failed: ${String(error)}`, "error");
// i wish this fucking worked
// pi.sendUserMessage("/reload", { deliverAs: "followUp" });
const theme = await loadThemeFromPath(THEME_PATH);
const res = ctx.ui.setTheme(theme);
if (res.success) {
ctx.ui.notify("Background changed. Colors: ", "info");
} else {
ctx.ui.notify("Theme update failed, fuck", "error");
}
}, 150);
},
@@ -132,7 +132,8 @@ export default function (pi: ExtensionAPI) {
pi.on("session_shutdown", () => {
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;
}
if (debounce) {

View 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"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["**/*.ts"]
}

View 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;
});
}

View 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;
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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");
}
},
});
}

View 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 },
};
},
});
}

View 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"));
}
});
}

View 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);
});
}

View File

@@ -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");
},
});
}

View 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);
});
}

View 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);
},
});
}

View File

@@ -1,7 +1,7 @@
{
"lastChangelogVersion": "0.54.0",
"defaultProvider": "openrouter",
"defaultModel": "openai/gpt-5.2-codex",
"defaultThinkingLevel": "minimal",
"defaultModel": "z-ai/glm-5",
"defaultThinkingLevel": "high",
"theme": "matugen"
}

View 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 $'[![developer_preview](https://img.shields.io/badge/Web_App_Preview-ready-green?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAADpSURBVHgBtZOBEYIwDEVTzwF0g7pJ3QQnkI3USRgBNygbwAYxKeUM2ObKCf8uV6Cvv01oAPYSIlqKhqLHUfxsE5wTHMfjh4tmHtNyC7OU/MyUXp6YVys4r3ANMyaCPQ0npSKXOHqFGYwx5wNsp3CgyfCtgB3tHIJPASVSis2qBFcrHKZMvZjnK1EnNq8w83N43iQW2DAxpqhlZZffeM0R/lR245hyKzLwsn6Cu+O3mybOLSGLedWCqxTOScNGAXvBeYWbdQqCrtWdUnZhdRV3yipNhjeKDjZQMIx36Urxgnz6A+il6WAPfQCiq+BEGLOCVwAAAABJRU5ErkJggg==)](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`).