[Fix] cleanup
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Global agent instructions
|
||||
|
||||
You are a coding assistant running on small, locally-hosted models. Be precise and economical with tokens.
|
||||
|
||||
## Working style
|
||||
- Act with tools instead of describing what you would do. Keep prose short.
|
||||
- Do the task that was asked. Don't add unrequested changes, refactors, or files.
|
||||
- When done, stop. A one- or two-line summary is enough; no recaps of obvious steps.
|
||||
|
||||
## Files & edits
|
||||
- Read a file before you edit it. Never guess at file paths, function names, or APIs — verify first.
|
||||
- Use the edit tool for changes. Make minimal, targeted diffs; never paste an entire file back to the user.
|
||||
- Match the surrounding code's style, naming, and imports.
|
||||
|
||||
## Shell
|
||||
- Run one command at a time and check its output before the next.
|
||||
- Prefer `rg` and `fd` for search. Use read-only commands when exploring.
|
||||
- Never run destructive or system-changing commands (`rm -rf`, `sudo`, package installs, force-push) unless explicitly asked.
|
||||
|
||||
## Honesty
|
||||
- If you're unsure, say so and check rather than inventing an answer.
|
||||
- Report failures plainly with the actual error; don't claim success you didn't verify.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"localcpp": {
|
||||
"type": "api_key",
|
||||
"key": "no-key-required"
|
||||
},
|
||||
"duskadiy": {
|
||||
"type": "api_key",
|
||||
"key": "$DUSKADIY_API_KEY"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Pi Notify Extension
|
||||
*
|
||||
* Sends a native terminal notification when Pi agent is done and waiting for input.
|
||||
* Supports multiple terminal protocols:
|
||||
* - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
|
||||
* - OSC 99: Kitty
|
||||
* - Windows toast: Windows Terminal (WSL)
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
function windowsToastScript(title: string, body: string): string {
|
||||
const type = "Windows.UI.Notifications";
|
||||
const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
|
||||
const template = `[${type}.ToastTemplateType]::ToastText01`;
|
||||
const toast = `[${type}.ToastNotification]::new($xml)`;
|
||||
return [
|
||||
`${mgr} > $null`,
|
||||
`$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
|
||||
`$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`,
|
||||
`[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function notifyOSC777(title: string, body: string): void {
|
||||
process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
|
||||
}
|
||||
|
||||
function notifyOSC99(title: string, body: string): void {
|
||||
// Kitty OSC 99: i=notification id, d=0 means not done yet, p=body for second part
|
||||
process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
|
||||
process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
|
||||
}
|
||||
|
||||
function notifyWindows(title: string, body: string): void {
|
||||
const { execFile } = require("child_process");
|
||||
execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)]);
|
||||
}
|
||||
|
||||
function notify(title: string, body: string): void {
|
||||
if (process.env.WT_SESSION) {
|
||||
notifyWindows(title, body);
|
||||
} else if (process.env.KITTY_WINDOW_ID) {
|
||||
notifyOSC99(title, body);
|
||||
} else {
|
||||
notifyOSC777(title, body);
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("agent_end", async () => {
|
||||
notify("Pi", "Ready for input");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Plan Mode Extension
|
||||
|
||||
Read-only exploration mode for safe code analysis.
|
||||
|
||||
## Features
|
||||
|
||||
- **Built-in write tools disabled**: Disables edit/write while preserving other active tools
|
||||
- **Bash allowlist**: Only read-only bash commands are allowed
|
||||
- **Plan extraction**: Extracts numbered steps from `Plan:` sections
|
||||
- **Progress tracking**: Widget shows completion status during execution
|
||||
- **[DONE:n] markers**: Explicit step completion tracking
|
||||
- **Session persistence**: State survives session resume
|
||||
|
||||
## Commands
|
||||
|
||||
- `/plan` - Toggle plan mode
|
||||
- `/todos` - Show current plan progress
|
||||
- `Shift+Tab` - Toggle plan mode (shortcut; this fork rebinds it from upstream's Ctrl+Alt+P)
|
||||
|
||||
## Usage
|
||||
|
||||
1. Enable plan mode with `/plan` or `--plan` flag
|
||||
2. Ask the agent to analyze code and create a plan
|
||||
3. The agent should output a numbered plan under a `Plan:` header:
|
||||
|
||||
```
|
||||
Plan:
|
||||
1. First step description
|
||||
2. Second step description
|
||||
3. Third step description
|
||||
```
|
||||
|
||||
4. Choose "Execute the plan" when prompted
|
||||
5. During execution, the agent marks steps complete with `[DONE:n]` tags
|
||||
6. Progress widget shows completion status
|
||||
|
||||
## How It Works
|
||||
|
||||
### Plan Mode (Read-Only)
|
||||
- Built-in edit/write tools disabled
|
||||
- Other active tools remain available
|
||||
- Bash commands filtered through allowlist
|
||||
- Agent creates a plan without making changes
|
||||
|
||||
### Execution Mode
|
||||
- Full tool access restored
|
||||
- Agent executes steps in order
|
||||
- `[DONE:n]` markers track completion
|
||||
- Widget shows progress
|
||||
|
||||
### Command Allowlist
|
||||
|
||||
Safe commands (allowed):
|
||||
- File inspection: `cat`, `head`, `tail`, `less`, `more`
|
||||
- Search: `grep`, `find`, `rg`, `fd`
|
||||
- Directory: `ls`, `pwd`, `tree`
|
||||
- Git read: `git status`, `git log`, `git diff`, `git branch`
|
||||
- Package info: `npm list`, `npm outdated`, `yarn info`
|
||||
- System info: `uname`, `whoami`, `date`, `uptime`
|
||||
|
||||
Blocked commands:
|
||||
- File modification: `rm`, `mv`, `cp`, `mkdir`, `touch`
|
||||
- Git write: `git add`, `git commit`, `git push`
|
||||
- Package install: `npm install`, `yarn add`, `pip install`
|
||||
- System: `sudo`, `kill`, `reboot`
|
||||
- Editors: `vim`, `nano`, `code`
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Plan Mode Extension
|
||||
*
|
||||
* Read-only exploration mode for safe code analysis.
|
||||
* When enabled, built-in write tools are disabled.
|
||||
*
|
||||
* Features:
|
||||
* - /plan command or Shift+Tab to toggle
|
||||
* - Bash restricted to allowlisted read-only commands
|
||||
* - Extracts numbered plan steps from "Plan:" sections
|
||||
* - [DONE:n] markers to complete steps during execution
|
||||
* - Progress tracking widget during execution
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.ts";
|
||||
|
||||
// Tools
|
||||
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
|
||||
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
|
||||
const PLAN_MODE_DISABLED_TOOLS = new Set<string>(["edit", "write"]);
|
||||
const PLAN_MANAGED_TOOLS = new Set<string>([...PLAN_MODE_TOOLS, ...NORMAL_MODE_TOOLS]);
|
||||
|
||||
interface PlanModeState {
|
||||
enabled: boolean;
|
||||
todos?: TodoItem[];
|
||||
executing?: boolean;
|
||||
toolsBeforePlanMode?: string[];
|
||||
}
|
||||
|
||||
// Type guard for assistant messages
|
||||
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
|
||||
return m.role === "assistant" && Array.isArray(m.content);
|
||||
}
|
||||
|
||||
// Extract text content from an assistant message
|
||||
function getTextContent(message: AssistantMessage): string {
|
||||
return message.content
|
||||
.filter((block): block is TextContent => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export default function planModeExtension(pi: ExtensionAPI): void {
|
||||
let planModeEnabled = false;
|
||||
let executionMode = false;
|
||||
let todoItems: TodoItem[] = [];
|
||||
let toolsBeforePlanMode: string[] | undefined;
|
||||
|
||||
pi.registerFlag("plan", {
|
||||
description: "Start in plan mode (read-only exploration)",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
|
||||
function updateStatus(ctx: ExtensionContext): void {
|
||||
// Footer status
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
const completed = todoItems.filter((t) => t.completed).length;
|
||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
|
||||
} else if (planModeEnabled) {
|
||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
|
||||
} else {
|
||||
ctx.ui.setStatus("plan-mode", undefined);
|
||||
}
|
||||
|
||||
// Widget showing todo list
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
const lines = todoItems.map((item) => {
|
||||
if (item.completed) {
|
||||
return (
|
||||
ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
|
||||
);
|
||||
}
|
||||
return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
|
||||
});
|
||||
ctx.ui.setWidget("plan-todos", lines);
|
||||
} else {
|
||||
ctx.ui.setWidget("plan-todos", undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueToolNames(toolNames: string[]): string[] {
|
||||
return [...new Set(toolNames)];
|
||||
}
|
||||
|
||||
function getPlanModeTools(activeToolNames: string[]): string[] {
|
||||
return uniqueToolNames([
|
||||
...activeToolNames.filter((name) => !PLAN_MODE_DISABLED_TOOLS.has(name)),
|
||||
...PLAN_MODE_TOOLS,
|
||||
]);
|
||||
}
|
||||
|
||||
function getNormalModeTools(activeToolNames: string[]): string[] {
|
||||
return uniqueToolNames([
|
||||
...NORMAL_MODE_TOOLS,
|
||||
...activeToolNames.filter((name) => !PLAN_MANAGED_TOOLS.has(name)),
|
||||
]);
|
||||
}
|
||||
|
||||
function enablePlanModeTools(): void {
|
||||
if (toolsBeforePlanMode === undefined) {
|
||||
toolsBeforePlanMode = pi.getActiveTools();
|
||||
}
|
||||
pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
|
||||
}
|
||||
|
||||
function restoreNormalModeTools(): void {
|
||||
pi.setActiveTools(toolsBeforePlanMode ?? getNormalModeTools(pi.getActiveTools()));
|
||||
toolsBeforePlanMode = undefined;
|
||||
}
|
||||
|
||||
function persistState(): void {
|
||||
pi.appendEntry("plan-mode", {
|
||||
enabled: planModeEnabled,
|
||||
todos: todoItems,
|
||||
executing: executionMode,
|
||||
toolsBeforePlanMode,
|
||||
});
|
||||
}
|
||||
|
||||
function togglePlanMode(ctx: ExtensionContext): void {
|
||||
planModeEnabled = !planModeEnabled;
|
||||
executionMode = false;
|
||||
todoItems = [];
|
||||
|
||||
if (planModeEnabled) {
|
||||
enablePlanModeTools();
|
||||
ctx.ui.notify("Plan mode enabled. Built-in write tools disabled.");
|
||||
} else {
|
||||
restoreNormalModeTools();
|
||||
ctx.ui.notify("Plan mode disabled. Full access restored.");
|
||||
}
|
||||
updateStatus(ctx);
|
||||
persistState();
|
||||
}
|
||||
|
||||
pi.registerCommand("plan", {
|
||||
description: "Toggle plan mode (read-only exploration)",
|
||||
handler: async (_args, ctx) => togglePlanMode(ctx),
|
||||
});
|
||||
|
||||
pi.registerCommand("todos", {
|
||||
description: "Show current plan todo list",
|
||||
handler: async (_args, ctx) => {
|
||||
if (todoItems.length === 0) {
|
||||
ctx.ui.notify("No todos. Create a plan first with /plan", "info");
|
||||
return;
|
||||
}
|
||||
const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
|
||||
ctx.ui.notify(`Plan Progress:\n${list}`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerShortcut("shift+tab", {
|
||||
description: "Toggle plan mode",
|
||||
handler: async (ctx) => togglePlanMode(ctx),
|
||||
});
|
||||
|
||||
// Block destructive bash commands in plan mode
|
||||
pi.on("tool_call", async (event) => {
|
||||
if (!planModeEnabled || event.toolName !== "bash") return;
|
||||
|
||||
const command = event.input.command as string;
|
||||
if (!isSafeCommand(command)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out stale plan mode context when not in plan mode
|
||||
pi.on("context", async (event) => {
|
||||
if (planModeEnabled) return;
|
||||
|
||||
return {
|
||||
messages: event.messages.filter((m) => {
|
||||
const msg = m as AgentMessage & { customType?: string };
|
||||
if (msg.customType === "plan-mode-context") return false;
|
||||
if (msg.role !== "user") return true;
|
||||
|
||||
const content = msg.content;
|
||||
if (typeof content === "string") {
|
||||
return !content.includes("[PLAN MODE ACTIVE]");
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return !content.some(
|
||||
(c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Inject plan/execution context before agent starts
|
||||
pi.on("before_agent_start", async () => {
|
||||
if (planModeEnabled) {
|
||||
return {
|
||||
message: {
|
||||
customType: "plan-mode-context",
|
||||
content: `[PLAN MODE ACTIVE]
|
||||
You are in plan mode - a read-only exploration mode for safe code analysis.
|
||||
|
||||
Restrictions:
|
||||
- Built-in edit and write tools are disabled
|
||||
- Other currently active tools remain available
|
||||
- Bash is restricted to an allowlist of read-only commands
|
||||
|
||||
Ask clarifying questions using the questionnaire tool.
|
||||
|
||||
Create a detailed numbered plan under a "Plan:" header:
|
||||
|
||||
Plan:
|
||||
1. First step description
|
||||
2. Second step description
|
||||
...
|
||||
|
||||
Do NOT attempt to make changes - just describe what you would do.`,
|
||||
display: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
const remaining = todoItems.filter((t) => !t.completed);
|
||||
const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
|
||||
return {
|
||||
message: {
|
||||
customType: "plan-execution-context",
|
||||
content: `[EXECUTING PLAN - Full tool access enabled]
|
||||
|
||||
Remaining steps:
|
||||
${todoList}
|
||||
|
||||
Execute each step in order.
|
||||
After completing a step, include a [DONE:n] tag in your response.`,
|
||||
display: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Track progress after each turn
|
||||
pi.on("turn_end", async (event, ctx) => {
|
||||
if (!executionMode || todoItems.length === 0) return;
|
||||
if (!isAssistantMessage(event.message)) return;
|
||||
|
||||
const text = getTextContent(event.message);
|
||||
if (markCompletedSteps(text, todoItems) > 0) {
|
||||
updateStatus(ctx);
|
||||
}
|
||||
persistState();
|
||||
});
|
||||
|
||||
// Handle plan completion and plan mode UI
|
||||
pi.on("agent_end", async (event, ctx) => {
|
||||
// Check if execution is complete
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
if (todoItems.every((t) => t.completed)) {
|
||||
const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
|
||||
pi.sendMessage(
|
||||
{ customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true },
|
||||
{ triggerTurn: false },
|
||||
);
|
||||
executionMode = false;
|
||||
todoItems = [];
|
||||
updateStatus(ctx);
|
||||
persistState(); // Save cleared state so resume doesn't restore old execution mode
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!planModeEnabled || !ctx.hasUI) return;
|
||||
|
||||
// Extract todos from last assistant message
|
||||
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
|
||||
if (lastAssistant) {
|
||||
const extracted = extractTodoItems(getTextContent(lastAssistant));
|
||||
if (extracted.length > 0) {
|
||||
todoItems = extracted;
|
||||
}
|
||||
}
|
||||
|
||||
if (todoItems.length === 0) return;
|
||||
persistState();
|
||||
|
||||
// Show plan steps and prompt for next action
|
||||
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
|
||||
const planTodoListMessage = {
|
||||
customType: "plan-todo-list",
|
||||
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
|
||||
display: true,
|
||||
};
|
||||
|
||||
const choice = await ctx.ui.select("Plan mode - what next?", [
|
||||
"Execute the plan (track progress)",
|
||||
"Stay in plan mode",
|
||||
"Refine the plan",
|
||||
]);
|
||||
|
||||
if (choice?.startsWith("Execute")) {
|
||||
const firstTodoItem = todoItems[0];
|
||||
if (!firstTodoItem) return;
|
||||
|
||||
planModeEnabled = false;
|
||||
executionMode = true;
|
||||
restoreNormalModeTools();
|
||||
updateStatus(ctx);
|
||||
persistState();
|
||||
|
||||
const remainingList = todoItems.map((t) => `${t.step}. ${t.text}`).join("\n");
|
||||
const execMessage = `Execute the plan.
|
||||
|
||||
Remaining steps:
|
||||
${remainingList}
|
||||
|
||||
Start with: ${firstTodoItem.text}
|
||||
After completing a step, include a [DONE:n] tag in your response.`;
|
||||
pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
|
||||
pi.sendMessage(
|
||||
{ customType: "plan-mode-execute", content: execMessage, display: true },
|
||||
{ triggerTurn: true, deliverAs: "followUp" },
|
||||
);
|
||||
} else if (choice === "Refine the plan") {
|
||||
const refinement = await ctx.ui.editor("Refine the plan:", "");
|
||||
if (refinement?.trim()) {
|
||||
pi.sendMessage(planTodoListMessage, { deliverAs: "followUp" });
|
||||
pi.sendUserMessage(refinement.trim(), { deliverAs: "followUp" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Restore state on session start/resume
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
if (pi.getFlag("plan") === true) {
|
||||
planModeEnabled = true;
|
||||
}
|
||||
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
|
||||
// Restore persisted state
|
||||
const planModeEntry = entries
|
||||
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
|
||||
.pop() as { data?: PlanModeState } | undefined;
|
||||
|
||||
if (planModeEntry?.data) {
|
||||
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
|
||||
todoItems = planModeEntry.data.todos ?? todoItems;
|
||||
executionMode = planModeEntry.data.executing ?? executionMode;
|
||||
toolsBeforePlanMode = planModeEntry.data.toolsBeforePlanMode ?? toolsBeforePlanMode;
|
||||
}
|
||||
|
||||
// On resume: re-scan messages to rebuild completion state
|
||||
// Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans
|
||||
const isResume = planModeEntry !== undefined;
|
||||
if (isResume && executionMode && todoItems.length > 0) {
|
||||
// Find the index of the last plan-mode-execute entry (marks when current execution started)
|
||||
let executeIndex = -1;
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i] as { type: string; customType?: string };
|
||||
if (entry.customType === "plan-mode-execute") {
|
||||
executeIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only scan messages after the execute marker
|
||||
const messages: AssistantMessage[] = [];
|
||||
for (let i = executeIndex + 1; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
|
||||
messages.push(entry.message as AssistantMessage);
|
||||
}
|
||||
}
|
||||
const allText = messages.map(getTextContent).join("\n");
|
||||
markCompletedSteps(allText, todoItems);
|
||||
}
|
||||
|
||||
if (planModeEnabled) {
|
||||
enablePlanModeTools();
|
||||
}
|
||||
updateStatus(ctx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Pure utility functions for plan mode.
|
||||
* Extracted for testability.
|
||||
*/
|
||||
|
||||
// Destructive commands blocked in plan mode
|
||||
const DESTRUCTIVE_PATTERNS = [
|
||||
/\brm\b/i,
|
||||
/\brmdir\b/i,
|
||||
/\bmv\b/i,
|
||||
/\bcp\b/i,
|
||||
/\bmkdir\b/i,
|
||||
/\btouch\b/i,
|
||||
/\bchmod\b/i,
|
||||
/\bchown\b/i,
|
||||
/\bchgrp\b/i,
|
||||
/\bln\b/i,
|
||||
/\btee\b/i,
|
||||
/\btruncate\b/i,
|
||||
/\bdd\b/i,
|
||||
/\bshred\b/i,
|
||||
/(^|[^<])>(?!>)/,
|
||||
/>>/,
|
||||
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
||||
/\byarn\s+(add|remove|install|publish)/i,
|
||||
/\bpnpm\s+(add|remove|install|publish)/i,
|
||||
/\bpip\s+(install|uninstall)/i,
|
||||
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
||||
/\bbrew\s+(install|uninstall|upgrade)/i,
|
||||
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
||||
/\bsudo\b/i,
|
||||
/\bsu\b/i,
|
||||
/\bkill\b/i,
|
||||
/\bpkill\b/i,
|
||||
/\bkillall\b/i,
|
||||
/\breboot\b/i,
|
||||
/\bshutdown\b/i,
|
||||
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
||||
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
||||
/\b(vim?|nano|emacs|code|subl)\b/i,
|
||||
];
|
||||
|
||||
// Safe read-only commands allowed in plan mode
|
||||
const SAFE_PATTERNS = [
|
||||
/^\s*cat\b/,
|
||||
/^\s*head\b/,
|
||||
/^\s*tail\b/,
|
||||
/^\s*less\b/,
|
||||
/^\s*more\b/,
|
||||
/^\s*grep\b/,
|
||||
/^\s*find\b/,
|
||||
/^\s*ls\b/,
|
||||
/^\s*pwd\b/,
|
||||
/^\s*echo\b/,
|
||||
/^\s*printf\b/,
|
||||
/^\s*wc\b/,
|
||||
/^\s*sort\b/,
|
||||
/^\s*uniq\b/,
|
||||
/^\s*diff\b/,
|
||||
/^\s*file\b/,
|
||||
/^\s*stat\b/,
|
||||
/^\s*du\b/,
|
||||
/^\s*df\b/,
|
||||
/^\s*tree\b/,
|
||||
/^\s*which\b/,
|
||||
/^\s*whereis\b/,
|
||||
/^\s*type\b/,
|
||||
/^\s*env\b/,
|
||||
/^\s*printenv\b/,
|
||||
/^\s*uname\b/,
|
||||
/^\s*whoami\b/,
|
||||
/^\s*id\b/,
|
||||
/^\s*date\b/,
|
||||
/^\s*cal\b/,
|
||||
/^\s*uptime\b/,
|
||||
/^\s*ps\b/,
|
||||
/^\s*top\b/,
|
||||
/^\s*htop\b/,
|
||||
/^\s*free\b/,
|
||||
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
||||
/^\s*git\s+ls-/i,
|
||||
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
||||
/^\s*yarn\s+(list|info|why|audit)/i,
|
||||
/^\s*node\s+--version/i,
|
||||
/^\s*python\s+--version/i,
|
||||
/^\s*curl\s/i,
|
||||
/^\s*wget\s+-O\s*-/i,
|
||||
/^\s*jq\b/,
|
||||
/^\s*sed\s+-n/i,
|
||||
/^\s*awk\b/,
|
||||
/^\s*rg\b/,
|
||||
/^\s*fd\b/,
|
||||
/^\s*bat\b/,
|
||||
/^\s*eza\b/,
|
||||
];
|
||||
|
||||
export function isSafeCommand(command: string): boolean {
|
||||
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
|
||||
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
|
||||
return !isDestructive && isSafe;
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
step: number;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export function cleanStepText(text: string): string {
|
||||
let cleaned = text
|
||||
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
|
||||
.replace(/`([^`]+)`/g, "$1") // Remove code
|
||||
.replace(
|
||||
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
|
||||
"",
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (cleaned.length > 0) {
|
||||
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
if (cleaned.length > 50) {
|
||||
cleaned = `${cleaned.slice(0, 47)}...`;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function extractTodoItems(message: string): TodoItem[] {
|
||||
const items: TodoItem[] = [];
|
||||
const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
|
||||
if (!headerMatch) return items;
|
||||
|
||||
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
|
||||
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
|
||||
|
||||
for (const match of planSection.matchAll(numberedPattern)) {
|
||||
const text = match[2]
|
||||
.trim()
|
||||
.replace(/\*{1,2}$/, "")
|
||||
.trim();
|
||||
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
|
||||
const cleaned = cleanStepText(text);
|
||||
if (cleaned.length > 3) {
|
||||
items.push({ step: items.length + 1, text: cleaned, completed: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function extractDoneSteps(message: string): number[] {
|
||||
const steps: number[] = [];
|
||||
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
|
||||
const step = Number(match[1]);
|
||||
if (Number.isFinite(step)) steps.push(step);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
export function markCompletedSteps(text: string, items: TodoItem[]): number {
|
||||
const doneSteps = extractDoneSteps(text);
|
||||
for (const step of doneSteps) {
|
||||
const item = items.find((t) => t.step === step);
|
||||
if (item) item.completed = true;
|
||||
}
|
||||
return doneSteps.length;
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 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 "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
Editor,
|
||||
type EditorTheme,
|
||||
Key,
|
||||
matchesKey,
|
||||
Text,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { Type } from "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.mode !== "tui") {
|
||||
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) return; // empty options with allowOther=false
|
||||
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 renderWidth = Math.max(1, width);
|
||||
const q = currentQuestion();
|
||||
const opts = currentOptions();
|
||||
|
||||
function addWrapped(text: string) {
|
||||
lines.push(...wrapTextWithAnsi(text, renderWidth));
|
||||
}
|
||||
|
||||
function addWrappedWithPrefix(prefix: string, text: string) {
|
||||
const prefixWidth = visibleWidth(prefix);
|
||||
if (prefixWidth >= renderWidth) {
|
||||
addWrapped(prefix + text);
|
||||
return;
|
||||
}
|
||||
const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
|
||||
const continuationPrefix = " ".repeat(prefixWidth);
|
||||
for (let i = 0; i < wrapped.length; i++) {
|
||||
lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
|
||||
|
||||
// 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} →`);
|
||||
addWrappedWithPrefix(" ", 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 label = `${i + 1}. ${opt.label}${isOther && inputMode ? " ✎" : ""}`;
|
||||
const color = selected || (isOther && inputMode) ? "accent" : "text";
|
||||
|
||||
addWrappedWithPrefix(prefix, theme.fg(color, label));
|
||||
if (opt.description) {
|
||||
addWrappedWithPrefix(" ", theme.fg("muted", opt.description));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
if (inputMode && q) {
|
||||
addWrappedWithPrefix(" ", theme.fg("text", q.prompt));
|
||||
lines.push("");
|
||||
// Show options for reference
|
||||
renderOptions();
|
||||
lines.push("");
|
||||
addWrappedWithPrefix(" ", theme.fg("muted", "Your answer:"));
|
||||
for (const line of editor.render(Math.max(1, renderWidth - 2))) {
|
||||
lines.push(` ${line}`);
|
||||
}
|
||||
lines.push("");
|
||||
addWrappedWithPrefix(" ", theme.fg("dim", "Enter to submit • Esc to cancel"));
|
||||
} else if (currentTab === questions.length) {
|
||||
addWrappedWithPrefix(" ", 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) " : "";
|
||||
const summary = `${theme.fg("muted", `${question.label}: `)}${theme.fg("text", prefix + answer.label)}`;
|
||||
addWrappedWithPrefix(" ", summary);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
if (allAnswered()) {
|
||||
addWrappedWithPrefix(" ", theme.fg("success", "Press Enter to submit"));
|
||||
} else {
|
||||
const missing = questions
|
||||
.filter((q) => !answers.has(q.id))
|
||||
.map((q) => q.label)
|
||||
.join(", ");
|
||||
addWrappedWithPrefix(" ", theme.fg("warning", `Unanswered: ${missing}`));
|
||||
}
|
||||
} else if (q) {
|
||||
addWrappedWithPrefix(" ", 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";
|
||||
addWrappedWithPrefix(" ", theme.fg("dim", help));
|
||||
}
|
||||
lines.push(theme.fg("accent", "─".repeat(renderWidth)));
|
||||
|
||||
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, _context) {
|
||||
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", ` (${labels})`);
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme, _context) {
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Pi status bar (custom footer)
|
||||
*
|
||||
* Replaces pi's default footer via `ctx.ui.setFooter()`. It mirrors the layout
|
||||
* and *meaning* of the Claude Code status line (`~/.config/claude/statusline.py`),
|
||||
* but swaps the emoji for Nerd Font glyphs — the same Material Design set already
|
||||
* used across tmux (waybar cpu/mem/net glyphs) and nvim — so it reads natively in
|
||||
* kitty (CaskaydiaCove Nerd Font Mono). Colors come from the active pi theme
|
||||
* (catppuccin-mocha), not hard-coded ANSI.
|
||||
*
|
||||
* Segments render left → right and truncate at the terminal edge, so the most
|
||||
* useful info stays visible on a narrow pane. Each icon echoes the Claude emoji
|
||||
* it replaces:
|
||||
*
|
||||
* dir cwd, ~-collapsed (Claude 📁) U+F024B nf-md-folder
|
||||
* git current branch (only inside a repo) (Claude 🌿) U+F062C nf-md-source_branch
|
||||
* model model id + · thinking level (Claude 🤖) U+F06A9 nf-md-robot
|
||||
* context % of context window used (+ tokens) (Claude 📝) U+F021A nf-md-text_box
|
||||
* t/s last response's decode throughput U+F04C5 nf-md-speedometer
|
||||
* ↑ ↓ tokens session input / output tokens (Claude 💰) plain arrows
|
||||
*
|
||||
* git, context, t/s and tokens only appear once there is data for them (i.e. after
|
||||
* the first response, and git only inside a git repo). Extension statuses (e.g.
|
||||
* plan-mode's "⏸ plan") are preserved at the far left. The Claude bar's system row
|
||||
* (RAM/CPU/temp/disk) is intentionally omitted — that data isn't in the footer API,
|
||||
* and the tmux bar below pi already shows cpu/mem/net.
|
||||
*
|
||||
* Two extras beyond the footer line:
|
||||
* - a themed pulse working-indicator (the spinner shown while pi streams), and
|
||||
* - a context-budget warning widget above the editor once the window passes
|
||||
* 80% full, nudging a /compact before things get truncated.
|
||||
*
|
||||
* Restore the built-in footer (and reset the indicator/widget) with `/statusbar`.
|
||||
*/
|
||||
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { truncateToWidth } from "@earendil-works/pi-tui";
|
||||
|
||||
// Nerd Font (Material Design) glyphs — verified to render in the user's font.
|
||||
const ICON = {
|
||||
dir: "\u{F024B}", // nf-md-folder
|
||||
git: "\u{F062C}", // nf-md-source_branch
|
||||
model: "\u{F06A9}", // nf-md-robot
|
||||
context: "\u{F021A}", // nf-md-text_box
|
||||
tps: "\u{F04C5}", // nf-md-speedometer
|
||||
warn: "\u{F002A}", // nf-md-alert_outline (same glyph nvim uses for warnings)
|
||||
up: "↑", // ↑
|
||||
down: "↓", // ↓
|
||||
};
|
||||
|
||||
// Widget key for the context-budget warning shown above the editor.
|
||||
const CTX_WIDGET = "statusbar-context-warning";
|
||||
// Show the warning once the context window is this full (%).
|
||||
const CTX_WARN_AT = 80;
|
||||
|
||||
// Current thinking level, tracked via thinking_level_select (footer render has no
|
||||
// direct access to it). Defaults to the settings.json default of "off".
|
||||
let thinkingLevel = "off";
|
||||
|
||||
// Decode throughput (tokens/sec) of the most recent assistant response, measured
|
||||
// from the first streamed token to message end so prompt-eval time is excluded.
|
||||
let lastTps = 0;
|
||||
let genFirstTokenAt = 0;
|
||||
let sawToken = false;
|
||||
|
||||
// Set from inside the footer factory so events can trigger a re-render.
|
||||
let requestRender: (() => void) | undefined;
|
||||
|
||||
function collapseHome(p: string): string {
|
||||
const home = process.env.HOME || "";
|
||||
if (p === home) return "~";
|
||||
if (home && p.startsWith(`${home}/`)) return `~${p.slice(home.length)}`;
|
||||
return p;
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
return n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`;
|
||||
}
|
||||
|
||||
/** Cumulative session tokens, plus the last request's input tokens (~ current
|
||||
* context-window fill). */
|
||||
function tokenStats(ctx: ExtensionContext): {
|
||||
input: number;
|
||||
output: number;
|
||||
lastInput: number;
|
||||
} {
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
let lastInput = 0;
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const usage = (entry.message as AssistantMessage).usage;
|
||||
if (!usage) continue; // aborted stream: no usage — a throwing render kills the TUI
|
||||
input += usage.input;
|
||||
output += usage.output;
|
||||
lastInput = usage.input;
|
||||
}
|
||||
}
|
||||
return { input, output, lastInput };
|
||||
}
|
||||
|
||||
/** Show/hide the above-editor warning when the context window is nearly full. */
|
||||
function updateContextWidget(ctx: ExtensionContext): void {
|
||||
if (ctx.mode !== "tui") return;
|
||||
const contextWindow = ctx.model?.contextWindow;
|
||||
const { lastInput } = tokenStats(ctx);
|
||||
if (!contextWindow || lastInput <= 0) {
|
||||
ctx.ui.setWidget(CTX_WIDGET, undefined);
|
||||
return;
|
||||
}
|
||||
const pct = (lastInput / contextWindow) * 100;
|
||||
if (pct < CTX_WARN_AT) {
|
||||
ctx.ui.setWidget(CTX_WIDGET, undefined);
|
||||
return;
|
||||
}
|
||||
const th = ctx.ui.theme;
|
||||
const col = pct >= 90 ? "error" : "warning";
|
||||
const line =
|
||||
th.fg(col, `${ICON.warn} context ${pct.toFixed(0)}%`) +
|
||||
th.fg("dim", ` (${fmtTokens(lastInput)}/${fmtTokens(contextWindow)}) — /compact soon`);
|
||||
ctx.ui.setWidget(CTX_WIDGET, [line]);
|
||||
}
|
||||
|
||||
/** A gentle catppuccin "breathing" pulse for the streaming working-indicator. */
|
||||
function pulseIndicator(ctx: ExtensionContext) {
|
||||
const th = ctx.ui.theme;
|
||||
return {
|
||||
frames: [th.fg("dim", "·"), th.fg("muted", "•"), th.fg("accent", "●"), th.fg("muted", "•")],
|
||||
intervalMs: 120,
|
||||
};
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let enabled = true;
|
||||
|
||||
pi.on("thinking_level_select", async (event) => {
|
||||
thinkingLevel = event.level;
|
||||
requestRender?.();
|
||||
});
|
||||
|
||||
pi.on("model_select", async () => {
|
||||
requestRender?.();
|
||||
});
|
||||
|
||||
// --- t/s timing: bracket each assistant response and clock its generation ---
|
||||
pi.on("message_start", async (event) => {
|
||||
if (event.message?.role !== "assistant") return;
|
||||
sawToken = false;
|
||||
genFirstTokenAt = 0;
|
||||
});
|
||||
|
||||
pi.on("message_update", async (event) => {
|
||||
if (event.message?.role !== "assistant") return;
|
||||
if (!sawToken) {
|
||||
sawToken = true;
|
||||
genFirstTokenAt = Date.now();
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("message_end", async (event, ctx) => {
|
||||
if (event.message?.role !== "assistant") return;
|
||||
const output = (event.message as AssistantMessage).usage?.output ?? 0;
|
||||
const secs = genFirstTokenAt ? (Date.now() - genFirstTokenAt) / 1000 : 0;
|
||||
if (secs > 0 && output > 0) lastTps = output / secs;
|
||||
requestRender?.();
|
||||
if (enabled) updateContextWidget(ctx);
|
||||
});
|
||||
|
||||
const install = (ctx: ExtensionContext) => {
|
||||
if (ctx.mode !== "tui") return;
|
||||
ctx.ui.setWorkingIndicator(pulseIndicator(ctx));
|
||||
updateContextWidget(ctx);
|
||||
ctx.ui.setFooter((tui, theme, footerData) => {
|
||||
const sep = theme.fg("dim", " │ ");
|
||||
requestRender = () => tui.requestRender();
|
||||
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
||||
return {
|
||||
dispose: unsub,
|
||||
invalidate() {},
|
||||
render(width: number): string[] {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Preserve extension statuses (plan-mode "⏸ plan", etc.) up front.
|
||||
for (const status of footerData.getExtensionStatuses().values()) {
|
||||
if (status) parts.push(status);
|
||||
}
|
||||
|
||||
// directory
|
||||
parts.push(theme.fg("accent", ICON.dir) + " " + theme.fg("text", collapseHome(ctx.cwd)));
|
||||
|
||||
// git branch (only inside a repo)
|
||||
const branch = footerData.getGitBranch();
|
||||
if (branch) parts.push(theme.fg("accent", ICON.git) + " " + theme.fg("success", branch));
|
||||
|
||||
// model (+ · thinking level)
|
||||
if (ctx.model?.id) {
|
||||
let model = theme.fg("accent", ICON.model) + " " + theme.fg("text", ctx.model.id);
|
||||
if (thinkingLevel && thinkingLevel !== "off") {
|
||||
model += theme.fg("dim", ` · ${thinkingLevel}`);
|
||||
}
|
||||
parts.push(model);
|
||||
}
|
||||
|
||||
// context window usage (% when the window is known, else raw tokens)
|
||||
const { input, output, lastInput } = tokenStats(ctx);
|
||||
if (lastInput > 0) {
|
||||
const contextWindow = ctx.model?.contextWindow;
|
||||
let seg = theme.fg("accent", ICON.context) + " ";
|
||||
if (contextWindow) {
|
||||
const pct = Math.min(100, (lastInput / contextWindow) * 100);
|
||||
const col = pct < 50 ? "success" : pct < 80 ? "warning" : "error";
|
||||
seg += theme.fg(col, `${pct.toFixed(0)}%`) + theme.fg("dim", ` (${fmtTokens(lastInput)})`);
|
||||
} else {
|
||||
seg += theme.fg("text", fmtTokens(lastInput));
|
||||
}
|
||||
parts.push(seg);
|
||||
}
|
||||
|
||||
// tokens/sec of the last response
|
||||
if (lastTps > 0) {
|
||||
parts.push(
|
||||
theme.fg("accent", ICON.tps) + " " + theme.fg("text", lastTps.toFixed(1)) + theme.fg("dim", " t/s"),
|
||||
);
|
||||
}
|
||||
|
||||
// ↑ ↓ cumulative session tokens (Claude's 💰 slot; local models are $0)
|
||||
if (input || output) {
|
||||
parts.push(theme.fg("dim", `${ICON.up}${fmtTokens(input)} ${ICON.down}${fmtTokens(output)}`));
|
||||
}
|
||||
|
||||
return [truncateToWidth(parts.join(sep), width)];
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
if (enabled) install(ctx);
|
||||
});
|
||||
|
||||
pi.registerCommand("statusbar", {
|
||||
description: "Toggle the custom pi status bar footer",
|
||||
handler: async (_args, ctx) => {
|
||||
enabled = !enabled;
|
||||
if (enabled) {
|
||||
install(ctx);
|
||||
ctx.ui.notify("Custom status bar enabled", "info");
|
||||
} else {
|
||||
ctx.ui.setFooter(undefined);
|
||||
ctx.ui.setWorkingIndicator(); // restore pi's default spinner
|
||||
ctx.ui.setWidget(CTX_WIDGET, undefined);
|
||||
ctx.ui.notify("Default footer restored", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* `/vim` — toggle the modal input editor off/on
|
||||
*
|
||||
* The modal editor itself is now the **npm package `pi-vim`** (declared in
|
||||
* `settings.json > packages`, configured under `settings.json > piVim`). That
|
||||
* package registers no commands, so this file restores the `/vim` toggle the
|
||||
* local `vim-editor.ts` fork used to provide — the fork is kept beside it as
|
||||
* `vim-editor.ts.disabled` (see ../../CLAUDE.md).
|
||||
*
|
||||
* OFF — drop the custom editor component, which puts pi's stock editor back for
|
||||
* the rest of the session (handy for a big paste, or any key the modal
|
||||
* layer swallows).
|
||||
* ON — `ctx.reload()`, the same flow as `/reload`, re-runs extension discovery
|
||||
* and so re-installs pi-vim's editor. That reloads statusbar/plan-mode too
|
||||
* and re-instantiates *this* file, which is why turning it back on needs no
|
||||
* bookkeeping: the fresh instance starts at `enabled = true` again.
|
||||
*
|
||||
* Nothing is installed on `session_start` — pi-vim already does that, so this
|
||||
* file only ever reacts to the command and never races it for the editor slot.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// pi-vim installs its editor at session_start, so we start out enabled.
|
||||
let enabled = true;
|
||||
|
||||
pi.registerCommand("vim", {
|
||||
description: "Toggle the vim (modal) input editor",
|
||||
handler: async (_args, ctx) => {
|
||||
if (ctx.mode !== "tui") return; // editor components are TUI-only
|
||||
if (enabled) {
|
||||
ctx.ui.setEditorComponent(undefined);
|
||||
enabled = false;
|
||||
ctx.ui.notify("Default editor restored — /vim to re-enable", "info");
|
||||
return;
|
||||
}
|
||||
// Notify *before* awaiting: after the reload this call frame belongs to
|
||||
// the pre-reload instance, and its UI handle is on the way out.
|
||||
ctx.ui.notify("Vim editor enabled", "info");
|
||||
await ctx.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"app.thinking.cycle": ["ctrl+shift+t"],
|
||||
"tui.editor.cursorUp": ["up", "ctrl+p"],
|
||||
"app.model.cycleForward": ["alt+p"]
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"providers": {
|
||||
"duskadiy": {
|
||||
"baseUrl": "https://llm.duskadiy.com/api/v1",
|
||||
"api": "openai-completions",
|
||||
"apiKey": "$DUSKADIY_API_KEY",
|
||||
"compat": {
|
||||
"supportsDeveloperRole": false,
|
||||
"supportsReasoningEffort": false,
|
||||
"maxTokensField": "max_tokens"
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"id": "gemma-4-E4B-it-UD-Q8_K_XL",
|
||||
"name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 65536,
|
||||
"maxTokens": 16384,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "gemma-4-26B-A4B-it-UD-IQ4_XS",
|
||||
"name": "Gemma 4 26B · 24k · vision — quality generalist",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 24576,
|
||||
"maxTokens": 4096,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL",
|
||||
"name": "Qwen3 Coder 30B · 32k — main agent coder",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"contextWindow": 32768,
|
||||
"maxTokens": 4096,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "Qwen3-Coder-Next-UD-IQ3_XXS",
|
||||
"name": "Qwen3 Coder Next 80B · 128k — long sessions",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"contextWindow": 131072,
|
||||
"maxTokens": 16384,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS",
|
||||
"name": "Qwen3.6 35B · 24k · vision — daily driver",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 24576,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "Qwen3.6-35B-A3B-Thinking",
|
||||
"name": "Qwen3.6 35B Thinking · 24k · vision — hard problems",
|
||||
"reasoning": true,
|
||||
"compat": { "thinkingFormat": "qwen-chat-template" },
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 24576,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "Qwen3.5-9B-UD-Q6_K_XL",
|
||||
"name": "Qwen3.5 9B · 32k · vision — quick tasks",
|
||||
"reasoning": true,
|
||||
"compat": { "thinkingFormat": "qwen-chat-template" },
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 32768,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "GLM-4.7-Flash-UD-Q4_K_XL",
|
||||
"name": "GLM-4.7 Flash · 24k — quality coder",
|
||||
"reasoning": true,
|
||||
"compat": { "thinkingFormat": "qwen-chat-template" },
|
||||
"input": ["text"],
|
||||
"contextWindow": 24576,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "gpt-oss-20b",
|
||||
"name": "gpt-oss 20B · 64k — fast reasoning + tools",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 65536,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "gpt-oss-20b-low",
|
||||
"name": "gpt-oss 20B low · 64k — snappy answers",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 65536,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
}
|
||||
]
|
||||
},
|
||||
"localcpp": {
|
||||
"baseUrl": "http://192.168.0.204:11343/v1",
|
||||
"api": "openai-completions",
|
||||
"apiKey": "no-key-required",
|
||||
"compat": {
|
||||
"supportsDeveloperRole": false,
|
||||
"supportsReasoningEffort": false,
|
||||
"maxTokensField": "max_tokens"
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"id": "gemma-4-E4B-it-UD-Q8_K_XL",
|
||||
"name": "Gemma 4 E4B · 64k · vision — fast generalist, long docs",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 65536,
|
||||
"maxTokens": 16384,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "gemma-4-26B-A4B-it-UD-IQ4_XS",
|
||||
"name": "Gemma 4 26B · 24k · vision — quality generalist",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 24576,
|
||||
"maxTokens": 4096,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "Qwen3-Coder-30B-Instruct-UD-Q3_K_XL",
|
||||
"name": "Qwen3 Coder 30B · 32k — main agent coder",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"contextWindow": 32768,
|
||||
"maxTokens": 4096,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "Qwen3-Coder-Next-UD-IQ3_XXS",
|
||||
"name": "Qwen3 Coder Next 80B · 128k — long sessions",
|
||||
"reasoning": false,
|
||||
"input": ["text"],
|
||||
"contextWindow": 131072,
|
||||
"maxTokens": 16384,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "Qwen3.6-35B-A3B-MTP-UD-IQ3_XXS",
|
||||
"name": "Qwen3.6 35B · 24k · vision — daily driver",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 24576,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "Qwen3.6-35B-A3B-Thinking",
|
||||
"name": "Qwen3.6 35B Thinking · 24k · vision — hard problems",
|
||||
"reasoning": true,
|
||||
"compat": { "thinkingFormat": "qwen-chat-template" },
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 24576,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "Qwen3.5-9B-UD-Q6_K_XL",
|
||||
"name": "Qwen3.5 9B · 32k · vision — quick tasks",
|
||||
"reasoning": true,
|
||||
"compat": { "thinkingFormat": "qwen-chat-template" },
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 32768,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "GLM-4.7-Flash-UD-Q4_K_XL",
|
||||
"name": "GLM-4.7 Flash · 24k — quality coder",
|
||||
"reasoning": true,
|
||||
"compat": { "thinkingFormat": "qwen-chat-template" },
|
||||
"input": ["text"],
|
||||
"contextWindow": 24576,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "gpt-oss-20b",
|
||||
"name": "gpt-oss 20B · 64k — fast reasoning + tools",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 65536,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
},
|
||||
{
|
||||
"id": "gpt-oss-20b-low",
|
||||
"name": "gpt-oss 20B low · 64k — snappy answers",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 65536,
|
||||
"maxTokens": 8192,
|
||||
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
description: Write a Conventional Commits message for the staged changes
|
||||
argument-hint: "[extra context]"
|
||||
---
|
||||
Inspect the staged changes with `git diff --cached` (and `git status` for context). Then write a single Conventional Commits message:
|
||||
|
||||
- A `<type>(<scope>): <subject>` summary line, imperative mood, ≤72 chars (types: feat, fix, refactor, docs, chore, test, perf, build).
|
||||
- An optional short body explaining the *why* only when it isn't obvious from the diff.
|
||||
- Do NOT run `git commit`. Output only the message inside a code block so I can review it.
|
||||
|
||||
Extra context to incorporate: ${1:-none}
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description: Review the current diff for bugs and issues
|
||||
argument-hint: "[focus area]"
|
||||
---
|
||||
Review the current changes. Check both `git diff` (unstaged) and `git diff --cached` (staged). Focus on:
|
||||
|
||||
- Correctness and logic bugs
|
||||
- Error handling and missing edge cases
|
||||
- Security issues (injection, secrets, unsafe input)
|
||||
- Anything that doesn't match the surrounding code's conventions
|
||||
|
||||
List concrete findings as `file:line — issue — suggested fix`, ordered by severity. Be concise; skip praise. If nothing is wrong, say so.
|
||||
|
||||
Extra focus this round: ${1:-general correctness}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"defaultProvider": "duskadiy",
|
||||
"defaultModel": "gemma-4-26B-A4B-it-UD-IQ4_XS",
|
||||
"defaultThinkingLevel": "off",
|
||||
"theme": "catppuccin-mocha",
|
||||
"quietStartup": false,
|
||||
"defaultProjectTrust": "ask",
|
||||
"enableInstallTelemetry": false,
|
||||
"enabledModels": [
|
||||
"duskadiy/*",
|
||||
"localcpp/*"
|
||||
],
|
||||
"compaction": {
|
||||
"enabled": true,
|
||||
"reserveTokens": 6144,
|
||||
"keepRecentTokens": 6000
|
||||
},
|
||||
"retry": {
|
||||
"enabled": true,
|
||||
"maxRetries": 3,
|
||||
"baseDelayMs": 2000,
|
||||
"provider": {
|
||||
"timeoutMs": 3600000
|
||||
}
|
||||
},
|
||||
"httpIdleTimeoutMs": 600000,
|
||||
"npmCommand": [
|
||||
"fnm",
|
||||
"exec",
|
||||
"--using=22",
|
||||
"--",
|
||||
"npm"
|
||||
],
|
||||
"showHardwareCursor": true,
|
||||
"piVim": {
|
||||
"clipboardMirror": "yank",
|
||||
"exCommand": {
|
||||
"piDispatch": true,
|
||||
"copyInputToClipboard": false
|
||||
},
|
||||
"modeColors": {
|
||||
"insert": "success",
|
||||
"normal": "accent",
|
||||
"visual": "warning",
|
||||
"ex": "bashMode"
|
||||
},
|
||||
"borderSync": {
|
||||
"insert": "host",
|
||||
"normal": "host",
|
||||
"visual": "host",
|
||||
"ex": "host"
|
||||
},
|
||||
"labelSync": {
|
||||
"insert": "mode",
|
||||
"normal": "mode",
|
||||
"visual": "mode",
|
||||
"ex": "mode"
|
||||
}
|
||||
},
|
||||
"lastChangelogVersion": "0.83.0",
|
||||
"packages": [
|
||||
"npm:pi-vim"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
"name": "catppuccin-mocha",
|
||||
"vars": {
|
||||
"rosewater": "#f5e0dc",
|
||||
"flamingo": "#f2cdcd",
|
||||
"pink": "#f5c2e7",
|
||||
"mauve": "#cba6f7",
|
||||
"red": "#f38ba8",
|
||||
"maroon": "#eba0ac",
|
||||
"peach": "#fab387",
|
||||
"yellow": "#f9e2af",
|
||||
"green": "#a6e3a1",
|
||||
"teal": "#94e2d5",
|
||||
"sky": "#89dceb",
|
||||
"sapphire": "#74c7ec",
|
||||
"blue": "#89b4fa",
|
||||
"lavender": "#b4befe",
|
||||
"fg": "#cdd6f4",
|
||||
"subtext1": "#bac2de",
|
||||
"subtext0": "#a6adc8",
|
||||
"overlay2": "#9399b2",
|
||||
"overlay1": "#7f849c",
|
||||
"overlay0": "#6c7086",
|
||||
"surface2": "#585b70",
|
||||
"surface1": "#45475a",
|
||||
"surface0": "#313244",
|
||||
"base": "#1e1e2e",
|
||||
"mantle": "#181825",
|
||||
"crust": "#11111b"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "mauve",
|
||||
"border": "surface1",
|
||||
"borderAccent": "mauve",
|
||||
"borderMuted": "surface0",
|
||||
"success": "green",
|
||||
"error": "red",
|
||||
"warning": "yellow",
|
||||
"muted": "subtext0",
|
||||
"dim": "overlay0",
|
||||
"text": "fg",
|
||||
"thinkingText": "overlay1",
|
||||
"selectedBg": "surface0",
|
||||
"userMessageBg": "surface0",
|
||||
"userMessageText": "fg",
|
||||
"customMessageBg": "surface0",
|
||||
"customMessageText": "fg",
|
||||
"customMessageLabel": "mauve",
|
||||
"toolPendingBg": "mantle",
|
||||
"toolSuccessBg": "#1e2b22",
|
||||
"toolErrorBg": "#2b1e22",
|
||||
"toolTitle": "mauve",
|
||||
"toolOutput": "fg",
|
||||
"mdHeading": "mauve",
|
||||
"mdLink": "blue",
|
||||
"mdLinkUrl": "sapphire",
|
||||
"mdCode": "green",
|
||||
"mdCodeBlock": "fg",
|
||||
"mdCodeBlockBorder": "surface1",
|
||||
"mdQuote": "subtext0",
|
||||
"mdQuoteBorder": "surface2",
|
||||
"mdHr": "surface1",
|
||||
"mdListBullet": "mauve",
|
||||
"toolDiffAdded": "green",
|
||||
"toolDiffRemoved": "red",
|
||||
"toolDiffContext": "overlay0",
|
||||
"syntaxComment": "overlay0",
|
||||
"syntaxKeyword": "mauve",
|
||||
"syntaxFunction": "blue",
|
||||
"syntaxVariable": "fg",
|
||||
"syntaxString": "green",
|
||||
"syntaxNumber": "peach",
|
||||
"syntaxType": "yellow",
|
||||
"syntaxOperator": "sky",
|
||||
"syntaxPunctuation": "overlay2",
|
||||
"thinkingOff": "surface1",
|
||||
"thinkingMinimal": "overlay0",
|
||||
"thinkingLow": "blue",
|
||||
"thinkingMedium": "teal",
|
||||
"thinkingHigh": "peach",
|
||||
"thinkingXhigh": "red",
|
||||
"bashMode": "peach"
|
||||
},
|
||||
"export": {
|
||||
"pageBg": "#11111b",
|
||||
"cardBg": "#1e1e2e",
|
||||
"infoBg": "#313244"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user