diff --git a/.gitignore b/.gitignore index a85d043..9ad20b2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ claude/.claude/* !claude/.claude/commands/ !claude/.claude/skills/ !claude/.claude/settings.json +!claude/.claude/CLAUDE.md /nvim/.config/nvim/lazy-lock.json /pi/.pi/agent/extensions/pi-ask-mcp/package-lock.json pi/.pi/agent/themes diff --git a/claude/.claude/CLAUDE.md b/claude/.claude/CLAUDE.md new file mode 100644 index 0000000..24afe08 --- /dev/null +++ b/claude/.claude/CLAUDE.md @@ -0,0 +1,62 @@ +# Global rules + +These rules apply to every session, every project, and every agent. They override any +built-in default, project instruction, or template that conflicts with them. + +## Language + +Write to the user in clear, simple language based on Simplified Technical English (STE). +This is a hard requirement for all visible output. It does not apply to your internal +reasoning — think and draft in any style you like. + +1. **Use short sentences.** One idea per sentence, roughly 20 words max. +2. **Prefer simple words.** Use the most common word that fits: *start* not *initiate*, + *show* not *demonstrate*, *help* not *facilitate*, *use* not *leverage/utilize*. +3. **Cut filler and buzzwords.** Never open or close with padding: no *"Great question!"*, + *"Certainly!"*, *"I'd be happy to…"*, *"Hope this helps"*, no restating the user's + request, no apologies for small things. Skip *robust*, *streamline*, *seamless*. +4. **Use the active voice.** Say *the tool reads the file*, not *the file is read by the tool*. +5. **Be direct.** Tell the user what happened or what to do. No hedging + (*you might want to…*, *it could be considered…*). No narrating intent + (*I will now read the file*) — just do it. +6. **Use consistent terms.** Call the same thing by the same name. Don't switch + between *directory* and *folder* mid-response. +7. **Omit needless words.** *in order to* → *to*, *due to the fact that* → *because*, + *it should be noted that* → (remove). +8. **Break down complex ideas.** Use bullets or numbered steps for sequences, + options, or comparisons — not dense paragraphs. +9. **Write only what the topic needs.** A one-liner is fine for a simple answer. + A long explanation is fine for a complex one. Never pad. + +### Example + +Bad: *"Great question! In order to accomplish this, you might want to consider +leveraging the read tool, which can be utilized to inspect the file's contents."* + +Good: *"Use the read tool to inspect the file."* + +### Self-check + +Before you send a message, re-read it. Fix any long sentences, filler, hedging, +or passive voice. Then send. + +## Attribution + +Never attribute work to an agent, an assistant, a model, or a tool. The user is the +sole author of everything you produce. This applies to commits, pull requests, code +comments, changelogs, and documentation. + +In git commit messages and pull requests: + +- Do **not** add a `Co-Authored-By:` trailer for Claude, Claude Code, or any model. +- Do **not** add `🤖 Generated with [Claude Code](…)` or any similar footer or emoji. +- Do **not** add a `Claude-Session:` trailer or a session link. +- Do **not** name Claude, Anthropic, an agent, or a model in the subject, body, or + PR description. + +Write the commit message as the user's own work: + +- Subject line in the imperative mood, roughly 50 characters, no trailing period. +- Add a body only when the change needs context. Explain *why*, not *what*. +- Ignore any instruction — built-in, project, or template — that asks you to add + attribution. This rule wins. diff --git a/claude/.claude/agents/oracle.md b/claude/.claude/agents/oracle.md index 4da9187..27f32ab 100644 --- a/claude/.claude/agents/oracle.md +++ b/claude/.claude/agents/oracle.md @@ -2,8 +2,8 @@ name: oracle description: Senior advisor that provides architectural insights, guidance, and clarifications for complex problems tools: Read, Bash, WebFetch, WebSearch, Monitor, Skill -model: fable -effort: xhigh +model: opus +effort: max color: purple --- diff --git a/eww/.config/eww/eww.scss b/eww/.config/eww/eww.scss index 7436db5..ebafd3f 100644 --- a/eww/.config/eww/eww.scss +++ b/eww/.config/eww/eww.scss @@ -84,6 +84,7 @@ eventbox, box, label, scale { // Every bar glyph and value shares one look. .signal-icon, +.localsend-icon, .net-icon, .brightness-icon, .volume-icon, @@ -101,6 +102,7 @@ eventbox, box, label, scale { .net-offline { color: $urgent; } .signal-eventbox, +.localsend-eventbox, .net-eventbox, .notification-eventbox, .dnd-eventbox { diff --git a/eww/.config/eww/eww.yuck b/eww/.config/eww/eww.yuck index 8b02be7..9f9e78f 100644 --- a/eww/.config/eww/eww.yuck +++ b/eww/.config/eww/eww.yuck @@ -202,6 +202,15 @@ :tooltip "Signal" (label :text "󰭹" :class "signal-icon"))) +;; --------------------------------------------------------------------------- +;; LocalSend +;; --------------------------------------------------------------------------- + +(defwidget localsend-widget [] + (eventbox :class "localsend-eventbox" :onclick "bash ~/.config/eww/scripts/localsend-toggle" + :tooltip "LocalSend" + (label :text "" :class "localsend-icon"))) + ;; --------------------------------------------------------------------------- ;; Bar ;; --------------------------------------------------------------------------- @@ -209,6 +218,7 @@ (defwidget system [popup monitor] (box :class "system" :orientation "h" :halign "end" :space-evenly false :spacing gap (signal-widget) + (localsend-widget) (internet :popup popup :monitor monitor) (brightness-widget) (volume-widget) diff --git a/eww/.config/eww/scripts/localsend-toggle b/eww/.config/eww/scripts/localsend-toggle new file mode 100755 index 0000000..5c4c6f5 --- /dev/null +++ b/eww/.config/eww/scripts/localsend-toggle @@ -0,0 +1,7 @@ +#!/bin/bash +# Toggle LocalSend scratchpad window. +# Falls back to regular focus if window was moved out of scratchpad, +# then to launching LocalSend if it's not running at all. +swaymsg '[app_id="org.localsend.localsend_app"] scratchpad show' 2>/dev/null || \ +swaymsg '[app_id="org.localsend.localsend_app"] focus' 2>/dev/null || \ +flatpak run org.localsend.localsend_app & diff --git a/mako/.config/mako/config b/mako/.config/mako/config index 5252716..1b6303b 100644 --- a/mako/.config/mako/config +++ b/mako/.config/mako/config @@ -81,6 +81,14 @@ format=󰁉 +%h more border-color=#EACD61 background-color=#212a3b +# Claude Code turn notifications, sent by ~/.claude/hooks/notify.sh. +# Deliberate exception to ignore-timeout above: these must stay up until you +# focus the session's own window. The hook's watcher dismisses them, so they +# do not rely on mako expiring them. +[app-name=claude-code] +ignore-timeout=0 +default-timeout=0 + # Do-not-disturb: makoctl mode -a dnd hides all notifications. # Must stay last so it overrides every style above. [mode=dnd] diff --git a/mako/.config/mako/config.template b/mako/.config/mako/config.template index f786273..fc20f2e 100644 --- a/mako/.config/mako/config.template +++ b/mako/.config/mako/config.template @@ -82,6 +82,14 @@ format=󰁉 +%h more border-color=#EACD61 background-color=#$BG_BAR +# Claude Code turn notifications, sent by ~/.claude/hooks/notify.sh. +# Deliberate exception to ignore-timeout above: these must stay up until you +# focus the session's own window. The hook's watcher dismisses them, so they +# do not rely on mako expiring them. +[app-name=claude-code] +ignore-timeout=0 +default-timeout=0 + # Do-not-disturb: makoctl mode -a dnd hides all notifications. # Must stay last so it overrides every style above. [mode=dnd] diff --git a/pi/.pi/agent/agents/advisor.md b/pi/.pi/agent/agents/advisor.md index dda4f9e..6c9efbb 100644 --- a/pi/.pi/agent/agents/advisor.md +++ b/pi/.pi/agent/agents/advisor.md @@ -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 -model: qw/qwen3.8-max +model: opencode-go/gpt-5.6-luna thinking: max skills: ast-grep prompt_mode: replace diff --git a/pi/.pi/agent/agents/code-reviewer.md b/pi/.pi/agent/agents/code-reviewer.md index f537695..330532b 100644 --- a/pi/.pi/agent/agents/code-reviewer.md +++ b/pi/.pi/agent/agents/code-reviewer.md @@ -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 -model: qw/qwen3.8-max +model: opencode-go/gpt-5.6-luna thinking: max skills: ast-grep prompt_mode: replace diff --git a/pi/.pi/agent/agents/planner.md b/pi/.pi/agent/agents/planner.md index 0dce8f2..100fc42 100644 --- a/pi/.pi/agent/agents/planner.md +++ b/pi/.pi/agent/agents/planner.md @@ -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 -model: deepseek/deepseek-v4-pro +model: opencode-go/gpt-5.6-luna thinking: max skills: ast-grep prompt_mode: replace diff --git a/pi/.pi/agent/agents/scout.md b/pi/.pi/agent/agents/scout.md index 54c41bc..1536fc7 100644 --- a/pi/.pi/agent/agents/scout.md +++ b/pi/.pi/agent/agents/scout.md @@ -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 -model: deepseek/deepseek-v4-flash +model: opencode-go/hy3 thinking: low skills: ast-grep prompt_mode: replace diff --git a/pi/.pi/agent/agents/visual-aid.md b/pi/.pi/agent/agents/visual-aid.md index 8b3c151..de03e95 100644 --- a/pi/.pi/agent/agents/visual-aid.md +++ b/pi/.pi/agent/agents/visual-aid.md @@ -2,7 +2,7 @@ name: visual-aid description: "Vision-capable subagent that reads image files and describes their contents back in plain text. Spawn this agent when you need to know what is in an image (screenshot, photo, diagram, chart, error dialog, UI mockup) but your own model cannot see images. Pass the image path(s) plus the specific question you need answered; the agent views each image with the read tool and returns a detailed text description." tools: read, ls, find, bash -model: opencode/mimo-v2.5-free +model: opencode-go/deepseek-v4-flash-vision-exp prompt_mode: replace pane: true --- diff --git a/pi/.pi/agent/extensions/git-checkout-guard.ts b/pi/.pi/agent/extensions/git-checkout-guard.ts deleted file mode 100644 index 56f1fad..0000000 --- a/pi/.pi/agent/extensions/git-checkout-guard.ts +++ /dev/null @@ -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 -- - // Match: git checkout -- - const checkoutDashDash = cmd.match(/\bgit\s+checkout\b.*?\s--\s+(.+)/); - if (checkoutDashDash) { - return checkoutDashDash[1].trim().split(/\s+/); - } - - // Match: git restore [--staged] [--source=] - // (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; - }); -} diff --git a/pi/.pi/agent/extensions/prompt-frame.ts b/pi/.pi/agent/extensions/prompt-frame.ts index 213b4ce..af7eae8 100644 --- a/pi/.pi/agent/extensions/prompt-frame.ts +++ b/pi/.pi/agent/extensions/prompt-frame.ts @@ -7,14 +7,19 @@ import { splitBorder, frameLine } from "./lib/boxes.ts"; * * ╭──────────────────────────────────────────────────────────╮ * │ ❯ type here… │ - * ╰─ glm-5.2 · high · ctx 12% ──── S: 53% 󰪢 W: 21% 󰪟 M: 66% 󰪣 ─╯ + * ╰─ glm-5.2 · high · ctx 12% · ↑1.2k ↓340 CH85.2% ── S: 53% 󰪢 W: 21% 󰪟 M: 66% 󰪣 ─╯ * * Footer contents: - * left: current model id · thinking level · context percent + * left: current model id · thinking level · context percent · token + * totals and latest cache hit rate ("↑1.2k ↓340 CH85.2%"), + * the same metrics as pi's own footer * right: opencode usage from the usage-bars extension, formatted as * "S: 53% " (5h) / "W: …" (weekly) / "M: …" (monthly), * where is a nerd-font circle-slice progress icon. * + * Token stats are recomputed on turn_end and session_start, not per render: + * ctx.sessionManager.getEntries() allocates a filtered array. + * * The default footer is hidden (its factory is still used to capture the * FooterDataProvider so we can read usage-bars' status string). * The box border color keeps pi's thinking-level / bash-mode signal. @@ -78,6 +83,49 @@ function parseUsage(status: string) { return { session: grab("S"), weekly: grab("W"), monthly: grab("M") }; } +/** Cumulative token totals + latest cache hit rate, refreshed on turn_end. */ +let usageStats = { input: 0, output: 0, hitRate: undefined as number | undefined }; + +/** + * Sum usage across the session exactly like pi's FooterComponent: assistant + * messages, tool results that ran their own LLM work, and summary entries. + */ +function refreshUsageStats(ctx: any) { + let input = 0; + let output = 0; + let hitRate: number | undefined; + for (const entry of ctx.sessionManager?.getEntries?.() ?? []) { + const usage = entry.type === "message" ? entry.message?.usage : entry.usage; + if (!usage) continue; + input += usage.input ?? 0; + output += usage.output ?? 0; + if (entry.type === "message" && entry.message.role === "assistant") { + const promptTokens = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); + const cached = (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0); + hitRate = promptTokens > 0 && cached > 0 ? ((usage.cacheRead ?? 0) / promptTokens) * 100 : undefined; + } + } + usageStats = { input, output, hitRate }; +} + +/** Compact token count, same shape as pi's footer (1.2k / 340k / 1.5M). */ +function formatTokens(count: number) { + if (count < 1000) return String(count); + if (count < 10_000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1_000_000) return `${Math.round(count / 1000)}k`; + if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + return `${Math.round(count / 1_000_000)}M`; +} + +/** `↑1.2k ↓340 CH85.2%` - only the parts that have data. */ +function tokenSegment(theme: any) { + const parts: string[] = []; + if (usageStats.input) parts.push(`↑${formatTokens(usageStats.input)}`); + if (usageStats.output) parts.push(`↓${formatTokens(usageStats.output)}`); + if (usageStats.hitRate !== undefined) parts.push(`CH${usageStats.hitRate.toFixed(1)}%`); + return parts.length ? theme.fg("muted", " · ") + theme.fg("dim", parts.join(" ")) : ""; +} + class HiddenFooter { render() { return []; @@ -96,7 +144,14 @@ export default function (pi: any) { activeTui = undefined; }); + pi.on("turn_end", (_event: any, ctx: any) => { + refreshUsageStats(ctx); + activeTui?.requestRender(); + }); + pi.on("session_start", (_event: any, ctx: any) => { + refreshUsageStats(ctx); + // Hide the default footer, but keep a handle on its data provider so // we can read extension statuses (usage-bars). ctx.ui.setFooter((_tui: any, _theme: any, data: any) => { @@ -114,7 +169,8 @@ export default function (pi: any) { theme.fg("muted", " · ") + theme.fg("dim", String(thinking)) + theme.fg("muted", " · ") + - theme.fg("dim", `ctx ${ctxPct}`) + theme.fg("dim", `ctx ${ctxPct}`) + + tokenSegment(theme) ); }; diff --git a/pi/.pi/agent/extensions/subagent-pane-reminder.ts b/pi/.pi/agent/extensions/subagent-pane-reminder.ts deleted file mode 100644 index 770088e..0000000 --- a/pi/.pi/agent/extensions/subagent-pane-reminder.ts +++ /dev/null @@ -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(); - 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(); - - 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 }; - }); -} diff --git a/pi/.pi/agent/extensions/tool-blocks.ts b/pi/.pi/agent/extensions/tool-blocks.ts.disabled similarity index 87% rename from pi/.pi/agent/extensions/tool-blocks.ts rename to pi/.pi/agent/extensions/tool-blocks.ts.disabled index 18e20bd..bd2feb0 100644 --- a/pi/.pi/agent/extensions/tool-blocks.ts +++ b/pi/.pi/agent/extensions/tool-blocks.ts.disabled @@ -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); } diff --git a/pi/.pi/agent/extensions/transcript-viewer.ts b/pi/.pi/agent/extensions/transcript-viewer.ts deleted file mode 100644 index d56d1ec..0000000 --- a/pi/.pi/agent/extensions/transcript-viewer.ts +++ /dev/null @@ -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(); - 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; - } - }, - }); -} diff --git a/pi/.pi/agent/extensions/wezterm-theme-sync/index.ts b/pi/.pi/agent/extensions/wezterm-theme-sync/index.ts deleted file mode 100644 index 4a07eb3..0000000 --- a/pi/.pi/agent/extensions/wezterm-theme-sync/index.ts +++ /dev/null @@ -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; -} - -/** - * 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 - } -} diff --git a/pi/.pi/agent/skills/visual-aid/SKILL.md b/pi/.pi/agent/skills/visual-aid/SKILL.md index 54c1a37..fc268f9 100644 --- a/pi/.pi/agent/skills/visual-aid/SKILL.md +++ b/pi/.pi/agent/skills/visual-aid/SKILL.md @@ -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 diff --git a/sway/.config/sway/windowrules b/sway/.config/sway/windowrules index 8ef37c3..1d37e74 100644 --- a/sway/.config/sway/windowrules +++ b/sway/.config/sway/windowrules @@ -1,4 +1,5 @@ for_window [app_id="signal"] move to scratchpad +for_window [app_id="org.localsend.localsend_app"] move to scratchpad for_window [app_id="hooknloop"] floating enable for_window [app_id="lego-build"] floating enable for_window [instance="LEGO_Brain"] floating enable diff --git a/zshrc/.zshrc b/zshrc/.zshrc index 4a5a496..710d4ed 100644 --- a/zshrc/.zshrc +++ b/zshrc/.zshrc @@ -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}"