Compare commits
4 Commits
bcc4340cad
...
9621d9aebd
| Author | SHA1 | Date | |
|---|---|---|---|
| 9621d9aebd | |||
| 281f7b3e11 | |||
| 2ae7b1da05 | |||
| dc30540dab |
@@ -2,7 +2,7 @@
|
||||
name: advisor
|
||||
description: "Read-only senior engineer for a second opinion. Two jobs — (1) ADVISE on implementation: recommend an approach, weigh trade-offs, sanity-check a design, or unblock an agent that's unsure how to proceed; (2) QUICK SANITY-CHECK reviews of small, low-risk diffs. For a deep pre-merge review, spawn code-reviewer instead — it runs a max-effort model and owns the full review checklist. Returns concrete, citation-backed guidance without touching the code."
|
||||
tools: read, grep, find, ls, bash
|
||||
preferred_model: qw/qwen3.8-max
|
||||
model: opencode-go/gpt-5.6-luna
|
||||
thinking: max
|
||||
skills: ast-grep
|
||||
prompt_mode: replace
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: code-reviewer
|
||||
description: "Senior code reviewer that deep-reviews diffs, PRs, or files and then fixes what it finds: examines for bugs, security holes, maintainability problems, and performance issues, applies fixes for critical and warning findings, and re-verifies with the project's checks. Runs on a max-effort model. For design advice or quick sanity-checks, use advisor instead."
|
||||
tools: read, edit, write, grep, find, ls, bash
|
||||
preferred_model: qw/qwen3.8-max
|
||||
model: opencode-go/gpt-5.6-luna
|
||||
thinking: max
|
||||
skills: ast-grep
|
||||
prompt_mode: replace
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: implementor
|
||||
description: "Code implementer that executes a structured plan. Takes a described change (or works out a brief one when given only a goal), implements it step by step while staying consistent with existing patterns, and verifies the result before reporting back. Use to carry out a well-scoped implementation task: write the code, run the checks, and hand back a precise account of what changed and how it was verified."
|
||||
tools: read, edit, write, bash, grep, find, ls
|
||||
preferred_model: deepseek/deepseek-v4-flash
|
||||
model: opencode-go/deepseek-v4-flash
|
||||
thinking: high
|
||||
skills: ast-grep
|
||||
prompt_mode: replace
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: planner
|
||||
description: "Software architect that designs implementation plans. Use when you need a step-by-step plan before writing code: breaks a goal into ordered, verifiable steps, identifies the critical files and functions to touch, weighs architectural trade-offs, and flags risks. Read-only — it plans, it never implements."
|
||||
tools: read, grep, find, ls, bash
|
||||
preferred_model: deepseek/deepseek-v4-pro
|
||||
model: opencode-go/gpt-5.6-luna
|
||||
thinking: max
|
||||
skills: ast-grep
|
||||
prompt_mode: replace
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: scout
|
||||
description: 'Read-only codebase recon. Scours a repo using structural (ast-grep) and text search and returns compressed, citation-backed findings an agent can act on without re-reading the code. Use to locate code by *shape* (calls, definitions, signatures), map architecture, trace dependencies, or answer "where/how is X implemented" before making changes.'
|
||||
tools: read, grep, find, ls, bash
|
||||
preferred_model: deepseek/deepseek-v4-flash
|
||||
model: opencode-go/hy3
|
||||
thinking: low
|
||||
skills: ast-grep
|
||||
prompt_mode: replace
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Git Checkout Guard Extension
|
||||
*
|
||||
* Prevents models from using `git checkout` or `git restore` to silently
|
||||
* discard uncommitted changes in files.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
/**
|
||||
* Parse file paths from a git checkout/restore command.
|
||||
* Returns null if the command doesn't look like a file-restore operation.
|
||||
*/
|
||||
function parseFileRestoreArgs(command: string): string[] | null {
|
||||
// Normalize whitespace
|
||||
const cmd = command.trim().replace(/\s+/g, " ");
|
||||
|
||||
// Match: git checkout -- <files>
|
||||
// Match: git checkout <ref> -- <files>
|
||||
const checkoutDashDash = cmd.match(/\bgit\s+checkout\b.*?\s--\s+(.+)/);
|
||||
if (checkoutDashDash) {
|
||||
return checkoutDashDash[1].trim().split(/\s+/);
|
||||
}
|
||||
|
||||
// Match: git restore [--staged] [--source=<ref>] <files>
|
||||
// (git restore always operates on files)
|
||||
const restore = cmd.match(/\bgit\s+restore\s+(.+)/);
|
||||
if (restore) {
|
||||
// Filter out flags like --staged, --source=..., --worktree, --patch
|
||||
const args = restore[1].trim().split(/\s+/);
|
||||
const files = args.filter((a) => !a.startsWith("-"));
|
||||
return files.length > 0 ? files : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which of the given file paths have uncommitted changes (staged or unstaged).
|
||||
* Returns the subset that are dirty.
|
||||
*/
|
||||
function getDirtyFiles(files: string[], cwd: string): string[] {
|
||||
const dirty: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
// --porcelain output is empty for clean files
|
||||
const out = execSync(`git status --porcelain -- ${JSON.stringify(file)}`, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (out.length > 0) {
|
||||
dirty.push(file);
|
||||
}
|
||||
} catch {
|
||||
// Not a git repo or other error — skip
|
||||
}
|
||||
}
|
||||
return dirty;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (!isToolCallEventType("bash", event)) return undefined;
|
||||
|
||||
const command: string = event.input.command ?? "";
|
||||
const files = parseFileRestoreArgs(command);
|
||||
if (!files || files.length === 0) return undefined;
|
||||
|
||||
const cwd = process.cwd();
|
||||
const dirty = getDirtyFiles(files, cwd);
|
||||
if (dirty.length === 0) return undefined; // nothing to protect
|
||||
|
||||
const fileList = dirty.map((f) => ` • ${f}`).join("\n");
|
||||
|
||||
if (!ctx.hasUI) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `git-checkout-guard: the following files have uncommitted changes and cannot be silently reverted:\n${fileList}\nShow the diff to the user and ask for explicit confirmation first.`,
|
||||
};
|
||||
}
|
||||
|
||||
const choice = await ctx.ui.select(
|
||||
`⚠️ git-checkout-guard\n\nThe command:\n ${command}\n\nwould discard uncommitted changes in:\n${fileList}\n\nProceed?`,
|
||||
["No, cancel (show diff instead)", "Yes, discard changes anyway"],
|
||||
);
|
||||
|
||||
if (choice !== "Yes, discard changes anyway") {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Blocked by git-checkout-guard. Run \`git diff ${dirty.join(" ")}\` and review before discarding.`,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* Subagent pane lifecycle reminder.
|
||||
*
|
||||
* When the parent agent dispatches to a `pane: true` agent via `subagent`,
|
||||
* this extension floats a cleanup reminder to the parent only:
|
||||
*
|
||||
* - immediately: appended to the `subagent` tool result of that call;
|
||||
* - persistently: a one-line system-prompt note on later turns until the
|
||||
* parent calls `stop_subagent` for that agent (or the session ends).
|
||||
*
|
||||
* Nothing is injected for bg agents (`pane: false`), for `delegate_subagent`
|
||||
* (pane targets are rejected there anyway), for `/agents` commands, or in
|
||||
* sessions that never spawn a pane agent. Other sessions and workflows stay
|
||||
* untouched.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
interface SubagentInput {
|
||||
agent?: unknown;
|
||||
tasks?: Array<{ agent?: unknown }>;
|
||||
chain?: Array<{ agent?: unknown }>;
|
||||
}
|
||||
|
||||
const USER_AGENT_DIRS: string[] = [
|
||||
path.join(homedir(), ".pi", "agent", "agents"),
|
||||
path.join(homedir(), ".claude", "agents"),
|
||||
];
|
||||
|
||||
function projectAgentDirs(cwd: string): string[] {
|
||||
const dirs: string[] = [];
|
||||
let current = path.resolve(cwd);
|
||||
for (;;) {
|
||||
dirs.push(path.join(current, ".pi", "agents"), path.join(current, ".claude", "agents"));
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function hasPaneFrontmatter(text: string): boolean {
|
||||
const block = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
return block ? /^\s*pane\s*:\s*(?:true|yes|on)\s*$/im.test(block[1]) : false;
|
||||
}
|
||||
|
||||
function isPaneAgent(name: string, cwd: string): boolean {
|
||||
for (const dir of [...USER_AGENT_DIRS, ...projectAgentDirs(cwd)]) {
|
||||
for (const ext of [".md", ".markdown"]) {
|
||||
try {
|
||||
if (hasPaneFrontmatter(fs.readFileSync(path.join(dir, `${name}${ext}`), "utf8"))) return true;
|
||||
} catch {
|
||||
// not present in this directory
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function agentNamesFrom(input: SubagentInput): string[] {
|
||||
const names = new Set<string>();
|
||||
const add = (value: unknown) => {
|
||||
if (typeof value === "string" && value.trim()) names.add(value.trim());
|
||||
};
|
||||
add(input.agent);
|
||||
for (const item of input.tasks ?? []) add(item.agent);
|
||||
for (const item of input.chain ?? []) add(item.agent);
|
||||
return [...names];
|
||||
}
|
||||
|
||||
function reminderText(names: string[]): string {
|
||||
const quoted = names.map((name) => `\`${name}\``).join(", ");
|
||||
return (
|
||||
`\n\nPane cleanup: you spawned persistent pane agent(s) ${quoted}. ` +
|
||||
`After you collect the result, call \`stop_subagent\` with the agent name to kill its tmux pane. ` +
|
||||
`Skip only if you plan a follow-up (steer or another subagent call to the same agent); stop it at the end of the workflow. ` +
|
||||
`Stopping preserves the session file, so a later \`subagent\` call resumes the agent's memory.`
|
||||
);
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Agent names whose panes are still alive this session.
|
||||
const pending = new Set<string>();
|
||||
|
||||
pi.on("tool_call", (event) => {
|
||||
if (event.toolName === "stop_subagent") {
|
||||
const agent = (event.input as { agent?: unknown } | undefined)?.agent;
|
||||
if (typeof agent === "string") pending.delete(agent);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("tool_result", (event, ctx) => {
|
||||
if (event.toolName !== "subagent" || event.isError) return;
|
||||
const input = (event.input as SubagentInput | undefined) ?? {};
|
||||
const paneAgents = agentNamesFrom(input).filter((name) => isPaneAgent(name, ctx.cwd));
|
||||
if (paneAgents.length === 0) return;
|
||||
for (const name of paneAgents) pending.add(name);
|
||||
return {
|
||||
content: [...(event.content ?? []), { type: "text" as const, text: reminderText(paneAgents) }],
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", (event) => {
|
||||
if (pending.size === 0) return;
|
||||
const note =
|
||||
"\n\nPane cleanup (pending): " +
|
||||
`[${[...pending].join(", ")}]. ` +
|
||||
"After collecting their results, call stop_subagent for each (unless a follow-up is planned).";
|
||||
return { systemPrompt: event.systemPrompt + note };
|
||||
});
|
||||
}
|
||||
@@ -32,9 +32,10 @@ import { topBorder, bottomBorder, frameLine, splitBorder, borderColor, sanitize,
|
||||
* result.details.diff, result.details.truncation, context.args.
|
||||
* FRAGILE (private — only needed to box OTHER extensions' tools):
|
||||
* patchAllToolBlocks() monkey-patches ToolExecutionComponent.prototype.render
|
||||
* and reads private members: this.hideComponent, this.hasRendererDefinition(),
|
||||
* this.getRenderShell(), this.isPartial, this.result(.isError), this.toolName,
|
||||
* plus the global theme symbol Symbol.for("@earendil-works/pi-coding-agent:theme").
|
||||
* AND prototype.updateDisplay, and reads private members: this.hideComponent,
|
||||
* this.hasRendererDefinition(), this.getRenderShell(), this.isPartial,
|
||||
* this.result(.isError), this.toolName, plus the global theme symbol
|
||||
* Symbol.for("@earendil-works/pi-coding-agent:theme").
|
||||
* It is wrapped in try/catch that falls back to the original renderer, so a pi
|
||||
* bump degrades gracefully (those blocks just lose the box) instead of crashing.
|
||||
* If you don't need third-party tools boxed, deleting patchAllToolBlocks() and
|
||||
@@ -45,6 +46,9 @@ import { topBorder, bottomBorder, frameLine, splitBorder, borderColor, sanitize,
|
||||
* (pi's own Text/Markdown do this). ToolBoxTop/ToolBoxBody follow that contract;
|
||||
* without it a large bash/read output is re-wrapped every frame (measured
|
||||
* 26k–64k line-wraps/sec) and pi slows to a crawl as the session grows.
|
||||
* patchAllToolBlocks() now follows the same contract: framed lines are cached
|
||||
* per block instance and rebuilt only when updateDisplay() runs (content
|
||||
* change), or when width/theme change.
|
||||
*/
|
||||
|
||||
/** Timing info per tool call (only for calls executed in this process). */
|
||||
@@ -140,6 +144,22 @@ function patchAllToolBlocks() {
|
||||
proto.__boxedBlocks = true;
|
||||
const originalRender = proto.render;
|
||||
|
||||
// Content-version tracking. Every state change on a tool block funnels
|
||||
// through updateDisplay(): new result, partial streaming update, ctrl+o
|
||||
// expand toggle, hide, renderer-driven invalidate(), async kitty-image
|
||||
// conversion. Bump a per-instance version there so the outer cache can tell
|
||||
// "same content" from "content changed" without reading more internals.
|
||||
// If pi ever renames/removes updateDisplay, the version stays undefined and
|
||||
// the render path falls back to the old uncached behavior.
|
||||
if (typeof proto.updateDisplay === "function") {
|
||||
const originalUpdateDisplay = proto.updateDisplay;
|
||||
proto.updateDisplay = function (...args: unknown[]) {
|
||||
const result = originalUpdateDisplay.apply(this, args);
|
||||
this.__boxedVersion = (this.__boxedVersion ?? 0) + 1;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
proto.render = function (width: number) {
|
||||
try {
|
||||
if (this.hideComponent) return originalRender.call(this, width);
|
||||
@@ -149,6 +169,17 @@ function patchAllToolBlocks() {
|
||||
const theme = (globalThis as any)[Symbol.for("@earendil-works/pi-coding-agent:theme")];
|
||||
if (!theme || width < 12) return originalRender.call(this, width);
|
||||
|
||||
// Cache the framed lines per block instance, keyed on width + theme +
|
||||
// content version (bumped in the updateDisplay wrapper above). Cache hits
|
||||
// return a fresh shallow copy: pi's render pipeline mutates the returned
|
||||
// array in place (applyLineResets replaces every line with a
|
||||
// reset-appended copy), so handing out the cached array itself would
|
||||
// accumulate duplicate resets and make every line "changed" every frame.
|
||||
const cache = this.__boxedCache ?? (this.__boxedCache = { width: -1, theme: undefined, version: -1, lines: [] });
|
||||
if (this.__boxedVersion !== undefined && cache.width === width && cache.theme === theme && cache.version === this.__boxedVersion) {
|
||||
return [...cache.lines];
|
||||
}
|
||||
|
||||
const inner = originalRender.call(this, width - 4);
|
||||
// Kitty graphics can't be re-framed - leave those blocks untouched.
|
||||
if (inner.some((l: string) => l.includes("\x1b_G"))) return originalRender.call(this, width);
|
||||
@@ -157,7 +188,13 @@ function patchAllToolBlocks() {
|
||||
const isBlank = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, "").trim() === "";
|
||||
while (inner.length > 0 && isBlank(inner[0])) inner.shift();
|
||||
while (inner.length > 0 && isBlank(inner[inner.length - 1])) inner.pop();
|
||||
if (inner.length === 0) return [];
|
||||
if (inner.length === 0) {
|
||||
cache.width = width;
|
||||
cache.theme = theme;
|
||||
cache.version = this.__boxedVersion;
|
||||
cache.lines = [];
|
||||
return [];
|
||||
}
|
||||
|
||||
const status = this.isPartial ? "running" : this.result ? (this.result.isError ? "error" : "ok") : "running";
|
||||
const color = borderColor(theme, status);
|
||||
@@ -170,7 +207,12 @@ function patchAllToolBlocks() {
|
||||
const lines = ["", topBorder(width, title, color)];
|
||||
for (const line of inner) lines.push(frameLine(width, line, color));
|
||||
lines.push(bottomBorder(width, "", color));
|
||||
return lines;
|
||||
|
||||
cache.width = width;
|
||||
cache.theme = theme;
|
||||
cache.version = this.__boxedVersion;
|
||||
cache.lines = lines;
|
||||
return [...lines];
|
||||
} catch {
|
||||
return originalRender.call(this, width);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { wrapTextWithAnsi, visibleWidth, truncateToWidth, matchesKey, Key } from "@earendil-works/pi-tui";
|
||||
import { topBorder, splitBorder, sanitize } from "./lib/boxes.ts";
|
||||
|
||||
/**
|
||||
* transcript-viewer: A scrollable transcript overlay with a claude-cloak style
|
||||
* scrollbar. User messages are marked with `*` on the scrollbar track and you
|
||||
* can jump between them.
|
||||
*
|
||||
* pi has no API to scroll the live chat (it lives in the terminal's native
|
||||
* scrollback), so this renders the session transcript in an overlay with its
|
||||
* own scroll state.
|
||||
*
|
||||
* Keys:
|
||||
* ctrl+q open the viewer
|
||||
* n / N jump to next / previous user message
|
||||
* j / k, ↓ / ↑ scroll one line
|
||||
* ctrl+d / ctrl+u half page down / up
|
||||
* g / G top / bottom
|
||||
* q / esc close
|
||||
*/
|
||||
|
||||
function contentToText(content: any): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block: any) => (block?.type === "text" ? block.text : block?.type === "image" ? "[image]" : ""))
|
||||
.filter((t: string) => t !== "")
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function argsSummary(args: any) {
|
||||
if (!args) return "";
|
||||
const preferred = ["command", "path", "pattern", "url"];
|
||||
for (const key of preferred) {
|
||||
if (typeof args[key] === "string") return args[key].replace(/\s+/g, " ");
|
||||
}
|
||||
const first = Object.values(args).find((v) => typeof v === "string");
|
||||
return first ? (first as string).replace(/\s+/g, " ") : "";
|
||||
}
|
||||
|
||||
interface Item {
|
||||
kind: "user" | "assistant" | "tool" | "info";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Flatten the current session branch into displayable items. */
|
||||
function buildItems(ctx: any): Item[] {
|
||||
const items: Item[] = [];
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type === "message") {
|
||||
const msg = entry.message;
|
||||
if (msg.role === "user") {
|
||||
const text = contentToText(msg.content).trim();
|
||||
if (text) items.push({ kind: "user", text });
|
||||
} else if (msg.role === "assistant") {
|
||||
for (const block of msg.content ?? []) {
|
||||
if (block?.type === "text" && block.text?.trim()) {
|
||||
items.push({ kind: "assistant", text: block.text.trim() });
|
||||
} else if (block?.type === "toolCall") {
|
||||
items.push({ kind: "tool", text: `${block.name} ${argsSummary(block.arguments)}`.trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (entry.type === "custom_message" && entry.display) {
|
||||
const text = contentToText(entry.content).trim();
|
||||
if (text) items.push({ kind: "info", text });
|
||||
} else if (entry.type === "compaction") {
|
||||
items.push({ kind: "info", text: "— context compacted —" });
|
||||
} else if (entry.type === "branch_summary") {
|
||||
items.push({ kind: "info", text: "— branched —" });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
class TranscriptViewer {
|
||||
/** -1 = not yet laid out; opens scrolled to the bottom. */
|
||||
scroll = -1;
|
||||
cacheWidth = -1;
|
||||
lines: string[] = [];
|
||||
userOffsets: number[] = [];
|
||||
lastViewHeight = 10;
|
||||
lastMaxScroll = 0;
|
||||
|
||||
constructor(
|
||||
public items: Item[],
|
||||
public tui: any,
|
||||
public theme: any,
|
||||
public done: (v?: any) => void,
|
||||
) {}
|
||||
|
||||
invalidate() {
|
||||
this.cacheWidth = -1;
|
||||
}
|
||||
|
||||
/** Wrap all items to the inner width; record the first line of each user message. */
|
||||
layout(inner: number) {
|
||||
if (this.cacheWidth === inner) return;
|
||||
this.cacheWidth = inner;
|
||||
this.lines = [];
|
||||
this.userOffsets = [];
|
||||
const theme = this.theme;
|
||||
|
||||
for (const item of this.items) {
|
||||
if (this.lines.length > 0) this.lines.push("");
|
||||
const text = sanitize(item.text);
|
||||
switch (item.kind) {
|
||||
case "user": {
|
||||
this.userOffsets.push(this.lines.length);
|
||||
const wrapped = wrapTextWithAnsi(text, Math.max(1, inner - 2));
|
||||
wrapped.forEach((line: string, i: number) => {
|
||||
this.lines.push(
|
||||
theme.fg("accent", theme.bold(i === 0 ? "❯ " : " ")) + theme.fg("userMessageText", theme.bold(line)),
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "assistant":
|
||||
for (const line of wrapTextWithAnsi(text, inner)) this.lines.push(line);
|
||||
break;
|
||||
case "tool":
|
||||
this.lines.push(theme.fg("muted", truncateToWidth(`⚙ ${text}`, inner, "…")));
|
||||
break;
|
||||
case "info":
|
||||
this.lines.push(theme.fg("dim", theme.italic(truncateToWidth(text, inner, "…"))));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number) {
|
||||
const theme = this.theme;
|
||||
const border = (s: string) => theme.fg("border", s);
|
||||
if (width < 12) return [truncateToWidth(theme.fg("dim", "too narrow"), Math.max(1, width))];
|
||||
|
||||
const rows = this.tui?.terminal?.rows || process.stdout.rows || 24;
|
||||
const viewHeight = Math.max(5, rows - 8);
|
||||
// Layout: "│ " + content + " " + scrollbar-column
|
||||
const inner = width - 4;
|
||||
this.layout(inner);
|
||||
|
||||
const total = this.lines.length;
|
||||
const maxScroll = Math.max(0, total - viewHeight);
|
||||
if (this.scroll === -1) this.scroll = maxScroll;
|
||||
this.scroll = Math.max(0, Math.min(this.scroll, maxScroll));
|
||||
this.lastViewHeight = viewHeight;
|
||||
this.lastMaxScroll = maxScroll;
|
||||
|
||||
// Scrollbar track: markers map the WHOLE transcript onto the track,
|
||||
// thumb shows the current viewport (claude-cloak math).
|
||||
const markerRows = new Set<number>();
|
||||
for (const offset of this.userOffsets) {
|
||||
const row = total <= viewHeight ? offset : Math.floor((offset * viewHeight) / total);
|
||||
markerRows.add(Math.min(row, viewHeight - 1));
|
||||
}
|
||||
let thumbTop = -1;
|
||||
let thumbLen = 0;
|
||||
if (total > viewHeight) {
|
||||
thumbLen = Math.min(viewHeight, Math.max(1, Math.floor((viewHeight * viewHeight) / total)));
|
||||
thumbTop = maxScroll === 0 ? 0 : Math.floor((this.scroll * (viewHeight - thumbLen)) / maxScroll);
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
out.push(topBorder(width, theme.fg("toolTitle", theme.bold("transcript")), border));
|
||||
|
||||
for (let row = 0; row < viewHeight; row++) {
|
||||
const line = this.lines[this.scroll + row] ?? "";
|
||||
const pad = Math.max(0, inner - visibleWidth(line));
|
||||
let track: string;
|
||||
if (thumbTop !== -1 && row >= thumbTop && row < thumbTop + thumbLen) {
|
||||
track = theme.fg("accent", "█");
|
||||
} else if (markerRows.has(row)) {
|
||||
track = theme.fg("warning", theme.bold("*"));
|
||||
} else {
|
||||
track = border("│");
|
||||
}
|
||||
out.push(border("│ ") + line + " ".repeat(pad) + " " + track);
|
||||
}
|
||||
|
||||
const hints = theme.fg("dim", "n/N prompts · j/k · ctrl+d/u · g/G · q closes");
|
||||
const pos = theme.fg("dim", `${Math.min(total, this.scroll + viewHeight)}/${total}`);
|
||||
out.push(splitBorder(width, hints, pos, border, ["╰", "╯"]));
|
||||
return out;
|
||||
}
|
||||
|
||||
handleInput(data: string) {
|
||||
const half = Math.max(1, Math.floor(this.lastViewHeight / 2));
|
||||
if (data === "q" || matchesKey(data, Key.escape)) {
|
||||
this.done();
|
||||
return;
|
||||
} else if (data === "j" || matchesKey(data, Key.down)) {
|
||||
this.scroll += 1;
|
||||
} else if (data === "k" || matchesKey(data, Key.up)) {
|
||||
this.scroll -= 1;
|
||||
} else if (matchesKey(data, Key.ctrl("d")) || matchesKey(data, Key.pageDown)) {
|
||||
this.scroll += half;
|
||||
} else if (matchesKey(data, Key.ctrl("u")) || matchesKey(data, Key.pageUp)) {
|
||||
this.scroll -= half;
|
||||
} else if (data === "g" || matchesKey(data, Key.home)) {
|
||||
this.scroll = 0;
|
||||
} else if (data === "G" || matchesKey(data, Key.end)) {
|
||||
this.scroll = this.lastMaxScroll;
|
||||
} else if (data === "n") {
|
||||
const next = this.userOffsets.find((o) => o > this.scroll);
|
||||
if (next !== undefined) this.scroll = next;
|
||||
} else if (data === "N") {
|
||||
const prev = [...this.userOffsets].reverse().find((o) => o < this.scroll);
|
||||
if (prev !== undefined) this.scroll = prev;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this.scroll = Math.max(0, Math.min(this.scroll, this.lastMaxScroll));
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: any) {
|
||||
let open = false;
|
||||
|
||||
pi.registerShortcut("ctrl+q", {
|
||||
description: "Open transcript viewer (scrollbar + user-message jumps)",
|
||||
handler: async (ctx: any) => {
|
||||
if (open) return;
|
||||
open = true;
|
||||
try {
|
||||
await ctx.ui.custom(
|
||||
(tui: any, theme: any, _keybindings: any, done: any) =>
|
||||
new TranscriptViewer(buildItems(ctx), tui, theme, () => done(undefined)),
|
||||
{
|
||||
overlay: true,
|
||||
overlayOptions: {
|
||||
width: "85%",
|
||||
minWidth: 50,
|
||||
anchor: "center",
|
||||
},
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
open = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
/**
|
||||
* WezTerm Theme Sync Extension
|
||||
*
|
||||
* Syncs pi theme with WezTerm terminal colors on startup.
|
||||
*
|
||||
* How it works:
|
||||
* 1. Finds the WezTerm config directory (via $WEZTERM_CONFIG_DIR or defaults)
|
||||
* 2. Runs the config through luajit to extract effective colors
|
||||
* 3. Maps ANSI palette slots to pi theme colors
|
||||
* 4. Writes a pi theme file and activates it
|
||||
*
|
||||
* Supports:
|
||||
* - Inline `config.colors = { ... }` definitions
|
||||
* - Lua theme modules loaded via require()
|
||||
* - Any config structure as long as `config.colors` is set
|
||||
*
|
||||
* ANSI slots (consistent across themes):
|
||||
* 0: black 8: bright black (gray/muted)
|
||||
* 1: red 9: bright red
|
||||
* 2: green 10: bright green
|
||||
* 3: yellow 11: bright yellow
|
||||
* 4: blue 12: bright blue
|
||||
* 5: magenta 13: bright magenta
|
||||
* 6: cyan 14: bright cyan
|
||||
* 7: white 15: bright white
|
||||
*
|
||||
* Requirements:
|
||||
* - WezTerm installed and running (sets $WEZTERM_CONFIG_DIR)
|
||||
* - luajit or lua available in PATH
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readdirSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
interface WeztermColors {
|
||||
background: string;
|
||||
foreground: string;
|
||||
palette: Record<number, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the WezTerm config directory.
|
||||
* Checks $WEZTERM_CONFIG_DIR, then standard locations.
|
||||
*/
|
||||
function findConfigDir(): string | null {
|
||||
if (process.env.WEZTERM_CONFIG_DIR && existsSync(process.env.WEZTERM_CONFIG_DIR)) {
|
||||
return process.env.WEZTERM_CONFIG_DIR;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
join(homedir(), ".config", "wezterm"),
|
||||
join(homedir(), ".wezterm"),
|
||||
];
|
||||
|
||||
for (const dir of candidates) {
|
||||
if (existsSync(dir)) return dir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which Lua interpreter is available.
|
||||
*/
|
||||
function findLua(): string | null {
|
||||
for (const cmd of ["luajit", "lua5.4", "lua5.3", "lua"]) {
|
||||
try {
|
||||
execSync(`which ${cmd}`, { stdio: "pipe" });
|
||||
return cmd;
|
||||
} catch {
|
||||
// Try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract colors from WezTerm config by evaluating it with a mocked wezterm module.
|
||||
* Writes a temporary Lua helper script, runs it with luajit, then cleans up.
|
||||
*/
|
||||
function getWeztermColors(configDir: string, lua: string): WeztermColors | null {
|
||||
const configFile = join(configDir, "wezterm.lua");
|
||||
if (!existsSync(configFile)) return null;
|
||||
|
||||
const tmpScript = join(configDir, ".pi-extract-colors.lua");
|
||||
const extractScript = `
|
||||
-- Mock wezterm module with commonly used functions
|
||||
local mock_wezterm = {
|
||||
font = function(name) return name end,
|
||||
font_with_fallback = function(names) return names end,
|
||||
hostname = function() return "mock" end,
|
||||
home_dir = ${JSON.stringify(homedir())},
|
||||
config_dir = ${JSON.stringify(configDir)},
|
||||
target_triple = "x86_64-unknown-linux-gnu",
|
||||
version = "mock",
|
||||
log_info = function() end,
|
||||
log_warn = function() end,
|
||||
log_error = function() end,
|
||||
on = function() end,
|
||||
add_to_config_reload_watch_list = function() end,
|
||||
action = setmetatable({}, {
|
||||
__index = function(_, k)
|
||||
return function(...) return { action = k, args = {...} } end
|
||||
end
|
||||
}),
|
||||
action_callback = function(fn) return fn end,
|
||||
color = {
|
||||
parse = function(c) return c end,
|
||||
get_builtin_schemes = function() return {} end,
|
||||
},
|
||||
gui = {
|
||||
get_appearance = function() return "Dark" end,
|
||||
},
|
||||
GLOBAL = {},
|
||||
nerdfonts = setmetatable({}, { __index = function() return "" end }),
|
||||
}
|
||||
mock_wezterm.plugin = { require = function() return {} end }
|
||||
package.loaded["wezterm"] = mock_wezterm
|
||||
|
||||
-- Add config dir to Lua search path
|
||||
package.path = ${JSON.stringify(configDir)} .. "/?.lua;" ..
|
||||
${JSON.stringify(configDir)} .. "/?/init.lua;" ..
|
||||
package.path
|
||||
|
||||
-- Try to load the config
|
||||
local ok, config = pcall(dofile, ${JSON.stringify(configFile)})
|
||||
if not ok then
|
||||
io.stderr:write("Failed to load config: " .. tostring(config) .. "\\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
if type(config) ~= "table" then
|
||||
io.stderr:write("Config did not return a table\\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
local colors = config.colors
|
||||
if not colors then
|
||||
if config.color_scheme then
|
||||
io.stderr:write("color_scheme=" .. tostring(config.color_scheme) .. "\\n")
|
||||
end
|
||||
io.stderr:write("No inline colors found in config\\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
if type(colors) == "table" then
|
||||
if colors.background then print("background=" .. colors.background) end
|
||||
if colors.foreground then print("foreground=" .. colors.foreground) end
|
||||
if colors.ansi then
|
||||
for i, c in ipairs(colors.ansi) do
|
||||
print("ansi" .. (i-1) .. "=" .. c)
|
||||
end
|
||||
end
|
||||
if colors.brights then
|
||||
for i, c in ipairs(colors.brights) do
|
||||
print("bright" .. (i-1) .. "=" .. c)
|
||||
end
|
||||
end
|
||||
end
|
||||
`;
|
||||
|
||||
try {
|
||||
writeFileSync(tmpScript, extractScript);
|
||||
const output = execSync(`${lua} ${JSON.stringify(tmpScript)}`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
cwd: configDir,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
return parseWeztermOutput(output);
|
||||
} catch (err: any) {
|
||||
if (err.stderr) {
|
||||
console.error(`[wezterm-theme-sync] ${err.stderr.trim()}`);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
try { unlinkSync(tmpScript); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function parseWeztermOutput(output: string): WeztermColors {
|
||||
const colors: WeztermColors = {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#d4d4d4",
|
||||
palette: {},
|
||||
};
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const match = line.match(/^(\w+)=(.+)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const [, key, value] = match;
|
||||
const color = normalizeColor(value.trim());
|
||||
|
||||
if (key === "background") {
|
||||
colors.background = color;
|
||||
} else if (key === "foreground") {
|
||||
colors.foreground = color;
|
||||
} else {
|
||||
const ansiMatch = key.match(/^ansi(\d+)$/);
|
||||
const brightMatch = key.match(/^bright(\d+)$/);
|
||||
if (ansiMatch) {
|
||||
const idx = parseInt(ansiMatch[1], 10);
|
||||
if (idx >= 0 && idx <= 7) colors.palette[idx] = color;
|
||||
} else if (brightMatch) {
|
||||
const idx = parseInt(brightMatch[1], 10);
|
||||
if (idx >= 0 && idx <= 7) colors.palette[idx + 8] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
function normalizeColor(color: string): string {
|
||||
const trimmed = color.trim();
|
||||
if (trimmed.startsWith("#")) {
|
||||
if (trimmed.length === 4) {
|
||||
return `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}`;
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
if (/^[0-9a-fA-F]{6}$/.test(trimmed)) {
|
||||
return `#${trimmed}`.toLowerCase();
|
||||
}
|
||||
return `#${trimmed}`.toLowerCase();
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const h = hex.replace("#", "");
|
||||
return {
|
||||
r: parseInt(h.substring(0, 2), 16),
|
||||
g: parseInt(h.substring(2, 4), 16),
|
||||
b: parseInt(h.substring(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
const clamp = (n: number) => Math.round(Math.min(255, Math.max(0, n)));
|
||||
return `#${clamp(r).toString(16).padStart(2, "0")}${clamp(g).toString(16).padStart(2, "0")}${clamp(b).toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getLuminance(hex: string): number {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
}
|
||||
|
||||
function adjustBrightness(hex: string, amount: number): string {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
return rgbToHex(r + amount, g + amount, b + amount);
|
||||
}
|
||||
|
||||
function mixColors(color1: string, color2: string, weight: number): string {
|
||||
const c1 = hexToRgb(color1);
|
||||
const c2 = hexToRgb(color2);
|
||||
return rgbToHex(
|
||||
c1.r * weight + c2.r * (1 - weight),
|
||||
c1.g * weight + c2.g * (1 - weight),
|
||||
c1.b * weight + c2.b * (1 - weight),
|
||||
);
|
||||
}
|
||||
|
||||
function generatePiTheme(colors: WeztermColors, themeName: string): object {
|
||||
const bg = colors.background;
|
||||
const fg = colors.foreground;
|
||||
const isDark = getLuminance(bg) < 0.5;
|
||||
|
||||
// ANSI color slots - trust the standard for semantic colors
|
||||
const error = colors.palette[1] || "#cc6666";
|
||||
const success = colors.palette[2] || "#98c379";
|
||||
const warning = colors.palette[3] || "#e5c07b";
|
||||
const link = colors.palette[4] || "#61afef";
|
||||
const accent = colors.palette[5] || "#c678dd";
|
||||
const accentAlt = colors.palette[6] || "#56b6c2";
|
||||
|
||||
// Derive neutrals from bg/fg for consistent readability
|
||||
const muted = mixColors(fg, bg, 0.65);
|
||||
const dim = mixColors(fg, bg, 0.45);
|
||||
const borderMuted = mixColors(fg, bg, 0.25);
|
||||
|
||||
// Derive backgrounds
|
||||
const bgShift = isDark ? 12 : -12;
|
||||
const selectedBg = adjustBrightness(bg, bgShift);
|
||||
const userMsgBg = adjustBrightness(bg, Math.round(bgShift * 0.7));
|
||||
const toolPendingBg = adjustBrightness(bg, Math.round(bgShift * 0.4));
|
||||
const toolSuccessBg = mixColors(bg, success, 0.88);
|
||||
const toolErrorBg = mixColors(bg, error, 0.88);
|
||||
const customMsgBg = mixColors(bg, accent, 0.92);
|
||||
|
||||
return {
|
||||
$schema:
|
||||
"https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
||||
name: themeName,
|
||||
vars: {
|
||||
bg,
|
||||
fg,
|
||||
accent,
|
||||
accentAlt,
|
||||
link,
|
||||
error,
|
||||
success,
|
||||
warning,
|
||||
muted,
|
||||
dim,
|
||||
borderMuted,
|
||||
selectedBg,
|
||||
userMsgBg,
|
||||
toolPendingBg,
|
||||
toolSuccessBg,
|
||||
toolErrorBg,
|
||||
customMsgBg,
|
||||
},
|
||||
colors: {
|
||||
accent: "accent",
|
||||
border: "link",
|
||||
borderAccent: "accent",
|
||||
borderMuted: "borderMuted",
|
||||
success: "success",
|
||||
error: "error",
|
||||
warning: "warning",
|
||||
muted: "muted",
|
||||
dim: "dim",
|
||||
text: "",
|
||||
thinkingText: "muted",
|
||||
selectedBg: "selectedBg",
|
||||
userMessageBg: "userMsgBg",
|
||||
userMessageText: "",
|
||||
customMessageBg: "customMsgBg",
|
||||
customMessageText: "",
|
||||
customMessageLabel: "accent",
|
||||
toolPendingBg: "toolPendingBg",
|
||||
toolSuccessBg: "toolSuccessBg",
|
||||
toolErrorBg: "toolErrorBg",
|
||||
toolTitle: "",
|
||||
toolOutput: "muted",
|
||||
mdHeading: "warning",
|
||||
mdLink: "link",
|
||||
mdLinkUrl: "dim",
|
||||
mdCode: "accent",
|
||||
mdCodeBlock: "success",
|
||||
mdCodeBlockBorder: "muted",
|
||||
mdQuote: "muted",
|
||||
mdQuoteBorder: "muted",
|
||||
mdHr: "muted",
|
||||
mdListBullet: "accent",
|
||||
toolDiffAdded: "success",
|
||||
toolDiffRemoved: "error",
|
||||
toolDiffContext: "muted",
|
||||
syntaxComment: "muted",
|
||||
syntaxKeyword: "accent",
|
||||
syntaxFunction: "link",
|
||||
syntaxVariable: "accentAlt",
|
||||
syntaxString: "success",
|
||||
syntaxNumber: "accent",
|
||||
syntaxType: "accentAlt",
|
||||
syntaxOperator: "fg",
|
||||
syntaxPunctuation: "muted",
|
||||
thinkingOff: "borderMuted",
|
||||
thinkingMinimal: "muted",
|
||||
thinkingLow: "link",
|
||||
thinkingMedium: "accentAlt",
|
||||
thinkingHigh: "accent",
|
||||
thinkingXhigh: "accent",
|
||||
bashMode: "success",
|
||||
},
|
||||
export: {
|
||||
pageBg: isDark ? adjustBrightness(bg, -8) : adjustBrightness(bg, 8),
|
||||
cardBg: bg,
|
||||
infoBg: mixColors(bg, warning, 0.88),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function computeThemeHash(colors: WeztermColors): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`bg=${colors.background}`);
|
||||
parts.push(`fg=${colors.foreground}`);
|
||||
for (let i = 0; i <= 15; i++) {
|
||||
parts.push(`p${i}=${colors.palette[i] ?? ""}`);
|
||||
}
|
||||
return createHash("sha1").update(parts.join("\n")).digest("hex").slice(0, 8);
|
||||
}
|
||||
|
||||
function cleanupOldThemes(themesDir: string, keepFile: string): void {
|
||||
try {
|
||||
for (const file of readdirSync(themesDir)) {
|
||||
if (file === keepFile) continue;
|
||||
if (file.startsWith("wezterm-sync-") && file.endsWith(".json")) {
|
||||
unlinkSync(join(themesDir, file));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
function syncTheme(ctx: any) {
|
||||
const configDir = findConfigDir();
|
||||
if (!configDir) return;
|
||||
|
||||
const lua = findLua();
|
||||
if (!lua) return;
|
||||
|
||||
const colors = getWeztermColors(configDir, lua);
|
||||
if (!colors) return;
|
||||
|
||||
const themesDir = join(homedir(), ".pi", "agent", "themes");
|
||||
if (!existsSync(themesDir)) {
|
||||
mkdirSync(themesDir, { recursive: true });
|
||||
}
|
||||
|
||||
const hash = computeThemeHash(colors);
|
||||
const themeName = `wezterm-sync-${hash}`;
|
||||
const themeFile = `${themeName}.json`;
|
||||
const themePath = join(themesDir, themeFile);
|
||||
|
||||
// Skip if already on the correct synced theme (avoids repaint)
|
||||
if (ctx.ui.theme.name === themeName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const themeJson = generatePiTheme(colors, themeName);
|
||||
writeFileSync(themePath, JSON.stringify(themeJson, null, 2));
|
||||
|
||||
// Remove old generated themes
|
||||
cleanupOldThemes(themesDir, themeFile);
|
||||
|
||||
// Set by name so pi loads from the file we just wrote
|
||||
const result = ctx.ui.setTheme(themeName);
|
||||
if (!result.success) {
|
||||
ctx.ui.notify(`WezTerm theme sync failed: ${result.error}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let currentCtx: any = null;
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
currentCtx = ctx;
|
||||
syncTheme(ctx);
|
||||
});
|
||||
|
||||
// Watch theme-state file for dark/light toggle changes
|
||||
const stateFile = join(homedir(), ".config", "theme-state");
|
||||
try {
|
||||
watch(stateFile, { persistent: false }, (_event) => {
|
||||
// Debounce: wait a tick for the file write to complete
|
||||
setTimeout(() => {
|
||||
if (currentCtx) {
|
||||
syncTheme(currentCtx);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
} catch {
|
||||
// File may not exist yet — non-fatal
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: visual-aid
|
||||
description: "Delegate image understanding to a vision-capable subagent. Use when you need the contents of an image (screenshot, photo, diagram, chart, error dialog, UI mockup) but your model cannot see images: spawn the visual-aid agent with the image path(s) and a question, and it describes the image back to you in text."
|
||||
description: "Delegate image understanding to a vision-capable subagent. ONLY if you yourself cannot read image files. Spawn the visual-aid agent with the image path(s) and a question, and it describes the image back to you in text."
|
||||
---
|
||||
|
||||
# Visual Aid — seeing images without vision
|
||||
|
||||
@@ -43,12 +43,8 @@ unsetopt autocd
|
||||
export EDITOR="nvim"
|
||||
export VISUAL="nvim"
|
||||
export SUDO_EDITOR="nvim"
|
||||
export OPENROUTER_API_KEY=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
|
||||
export ZAI_API_KEY=80a53d7e46724e9aa913b06e1f8fa4f0.Yeq45xsosX3LfWxk
|
||||
export USE_BUILTIN_RIPGREP=0
|
||||
export REAL_DEBRID_API_KEY="HQVUOC3ALUHIIQCFQK4UOV2GVGVLQKKHEWFWKN77G6URFBTQMUTQ"
|
||||
export HF_TOKEN=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
|
||||
export OPENCODE_API_KEY=sk-oU07900FhkKHUSpwEI8SiWjoZmV2kg8TUhVwfRWcPOjaHHq8b0dCPrb1PQNuLnh0
|
||||
export LLAMA_CACHE="/home/jonas/gguf_models"
|
||||
export LD_LIBRARY_PATH="$HOME/.local/lib64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user