From 0ce40d991aede0d8e894f25557fee754c46bb539 Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Tue, 4 Aug 2026 13:24:40 +0200 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20add=20pi-toolview=20=E2=80=94=20c?= =?UTF-8?q?ompact=20tool=20output=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces pi's verbose built-in tool rendering with one-line summaries, expandable on demand (ctrl+o). Execution delegates to originals so the LLM still sees complete output. Features: - Smart paths: relative inside cwd, ~/ under HOME, absolute otherwise - Bash timing: parses duration from raw output (✓ · 42 lines · 12.3s) - Write file size: byte count for sanity checks (156 lines · 4.2 KB) - Error emphasis: ✗ prefix with red for exit codes and error keywords - Edit context hint: extracts enclosing function/class from diff - /toolview command: toggle on/off globally or per-tool, persisted - Falls back to original verbose rendering when disabled --- README.md | 1 + package.json | 3 +- packages/pi-toolview/LICENSE | 21 + packages/pi-toolview/README.md | 100 +++++ packages/pi-toolview/index.ts | 668 ++++++++++++++++++++++++++++++ packages/pi-toolview/package.json | 23 + 6 files changed, 815 insertions(+), 1 deletion(-) create mode 100644 packages/pi-toolview/LICENSE create mode 100644 packages/pi-toolview/README.md create mode 100644 packages/pi-toolview/index.ts create mode 100644 packages/pi-toolview/package.json diff --git a/README.md b/README.md index b5a2e22..5cfc85f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Each package under `packages/` is independent: its own `package.json`, entrypoin |---------|-----|-------------| | [pi-pacman](packages/pi-pacman) | [`@pi-extensions/pi-pacman`](https://www.npmjs.com/package/@pi-extensions/pi-pacman) | Pac-Man working / thinking indicator | | [pi-statusline](packages/pi-statusline) | [`@pi-extensions/pi-statusline`](https://www.npmjs.com/package/@pi-extensions/pi-statusline) | Rounded editor box with bottom-right session name; model/effort, context, usage, git/PR footer | +| [pi-toolview](packages/pi-toolview) | [`@pi-extensions/pi-toolview`](https://www.npmjs.com/package/@pi-extensions/pi-toolview) | Compact tool output: one-line summaries (expandable) instead of raw output | Package docs, install, and commands live in each package’s README (e.g. [packages/pi-pacman/README.md](packages/pi-pacman/README.md)). diff --git a/package.json b/package.json index f5a1c5a..15cbb73 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "pi": { "extensions": [ "./packages/pi-pacman", - "./packages/pi-statusline" + "./packages/pi-statusline", + "./packages/pi-toolview" ] }, "devDependencies": { diff --git a/packages/pi-toolview/LICENSE b/packages/pi-toolview/LICENSE new file mode 100644 index 0000000..a3cdccc --- /dev/null +++ b/packages/pi-toolview/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Saeed Marzban + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the Software), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/pi-toolview/README.md b/packages/pi-toolview/README.md new file mode 100644 index 0000000..29426ca --- /dev/null +++ b/packages/pi-toolview/README.md @@ -0,0 +1,100 @@ +# pi-toolview + +Compact tool output display for [pi](https://github.com/earendil-works/pi). + +Replaces pi's verbose built-in tool rendering with one-line summaries, expandable on demand (ctrl+o). Execution is fully delegated to the originals, so the LLM still sees complete output. + +## Before / after + +**Default pi** shows full tool output inline — every line of bash, every file read, full diffs, etc. + +**With pi-toolview:** + +``` +$ npm test +✓ · 42 lines · 12.3s + +read src/app.ts (offset=100, limit=50) +85 lines (truncated from 200) + +edit src/utils.ts (3 changes) ++12 / -4 in parseConfig + +write dist/output.js (156 lines · 4.2 KB) +Written + +grep /TODO/ in src --glob=*.ts +7 matches + +find *.test.ts in src +23 results + +ls packages +12 entries + +$ rm nonexistent +✗ exit 1 · 2 lines · 0.0s +``` + +Press **ctrl+o** to expand all and see actual output, diffs, or search matches. + +## Install + +```bash +pi install /path/to/pi-extensions/packages/pi-toolview +``` + +## Features + +| Feature | Example | +|---------|---------| +| **Smart paths** | `src/utils.ts` inside project, `~/code/file.ts` under HOME, absolute otherwise | +| **Bash timing** | `✓ · 42 lines · 12.3s` — duration parsed from raw output | +| **Write file size** | `(156 lines · 4.2 KB)` — catch accidental huge writes | +| **Error emphasis** | `✗ exit 1` in red — also detects "error:", "not found" etc. | +| **Edit context hint** | `+12 / -4 in parseConfig` — enclosing function from diff | +| **Per-tool control** | `/toolview bash off` — that tool reverts to verbose original | + +## Commands + +| Command | Effect | +|---------|--------| +| `/toolview` | Show current status | +| `/toolview off` | All tools back to verbose (original rendering) | +| `/toolview on` | Re-enable compact for all tools | +| `/toolview ` | Toggle one tool (e.g. `/toolview bash`) | +| `/toolview off` | One tool back to verbose | +| `/toolview on` | Re-enable compact for one tool | + +Tools: `bash`, `read`, `edit`, `write`, `grep`, `find`, `ls` + +State persists in `~/.pi/agent/toolview.json`. + +## Tools + +| Tool | Collapsed | Expanded (ctrl+o) | +|------|-----------|-------------------| +| bash | `✓` / `✗ exit N` + lines + timing | First 30 lines | +| read | Line count + truncation | First 20 lines | +| edit | `+N / -N` + function hint | Full diff (40 lines) | +| write | `Written` | N/A | +| grep | Match count (0 = muted) | First 20 matches | +| find | Result count | First 20 paths | +| ls | Entry count | First 20 entries | + +## Partial override + +Want to keep some tools at default? Use `/toolview bash off` to revert just bash to pi's original verbose rendering. Or copy `index.ts` and delete the `pi.registerTool()` block for any tool. + +## How it works + +- Re-registers each built-in tool with the same name (pi uses the last registration) +- `execute()` delegates to the original `create*Tool(cwd)` factory — behavior is identical +- Only `renderCall()` and `renderResult()` are custom (TUI display only) +- The LLM still receives full, unmodified `result.content` +- When disabled via `/toolview`, falls back to the original tool's renderer +- State reconstruction from session history works normally + +## License + +MIT diff --git a/packages/pi-toolview/index.ts b/packages/pi-toolview/index.ts new file mode 100644 index 0000000..d089271 --- /dev/null +++ b/packages/pi-toolview/index.ts @@ -0,0 +1,668 @@ +/** + * pi-compact-tools — compact tool output display + * + * Replaces pi's verbose built-in tool rendering with one-line summaries, + * expandable on demand (ctrl+o). Execution is fully delegated to the + * originals, so the LLM still sees the complete output. + * + * Features: + * - Smart paths: relative to cwd inside project, ~/ under HOME, absolute otherwise + * - Bash timing: shows duration parsed from raw output + * - Write file size: byte count for sanity checks + * - Error emphasis: prominent ✗ prefix in red + * - Edit context hint: shows the enclosing function/class from the diff + * - /compact command: toggle on/off globally or per-tool + * + * Install: + * pi install /path/to/pi-extensions/packages/pi-compact-tools + * + * Commands: + * /toolview Show status + * /toolview off Disable all compact rendering (original verbose) + * /toolview on Enable all compact rendering + * /toolview off One tool back to verbose (bash/read/edit/write/grep/find/ls) + * /toolview on Re-enable compact for that tool + */ + +import { + createBashTool, + createEditTool, + createFindTool, + createGrepTool, + createLsTool, + createReadTool, + createWriteTool, + type BashToolDetails, + type EditToolDetails, + type ExtensionAPI, + type FindToolDetails, + type GrepToolDetails, + type LsToolDetails, + type ReadToolDetails, +} from "@earendil-works/pi-coding-agent"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, relative, sep } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { Text } from "@earendil-works/pi-tui"; + +// ── path display ──────────────────────────────────────────────────── + +/** Short path: relative inside cwd, ~ under HOME, absolute otherwise. */ +function displayPath(p: string, cwd: string): string { + const home = process.env.HOME || process.env.USERPROFILE; + const rel = relative(cwd, p); + // Inside cwd: relative path doesn't start with .. + if (!rel.startsWith("..") && !rel.startsWith(sep + "..")) return rel || "."; + // Under HOME + if (home && p.startsWith(home)) return `~${p.slice(home.length)}`; + // Absolute + return p; +} + +// ── formatting helpers ────────────────────────────────────────────── + +/** Format byte count to human-readable. */ +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) { + const kb = n / 1024; + return kb < 10 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`; + } + const mb = n / (1024 * 1024); + return mb < 10 ? `${mb.toFixed(1)} MB` : `${Math.round(mb)} MB`; +} + +/** Extract "Took X.Xs" timing from bash output metadata. */ +function extractTiming(output: string): string | null { + const m = output.match(/\[?Took (\d+\.?\d*)s\]?/); + return m ? `${m[1]}s` : null; +} + +/** + * Strip metadata lines appended by pi (timing, exit code, truncation notice). + */ +function stripBashMeta(output: string): string { + return output + .replace(/\n\[Took \d+\.\d+s\]\s*$/g, "") + .replace(/\nexit code: \d+\s*$/g, "") + .replace(/\n\[Output truncated:[^\]]*\]\s*$/g, "") + .trimEnd(); +} + +/** Truncate visible text with ellipsis. */ +function clip(text: string, maxLen: number): string { + const flat = text.replace(/\n/g, " ").trim(); + return flat.length <= maxLen ? flat : `${flat.slice(0, maxLen - 1)}…`; +} + +/** Count +/- lines in a diff. */ +function diffStats(diff: string): { add: number; rem: number } { + let add = 0; + let rem = 0; + for (const line of diff.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) add++; + else if (line.startsWith("-") && !line.startsWith("---")) rem++; + } + return { add, rem }; +} + +/** Count non-empty lines. */ +function lineCount(text: string): number { + return text.split("\n").filter((l) => l.trim()).length; +} + +/** Extract enclosing function/class name from a diff (best-effort, multi-lang). */ +function extractFuncHint(diff: string, patch?: string): string | null { + // Try patch hunk header first: @@ ... @@ function_name + if (patch) { + const hunk = patch.match(/@@.*?@@\s+(.+)$/m); + if (hunk) { + const ctx = hunk[1]!.trim(); + const fn = parseFuncName(ctx); + if (fn) return fn; + } + } + // Scan context and changed lines for function/class declarations + const patterns: RegExp[] = [ + /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)/, + /^\s*(?:export\s+)?class\s+(\w+)/, + /^\s*const\s+(\w+)\s*=\s*(?:async\s*)?\(/, + /^\s*def\s+(\w+)/, + /^\s*fn\s+(\w+)/, + /^\s*func\s+(\w+)/, + /^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/, + /^\s*(?:public|private|protected|static|\s)*\s*(?:async\s+)?(\w+)\s*\(/, + ]; + for (const line of diff.split("\n")) { + const raw = line.slice(1); // strip leading +/- /space + if (raw.trim().length < 5) continue; + for (const pat of patterns) { + const m = raw.match(pat); + if (m?.[1] && !["if", "for", "while", "switch", "catch", "return"].includes(m[1])) { + return m[1]; + } + } + } + return null; +} + +function parseFuncName(ctx: string): string | null { + const patterns: RegExp[] = [ + /(?:export\s+)?(?:async\s+)?function\s+(\w+)/, + /class\s+(\w+)/, + /def\s+(\w+)/, + /fn\s+(\w+)/, + /func\s+(\w+)/, + /(\w+)\s*\(/, + ]; + for (const pat of patterns) { + const m = ctx.match(pat); + if (m?.[1] && !["if", "for", "while", "switch", "catch"].includes(m[1])) { + return m[1]; + } + } + return null; +} + +// ── persisted state ───────────────────────────────────────────────── + +type ToolName = "bash" | "read" | "edit" | "write" | "grep" | "find" | "ls"; +const TOOL_NAMES: ToolName[] = ["bash", "read", "edit", "write", "grep", "find", "ls"]; + +interface CompactState { + enabled: boolean; + tools: Partial>; +} + +function statePath(): string { + return join(getAgentDir(), "toolview.json"); +} + +function loadState(): CompactState { + const path = statePath(); + if (!existsSync(path)) return { enabled: true, tools: {} }; + try { + const raw = JSON.parse(readFileSync(path, "utf-8")); + return { enabled: raw.enabled !== false, tools: raw.tools ?? {} }; + } catch { + return { enabled: true, tools: {} }; + } +} + +function saveState(state: CompactState): void { + const path = statePath(); + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(state, null, "\t")}\n`, "utf-8"); + } catch { + // non-fatal + } +} + +// ── main ──────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + const cwd = process.cwd(); + + // Original tool instances (execution + fallback rendering) + const originals = { + bash: createBashTool(cwd), + read: createReadTool(cwd), + edit: createEditTool(cwd), + write: createWriteTool(cwd), + grep: createGrepTool(cwd), + find: createFindTool(cwd), + ls: createLsTool(cwd), + }; + + const state = loadState(); + const isOn = (t: ToolName): boolean => + state.enabled && state.tools[t] !== false; + + const d = (p: string) => displayPath(p, cwd); + + // Themed helpers + const toolLabel = (theme: any, on: boolean, label: string) => + on ? theme.fg("toolTitle", theme.bold(label)) : theme.fg("muted", label); + const dimOrMuted = (theme: any, on: boolean, text: string) => + on ? theme.fg("dim", text) : theme.fg("muted", theme.fg("dim", text)); + + // ── bash ──────────────────────────────────────────────────────── + + pi.registerTool({ + name: "bash", + label: "bash", + description: originals.bash.description, + parameters: originals.bash.parameters, + + async execute(id, params, signal, onUpdate) { + return originals.bash.execute(id, params, signal, onUpdate); + }, + + renderCall(args, theme) { + const on = isOn("bash"); + const cmd = clip(args.command, 72); + let t = `${toolLabel(theme, on, "$")} ${theme.fg("accent", cmd)}`; + if (args.timeout) t += dimOrMuted(theme, on, ` (${args.timeout}s)`); + return new Text(t, 0, 0); + }, + + renderResult(result, opts, theme) { + if (!isOn("bash")) + return originals.bash.renderResult!(result, opts, theme, {} as any); + if (opts.isPartial) return new Text(theme.fg("dim", "Running…"), 0, 0); + + const details = result.details as BashToolDetails | undefined; + const raw = result.content[0]?.type === "text" ? result.content[0].text : ""; + const timing = extractTiming(raw); + const clean = stripBashMeta(raw); + const lines = lineCount(clean); + + // Error detection: explicit exit code, or "error" / "not found" in first few lines + const exitMatch = raw.match(/exit code: (\d+)/); + const code = exitMatch ? Number(exitMatch[1]) : null; + const firstLines = clean.split("\n").slice(0, 5).join("\n").toLowerCase(); + const hasErrorKeyword = + /error:|command not found|no such file|permission denied/.test(firstLines); + + let t: string; + if (code !== null && code !== 0) { + t = theme.fg("error", `✗ exit ${code}`); + } else if (hasErrorKeyword && code === null) { + t = theme.fg("error", "✗"); + } else { + t = theme.fg("success", "✓"); + } + + if (lines > 0) t += theme.fg("dim", ` · ${lines} line${lines === 1 ? "" : "s"}`); + if (timing) t += theme.fg("dim", ` · ${timing}`); + if (details?.truncation?.truncated) t += theme.fg("warning", " [truncated]"); + + if (opts.expanded && clean) { + const preview = clean.split("\n").slice(0, 30); + for (const line of preview) t += `\n${theme.fg("dim", line)}`; + const total = clean.split("\n").length; + if (total > 30) + t += `\n${theme.fg("muted", `… ${total - 30} more lines`)}`; + } + return new Text(t, 0, 0); + }, + }); + + // ── read ──────────────────────────────────────────────────────── + + pi.registerTool({ + name: "read", + label: "read", + description: originals.read.description, + parameters: originals.read.parameters, + + async execute(id, params, signal, onUpdate) { + return originals.read.execute(id, params, signal, onUpdate); + }, + + renderCall(args, theme) { + const on = isOn("read"); + let t = `${toolLabel(theme, on, "read")} ${theme.fg("accent", d(args.path))}`; + if (args.offset !== undefined || args.limit !== undefined) { + const bits: string[] = []; + if (args.offset) bits.push(`offset=${args.offset}`); + if (args.limit) bits.push(`limit=${args.limit}`); + t += dimOrMuted(theme, on, ` (${bits.join(", ")})`); + } + return new Text(t, 0, 0); + }, + + renderResult(result, opts, theme) { + if (!isOn("read")) + return originals.read.renderResult!(result, opts, theme, {} as any); + if (opts.isPartial) return new Text(theme.fg("dim", "Reading…"), 0, 0); + + const details = result.details as ReadToolDetails | undefined; + const content = result.content[0]; + + if (content?.type === "image") + return new Text(theme.fg("success", "Image loaded"), 0, 0); + + if (content?.type === "text" && content.text.startsWith("Error")) + return new Text(theme.fg("error", `✗ ${content.text.split("\n")[0]}`), 0, 0); + + if (content?.type !== "text") + return new Text(theme.fg("error", "✗ No content"), 0, 0); + + const lines = content.text.split("\n").length; + let t = theme.fg("success", `${lines} line${lines === 1 ? "" : "s"}`); + if (details?.truncation?.truncated) + t += theme.fg("warning", ` (truncated from ${details.truncation.totalLines})`); + + if (opts.expanded) { + const preview = content.text.split("\n").slice(0, 20); + for (const line of preview) t += `\n${theme.fg("dim", line)}`; + if (lines > 20) + t += `\n${theme.fg("muted", `… ${lines - 20} more lines`)}`; + } + return new Text(t, 0, 0); + }, + }); + + // ── edit ──────────────────────────────────────────────────────── + + pi.registerTool({ + name: "edit", + label: "edit", + description: originals.edit.description, + parameters: originals.edit.parameters, + + async execute(id, params, signal, onUpdate) { + return originals.edit.execute(id, params, signal, onUpdate); + }, + + renderCall(args, theme) { + const on = isOn("edit"); + const n = args.edits?.length ?? 1; + return new Text( + `${toolLabel(theme, on, "edit")} ${theme.fg("accent", d(args.path))}${dimOrMuted(theme, on, ` (${n} change${n === 1 ? "" : "s"})`)}`, + 0, + 0, + ); + }, + + renderResult(result, opts, theme) { + if (!isOn("edit")) + return originals.edit.renderResult!(result, opts, theme, {} as any); + if (opts.isPartial) return new Text(theme.fg("dim", "Editing…"), 0, 0); + + const details = result.details as EditToolDetails | undefined; + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + if (text.startsWith("Error")) + return new Text(theme.fg("error", `✗ ${text.split("\n")[0]}`), 0, 0); + + if (!details?.diff) + return new Text(theme.fg("success", "Applied"), 0, 0); + + const { add, rem } = diffStats(details.diff); + const funcHint = extractFuncHint(details.diff, details.patch); + + let t: string; + if (add === 0 && rem === 0) { + t = theme.fg("success", "Applied"); + } else { + t = `${theme.fg("success", `+${add}`)}${theme.fg("dim", " / ")}${theme.fg("error", `-${rem}`)}`; + } + if (funcHint) t += theme.fg("muted", ` in ${funcHint}`); + + if (opts.expanded) { + const diffLines = details.diff.split("\n").slice(0, 40); + for (const line of diffLines) { + if (line.startsWith("+") && !line.startsWith("+++")) + t += `\n${theme.fg("success", line)}`; + else if (line.startsWith("-") && !line.startsWith("---")) + t += `\n${theme.fg("error", line)}`; + else t += `\n${theme.fg("dim", line)}`; + } + const total = details.diff.split("\n").length; + if (total > 40) + t += `\n${theme.fg("muted", `… ${total - 40} more diff lines`)}`; + } + return new Text(t, 0, 0); + }, + }); + + // ── write ─────────────────────────────────────────────────────── + + pi.registerTool({ + name: "write", + label: "write", + description: originals.write.description, + parameters: originals.write.parameters, + + async execute(id, params, signal, onUpdate) { + return originals.write.execute(id, params, signal, onUpdate); + }, + + renderCall(args, theme) { + const on = isOn("write"); + const lines = args.content.split("\n").length; + const size = new TextEncoder().encode(args.content).length; + return new Text( + `${toolLabel(theme, on, "write")} ${theme.fg("accent", d(args.path))}${dimOrMuted(theme, on, ` (${lines} lines · ${formatBytes(size)})`)}`, + 0, + 0, + ); + }, + + renderResult(result, opts, theme) { + if (!isOn("write")) + return originals.write.renderResult!(result, opts, theme, {} as any); + if (opts.isPartial) return new Text(theme.fg("dim", "Writing…"), 0, 0); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + if (text.startsWith("Error")) + return new Text(theme.fg("error", `✗ ${text.split("\n")[0]}`), 0, 0); + return new Text(theme.fg("success", "Written"), 0, 0); + }, + }); + + // ── grep ──────────────────────────────────────────────────────── + + pi.registerTool({ + name: "grep", + label: "grep", + description: originals.grep.description, + parameters: originals.grep.parameters, + + async execute(id, params, signal, onUpdate) { + return originals.grep.execute(id, params, signal, onUpdate); + }, + + renderCall(args, theme) { + const on = isOn("grep"); + let t = `${toolLabel(theme, on, "grep")} ${theme.fg("accent", `/${args.pattern}/`)}`; + if (args.path) t += dimOrMuted(theme, on, ` in ${d(args.path)}`); + if (args.glob) t += dimOrMuted(theme, on, ` --glob=${args.glob}`); + return new Text(t, 0, 0); + }, + + renderResult(result, opts, theme) { + if (!isOn("grep")) + return originals.grep.renderResult!(result, opts, theme, {} as any); + if (opts.isPartial) return new Text(theme.fg("dim", "Searching…"), 0, 0); + + const details = result.details as GrepToolDetails | undefined; + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + + if (text.startsWith("No matches")) + return new Text(theme.fg("muted", "0 matches"), 0, 0); + + const matches = lineCount(text); + let t = theme.fg("success", `${matches} match${matches === 1 ? "" : "es"}`); + if (details?.matchLimitReached) + t += theme.fg("warning", ` (limit ${details.matchLimitReached})`); + if (details?.truncation?.truncated) t += theme.fg("warning", " [truncated]"); + + if (opts.expanded && text) { + const preview = text.split("\n").slice(0, 20); + for (const line of preview) t += `\n${theme.fg("dim", line)}`; + if (matches > 20) + t += `\n${theme.fg("muted", `… ${matches - 20} more matches`)}`; + } + return new Text(t, 0, 0); + }, + }); + + // ── find ──────────────────────────────────────────────────────── + + pi.registerTool({ + name: "find", + label: "find", + description: originals.find.description, + parameters: originals.find.parameters, + + async execute(id, params, signal, onUpdate) { + return originals.find.execute(id, params, signal, onUpdate); + }, + + renderCall(args, theme) { + const on = isOn("find"); + let t = `${toolLabel(theme, on, "find")} ${theme.fg("accent", args.pattern)}`; + if (args.path) t += dimOrMuted(theme, on, ` in ${d(args.path)}`); + if (args.limit) t += dimOrMuted(theme, on, ` (limit ${args.limit})`); + return new Text(t, 0, 0); + }, + + renderResult(result, opts, theme) { + if (!isOn("find")) + return originals.find.renderResult!(result, opts, theme, {} as any); + if (opts.isPartial) return new Text(theme.fg("dim", "Searching…"), 0, 0); + + const details = result.details as FindToolDetails | undefined; + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + const count = lineCount(text); + + let t = theme.fg("success", `${count} result${count === 1 ? "" : "s"}`); + if (details?.resultLimitReached) + t += theme.fg("warning", ` (limit ${details.resultLimitReached})`); + if (details?.truncation?.truncated) t += theme.fg("warning", " [truncated]"); + + if (opts.expanded && text) { + const preview = text.split("\n").slice(0, 20); + for (const line of preview) t += `\n${theme.fg("dim", line)}`; + if (count > 20) + t += `\n${theme.fg("muted", `… ${count - 20} more results`)}`; + } + return new Text(t, 0, 0); + }, + }); + + // ── ls ────────────────────────────────────────────────────────── + + pi.registerTool({ + name: "ls", + label: "ls", + description: originals.ls.description, + parameters: originals.ls.parameters, + + async execute(id, params, signal, onUpdate) { + return originals.ls.execute(id, params, signal, onUpdate); + }, + + renderCall(args, theme) { + const on = isOn("ls"); + const target = args.path ? d(args.path) : "."; + let t = `${toolLabel(theme, on, "ls")} ${theme.fg("accent", target)}`; + if (args.limit) t += dimOrMuted(theme, on, ` (limit ${args.limit})`); + return new Text(t, 0, 0); + }, + + renderResult(result, opts, theme) { + if (!isOn("ls")) + return originals.ls.renderResult!(result, opts, theme, {} as any); + if (opts.isPartial) return new Text(theme.fg("dim", "Listing…"), 0, 0); + + const details = result.details as LsToolDetails | undefined; + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + const count = lineCount(text); + + let t = theme.fg("success", `${count} entr${count === 1 ? "y" : "ies"}`); + if (details?.entryLimitReached) + t += theme.fg("warning", ` (limit ${details.entryLimitReached})`); + if (details?.truncation?.truncated) t += theme.fg("warning", " [truncated]"); + + if (opts.expanded && text) { + const preview = text.split("\n").slice(0, 20); + for (const line of preview) t += `\n${theme.fg("dim", line)}`; + if (count > 20) + t += `\n${theme.fg("muted", `… ${count - 20} more entries`)}`; + } + return new Text(t, 0, 0); + }, + }); + + // ── /toolview command ─────────────────────────────────────────── + + pi.registerCommand("toolview", { + description: "Toggle compact tool output. /toolview off · /toolview bash off", + handler: async (args, ctx) => { + const raw = args.trim().toLowerCase(); + + if (!raw) { + // Show status + const status = state.enabled ? "on" : "off"; + const perTool = TOOL_NAMES.map((t) => { + const on = state.tools[t] !== false; + return on ? t : `${t}(off)`; + }).join(", "); + ctx.ui.notify(`toolview: ${status} — ${perTool}`, "info"); + return; + } + + if (raw === "on") { + state.enabled = true; + saveState(state); + ctx.ui.notify("toolview: all tools compact", "info"); + return; + } + if (raw === "off") { + state.enabled = false; + saveState(state); + ctx.ui.notify("toolview: all tools verbose (original)", "info"); + return; + } + + const parts = raw.split(/\s+/); + + // /compact on|off + if (parts.length === 2) { + const tool = parts[0] as ToolName; + const action = parts[1]; + if (!TOOL_NAMES.includes(tool)) { + ctx.ui.notify( + `toolview: unknown tool "${tool}". Tools: ${TOOL_NAMES.join(", ")}`, + "error", + ); + return; + } + if (action === "on") { + delete state.tools[tool]; + saveState(state); + ctx.ui.notify(`toolview: ${tool} → compact`, "info"); + return; + } + if (action === "off") { + state.tools[tool] = false; + saveState(state); + ctx.ui.notify(`toolview: ${tool} → verbose (original)`, "info"); + return; + } + } + + // /toolview — toggle single tool + if (parts.length === 1) { + const tool = parts[0] as ToolName; + if (!TOOL_NAMES.includes(tool)) { + ctx.ui.notify( + `toolview: unknown tool "${tool}". Tools: ${TOOL_NAMES.join(", ")}`, + "error", + ); + return; + } + const currentlyOn = state.tools[tool] !== false; + if (currentlyOn) { + state.tools[tool] = false; + } else { + delete state.tools[tool]; + } + saveState(state); + ctx.ui.notify( + `toolview: ${tool} → ${currentlyOn ? "verbose" : "compact"}`, + "info", + ); + return; + } + + ctx.ui.notify( + "Usage: /toolview [on|off] · /toolview [on|off] · /toolview (status)", + "info", + ); + }, + }); +} diff --git a/packages/pi-toolview/package.json b/packages/pi-toolview/package.json new file mode 100644 index 0000000..75d8d23 --- /dev/null +++ b/packages/pi-toolview/package.json @@ -0,0 +1,23 @@ +{ + "name": "pi-toolview", + "version": "0.1.0", + "description": "Compact tool output display for pi — one-line summaries instead of raw output, expandable on demand", + "license": "MIT", + "type": "module", + "keywords": [ + "pi-package", + "pi-extension", + "toolview", + "compact", + "tool-output" + ], + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" + } +} From 1fc3290ee88fb8d84e809491acdc6f1d24a840f5 Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Tue, 4 Aug 2026 14:18:57 +0200 Subject: [PATCH 02/12] fix: toolview bash timing, error detection, and command truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timing: pi never puts duration in the output text; the built-in tracks it via render state. Now uses the same context.state startedAt/endedAt mechanism, so durations actually show (e.g. '· 12.3s'). Errors: non-zero exits arrive as isError results with a status suffix, not 'exit code:' in text. Switch bash/read/edit/write to context.isError and parse the real status line ('Command exited with code N'). Truncation markers: match pi's actual suffixes ([Showing lines X-Y...], Command aborted/timed out) instead of made-up ones. Command clip: keep head + tail with middle ellipsis so long env assignments or 'cd /long/path &&' prefixes don't hide the real command. Also: find shows '0 results' for no-match, grep/ls/read fallbacks pass the real render context. --- packages/pi-toolview/README.md | 7 +- packages/pi-toolview/index.ts | 122 ++++++++++++++++++++------------- 2 files changed, 80 insertions(+), 49 deletions(-) diff --git a/packages/pi-toolview/README.md b/packages/pi-toolview/README.md index 29426ca..69a89c4 100644 --- a/packages/pi-toolview/README.md +++ b/packages/pi-toolview/README.md @@ -49,9 +49,9 @@ pi install /path/to/pi-extensions/packages/pi-toolview | Feature | Example | |---------|---------| | **Smart paths** | `src/utils.ts` inside project, `~/code/file.ts` under HOME, absolute otherwise | -| **Bash timing** | `✓ · 42 lines · 12.3s` — duration parsed from raw output | +| **Bash timing** | `✓ · 42 lines · 12.3s` — measured via render state, same as built-in | | **Write file size** | `(156 lines · 4.2 KB)` — catch accidental huge writes | -| **Error emphasis** | `✗ exit 1` in red — also detects "error:", "not found" etc. | +| **Error emphasis** | `✗ exit 1` in red, based on the tool's isError flag | | **Edit context hint** | `+12 / -4 in parseConfig` — enclosing function from diff | | **Per-tool control** | `/toolview bash off` — that tool reverts to verbose original | @@ -89,7 +89,8 @@ Want to keep some tools at default? Use `/toolview bash off` to revert just bash ## How it works - Re-registers each built-in tool with the same name (pi uses the last registration) -- `execute()` delegates to the original `create*Tool(cwd)` factory — behavior is identical +- `execute()` delegates to the original `create*Tool(cwd)` factory, behavior is identical +- Bash timing uses the same `context.state` mechanism the built-in renderer uses - Only `renderCall()` and `renderResult()` are custom (TUI display only) - The LLM still receives full, unmodified `result.content` - When disabled via `/toolview`, falls back to the original tool's renderer diff --git a/packages/pi-toolview/index.ts b/packages/pi-toolview/index.ts index d089271..7b09d74 100644 --- a/packages/pi-toolview/index.ts +++ b/packages/pi-toolview/index.ts @@ -72,10 +72,11 @@ function formatBytes(n: number): string { return mb < 10 ? `${mb.toFixed(1)} MB` : `${Math.round(mb)} MB`; } -/** Extract "Took X.Xs" timing from bash output metadata. */ -function extractTiming(output: string): string | null { - const m = output.match(/\[?Took (\d+\.?\d*)s\]?/); - return m ? `${m[1]}s` : null; +/** Human-readable duration. */ +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const s = ms / 1000; + return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`; } /** @@ -83,9 +84,9 @@ function extractTiming(output: string): string | null { */ function stripBashMeta(output: string): string { return output - .replace(/\n\[Took \d+\.\d+s\]\s*$/g, "") - .replace(/\nexit code: \d+\s*$/g, "") - .replace(/\n\[Output truncated:[^\]]*\]\s*$/g, "") + .replace(/\n\n\[Showing lines [^\]]*\]\s*$/g, "") + .replace(/\n\n\[Showing last [^\]]*\]\s*$/g, "") + .replace(/\n\nCommand (exited with code \d+|aborted|timed out after [^\n]*)\s*$/g, "") .trimEnd(); } @@ -95,6 +96,18 @@ function clip(text: string, maxLen: number): string { return flat.length <= maxLen ? flat : `${flat.slice(0, maxLen - 1)}…`; } +/** + * Truncate long commands keeping head + tail. The tail usually holds the + * actual command when the head is a long env assignment or `cd /long/path &&`. + */ +function clipCommand(text: string, maxLen: number): string { + const flat = text.replace(/\n/g, " ").trim(); + if (flat.length <= maxLen) return flat; + const head = Math.floor(maxLen * 0.3); + const tail = maxLen - head - 1; + return `${flat.slice(0, head)}…${flat.slice(flat.length - tail)}`; +} + /** Count +/- lines in a diff. */ function diffStats(diff: string): { add: number; rem: number } { let add = 0; @@ -229,6 +242,9 @@ export default function (pi: ExtensionAPI) { // ── bash ──────────────────────────────────────────────────────── + // Timing lives in shared render state, same mechanism the built-in uses. + type BashRenderState = { startedAt?: number; endedAt?: number }; + pi.registerTool({ name: "bash", label: "bash", @@ -239,43 +255,50 @@ export default function (pi: ExtensionAPI) { return originals.bash.execute(id, params, signal, onUpdate); }, - renderCall(args, theme) { - const on = isOn("bash"); - const cmd = clip(args.command, 72); - let t = `${toolLabel(theme, on, "$")} ${theme.fg("accent", cmd)}`; - if (args.timeout) t += dimOrMuted(theme, on, ` (${args.timeout}s)`); + renderCall(args, theme, context) { + // Track start time regardless of on/off so toggling keeps timing intact + const state = context.state as BashRenderState; + if (context.executionStarted && state.startedAt === undefined) { + state.startedAt = Date.now(); + state.endedAt = undefined; + } + if (!isOn("bash")) + return originals.bash.renderCall!(args, theme, context as any); + + const cmd = clipCommand(args.command, 76); + let t = `${toolLabel(theme, true, "$")} ${theme.fg("accent", cmd)}`; + if (args.timeout) t += theme.fg("dim", ` (${args.timeout}s)`); return new Text(t, 0, 0); }, - renderResult(result, opts, theme) { + renderResult(result, opts, theme, context) { if (!isOn("bash")) - return originals.bash.renderResult!(result, opts, theme, {} as any); + return originals.bash.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return new Text(theme.fg("dim", "Running…"), 0, 0); + const state = context.state as BashRenderState; + if (state.startedAt !== undefined) state.endedAt ??= Date.now(); + const durationMs = + state.startedAt !== undefined + ? (state.endedAt ?? Date.now()) - state.startedAt + : undefined; + const details = result.details as BashToolDetails | undefined; const raw = result.content[0]?.type === "text" ? result.content[0].text : ""; - const timing = extractTiming(raw); const clean = stripBashMeta(raw); const lines = lineCount(clean); - // Error detection: explicit exit code, or "error" / "not found" in first few lines - const exitMatch = raw.match(/exit code: (\d+)/); - const code = exitMatch ? Number(exitMatch[1]) : null; - const firstLines = clean.split("\n").slice(0, 5).join("\n").toLowerCase(); - const hasErrorKeyword = - /error:|command not found|no such file|permission denied/.test(firstLines); - + // Non-zero exits arrive as error results; pull the code from the status line let t: string; - if (code !== null && code !== 0) { - t = theme.fg("error", `✗ exit ${code}`); - } else if (hasErrorKeyword && code === null) { - t = theme.fg("error", "✗"); + if (context.isError) { + const m = raw.match(/exited with code (\d+)/); + t = m ? theme.fg("error", `✗ exit ${m[1]}`) : theme.fg("error", "✗"); } else { t = theme.fg("success", "✓"); } if (lines > 0) t += theme.fg("dim", ` · ${lines} line${lines === 1 ? "" : "s"}`); - if (timing) t += theme.fg("dim", ` · ${timing}`); + if (durationMs !== undefined) t += theme.fg("dim", ` · ${formatDuration(durationMs)}`); if (details?.truncation?.truncated) t += theme.fg("warning", " [truncated]"); if (opts.expanded && clean) { @@ -313,20 +336,23 @@ export default function (pi: ExtensionAPI) { return new Text(t, 0, 0); }, - renderResult(result, opts, theme) { + renderResult(result, opts, theme, context) { if (!isOn("read")) - return originals.read.renderResult!(result, opts, theme, {} as any); + return originals.read.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return new Text(theme.fg("dim", "Reading…"), 0, 0); const details = result.details as ReadToolDetails | undefined; const content = result.content[0]; + if (context.isError) { + const firstLine = + content?.type === "text" ? content.text.split("\n")[0] : "Read failed"; + return new Text(theme.fg("error", `✗ ${firstLine}`), 0, 0); + } + if (content?.type === "image") return new Text(theme.fg("success", "Image loaded"), 0, 0); - if (content?.type === "text" && content.text.startsWith("Error")) - return new Text(theme.fg("error", `✗ ${content.text.split("\n")[0]}`), 0, 0); - if (content?.type !== "text") return new Text(theme.fg("error", "✗ No content"), 0, 0); @@ -367,15 +393,15 @@ export default function (pi: ExtensionAPI) { ); }, - renderResult(result, opts, theme) { + renderResult(result, opts, theme, context) { if (!isOn("edit")) - return originals.edit.renderResult!(result, opts, theme, {} as any); + return originals.edit.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return new Text(theme.fg("dim", "Editing…"), 0, 0); const details = result.details as EditToolDetails | undefined; const text = result.content[0]?.type === "text" ? result.content[0].text : ""; - if (text.startsWith("Error")) - return new Text(theme.fg("error", `✗ ${text.split("\n")[0]}`), 0, 0); + if (context.isError) + return new Text(theme.fg("error", `✗ ${text.split("\n")[0] || "Edit failed"}`), 0, 0); if (!details?.diff) return new Text(theme.fg("success", "Applied"), 0, 0); @@ -431,13 +457,13 @@ export default function (pi: ExtensionAPI) { ); }, - renderResult(result, opts, theme) { + renderResult(result, opts, theme, context) { if (!isOn("write")) - return originals.write.renderResult!(result, opts, theme, {} as any); + return originals.write.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return new Text(theme.fg("dim", "Writing…"), 0, 0); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; - if (text.startsWith("Error")) - return new Text(theme.fg("error", `✗ ${text.split("\n")[0]}`), 0, 0); + if (context.isError) + return new Text(theme.fg("error", `✗ ${text.split("\n")[0] || "Write failed"}`), 0, 0); return new Text(theme.fg("success", "Written"), 0, 0); }, }); @@ -462,9 +488,9 @@ export default function (pi: ExtensionAPI) { return new Text(t, 0, 0); }, - renderResult(result, opts, theme) { + renderResult(result, opts, theme, context) { if (!isOn("grep")) - return originals.grep.renderResult!(result, opts, theme, {} as any); + return originals.grep.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return new Text(theme.fg("dim", "Searching…"), 0, 0); const details = result.details as GrepToolDetails | undefined; @@ -509,13 +535,17 @@ export default function (pi: ExtensionAPI) { return new Text(t, 0, 0); }, - renderResult(result, opts, theme) { + renderResult(result, opts, theme, context) { if (!isOn("find")) - return originals.find.renderResult!(result, opts, theme, {} as any); + return originals.find.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return new Text(theme.fg("dim", "Searching…"), 0, 0); const details = result.details as FindToolDetails | undefined; const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + + if (text.startsWith("No files")) + return new Text(theme.fg("muted", "0 results"), 0, 0); + const count = lineCount(text); let t = theme.fg("success", `${count} result${count === 1 ? "" : "s"}`); @@ -553,9 +583,9 @@ export default function (pi: ExtensionAPI) { return new Text(t, 0, 0); }, - renderResult(result, opts, theme) { + renderResult(result, opts, theme, context) { if (!isOn("ls")) - return originals.ls.renderResult!(result, opts, theme, {} as any); + return originals.ls.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return new Text(theme.fg("dim", "Listing…"), 0, 0); const details = result.details as LsToolDetails | undefined; From a64f886bc70c248d1e03b466a8df0c534a0f8282 Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Tue, 4 Aug 2026 14:43:21 +0200 Subject: [PATCH 03/12] feat: tighter toolview blocks via renderShell self Switch all 7 tools to renderShell: 'self', dropping the default Box's paddingY (one blank line top + bottom of each pill). Re-apply the success/error/pending background color manually on each row so blocks stay colored but sit tighter together. Pi hardcodes a Spacer(1) above every tool block in the component constructor, so one separator line between blocks remains and cannot be removed by an extension. --- packages/pi-toolview/README.md | 3 + packages/pi-toolview/index.ts | 169 ++++++++++++++++++--------------- 2 files changed, 98 insertions(+), 74 deletions(-) diff --git a/packages/pi-toolview/README.md b/packages/pi-toolview/README.md index 69a89c4..f26e278 100644 --- a/packages/pi-toolview/README.md +++ b/packages/pi-toolview/README.md @@ -91,6 +91,9 @@ Want to keep some tools at default? Use `/toolview bash off` to revert just bash - Re-registers each built-in tool with the same name (pi uses the last registration) - `execute()` delegates to the original `create*Tool(cwd)` factory, behavior is identical - Bash timing uses the same `context.state` mechanism the built-in renderer uses +- `renderShell: "self"` drops the default padded Box; the success/error/pending + background color is re-applied manually, so blocks stay colored but sit tighter. + Pi hardcodes one blank line above every tool block, so a single separator remains. - Only `renderCall()` and `renderResult()` are custom (TUI display only) - The LLM still receives full, unmodified `result.content` - When disabled via `/toolview`, falls back to the original tool's renderer diff --git a/packages/pi-toolview/index.ts b/packages/pi-toolview/index.ts index 7b09d74..00466a1 100644 --- a/packages/pi-toolview/index.ts +++ b/packages/pi-toolview/index.ts @@ -1,20 +1,25 @@ /** - * pi-compact-tools — compact tool output display + * pi-toolview — compact tool output display * * Replaces pi's verbose built-in tool rendering with one-line summaries, * expandable on demand (ctrl+o). Execution is fully delegated to the * originals, so the LLM still sees the complete output. * + * Uses renderShell: "self" to drop the default Box padding, then re-applies + * the success/error/pending background color manually, so blocks stay colored + * but sit tighter together. (pi hardcodes one blank line above every tool + * block, so a single separator line remains and cannot be removed here.) + * * Features: * - Smart paths: relative to cwd inside project, ~/ under HOME, absolute otherwise - * - Bash timing: shows duration parsed from raw output + * - Bash timing: measured via render state (same as built-in), not parsed from text * - Write file size: byte count for sanity checks - * - Error emphasis: prominent ✗ prefix in red + * - Error emphasis: prominent ✗ prefix in red, based on the isError flag * - Edit context hint: shows the enclosing function/class from the diff - * - /compact command: toggle on/off globally or per-tool + * - /toolview command: toggle on/off globally or per-tool, persisted * * Install: - * pi install /path/to/pi-extensions/packages/pi-compact-tools + * pi install /path/to/pi-extensions/packages/pi-toolview * * Commands: * /toolview Show status @@ -80,7 +85,8 @@ function formatDuration(ms: number): string { } /** - * Strip metadata lines appended by pi (timing, exit code, truncation notice). + * Strip metadata pi appends to bash output: truncation notices and exit/abort + * status lines. Timing is NOT in the text; it comes from render state. */ function stripBashMeta(output: string): string { return output @@ -237,8 +243,17 @@ export default function (pi: ExtensionAPI) { // Themed helpers const toolLabel = (theme: any, on: boolean, label: string) => on ? theme.fg("toolTitle", theme.bold(label)) : theme.fg("muted", label); - const dimOrMuted = (theme: any, on: boolean, text: string) => - on ? theme.fg("dim", text) : theme.fg("muted", theme.fg("dim", text)); + + // renderShell: "self" drops the default Box, so we re-apply the pill + // background ourselves. One colored row, tight vertical padding. + const row = (text: string, theme: any, context: any, partial: boolean): Text => { + const bg = partial + ? (s: string) => theme.bg("toolPendingBg", s) + : context.isError + ? (s: string) => theme.bg("toolErrorBg", s) + : (s: string) => theme.bg("toolSuccessBg", s); + return new Text(text, 1, 0, bg); + }; // ── bash ──────────────────────────────────────────────────────── @@ -250,6 +265,7 @@ export default function (pi: ExtensionAPI) { label: "bash", description: originals.bash.description, parameters: originals.bash.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.bash.execute(id, params, signal, onUpdate); @@ -266,21 +282,21 @@ export default function (pi: ExtensionAPI) { return originals.bash.renderCall!(args, theme, context as any); const cmd = clipCommand(args.command, 76); - let t = `${toolLabel(theme, true, "$")} ${theme.fg("accent", cmd)}`; + let t = `${theme.fg("toolTitle", theme.bold("$"))} ${theme.fg("accent", cmd)}`; if (args.timeout) t += theme.fg("dim", ` (${args.timeout}s)`); - return new Text(t, 0, 0); + return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { if (!isOn("bash")) return originals.bash.renderResult!(result, opts, theme, context as any); - if (opts.isPartial) return new Text(theme.fg("dim", "Running…"), 0, 0); + if (opts.isPartial) return row(theme.fg("dim", "Running…"), theme, context, true); - const state = context.state as BashRenderState; - if (state.startedAt !== undefined) state.endedAt ??= Date.now(); + const bstate = context.state as BashRenderState; + if (bstate.startedAt !== undefined) bstate.endedAt ??= Date.now(); const durationMs = - state.startedAt !== undefined - ? (state.endedAt ?? Date.now()) - state.startedAt + bstate.startedAt !== undefined + ? (bstate.endedAt ?? Date.now()) - bstate.startedAt : undefined; const details = result.details as BashToolDetails | undefined; @@ -308,7 +324,7 @@ export default function (pi: ExtensionAPI) { if (total > 30) t += `\n${theme.fg("muted", `… ${total - 30} more lines`)}`; } - return new Text(t, 0, 0); + return row(t, theme, context, false); }, }); @@ -319,27 +335,29 @@ export default function (pi: ExtensionAPI) { label: "read", description: originals.read.description, parameters: originals.read.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.read.execute(id, params, signal, onUpdate); }, - renderCall(args, theme) { - const on = isOn("read"); - let t = `${toolLabel(theme, on, "read")} ${theme.fg("accent", d(args.path))}`; + renderCall(args, theme, context) { + if (!isOn("read")) + return originals.read.renderCall!(args, theme, context as any); + let t = `${toolLabel(theme, true, "read")} ${theme.fg("accent", d(args.path))}`; if (args.offset !== undefined || args.limit !== undefined) { const bits: string[] = []; if (args.offset) bits.push(`offset=${args.offset}`); if (args.limit) bits.push(`limit=${args.limit}`); - t += dimOrMuted(theme, on, ` (${bits.join(", ")})`); + t += theme.fg("dim", ` (${bits.join(", ")})`); } - return new Text(t, 0, 0); + return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { if (!isOn("read")) return originals.read.renderResult!(result, opts, theme, context as any); - if (opts.isPartial) return new Text(theme.fg("dim", "Reading…"), 0, 0); + if (opts.isPartial) return row(theme.fg("dim", "Reading…"), theme, context, true); const details = result.details as ReadToolDetails | undefined; const content = result.content[0]; @@ -347,14 +365,14 @@ export default function (pi: ExtensionAPI) { if (context.isError) { const firstLine = content?.type === "text" ? content.text.split("\n")[0] : "Read failed"; - return new Text(theme.fg("error", `✗ ${firstLine}`), 0, 0); + return row(theme.fg("error", `✗ ${firstLine}`), theme, context, false); } if (content?.type === "image") - return new Text(theme.fg("success", "Image loaded"), 0, 0); + return row(theme.fg("success", "Image loaded"), theme, context, false); if (content?.type !== "text") - return new Text(theme.fg("error", "✗ No content"), 0, 0); + return row(theme.fg("error", "✗ No content"), theme, context, false); const lines = content.text.split("\n").length; let t = theme.fg("success", `${lines} line${lines === 1 ? "" : "s"}`); @@ -367,7 +385,7 @@ export default function (pi: ExtensionAPI) { if (lines > 20) t += `\n${theme.fg("muted", `… ${lines - 20} more lines`)}`; } - return new Text(t, 0, 0); + return row(t, theme, context, false); }, }); @@ -378,33 +396,32 @@ export default function (pi: ExtensionAPI) { label: "edit", description: originals.edit.description, parameters: originals.edit.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.edit.execute(id, params, signal, onUpdate); }, - renderCall(args, theme) { - const on = isOn("edit"); + renderCall(args, theme, context) { + if (!isOn("edit")) + return originals.edit.renderCall!(args, theme, context as any); const n = args.edits?.length ?? 1; - return new Text( - `${toolLabel(theme, on, "edit")} ${theme.fg("accent", d(args.path))}${dimOrMuted(theme, on, ` (${n} change${n === 1 ? "" : "s"})`)}`, - 0, - 0, - ); + const t = `${toolLabel(theme, true, "edit")} ${theme.fg("accent", d(args.path))}${theme.fg("dim", ` (${n} change${n === 1 ? "" : "s"})`)}`; + return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { if (!isOn("edit")) return originals.edit.renderResult!(result, opts, theme, context as any); - if (opts.isPartial) return new Text(theme.fg("dim", "Editing…"), 0, 0); + if (opts.isPartial) return row(theme.fg("dim", "Editing…"), theme, context, true); const details = result.details as EditToolDetails | undefined; const text = result.content[0]?.type === "text" ? result.content[0].text : ""; if (context.isError) - return new Text(theme.fg("error", `✗ ${text.split("\n")[0] || "Edit failed"}`), 0, 0); + return row(theme.fg("error", `✗ ${text.split("\n")[0] || "Edit failed"}`), theme, context, false); if (!details?.diff) - return new Text(theme.fg("success", "Applied"), 0, 0); + return row(theme.fg("success", "Applied"), theme, context, false); const { add, rem } = diffStats(details.diff); const funcHint = extractFuncHint(details.diff, details.patch); @@ -430,7 +447,7 @@ export default function (pi: ExtensionAPI) { if (total > 40) t += `\n${theme.fg("muted", `… ${total - 40} more diff lines`)}`; } - return new Text(t, 0, 0); + return row(t, theme, context, false); }, }); @@ -441,30 +458,29 @@ export default function (pi: ExtensionAPI) { label: "write", description: originals.write.description, parameters: originals.write.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.write.execute(id, params, signal, onUpdate); }, - renderCall(args, theme) { - const on = isOn("write"); + renderCall(args, theme, context) { + if (!isOn("write")) + return originals.write.renderCall!(args, theme, context as any); const lines = args.content.split("\n").length; const size = new TextEncoder().encode(args.content).length; - return new Text( - `${toolLabel(theme, on, "write")} ${theme.fg("accent", d(args.path))}${dimOrMuted(theme, on, ` (${lines} lines · ${formatBytes(size)})`)}`, - 0, - 0, - ); + const t = `${toolLabel(theme, true, "write")} ${theme.fg("accent", d(args.path))}${theme.fg("dim", ` (${lines} lines · ${formatBytes(size)})`)}`; + return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { if (!isOn("write")) return originals.write.renderResult!(result, opts, theme, context as any); - if (opts.isPartial) return new Text(theme.fg("dim", "Writing…"), 0, 0); + if (opts.isPartial) return row(theme.fg("dim", "Writing…"), theme, context, true); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; if (context.isError) - return new Text(theme.fg("error", `✗ ${text.split("\n")[0] || "Write failed"}`), 0, 0); - return new Text(theme.fg("success", "Written"), 0, 0); + return row(theme.fg("error", `✗ ${text.split("\n")[0] || "Write failed"}`), theme, context, false); + return row(theme.fg("success", "Written"), theme, context, false); }, }); @@ -475,29 +491,31 @@ export default function (pi: ExtensionAPI) { label: "grep", description: originals.grep.description, parameters: originals.grep.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.grep.execute(id, params, signal, onUpdate); }, - renderCall(args, theme) { - const on = isOn("grep"); - let t = `${toolLabel(theme, on, "grep")} ${theme.fg("accent", `/${args.pattern}/`)}`; - if (args.path) t += dimOrMuted(theme, on, ` in ${d(args.path)}`); - if (args.glob) t += dimOrMuted(theme, on, ` --glob=${args.glob}`); - return new Text(t, 0, 0); + renderCall(args, theme, context) { + if (!isOn("grep")) + return originals.grep.renderCall!(args, theme, context as any); + let t = `${toolLabel(theme, true, "grep")} ${theme.fg("accent", `/${args.pattern}/`)}`; + if (args.path) t += theme.fg("dim", ` in ${d(args.path)}`); + if (args.glob) t += theme.fg("dim", ` --glob=${args.glob}`); + return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { if (!isOn("grep")) return originals.grep.renderResult!(result, opts, theme, context as any); - if (opts.isPartial) return new Text(theme.fg("dim", "Searching…"), 0, 0); + if (opts.isPartial) return row(theme.fg("dim", "Searching…"), theme, context, true); const details = result.details as GrepToolDetails | undefined; const text = result.content[0]?.type === "text" ? result.content[0].text : ""; if (text.startsWith("No matches")) - return new Text(theme.fg("muted", "0 matches"), 0, 0); + return row(theme.fg("muted", "0 matches"), theme, context, false); const matches = lineCount(text); let t = theme.fg("success", `${matches} match${matches === 1 ? "" : "es"}`); @@ -511,7 +529,7 @@ export default function (pi: ExtensionAPI) { if (matches > 20) t += `\n${theme.fg("muted", `… ${matches - 20} more matches`)}`; } - return new Text(t, 0, 0); + return row(t, theme, context, false); }, }); @@ -522,32 +540,33 @@ export default function (pi: ExtensionAPI) { label: "find", description: originals.find.description, parameters: originals.find.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.find.execute(id, params, signal, onUpdate); }, - renderCall(args, theme) { - const on = isOn("find"); - let t = `${toolLabel(theme, on, "find")} ${theme.fg("accent", args.pattern)}`; - if (args.path) t += dimOrMuted(theme, on, ` in ${d(args.path)}`); - if (args.limit) t += dimOrMuted(theme, on, ` (limit ${args.limit})`); - return new Text(t, 0, 0); + renderCall(args, theme, context) { + if (!isOn("find")) + return originals.find.renderCall!(args, theme, context as any); + let t = `${toolLabel(theme, true, "find")} ${theme.fg("accent", args.pattern)}`; + if (args.path) t += theme.fg("dim", ` in ${d(args.path)}`); + if (args.limit) t += theme.fg("dim", ` (limit ${args.limit})`); + return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { if (!isOn("find")) return originals.find.renderResult!(result, opts, theme, context as any); - if (opts.isPartial) return new Text(theme.fg("dim", "Searching…"), 0, 0); + if (opts.isPartial) return row(theme.fg("dim", "Searching…"), theme, context, true); const details = result.details as FindToolDetails | undefined; const text = result.content[0]?.type === "text" ? result.content[0].text : ""; if (text.startsWith("No files")) - return new Text(theme.fg("muted", "0 results"), 0, 0); + return row(theme.fg("muted", "0 results"), theme, context, false); const count = lineCount(text); - let t = theme.fg("success", `${count} result${count === 1 ? "" : "s"}`); if (details?.resultLimitReached) t += theme.fg("warning", ` (limit ${details.resultLimitReached})`); @@ -559,7 +578,7 @@ export default function (pi: ExtensionAPI) { if (count > 20) t += `\n${theme.fg("muted", `… ${count - 20} more results`)}`; } - return new Text(t, 0, 0); + return row(t, theme, context, false); }, }); @@ -570,23 +589,25 @@ export default function (pi: ExtensionAPI) { label: "ls", description: originals.ls.description, parameters: originals.ls.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.ls.execute(id, params, signal, onUpdate); }, - renderCall(args, theme) { - const on = isOn("ls"); + renderCall(args, theme, context) { + if (!isOn("ls")) + return originals.ls.renderCall!(args, theme, context as any); const target = args.path ? d(args.path) : "."; - let t = `${toolLabel(theme, on, "ls")} ${theme.fg("accent", target)}`; - if (args.limit) t += dimOrMuted(theme, on, ` (limit ${args.limit})`); - return new Text(t, 0, 0); + let t = `${toolLabel(theme, true, "ls")} ${theme.fg("accent", target)}`; + if (args.limit) t += theme.fg("dim", ` (limit ${args.limit})`); + return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { if (!isOn("ls")) return originals.ls.renderResult!(result, opts, theme, context as any); - if (opts.isPartial) return new Text(theme.fg("dim", "Listing…"), 0, 0); + if (opts.isPartial) return row(theme.fg("dim", "Listing…"), theme, context, true); const details = result.details as LsToolDetails | undefined; const text = result.content[0]?.type === "text" ? result.content[0].text : ""; @@ -603,7 +624,7 @@ export default function (pi: ExtensionAPI) { if (count > 20) t += `\n${theme.fg("muted", `… ${count - 20} more entries`)}`; } - return new Text(t, 0, 0); + return row(t, theme, context, false); }, }); @@ -640,7 +661,7 @@ export default function (pi: ExtensionAPI) { const parts = raw.split(/\s+/); - // /compact on|off + // /toolview on|off if (parts.length === 2) { const tool = parts[0] as ToolName; const action = parts[1]; From 8f6543aaa45d8c409f6dab5555b4012c6ae95cdb Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Tue, 4 Aug 2026 15:03:39 +0200 Subject: [PATCH 04/12] fix: instant toolview toggle + restore original spacing when off Two related fixes: Instant toggle (no reload): pi caches already-rendered tool blocks, so a toggle only affected newly-rendered tools. The handler now calls ctx.ui.setToolsExpanded(getToolsExpanded()) after each state change, which re-runs renderCall/renderResult on every existing block. Toggling applies immediately. Original spacing when off: revert renderShell:'self' back to the default tool Box. The self shell dropped the pill padding but also meant turning toolview off rendered the original content in the tight frame instead of the native box. With the default shell, off restores pi's exact original look. Trade-off: 'on' uses the standard pill padding rather than the extra tight self shell. --- packages/pi-toolview/README.md | 6 ++--- packages/pi-toolview/index.ts | 44 ++++++++++++++++++---------------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/pi-toolview/README.md b/packages/pi-toolview/README.md index f26e278..5034743 100644 --- a/packages/pi-toolview/README.md +++ b/packages/pi-toolview/README.md @@ -91,9 +91,9 @@ Want to keep some tools at default? Use `/toolview bash off` to revert just bash - Re-registers each built-in tool with the same name (pi uses the last registration) - `execute()` delegates to the original `create*Tool(cwd)` factory, behavior is identical - Bash timing uses the same `context.state` mechanism the built-in renderer uses -- `renderShell: "self"` drops the default padded Box; the success/error/pending - background color is re-applied manually, so blocks stay colored but sit tighter. - Pi hardcodes one blank line above every tool block, so a single separator remains. +- Uses the default tool Box shell, so background/padding match pi's native look and + turning toolview off restores the exact original rendering +- `/toolview` toggles re-render already-drawn blocks immediately, no `/reload` needed - Only `renderCall()` and `renderResult()` are custom (TUI display only) - The LLM still receives full, unmodified `result.content` - When disabled via `/toolview`, falls back to the original tool's renderer diff --git a/packages/pi-toolview/index.ts b/packages/pi-toolview/index.ts index 00466a1..b14dfd1 100644 --- a/packages/pi-toolview/index.ts +++ b/packages/pi-toolview/index.ts @@ -5,10 +5,10 @@ * expandable on demand (ctrl+o). Execution is fully delegated to the * originals, so the LLM still sees the complete output. * - * Uses renderShell: "self" to drop the default Box padding, then re-applies - * the success/error/pending background color manually, so blocks stay colored - * but sit tighter together. (pi hardcodes one blank line above every tool - * block, so a single separator line remains and cannot be removed here.) + * Uses the default tool Box shell so the success/error/pending background and + * padding match pi's native look. This also means turning toolview off restores + * the exact original rendering. Toggling re-renders existing blocks via + * ctx.ui.setToolsExpanded so no /reload is needed. * * Features: * - Smart paths: relative to cwd inside project, ~/ under HOME, absolute otherwise @@ -244,16 +244,10 @@ export default function (pi: ExtensionAPI) { const toolLabel = (theme: any, on: boolean, label: string) => on ? theme.fg("toolTitle", theme.bold(label)) : theme.fg("muted", label); - // renderShell: "self" drops the default Box, so we re-apply the pill - // background ourselves. One colored row, tight vertical padding. - const row = (text: string, theme: any, context: any, partial: boolean): Text => { - const bg = partial - ? (s: string) => theme.bg("toolPendingBg", s) - : context.isError - ? (s: string) => theme.bg("toolErrorBg", s) - : (s: string) => theme.bg("toolSuccessBg", s); - return new Text(text, 1, 0, bg); - }; + // Default tool Box shell supplies the background color and padding, so a + // plain zero-padded Text is all each row needs. + const row = (text: string, _theme?: any, _context?: any, _partial?: boolean): Text => + new Text(text, 0, 0); // ── bash ──────────────────────────────────────────────────────── @@ -265,7 +259,6 @@ export default function (pi: ExtensionAPI) { label: "bash", description: originals.bash.description, parameters: originals.bash.parameters, - renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.bash.execute(id, params, signal, onUpdate); @@ -335,7 +328,6 @@ export default function (pi: ExtensionAPI) { label: "read", description: originals.read.description, parameters: originals.read.parameters, - renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.read.execute(id, params, signal, onUpdate); @@ -396,7 +388,6 @@ export default function (pi: ExtensionAPI) { label: "edit", description: originals.edit.description, parameters: originals.edit.parameters, - renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.edit.execute(id, params, signal, onUpdate); @@ -458,7 +449,6 @@ export default function (pi: ExtensionAPI) { label: "write", description: originals.write.description, parameters: originals.write.parameters, - renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.write.execute(id, params, signal, onUpdate); @@ -491,7 +481,6 @@ export default function (pi: ExtensionAPI) { label: "grep", description: originals.grep.description, parameters: originals.grep.parameters, - renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.grep.execute(id, params, signal, onUpdate); @@ -540,7 +529,6 @@ export default function (pi: ExtensionAPI) { label: "find", description: originals.find.description, parameters: originals.find.parameters, - renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.find.execute(id, params, signal, onUpdate); @@ -589,7 +577,6 @@ export default function (pi: ExtensionAPI) { label: "ls", description: originals.ls.description, parameters: originals.ls.parameters, - renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.ls.execute(id, params, signal, onUpdate); @@ -633,6 +620,16 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("toolview", { description: "Toggle compact tool output. /toolview off · /toolview bash off", handler: async (args, ctx) => { + // Re-render already-drawn tool blocks so a toggle applies immediately, + // no /reload needed. setToolsExpanded re-runs renderCall/renderResult + // on every block; passing the current value changes nothing visually. + const refresh = () => { + try { + ctx.ui.setToolsExpanded(ctx.ui.getToolsExpanded()); + } catch { + // non-fatal: toggle still applies to newly-rendered tools + } + }; const raw = args.trim().toLowerCase(); if (!raw) { @@ -649,12 +646,14 @@ export default function (pi: ExtensionAPI) { if (raw === "on") { state.enabled = true; saveState(state); + refresh(); ctx.ui.notify("toolview: all tools compact", "info"); return; } if (raw === "off") { state.enabled = false; saveState(state); + refresh(); ctx.ui.notify("toolview: all tools verbose (original)", "info"); return; } @@ -675,12 +674,14 @@ export default function (pi: ExtensionAPI) { if (action === "on") { delete state.tools[tool]; saveState(state); + refresh(); ctx.ui.notify(`toolview: ${tool} → compact`, "info"); return; } if (action === "off") { state.tools[tool] = false; saveState(state); + refresh(); ctx.ui.notify(`toolview: ${tool} → verbose (original)`, "info"); return; } @@ -703,6 +704,7 @@ export default function (pi: ExtensionAPI) { delete state.tools[tool]; } saveState(state); + refresh(); ctx.ui.notify( `toolview: ${tool} → ${currentlyOn ? "verbose" : "compact"}`, "info", From 9edb09f4027afdde4dd0c7eb1576285aaa6ad3c6 Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Tue, 4 Aug 2026 15:19:19 +0200 Subject: [PATCH 05/12] feat: restore tight renderShell self spacing, keep instant toggle Re-apply renderShell:'self' on all 7 tools to drop the pill padding for the tight look, with the success/error/pending background re-applied manually. Keeps the instant-toggle refresh() so /toolview applies without a reload. Trade-off noted: with the self shell, turning a tool off renders the original content in the tight frame rather than the native pill. --- packages/pi-toolview/README.md | 7 +++++-- packages/pi-toolview/index.ts | 31 +++++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/pi-toolview/README.md b/packages/pi-toolview/README.md index 5034743..03c6f9f 100644 --- a/packages/pi-toolview/README.md +++ b/packages/pi-toolview/README.md @@ -91,8 +91,11 @@ Want to keep some tools at default? Use `/toolview bash off` to revert just bash - Re-registers each built-in tool with the same name (pi uses the last registration) - `execute()` delegates to the original `create*Tool(cwd)` factory, behavior is identical - Bash timing uses the same `context.state` mechanism the built-in renderer uses -- Uses the default tool Box shell, so background/padding match pi's native look and - turning toolview off restores the exact original rendering +- `renderShell: "self"` drops the default padded Box for a tighter look; the + success/error/pending background color is re-applied manually. Pi hardcodes one + blank line above every tool block, so a single separator remains. + With the self shell, turning a tool off renders the original content in the + tight frame rather than the native pill. - `/toolview` toggles re-render already-drawn blocks immediately, no `/reload` needed - Only `renderCall()` and `renderResult()` are custom (TUI display only) - The LLM still receives full, unmodified `result.content` diff --git a/packages/pi-toolview/index.ts b/packages/pi-toolview/index.ts index b14dfd1..933bee3 100644 --- a/packages/pi-toolview/index.ts +++ b/packages/pi-toolview/index.ts @@ -5,10 +5,12 @@ * expandable on demand (ctrl+o). Execution is fully delegated to the * originals, so the LLM still sees the complete output. * - * Uses the default tool Box shell so the success/error/pending background and - * padding match pi's native look. This also means turning toolview off restores - * the exact original rendering. Toggling re-renders existing blocks via - * ctx.ui.setToolsExpanded so no /reload is needed. + * Uses renderShell: "self" to drop the default Box padding for a tighter look, + * re-applying the success/error/pending background color manually. (pi hardcodes + * one blank line above every tool block, so a single separator remains.) + * Note: with the self shell, turning a tool off renders the original content in + * the tight frame rather than the native pill. Toggling re-renders existing + * blocks via ctx.ui.setToolsExpanded so no /reload is needed. * * Features: * - Smart paths: relative to cwd inside project, ~/ under HOME, absolute otherwise @@ -244,10 +246,16 @@ export default function (pi: ExtensionAPI) { const toolLabel = (theme: any, on: boolean, label: string) => on ? theme.fg("toolTitle", theme.bold(label)) : theme.fg("muted", label); - // Default tool Box shell supplies the background color and padding, so a - // plain zero-padded Text is all each row needs. - const row = (text: string, _theme?: any, _context?: any, _partial?: boolean): Text => - new Text(text, 0, 0); + // renderShell: "self" drops the default Box, so we re-apply the pill + // background ourselves. One colored row, tight vertical padding. + const row = (text: string, theme: any, context: any, partial: boolean): Text => { + const bg = partial + ? (s: string) => theme.bg("toolPendingBg", s) + : context.isError + ? (s: string) => theme.bg("toolErrorBg", s) + : (s: string) => theme.bg("toolSuccessBg", s); + return new Text(text, 1, 0, bg); + }; // ── bash ──────────────────────────────────────────────────────── @@ -259,6 +267,7 @@ export default function (pi: ExtensionAPI) { label: "bash", description: originals.bash.description, parameters: originals.bash.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.bash.execute(id, params, signal, onUpdate); @@ -328,6 +337,7 @@ export default function (pi: ExtensionAPI) { label: "read", description: originals.read.description, parameters: originals.read.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.read.execute(id, params, signal, onUpdate); @@ -388,6 +398,7 @@ export default function (pi: ExtensionAPI) { label: "edit", description: originals.edit.description, parameters: originals.edit.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.edit.execute(id, params, signal, onUpdate); @@ -449,6 +460,7 @@ export default function (pi: ExtensionAPI) { label: "write", description: originals.write.description, parameters: originals.write.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.write.execute(id, params, signal, onUpdate); @@ -481,6 +493,7 @@ export default function (pi: ExtensionAPI) { label: "grep", description: originals.grep.description, parameters: originals.grep.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.grep.execute(id, params, signal, onUpdate); @@ -529,6 +542,7 @@ export default function (pi: ExtensionAPI) { label: "find", description: originals.find.description, parameters: originals.find.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.find.execute(id, params, signal, onUpdate); @@ -577,6 +591,7 @@ export default function (pi: ExtensionAPI) { label: "ls", description: originals.ls.description, parameters: originals.ls.parameters, + renderShell: "self", async execute(id, params, signal, onUpdate) { return originals.ls.execute(id, params, signal, onUpdate); From d28a68482efd456cbfa6ac1310ee5d475131dc3b Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Tue, 4 Aug 2026 15:36:45 +0200 Subject: [PATCH 06/12] fix: off-fallback now delegates to pi's native renderers The off path called originals.X.renderCall, but createXTool returns an AgentTool whose wrapToolDefinition drops renderCall/renderResult, so the delegation threw 'renderCall is not a function' and pi silently fell back to the bare tool name. Turning a tool off appeared to do nothing. Use createXToolDefinition (which carries renderCall/renderResult) for the off-fallback. Execution/description/parameters still come from the AgentTool. Verified all 7 definitions expose both renderers. --- packages/pi-toolview/index.ts | 48 +++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/pi-toolview/index.ts b/packages/pi-toolview/index.ts index 933bee3..9d6e85a 100644 --- a/packages/pi-toolview/index.ts +++ b/packages/pi-toolview/index.ts @@ -33,12 +33,19 @@ import { createBashTool, + createBashToolDefinition, createEditTool, + createEditToolDefinition, createFindTool, + createFindToolDefinition, createGrepTool, + createGrepToolDefinition, createLsTool, + createLsToolDefinition, createReadTool, + createReadToolDefinition, createWriteTool, + createWriteToolDefinition, type BashToolDetails, type EditToolDetails, type ExtensionAPI, @@ -236,6 +243,19 @@ export default function (pi: ExtensionAPI) { ls: createLsTool(cwd), }; + // renderCall/renderResult live on the tool DEFINITION, not on the AgentTool + // returned by createXTool (wrapToolDefinition drops them). Needed so the + // off-fallback can delegate to pi's native renderers. + const origDefs = { + bash: createBashToolDefinition(cwd), + read: createReadToolDefinition(cwd), + edit: createEditToolDefinition(cwd), + write: createWriteToolDefinition(cwd), + grep: createGrepToolDefinition(cwd), + find: createFindToolDefinition(cwd), + ls: createLsToolDefinition(cwd), + }; + const state = loadState(); const isOn = (t: ToolName): boolean => state.enabled && state.tools[t] !== false; @@ -281,7 +301,7 @@ export default function (pi: ExtensionAPI) { state.endedAt = undefined; } if (!isOn("bash")) - return originals.bash.renderCall!(args, theme, context as any); + return origDefs.bash.renderCall!(args, theme, context as any); const cmd = clipCommand(args.command, 76); let t = `${theme.fg("toolTitle", theme.bold("$"))} ${theme.fg("accent", cmd)}`; @@ -291,7 +311,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("bash")) - return originals.bash.renderResult!(result, opts, theme, context as any); + return origDefs.bash.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return row(theme.fg("dim", "Running…"), theme, context, true); const bstate = context.state as BashRenderState; @@ -345,7 +365,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("read")) - return originals.read.renderCall!(args, theme, context as any); + return origDefs.read.renderCall!(args, theme, context as any); let t = `${toolLabel(theme, true, "read")} ${theme.fg("accent", d(args.path))}`; if (args.offset !== undefined || args.limit !== undefined) { const bits: string[] = []; @@ -358,7 +378,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("read")) - return originals.read.renderResult!(result, opts, theme, context as any); + return origDefs.read.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return row(theme.fg("dim", "Reading…"), theme, context, true); const details = result.details as ReadToolDetails | undefined; @@ -406,7 +426,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("edit")) - return originals.edit.renderCall!(args, theme, context as any); + return origDefs.edit.renderCall!(args, theme, context as any); const n = args.edits?.length ?? 1; const t = `${toolLabel(theme, true, "edit")} ${theme.fg("accent", d(args.path))}${theme.fg("dim", ` (${n} change${n === 1 ? "" : "s"})`)}`; return row(t, theme, context, context.isPartial); @@ -414,7 +434,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("edit")) - return originals.edit.renderResult!(result, opts, theme, context as any); + return origDefs.edit.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return row(theme.fg("dim", "Editing…"), theme, context, true); const details = result.details as EditToolDetails | undefined; @@ -468,7 +488,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("write")) - return originals.write.renderCall!(args, theme, context as any); + return origDefs.write.renderCall!(args, theme, context as any); const lines = args.content.split("\n").length; const size = new TextEncoder().encode(args.content).length; const t = `${toolLabel(theme, true, "write")} ${theme.fg("accent", d(args.path))}${theme.fg("dim", ` (${lines} lines · ${formatBytes(size)})`)}`; @@ -477,7 +497,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("write")) - return originals.write.renderResult!(result, opts, theme, context as any); + return origDefs.write.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return row(theme.fg("dim", "Writing…"), theme, context, true); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; if (context.isError) @@ -501,7 +521,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("grep")) - return originals.grep.renderCall!(args, theme, context as any); + return origDefs.grep.renderCall!(args, theme, context as any); let t = `${toolLabel(theme, true, "grep")} ${theme.fg("accent", `/${args.pattern}/`)}`; if (args.path) t += theme.fg("dim", ` in ${d(args.path)}`); if (args.glob) t += theme.fg("dim", ` --glob=${args.glob}`); @@ -510,7 +530,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("grep")) - return originals.grep.renderResult!(result, opts, theme, context as any); + return origDefs.grep.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return row(theme.fg("dim", "Searching…"), theme, context, true); const details = result.details as GrepToolDetails | undefined; @@ -550,7 +570,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("find")) - return originals.find.renderCall!(args, theme, context as any); + return origDefs.find.renderCall!(args, theme, context as any); let t = `${toolLabel(theme, true, "find")} ${theme.fg("accent", args.pattern)}`; if (args.path) t += theme.fg("dim", ` in ${d(args.path)}`); if (args.limit) t += theme.fg("dim", ` (limit ${args.limit})`); @@ -559,7 +579,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("find")) - return originals.find.renderResult!(result, opts, theme, context as any); + return origDefs.find.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return row(theme.fg("dim", "Searching…"), theme, context, true); const details = result.details as FindToolDetails | undefined; @@ -599,7 +619,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("ls")) - return originals.ls.renderCall!(args, theme, context as any); + return origDefs.ls.renderCall!(args, theme, context as any); const target = args.path ? d(args.path) : "."; let t = `${toolLabel(theme, true, "ls")} ${theme.fg("accent", target)}`; if (args.limit) t += theme.fg("dim", ` (limit ${args.limit})`); @@ -608,7 +628,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("ls")) - return originals.ls.renderResult!(result, opts, theme, context as any); + return origDefs.ls.renderResult!(result, opts, theme, context as any); if (opts.isPartial) return row(theme.fg("dim", "Listing…"), theme, context, true); const details = result.details as LsToolDetails | undefined; From b8d177472d3fb42cf57db1b3e0f9fb63233f6e7f Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Tue, 4 Aug 2026 16:02:51 +0200 Subject: [PATCH 07/12] fix: stop lastComponent clash when delegating off-rendering to native tools The native bash/read/grep/find/ls renderers reuse context.lastComponent and call container methods on it (.clear/.addChild). After a compact render that slot holds toolview's plain Text, so the off-fallback threw 'component.clear is not a function' and pi fell back to the bare tool name. Only edit survived because it is self-shell native and builds fresh components. Clear context.lastComponent before each off delegation so the native renderer constructs a fresh component. Verified bash/grep/find/ls now render original output; read/write/edit follow their native collapsed/expanded behavior. --- packages/pi-toolview/index.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/pi-toolview/index.ts b/packages/pi-toolview/index.ts index 9d6e85a..00d5184 100644 --- a/packages/pi-toolview/index.ts +++ b/packages/pi-toolview/index.ts @@ -301,7 +301,7 @@ export default function (pi: ExtensionAPI) { state.endedAt = undefined; } if (!isOn("bash")) - return origDefs.bash.renderCall!(args, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.bash.renderCall!(args, theme, context as any); } const cmd = clipCommand(args.command, 76); let t = `${theme.fg("toolTitle", theme.bold("$"))} ${theme.fg("accent", cmd)}`; @@ -311,7 +311,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("bash")) - return origDefs.bash.renderResult!(result, opts, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.bash.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Running…"), theme, context, true); const bstate = context.state as BashRenderState; @@ -365,7 +365,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("read")) - return origDefs.read.renderCall!(args, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.read.renderCall!(args, theme, context as any); } let t = `${toolLabel(theme, true, "read")} ${theme.fg("accent", d(args.path))}`; if (args.offset !== undefined || args.limit !== undefined) { const bits: string[] = []; @@ -378,7 +378,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("read")) - return origDefs.read.renderResult!(result, opts, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.read.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Reading…"), theme, context, true); const details = result.details as ReadToolDetails | undefined; @@ -426,7 +426,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("edit")) - return origDefs.edit.renderCall!(args, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.edit.renderCall!(args, theme, context as any); } const n = args.edits?.length ?? 1; const t = `${toolLabel(theme, true, "edit")} ${theme.fg("accent", d(args.path))}${theme.fg("dim", ` (${n} change${n === 1 ? "" : "s"})`)}`; return row(t, theme, context, context.isPartial); @@ -434,7 +434,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("edit")) - return origDefs.edit.renderResult!(result, opts, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.edit.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Editing…"), theme, context, true); const details = result.details as EditToolDetails | undefined; @@ -488,7 +488,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("write")) - return origDefs.write.renderCall!(args, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.write.renderCall!(args, theme, context as any); } const lines = args.content.split("\n").length; const size = new TextEncoder().encode(args.content).length; const t = `${toolLabel(theme, true, "write")} ${theme.fg("accent", d(args.path))}${theme.fg("dim", ` (${lines} lines · ${formatBytes(size)})`)}`; @@ -497,7 +497,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("write")) - return origDefs.write.renderResult!(result, opts, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.write.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Writing…"), theme, context, true); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; if (context.isError) @@ -521,7 +521,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("grep")) - return origDefs.grep.renderCall!(args, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.grep.renderCall!(args, theme, context as any); } let t = `${toolLabel(theme, true, "grep")} ${theme.fg("accent", `/${args.pattern}/`)}`; if (args.path) t += theme.fg("dim", ` in ${d(args.path)}`); if (args.glob) t += theme.fg("dim", ` --glob=${args.glob}`); @@ -530,7 +530,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("grep")) - return origDefs.grep.renderResult!(result, opts, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.grep.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Searching…"), theme, context, true); const details = result.details as GrepToolDetails | undefined; @@ -570,7 +570,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("find")) - return origDefs.find.renderCall!(args, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.find.renderCall!(args, theme, context as any); } let t = `${toolLabel(theme, true, "find")} ${theme.fg("accent", args.pattern)}`; if (args.path) t += theme.fg("dim", ` in ${d(args.path)}`); if (args.limit) t += theme.fg("dim", ` (limit ${args.limit})`); @@ -579,7 +579,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("find")) - return origDefs.find.renderResult!(result, opts, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.find.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Searching…"), theme, context, true); const details = result.details as FindToolDetails | undefined; @@ -619,7 +619,7 @@ export default function (pi: ExtensionAPI) { renderCall(args, theme, context) { if (!isOn("ls")) - return origDefs.ls.renderCall!(args, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.ls.renderCall!(args, theme, context as any); } const target = args.path ? d(args.path) : "."; let t = `${toolLabel(theme, true, "ls")} ${theme.fg("accent", target)}`; if (args.limit) t += theme.fg("dim", ` (limit ${args.limit})`); @@ -628,7 +628,7 @@ export default function (pi: ExtensionAPI) { renderResult(result, opts, theme, context) { if (!isOn("ls")) - return origDefs.ls.renderResult!(result, opts, theme, context as any); + { (context as any).lastComponent = undefined; return origDefs.ls.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Listing…"), theme, context, true); const details = result.details as LsToolDetails | undefined; From f4148647b2a9b295d994731c15b7178666925f55 Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Tue, 4 Aug 2026 16:39:17 +0200 Subject: [PATCH 08/12] fix: off-mode draws full content in the pill instead of native renderers The native per-tool renderResult functions are built for the Box shell and render with no background or padding when drawn in the tight self frame, so toggled-off tools hugged the left edge (read most visibly, since its content is indented). Verified all seven native renderers lose their pill bg in the self container. Stop delegating off-mode to the native renderers. Each tool now draws its full result content through the same row() pill (bg + padding): bash/grep/find/ls output, read file content, write message, and edit's colored diff. Drop the createXToolDefinition renderers and lastComponent clearing they required. Compact (on) rendering is unchanged. --- packages/pi-toolview/README.md | 5 +- packages/pi-toolview/index.ts | 269 ++++++++++++++------------------- 2 files changed, 120 insertions(+), 154 deletions(-) diff --git a/packages/pi-toolview/README.md b/packages/pi-toolview/README.md index 03c6f9f..cbbc908 100644 --- a/packages/pi-toolview/README.md +++ b/packages/pi-toolview/README.md @@ -94,8 +94,9 @@ Want to keep some tools at default? Use `/toolview bash off` to revert just bash - `renderShell: "self"` drops the default padded Box for a tighter look; the success/error/pending background color is re-applied manually. Pi hardcodes one blank line above every tool block, so a single separator remains. - With the self shell, turning a tool off renders the original content in the - tight frame rather than the native pill. +- When a tool is toggled off, its full result content is drawn through the same + pill row. The native per-tool renderers are built for the Box shell and lose their + background in the tight self frame, so they are not delegated to. - `/toolview` toggles re-render already-drawn blocks immediately, no `/reload` needed - Only `renderCall()` and `renderResult()` are custom (TUI display only) - The LLM still receives full, unmodified `result.content` diff --git a/packages/pi-toolview/index.ts b/packages/pi-toolview/index.ts index 00d5184..fd7531d 100644 --- a/packages/pi-toolview/index.ts +++ b/packages/pi-toolview/index.ts @@ -8,9 +8,12 @@ * Uses renderShell: "self" to drop the default Box padding for a tighter look, * re-applying the success/error/pending background color manually. (pi hardcodes * one blank line above every tool block, so a single separator remains.) - * Note: with the self shell, turning a tool off renders the original content in - * the tight frame rather than the native pill. Toggling re-renders existing - * blocks via ctx.ui.setToolsExpanded so no /reload is needed. + * + * Off behaviour: the native per-tool renderers are built for the Box shell and + * lose their background when drawn in the tight self frame, so when a tool is + * toggled off we draw its full result content through the same pill row instead + * of delegating to the native renderer. Toggling re-renders existing blocks via + * ctx.ui.setToolsExpanded so no /reload is needed. * * Features: * - Smart paths: relative to cwd inside project, ~/ under HOME, absolute otherwise @@ -20,32 +23,22 @@ * - Edit context hint: shows the enclosing function/class from the diff * - /toolview command: toggle on/off globally or per-tool, persisted * - * Install: - * pi install /path/to/pi-extensions/packages/pi-toolview - * * Commands: * /toolview Show status - * /toolview off Disable all compact rendering (original verbose) + * /toolview off Disable all compact rendering (full output) * /toolview on Enable all compact rendering - * /toolview off One tool back to verbose (bash/read/edit/write/grep/find/ls) + * /toolview off One tool back to full output (bash/read/edit/write/grep/find/ls) * /toolview on Re-enable compact for that tool */ import { createBashTool, - createBashToolDefinition, createEditTool, - createEditToolDefinition, createFindTool, - createFindToolDefinition, createGrepTool, - createGrepToolDefinition, createLsTool, - createLsToolDefinition, createReadTool, - createReadToolDefinition, createWriteTool, - createWriteToolDefinition, type BashToolDetails, type EditToolDetails, type ExtensionAPI, @@ -65,17 +58,13 @@ import { Text } from "@earendil-works/pi-tui"; function displayPath(p: string, cwd: string): string { const home = process.env.HOME || process.env.USERPROFILE; const rel = relative(cwd, p); - // Inside cwd: relative path doesn't start with .. if (!rel.startsWith("..") && !rel.startsWith(sep + "..")) return rel || "."; - // Under HOME if (home && p.startsWith(home)) return `~${p.slice(home.length)}`; - // Absolute return p; } // ── formatting helpers ────────────────────────────────────────────── -/** Format byte count to human-readable. */ function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) { @@ -86,17 +75,13 @@ function formatBytes(n: number): string { return mb < 10 ? `${mb.toFixed(1)} MB` : `${Math.round(mb)} MB`; } -/** Human-readable duration. */ function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; const s = ms / 1000; return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`; } -/** - * Strip metadata pi appends to bash output: truncation notices and exit/abort - * status lines. Timing is NOT in the text; it comes from render state. - */ +/** Strip metadata pi appends to bash output (truncation notices, exit status). */ function stripBashMeta(output: string): string { return output .replace(/\n\n\[Showing lines [^\]]*\]\s*$/g, "") @@ -105,16 +90,12 @@ function stripBashMeta(output: string): string { .trimEnd(); } -/** Truncate visible text with ellipsis. */ function clip(text: string, maxLen: number): string { const flat = text.replace(/\n/g, " ").trim(); return flat.length <= maxLen ? flat : `${flat.slice(0, maxLen - 1)}…`; } -/** - * Truncate long commands keeping head + tail. The tail usually holds the - * actual command when the head is a long env assignment or `cd /long/path &&`. - */ +/** Truncate long commands keeping head + tail. */ function clipCommand(text: string, maxLen: number): string { const flat = text.replace(/\n/g, " ").trim(); if (flat.length <= maxLen) return flat; @@ -123,7 +104,6 @@ function clipCommand(text: string, maxLen: number): string { return `${flat.slice(0, head)}…${flat.slice(flat.length - tail)}`; } -/** Count +/- lines in a diff. */ function diffStats(diff: string): { add: number; rem: number } { let add = 0; let rem = 0; @@ -134,23 +114,34 @@ function diffStats(diff: string): { add: number; rem: number } { return { add, rem }; } -/** Count non-empty lines. */ function lineCount(text: string): number { return text.split("\n").filter((l) => l.trim()).length; } -/** Extract enclosing function/class name from a diff (best-effort, multi-lang). */ +/** Color a unified diff: +green, -red, context dim. */ +function colorDiff(diff: string, theme: any, maxLines?: number): string { + const lines = diff.split("\n"); + const shown = maxLines !== undefined ? lines.slice(0, maxLines) : lines; + const colored = shown.map((line) => { + if (line.startsWith("+") && !line.startsWith("+++")) return theme.fg("success", line); + if (line.startsWith("-") && !line.startsWith("---")) return theme.fg("error", line); + return theme.fg("dim", line); + }); + let out = colored.join("\n"); + if (maxLines !== undefined && lines.length > maxLines) { + out += `\n${theme.fg("muted", `… ${lines.length - maxLines} more diff lines`)}`; + } + return out; +} + function extractFuncHint(diff: string, patch?: string): string | null { - // Try patch hunk header first: @@ ... @@ function_name if (patch) { const hunk = patch.match(/@@.*?@@\s+(.+)$/m); if (hunk) { - const ctx = hunk[1]!.trim(); - const fn = parseFuncName(ctx); + const fn = parseFuncName(hunk[1]!.trim()); if (fn) return fn; } } - // Scan context and changed lines for function/class declarations const patterns: RegExp[] = [ /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)/, /^\s*(?:export\s+)?class\s+(\w+)/, @@ -162,7 +153,7 @@ function extractFuncHint(diff: string, patch?: string): string | null { /^\s*(?:public|private|protected|static|\s)*\s*(?:async\s+)?(\w+)\s*\(/, ]; for (const line of diff.split("\n")) { - const raw = line.slice(1); // strip leading +/- /space + const raw = line.slice(1); if (raw.trim().length < 5) continue; for (const pat of patterns) { const m = raw.match(pat); @@ -232,7 +223,7 @@ function saveState(state: CompactState): void { export default function (pi: ExtensionAPI) { const cwd = process.cwd(); - // Original tool instances (execution + fallback rendering) + // Original tool instances for execution / metadata. const originals = { bash: createBashTool(cwd), read: createReadTool(cwd), @@ -243,28 +234,14 @@ export default function (pi: ExtensionAPI) { ls: createLsTool(cwd), }; - // renderCall/renderResult live on the tool DEFINITION, not on the AgentTool - // returned by createXTool (wrapToolDefinition drops them). Needed so the - // off-fallback can delegate to pi's native renderers. - const origDefs = { - bash: createBashToolDefinition(cwd), - read: createReadToolDefinition(cwd), - edit: createEditToolDefinition(cwd), - write: createWriteToolDefinition(cwd), - grep: createGrepToolDefinition(cwd), - find: createFindToolDefinition(cwd), - ls: createLsToolDefinition(cwd), - }; - const state = loadState(); const isOn = (t: ToolName): boolean => state.enabled && state.tools[t] !== false; const d = (p: string) => displayPath(p, cwd); - // Themed helpers - const toolLabel = (theme: any, on: boolean, label: string) => - on ? theme.fg("toolTitle", theme.bold(label)) : theme.fg("muted", label); + const toolLabel = (theme: any, label: string) => + theme.fg("toolTitle", theme.bold(label)); // renderShell: "self" drops the default Box, so we re-apply the pill // background ourselves. One colored row, tight vertical padding. @@ -277,9 +254,12 @@ export default function (pi: ExtensionAPI) { return new Text(text, 1, 0, bg); }; + // Extract the text payload from a tool result. + const textOf = (result: any): string => + result.content?.[0]?.type === "text" ? result.content[0].text : ""; + // ── bash ──────────────────────────────────────────────────────── - // Timing lives in shared render state, same mechanism the built-in uses. type BashRenderState = { startedAt?: number; endedAt?: number }; pi.registerTool({ @@ -294,39 +274,37 @@ export default function (pi: ExtensionAPI) { }, renderCall(args, theme, context) { - // Track start time regardless of on/off so toggling keeps timing intact - const state = context.state as BashRenderState; - if (context.executionStarted && state.startedAt === undefined) { - state.startedAt = Date.now(); - state.endedAt = undefined; + const st = context.state as BashRenderState; + if (context.executionStarted && st.startedAt === undefined) { + st.startedAt = Date.now(); + st.endedAt = undefined; } - if (!isOn("bash")) - { (context as any).lastComponent = undefined; return origDefs.bash.renderCall!(args, theme, context as any); } - const cmd = clipCommand(args.command, 76); - let t = `${theme.fg("toolTitle", theme.bold("$"))} ${theme.fg("accent", cmd)}`; + let t = `${toolLabel(theme, "$")} ${theme.fg("accent", cmd)}`; if (args.timeout) t += theme.fg("dim", ` (${args.timeout}s)`); return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { - if (!isOn("bash")) - { (context as any).lastComponent = undefined; return origDefs.bash.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Running…"), theme, context, true); - const bstate = context.state as BashRenderState; - if (bstate.startedAt !== undefined) bstate.endedAt ??= Date.now(); + const st = context.state as BashRenderState; + if (st.startedAt !== undefined) st.endedAt ??= Date.now(); const durationMs = - bstate.startedAt !== undefined - ? (bstate.endedAt ?? Date.now()) - bstate.startedAt - : undefined; + st.startedAt !== undefined ? (st.endedAt ?? Date.now()) - st.startedAt : undefined; + + const raw = textOf(result); + // OFF: full original output in the pill. + if (!isOn("bash")) { + return row(raw.trimEnd() || theme.fg("muted", "(no output)"), theme, context, false); + } + + // ON: compact summary. const details = result.details as BashToolDetails | undefined; - const raw = result.content[0]?.type === "text" ? result.content[0].text : ""; const clean = stripBashMeta(raw); const lines = lineCount(clean); - // Non-zero exits arrive as error results; pull the code from the status line let t: string; if (context.isError) { const m = raw.match(/exited with code (\d+)/); @@ -334,7 +312,6 @@ export default function (pi: ExtensionAPI) { } else { t = theme.fg("success", "✓"); } - if (lines > 0) t += theme.fg("dim", ` · ${lines} line${lines === 1 ? "" : "s"}`); if (durationMs !== undefined) t += theme.fg("dim", ` · ${formatDuration(durationMs)}`); if (details?.truncation?.truncated) t += theme.fg("warning", " [truncated]"); @@ -343,8 +320,7 @@ export default function (pi: ExtensionAPI) { const preview = clean.split("\n").slice(0, 30); for (const line of preview) t += `\n${theme.fg("dim", line)}`; const total = clean.split("\n").length; - if (total > 30) - t += `\n${theme.fg("muted", `… ${total - 30} more lines`)}`; + if (total > 30) t += `\n${theme.fg("muted", `… ${total - 30} more lines`)}`; } return row(t, theme, context, false); }, @@ -364,9 +340,7 @@ export default function (pi: ExtensionAPI) { }, renderCall(args, theme, context) { - if (!isOn("read")) - { (context as any).lastComponent = undefined; return origDefs.read.renderCall!(args, theme, context as any); } - let t = `${toolLabel(theme, true, "read")} ${theme.fg("accent", d(args.path))}`; + let t = `${toolLabel(theme, "read")} ${theme.fg("accent", d(args.path))}`; if (args.offset !== undefined || args.limit !== undefined) { const bits: string[] = []; if (args.offset) bits.push(`offset=${args.offset}`); @@ -377,25 +351,25 @@ export default function (pi: ExtensionAPI) { }, renderResult(result, opts, theme, context) { - if (!isOn("read")) - { (context as any).lastComponent = undefined; return origDefs.read.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Reading…"), theme, context, true); - const details = result.details as ReadToolDetails | undefined; const content = result.content[0]; - + if (content?.type === "image") + return row(theme.fg("success", "Image loaded"), theme, context, false); if (context.isError) { - const firstLine = - content?.type === "text" ? content.text.split("\n")[0] : "Read failed"; + const firstLine = content?.type === "text" ? content.text.split("\n")[0] : "Read failed"; return row(theme.fg("error", `✗ ${firstLine}`), theme, context, false); } - - if (content?.type === "image") - return row(theme.fg("success", "Image loaded"), theme, context, false); - if (content?.type !== "text") return row(theme.fg("error", "✗ No content"), theme, context, false); + // OFF: full file content in the pill. + if (!isOn("read")) { + return row(content.text.trimEnd() || theme.fg("muted", "(empty)"), theme, context, false); + } + + // ON: compact summary. + const details = result.details as ReadToolDetails | undefined; const lines = content.text.split("\n").length; let t = theme.fg("success", `${lines} line${lines === 1 ? "" : "s"}`); if (details?.truncation?.truncated) @@ -404,8 +378,7 @@ export default function (pi: ExtensionAPI) { if (opts.expanded) { const preview = content.text.split("\n").slice(0, 20); for (const line of preview) t += `\n${theme.fg("dim", line)}`; - if (lines > 20) - t += `\n${theme.fg("muted", `… ${lines - 20} more lines`)}`; + if (lines > 20) t += `\n${theme.fg("muted", `… ${lines - 20} more lines`)}`; } return row(t, theme, context, false); }, @@ -425,25 +398,27 @@ export default function (pi: ExtensionAPI) { }, renderCall(args, theme, context) { - if (!isOn("edit")) - { (context as any).lastComponent = undefined; return origDefs.edit.renderCall!(args, theme, context as any); } const n = args.edits?.length ?? 1; - const t = `${toolLabel(theme, true, "edit")} ${theme.fg("accent", d(args.path))}${theme.fg("dim", ` (${n} change${n === 1 ? "" : "s"})`)}`; + const t = `${toolLabel(theme, "edit")} ${theme.fg("accent", d(args.path))}${theme.fg("dim", ` (${n} change${n === 1 ? "" : "s"})`)}`; return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { - if (!isOn("edit")) - { (context as any).lastComponent = undefined; return origDefs.edit.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Editing…"), theme, context, true); const details = result.details as EditToolDetails | undefined; - const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + const text = textOf(result); if (context.isError) return row(theme.fg("error", `✗ ${text.split("\n")[0] || "Edit failed"}`), theme, context, false); - if (!details?.diff) - return row(theme.fg("success", "Applied"), theme, context, false); + // OFF: full colored diff in the pill. + if (!isOn("edit")) { + if (details?.diff) return row(colorDiff(details.diff, theme), theme, context, false); + return row(text || theme.fg("success", "Applied"), theme, context, false); + } + + // ON: compact +N/-N summary. + if (!details?.diff) return row(theme.fg("success", "Applied"), theme, context, false); const { add, rem } = diffStats(details.diff); const funcHint = extractFuncHint(details.diff, details.patch); @@ -457,17 +432,7 @@ export default function (pi: ExtensionAPI) { if (funcHint) t += theme.fg("muted", ` in ${funcHint}`); if (opts.expanded) { - const diffLines = details.diff.split("\n").slice(0, 40); - for (const line of diffLines) { - if (line.startsWith("+") && !line.startsWith("+++")) - t += `\n${theme.fg("success", line)}`; - else if (line.startsWith("-") && !line.startsWith("---")) - t += `\n${theme.fg("error", line)}`; - else t += `\n${theme.fg("dim", line)}`; - } - const total = details.diff.split("\n").length; - if (total > 40) - t += `\n${theme.fg("muted", `… ${total - 40} more diff lines`)}`; + t += `\n${colorDiff(details.diff, theme, 40)}`; } return row(t, theme, context, false); }, @@ -487,21 +452,23 @@ export default function (pi: ExtensionAPI) { }, renderCall(args, theme, context) { - if (!isOn("write")) - { (context as any).lastComponent = undefined; return origDefs.write.renderCall!(args, theme, context as any); } const lines = args.content.split("\n").length; const size = new TextEncoder().encode(args.content).length; - const t = `${toolLabel(theme, true, "write")} ${theme.fg("accent", d(args.path))}${theme.fg("dim", ` (${lines} lines · ${formatBytes(size)})`)}`; + const t = `${toolLabel(theme, "write")} ${theme.fg("accent", d(args.path))}${theme.fg("dim", ` (${lines} lines · ${formatBytes(size)})`)}`; return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { - if (!isOn("write")) - { (context as any).lastComponent = undefined; return origDefs.write.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Writing…"), theme, context, true); - const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + const text = textOf(result); if (context.isError) return row(theme.fg("error", `✗ ${text.split("\n")[0] || "Write failed"}`), theme, context, false); + + // OFF: full result message in the pill. + if (!isOn("write")) { + return row(text || theme.fg("success", "Written"), theme, context, false); + } + return row(theme.fg("success", "Written"), theme, context, false); }, }); @@ -520,25 +487,27 @@ export default function (pi: ExtensionAPI) { }, renderCall(args, theme, context) { - if (!isOn("grep")) - { (context as any).lastComponent = undefined; return origDefs.grep.renderCall!(args, theme, context as any); } - let t = `${toolLabel(theme, true, "grep")} ${theme.fg("accent", `/${args.pattern}/`)}`; + let t = `${toolLabel(theme, "grep")} ${theme.fg("accent", `/${args.pattern}/`)}`; if (args.path) t += theme.fg("dim", ` in ${d(args.path)}`); if (args.glob) t += theme.fg("dim", ` --glob=${args.glob}`); return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { - if (!isOn("grep")) - { (context as any).lastComponent = undefined; return origDefs.grep.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Searching…"), theme, context, true); - const details = result.details as GrepToolDetails | undefined; - const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + const text = textOf(result); + // OFF: full match list in the pill. + if (!isOn("grep")) { + return row(text.trimEnd() || theme.fg("muted", "0 matches"), theme, context, false); + } + + // ON: compact match count. if (text.startsWith("No matches")) return row(theme.fg("muted", "0 matches"), theme, context, false); + const details = result.details as GrepToolDetails | undefined; const matches = lineCount(text); let t = theme.fg("success", `${matches} match${matches === 1 ? "" : "es"}`); if (details?.matchLimitReached) @@ -548,8 +517,7 @@ export default function (pi: ExtensionAPI) { if (opts.expanded && text) { const preview = text.split("\n").slice(0, 20); for (const line of preview) t += `\n${theme.fg("dim", line)}`; - if (matches > 20) - t += `\n${theme.fg("muted", `… ${matches - 20} more matches`)}`; + if (matches > 20) t += `\n${theme.fg("muted", `… ${matches - 20} more matches`)}`; } return row(t, theme, context, false); }, @@ -569,25 +537,27 @@ export default function (pi: ExtensionAPI) { }, renderCall(args, theme, context) { - if (!isOn("find")) - { (context as any).lastComponent = undefined; return origDefs.find.renderCall!(args, theme, context as any); } - let t = `${toolLabel(theme, true, "find")} ${theme.fg("accent", args.pattern)}`; + let t = `${toolLabel(theme, "find")} ${theme.fg("accent", args.pattern)}`; if (args.path) t += theme.fg("dim", ` in ${d(args.path)}`); if (args.limit) t += theme.fg("dim", ` (limit ${args.limit})`); return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { - if (!isOn("find")) - { (context as any).lastComponent = undefined; return origDefs.find.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Searching…"), theme, context, true); - const details = result.details as FindToolDetails | undefined; - const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + const text = textOf(result); + + // OFF: full path list in the pill. + if (!isOn("find")) { + return row(text.trimEnd() || theme.fg("muted", "0 results"), theme, context, false); + } + // ON: compact result count. if (text.startsWith("No files")) return row(theme.fg("muted", "0 results"), theme, context, false); + const details = result.details as FindToolDetails | undefined; const count = lineCount(text); let t = theme.fg("success", `${count} result${count === 1 ? "" : "s"}`); if (details?.resultLimitReached) @@ -597,8 +567,7 @@ export default function (pi: ExtensionAPI) { if (opts.expanded && text) { const preview = text.split("\n").slice(0, 20); for (const line of preview) t += `\n${theme.fg("dim", line)}`; - if (count > 20) - t += `\n${theme.fg("muted", `… ${count - 20} more results`)}`; + if (count > 20) t += `\n${theme.fg("muted", `… ${count - 20} more results`)}`; } return row(t, theme, context, false); }, @@ -618,23 +587,25 @@ export default function (pi: ExtensionAPI) { }, renderCall(args, theme, context) { - if (!isOn("ls")) - { (context as any).lastComponent = undefined; return origDefs.ls.renderCall!(args, theme, context as any); } const target = args.path ? d(args.path) : "."; - let t = `${toolLabel(theme, true, "ls")} ${theme.fg("accent", target)}`; + let t = `${toolLabel(theme, "ls")} ${theme.fg("accent", target)}`; if (args.limit) t += theme.fg("dim", ` (limit ${args.limit})`); return row(t, theme, context, context.isPartial); }, renderResult(result, opts, theme, context) { - if (!isOn("ls")) - { (context as any).lastComponent = undefined; return origDefs.ls.renderResult!(result, opts, theme, context as any); } if (opts.isPartial) return row(theme.fg("dim", "Listing…"), theme, context, true); + const text = textOf(result); + + // OFF: full entry list in the pill. + if (!isOn("ls")) { + return row(text.trimEnd() || theme.fg("muted", "(empty)"), theme, context, false); + } + + // ON: compact entry count. const details = result.details as LsToolDetails | undefined; - const text = result.content[0]?.type === "text" ? result.content[0].text : ""; const count = lineCount(text); - let t = theme.fg("success", `${count} entr${count === 1 ? "y" : "ies"}`); if (details?.entryLimitReached) t += theme.fg("warning", ` (limit ${details.entryLimitReached})`); @@ -643,8 +614,7 @@ export default function (pi: ExtensionAPI) { if (opts.expanded && text) { const preview = text.split("\n").slice(0, 20); for (const line of preview) t += `\n${theme.fg("dim", line)}`; - if (count > 20) - t += `\n${theme.fg("muted", `… ${count - 20} more entries`)}`; + if (count > 20) t += `\n${theme.fg("muted", `… ${count - 20} more entries`)}`; } return row(t, theme, context, false); }, @@ -655,9 +625,7 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("toolview", { description: "Toggle compact tool output. /toolview off · /toolview bash off", handler: async (args, ctx) => { - // Re-render already-drawn tool blocks so a toggle applies immediately, - // no /reload needed. setToolsExpanded re-runs renderCall/renderResult - // on every block; passing the current value changes nothing visually. + // Re-render already-drawn tool blocks so a toggle applies immediately. const refresh = () => { try { ctx.ui.setToolsExpanded(ctx.ui.getToolsExpanded()); @@ -668,7 +636,6 @@ export default function (pi: ExtensionAPI) { const raw = args.trim().toLowerCase(); if (!raw) { - // Show status const status = state.enabled ? "on" : "off"; const perTool = TOOL_NAMES.map((t) => { const on = state.tools[t] !== false; @@ -689,13 +656,12 @@ export default function (pi: ExtensionAPI) { state.enabled = false; saveState(state); refresh(); - ctx.ui.notify("toolview: all tools verbose (original)", "info"); + ctx.ui.notify("toolview: all tools full output", "info"); return; } const parts = raw.split(/\s+/); - // /toolview on|off if (parts.length === 2) { const tool = parts[0] as ToolName; const action = parts[1]; @@ -717,12 +683,11 @@ export default function (pi: ExtensionAPI) { state.tools[tool] = false; saveState(state); refresh(); - ctx.ui.notify(`toolview: ${tool} → verbose (original)`, "info"); + ctx.ui.notify(`toolview: ${tool} → full output`, "info"); return; } } - // /toolview — toggle single tool if (parts.length === 1) { const tool = parts[0] as ToolName; if (!TOOL_NAMES.includes(tool)) { @@ -741,7 +706,7 @@ export default function (pi: ExtensionAPI) { saveState(state); refresh(); ctx.ui.notify( - `toolview: ${tool} → ${currentlyOn ? "verbose" : "compact"}`, + `toolview: ${tool} → ${currentlyOn ? "full output" : "compact"}`, "info", ); return; From b9569947008429b1d033309a477fe88dd3f3a4c3 Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Tue, 4 Aug 2026 16:52:29 +0200 Subject: [PATCH 09/12] fix: /toolview on|off clears per-tool overrides /toolview on only set enabled=true, leaving per-tool false entries in state.tools, and isOn() checks both. So tools toggled off individually stayed off after /toolview on. Both on and off now reset the per-tool map so the global toggle is a true 'everything' toggle. --- packages/pi-toolview/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/pi-toolview/index.ts b/packages/pi-toolview/index.ts index fd7531d..0421097 100644 --- a/packages/pi-toolview/index.ts +++ b/packages/pi-toolview/index.ts @@ -647,6 +647,7 @@ export default function (pi: ExtensionAPI) { if (raw === "on") { state.enabled = true; + state.tools = {}; saveState(state); refresh(); ctx.ui.notify("toolview: all tools compact", "info"); @@ -654,6 +655,7 @@ export default function (pi: ExtensionAPI) { } if (raw === "off") { state.enabled = false; + state.tools = {}; saveState(state); refresh(); ctx.ui.notify("toolview: all tools full output", "info"); From adb46b361b7388aa61082bcb56d1514a9fd8cf0c Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Tue, 4 Aug 2026 17:34:16 +0200 Subject: [PATCH 10/12] feat: rename on/off to compact/full in /toolview command The on/off verbs were confusing since toolview itself is always active; the toggle is between compact summaries and full output. compact/full are now the primary verbs; on/off remain as aliases for compatibility. --- packages/pi-toolview/README.md | 14 ++++++----- packages/pi-toolview/index.ts | 46 +++++++++++++++++----------------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/packages/pi-toolview/README.md b/packages/pi-toolview/README.md index cbbc908..09e8af6 100644 --- a/packages/pi-toolview/README.md +++ b/packages/pi-toolview/README.md @@ -53,18 +53,20 @@ pi install /path/to/pi-extensions/packages/pi-toolview | **Write file size** | `(156 lines · 4.2 KB)` — catch accidental huge writes | | **Error emphasis** | `✗ exit 1` in red, based on the tool's isError flag | | **Edit context hint** | `+12 / -4 in parseConfig` — enclosing function from diff | -| **Per-tool control** | `/toolview bash off` — that tool reverts to verbose original | +| **Per-tool control** | `/toolview bash full` — that tool shows full output | ## Commands | Command | Effect | |---------|--------| | `/toolview` | Show current status | -| `/toolview off` | All tools back to verbose (original rendering) | -| `/toolview on` | Re-enable compact for all tools | +| `/toolview compact` | All tools compact (summaries) | +| `/toolview full` | All tools full output | | `/toolview ` | Toggle one tool (e.g. `/toolview bash`) | -| `/toolview off` | One tool back to verbose | -| `/toolview on` | Re-enable compact for one tool | +| `/toolview compact` | One tool compact | +| `/toolview full` | One tool full output | + +`on`/`off` are accepted as aliases for `compact`/`full`. Tools: `bash`, `read`, `edit`, `write`, `grep`, `find`, `ls` @@ -84,7 +86,7 @@ State persists in `~/.pi/agent/toolview.json`. ## Partial override -Want to keep some tools at default? Use `/toolview bash off` to revert just bash to pi's original verbose rendering. Or copy `index.ts` and delete the `pi.registerTool()` block for any tool. +Want to keep some tools at default? Use `/toolview bash full` to show full output for just bash. Or copy `index.ts` and delete the `pi.registerTool()` block for any tool. ## How it works diff --git a/packages/pi-toolview/index.ts b/packages/pi-toolview/index.ts index 0421097..79cfc44 100644 --- a/packages/pi-toolview/index.ts +++ b/packages/pi-toolview/index.ts @@ -25,10 +25,10 @@ * * Commands: * /toolview Show status - * /toolview off Disable all compact rendering (full output) - * /toolview on Enable all compact rendering - * /toolview off One tool back to full output (bash/read/edit/write/grep/find/ls) - * /toolview on Re-enable compact for that tool + * /toolview compact All tools compact (summaries) + * /toolview full All tools full output + * /toolview [compact|full] One tool + * (on/off accepted as aliases for compact/full) */ import { @@ -623,7 +623,8 @@ export default function (pi: ExtensionAPI) { // ── /toolview command ─────────────────────────────────────────── pi.registerCommand("toolview", { - description: "Toggle compact tool output. /toolview off · /toolview bash off", + description: + "Compact vs full tool output. /toolview compact · /toolview bash full", handler: async (args, ctx) => { // Re-render already-drawn tool blocks so a toggle applies immediately. const refresh = () => { @@ -633,19 +634,21 @@ export default function (pi: ExtensionAPI) { // non-fatal: toggle still applies to newly-rendered tools } }; + // Accept "compact"/"full" as primary, "on"/"off" as legacy aliases. + const norm = (a: string) => (a === "on" ? "compact" : a === "off" ? "full" : a); const raw = args.trim().toLowerCase(); if (!raw) { - const status = state.enabled ? "on" : "off"; + const status = state.enabled ? "compact" : "full"; const perTool = TOOL_NAMES.map((t) => { const on = state.tools[t] !== false; - return on ? t : `${t}(off)`; + return on ? t : `${t}(full)`; }).join(", "); ctx.ui.notify(`toolview: ${status} — ${perTool}`, "info"); return; } - if (raw === "on") { + if (norm(raw) === "compact") { state.enabled = true; state.tools = {}; saveState(state); @@ -653,7 +656,7 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify("toolview: all tools compact", "info"); return; } - if (raw === "off") { + if (norm(raw) === "full") { state.enabled = false; state.tools = {}; saveState(state); @@ -666,7 +669,7 @@ export default function (pi: ExtensionAPI) { if (parts.length === 2) { const tool = parts[0] as ToolName; - const action = parts[1]; + const action = norm(parts[1]); if (!TOOL_NAMES.includes(tool)) { ctx.ui.notify( `toolview: unknown tool "${tool}". Tools: ${TOOL_NAMES.join(", ")}`, @@ -674,18 +677,15 @@ export default function (pi: ExtensionAPI) { ); return; } - if (action === "on") { - delete state.tools[tool]; + if (action === "compact" || action === "full") { + if (action === "compact") { + delete state.tools[tool]; + } else { + state.tools[tool] = false; + } saveState(state); refresh(); - ctx.ui.notify(`toolview: ${tool} → compact`, "info"); - return; - } - if (action === "off") { - state.tools[tool] = false; - saveState(state); - refresh(); - ctx.ui.notify(`toolview: ${tool} → full output`, "info"); + ctx.ui.notify(`toolview: ${tool} → ${action}`, "info"); return; } } @@ -695,7 +695,7 @@ export default function (pi: ExtensionAPI) { if (!TOOL_NAMES.includes(tool)) { ctx.ui.notify( `toolview: unknown tool "${tool}". Tools: ${TOOL_NAMES.join(", ")}`, - "error", + "error", ); return; } @@ -708,14 +708,14 @@ export default function (pi: ExtensionAPI) { saveState(state); refresh(); ctx.ui.notify( - `toolview: ${tool} → ${currentlyOn ? "full output" : "compact"}`, + `toolview: ${tool} → ${currentlyOn ? "full" : "compact"}`, "info", ); return; } ctx.ui.notify( - "Usage: /toolview [on|off] · /toolview [on|off] · /toolview (status)", + "Usage: /toolview [compact|full] · /toolview [compact|full] · /toolview (status)", "info", ); }, From 4d2ea65bfabdd2ed47df061b48ab278a2356b347 Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Tue, 4 Aug 2026 17:37:37 +0200 Subject: [PATCH 11/12] docs: add HANDOFF.md with current state and decisions --- HANDOFF.md | 148 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..b00e3df --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,148 @@ +# HANDOFF.md + +## Current State + +Built `pi-toolview` extension for pi (compact tool output display). + +**Status**: Working, PR open at https://github.com/smarzban/pi-extensions/pull/11 + +**Branch**: `pi-toolview` (based on `main`) + +**Latest commit**: `adb46b3` - renamed `/toolview on|off` to `/toolview compact|full` (with on/off as aliases) + +## What's Done + +### pi-toolview Extension + +Compact tool output for pi's 7 built-in tools (bash, read, edit, write, grep, find, ls). + +**Features**: +- One-line summaries instead of full output (e.g., `✓ · 42 lines · 12.3s`) +- Smart paths (relative inside cwd, `~/` under HOME) +- Bash timing (via render state, not parsed from text) +- Write file size display +- Error emphasis (✗ prefix in red) +- Edit context hint (shows enclosing function from diff) +- `/toolview compact|full` command to toggle (per-tool or global) +- Instant toggle (no `/reload` needed) via `setToolsExpanded()` + +**Architecture**: +- Re-registers each built-in tool with same name +- `execute()` delegates to original `createXTool(cwd)` factory +- `renderShell: "self"` drops default Box padding for tight look +- `row()` helper re-applies success/error/pending background manually +- Off-mode renders full content through `row()` (not native renderers) + +## Key Decisions & Gotchas + +### renderShell: "self" Trade-off + +Used `renderShell: "self"` on all 7 tools to drop Box padding for tight spacing. This means: +- ✅ Tight spacing when compact (on) +- ❌ Native renderers break in self container (lose background/padding) +- ✅ Solution: off-mode renders full content through `row()` instead of delegating to native + +**Why not delegate to native renderers when off?** +Native renderers (bash/read/grep/find/ls) reuse `context.lastComponent` and call `.clear()`/`.addChild()` on it. After compact rendering, that slot holds a plain `Text` (not a Container), so delegation throws. Edit works because it's self-shell native and builds fresh components. + +**Workaround implemented**: Off-mode renders `result.content[0].text` through `row()` with bg/padding. Edit off-mode renders colored diff through `row()`. + +### Bash Timing + +Pi doesn't put "Took Xs" in output text. The built-in tracks timing via `context.state` (startedAt/endedAt). Toolview uses the same mechanism: +```typescript +type BashRenderState = { startedAt?: number; endedAt?: number }; +// In renderCall: state.startedAt = Date.now() +// In renderResult: state.endedAt ??= Date.now() +``` + +### Error Detection + +Non-zero exits come back as error results (`isError: true`), not "exit code:" in text. Toolview checks `context.isError` and parses "Command exited with code N" from the status line. + +### Instant Toggle + +`/toolview` toggles apply immediately via: +```typescript +ctx.ui.setToolsExpanded(ctx.ui.getToolsExpanded()) +``` +This re-runs `renderCall`/`renderResult` on all existing blocks. + +### State Persistence + +State in `~/.pi/agent/toolview.json`: +```json +{ + "enabled": true, + "tools": { "bash": false, "read": false } +} +``` +- `enabled`: global toggle +- `tools`: per-tool overrides (false = full output) +- `/toolview compact` clears `tools` map (sets all compact) +- `/toolview full` clears `tools` map (sets all full) + +### Command Verbs + +- Primary: `compact` / `full` +- Aliases: `on` / `off` (for compatibility) +- Per-tool: `/toolview bash compact` or `/toolview bash full` +- Toggle: `/toolview bash` (toggles single tool) + +## What's Not Done + +### Wishlist Items (Deferred) + +1. **ctrl+s draft stash** (Claude Code style) - Not built yet. Would need `pi.registerShortcut("ctrl+s")` + `getEditorText()`/`setEditorText()` + `appendEntry()` for persistence. + +2. **Double-paste to expand** - Partially feasible. Extension can't easily see raw paste events. Could add a shortcut to re-insert clipboard via `setEditorText()` (bypasses collapse). + +3. **Prompt pinning at top** - Partially feasible. `ctx.ui.setHeader()` exists but unverified if it stays pinned during streaming. No mouse support in pi-tui (can't click to jump). + +### Testing + +- No automated tests yet +- Manual testing via `pi install /path/to/pi-extensions/packages/pi-toolview` +- Verified all 7 tools render correctly in both compact and full modes + +### Documentation + +- README.md is comprehensive +- No usage guide or examples beyond README + +## Next Steps + +1. **Merge PR** - PR #11 is ready for review/merge +2. **Publish to npm** - After merge, tag `pi-toolview-v0.1.0` and push to trigger release workflow +3. **User feedback** - See if the tight spacing + instant toggle meets expectations +4. **Wishlist items** - If user wants ctrl+s stash or other features, build those next + +## Files + +``` +packages/pi-toolview/ +├── index.ts # Main extension (24KB, ~700 lines) +├── package.json # Package metadata +├── README.md # User documentation +└── LICENSE # MIT +``` + +## Testing Locally + +```bash +# Install from local path +pi install /Users/saeed/Workspace/pi-extensions/packages/pi-toolview + +# Or test without installing +pi -e /Users/saeed/Workspace/pi-extensions/packages/pi-toolview/index.ts + +# After changes, /reload in pi +``` + +## PR Status + +- Branch: `pi-toolview` +- Base: `main` +- Commits: 10 (see git log) +- Status: Ready for review +- URL: https://github.com/smarzban/pi-extensions/pull/11 From 4db49f1c14ca9d0aa9562f4eb6f6a01662287041 Mon Sep 17 00:00:00 2001 From: Saeed Marzban Date: Wed, 5 Aug 2026 09:54:15 +0200 Subject: [PATCH 12/12] feat: duration formatting adds minutes and hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12ms → 12ms, 9.5s → 9.5s, 120s → 2m, 2h flat → 2h --- packages/pi-toolview/index.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/pi-toolview/index.ts b/packages/pi-toolview/index.ts index 79cfc44..a752df4 100644 --- a/packages/pi-toolview/index.ts +++ b/packages/pi-toolview/index.ts @@ -76,9 +76,14 @@ function formatBytes(n: number): string { } function formatDuration(ms: number): string { - if (ms < 1000) return `${ms}ms`; + if (ms < 1000) return `${Math.round(ms)}ms`; const s = ms / 1000; - return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`; + if (s < 60) return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`; + const m = Math.round(s / 60); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + const rem = m % 60; + return rem === 0 ? `${h}h` : `${h}h ${rem}m`; } /** Strip metadata pi appends to bash output (truncation notices, exit status). */