@anthodev/pi-tools-style 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-tools-style contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,175 @@
1
+ <h1 align="center">pi-tools-style</h1>
2
+
3
+ <p align="center"><strong>Give Pi tool activity clear, theme-aware structure without changing how tools run.</strong></p>
4
+
5
+ `pi-tools-style` adds rounded frames, tool-specific icons, and live running indicators to Pi tool calls and direct shell commands. It decorates the output Pi already produces instead of replacing tool definitions or renderers.
6
+
7
+ Built-in tools, MCP integrations, AFT, and third-party extensions keep control of their schemas, permissions, prompts, execution backends, and rendered content. When an output cannot be framed safely, the extension leaves it untouched.
8
+
9
+ ## Highlights
10
+
11
+ - **Presentation without ownership** — wraps existing TUI output and never registers or replaces a tool.
12
+ - **Theme-aware borders** — assigns semantic Pi theme colors by tool category and follows theme changes live.
13
+ - **Three icon modes** — uses portable ASCII labels by default, richer Nerd Font glyphs when enabled, or icon-free titles.
14
+ - **Live running state** — animates the title while a model tool call is still executing, then removes the spinner on completion.
15
+ - **Terminal-correct sizing** — measures visible ANSI width before padding, truncating, or drawing borders.
16
+ - **Renderer-friendly composition** — preserves Pi, AFT, MCP, and extension-provided output inside the frame.
17
+ - **Image-safe fallback** — skips Kitty, iTerm2, and Sixel rows instead of corrupting terminal image sequences.
18
+ - **Reload-safe state** — keeps wrappers and runtime configuration idempotent across `/reload`.
19
+
20
+ ## What it looks like
21
+
22
+ ASCII mode stays readable in any terminal:
23
+
24
+ ```text
25
+ ╭─ [F] | read / ───────────────────────╮
26
+ │ Reading README.md │
27
+ ╰──────────────────────────────────────╯
28
+ ```
29
+
30
+ Nerd Font mode uses tool-specific glyphs and Braille spinner frames:
31
+
32
+ ```text
33
+ ╭─  | read ⠹ ─────────────────────────╮
34
+ │ Reading README.md │
35
+ ╰──────────────────────────────────────╯
36
+ ```
37
+
38
+ Once execution finishes, the spinner disappears and the title remains stable:
39
+
40
+ ```text
41
+ ╭─  | read ───────────────────────────╮
42
+ │ README content │
43
+ ╰──────────────────────────────────────╯
44
+ ```
45
+
46
+ Direct `!` and `!!` commands use the same frame with a `shell` title. Slash-command output remains unchanged because Pi does not expose one uniform transcript component for slash commands.
47
+
48
+ ## Installation
49
+
50
+ The current release targets Pi `0.84.x` and requires Node.js 22 or newer.
51
+
52
+ To install this checkout as a local Pi package:
53
+
54
+ ```bash
55
+ cd /absolute/path/to/pi-tools-style
56
+ npm install --legacy-peer-deps
57
+ pi install "$(pwd)"
58
+ ```
59
+
60
+ Pi stores the package in user settings and references the directory directly, so local changes become available after `/reload` or a restart.
61
+
62
+ To install it only for the current project, run this from that project:
63
+
64
+ ```bash
65
+ pi install -l /absolute/path/to/pi-tools-style
66
+ ```
67
+
68
+ To try the extension without installing it:
69
+
70
+ ```bash
71
+ pi -e /absolute/path/to/pi-tools-style/index.ts
72
+ ```
73
+
74
+ Once the package is published to npm, it can also be installed with:
75
+
76
+ ```bash
77
+ pi install npm:@anthodev/pi-tools-style
78
+ ```
79
+
80
+ ## Usage
81
+
82
+ Boxes start enabled. Manage them from Pi with:
83
+
84
+ ```text
85
+ /tools-style
86
+ /tools-style on
87
+ /tools-style off
88
+ /tools-style icons ascii
89
+ /tools-style icons nerd-font
90
+ /tools-style icons off
91
+ /tstyle
92
+ ```
93
+
94
+ `/tools-style` toggles framing. `/tstyle` is a short alias with the same arguments. The explicit `on` and `off` forms set the desired state without depending on its current value.
95
+
96
+ Icon mode persists in `~/.pi/agent/config/tools-style.json`. ASCII remains the package default; Nerd Font mode can be enabled once for terminals that provide the required glyphs. `icons off` keeps frames and the ASCII running spinner, but removes the static icon and its `|` separator from titles.
97
+
98
+ ```json
99
+ {
100
+ "iconMode": "nerd-font"
101
+ }
102
+ ```
103
+
104
+ Set `PI_TOOLS_STYLE=0` before startup to load the extension with framing disabled.
105
+
106
+ ## Icons and running indicators
107
+
108
+ Common tools receive dedicated icons, while unknown tools fall back to their semantic category.
109
+
110
+ | Purpose | ASCII | Nerd Font | Examples |
111
+ | --- | --- | --- | --- |
112
+ | Read a file | `[F]` | `` | `read`, `read_symbol` |
113
+ | Write a file | `[W]` | `` | `write` |
114
+ | Edit content | `[E]` | `` | `replace`, `apply_patch` |
115
+ | Run a command | `[$]` | `` | `bash`, shell tools |
116
+ | Search | `[?]` | `` | `symbol_search`, `grep` |
117
+ | Use the web | `[@]` | `` | `web_search`, browser tools |
118
+ | Fetch content | `[v]` | `` | `fetch_content` |
119
+ | Call MCP | `[M]` | `` | `mcp`, `mcpScript` |
120
+ | Orchestrate work | `[*]` | `` | `workflow`, `subagent` |
121
+ | Track tasks | `[#]` | `` | `todo` |
122
+ | Ask the user | `[!]` | `` | `ask_user`, confirmation tools |
123
+ | Show diagnostics | `[D]` | `` | `lsp_diagnostics`, `lens_diagnostics` |
124
+ | Unknown tool | `[T]` | `` | third-party fallback |
125
+
126
+ While a model tool call is active, ASCII mode cycles through `| / - \` and Nerd Font mode cycles through `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`. One shared timer drives all active titles and stops after completion, interruption, box disablement, stale transcript removal, or session shutdown.
127
+
128
+ ## Theme-aware palette
129
+
130
+ Borders resolve their color from the active Pi theme on every render. Switching themes therefore updates existing tool categories without reloading the extension.
131
+
132
+ | Category | Typical tools | Pi theme token |
133
+ | --- | --- | --- |
134
+ | Inspect | reads, searches, reports, diagnostics | `mdLink` |
135
+ | Mutate | writes, edits, create/update/delete operations | `warning` |
136
+ | Execute | bash, shell, tests, terminal commands | `bashMode` |
137
+ | External | web, fetch, MCP, browser, GitHub, Slack | `syntaxType` |
138
+ | Orchestrate | workflow, subagent, Ralph, todo | `customMessageLabel` |
139
+ | Interact | ask, question, confirm, select | `accent` |
140
+ | Other | unknown third-party tools | `borderMuted` |
141
+
142
+ ## How it works
143
+
144
+ Pi `0.84.x` does not expose public render middleware around `ToolExecutionComponent` and `BashExecutionComponent`. `pi-tools-style` therefore installs a narrow wrapper around their existing `render()` methods.
145
+
146
+ The wrapper asks the previous renderer to draw at the frame's inner width, removes only paired outer horizontal rules, and then adds ANSI-aware chrome. It does not call `pi.registerTool()`, replace tool definitions, or intercept execution.
147
+
148
+ Configuration and animation state live behind `Symbol.for()` keys. Reloading updates the active configuration without stacking wrappers or leaving duplicate timers behind. Any incompatibility or decoration error falls back to Pi's original full-width renderer.
149
+
150
+ ## Compatibility and limits
151
+
152
+ - Pi `0.84.x` is the supported target. Private TUI internals may change in later releases.
153
+ - The render wrapper is necessarily unsupported until Pi exposes public, composable render middleware.
154
+ - Kitty, iTerm2, and Sixel image output remains unboxed by design.
155
+ - Slash commands remain outside the current scope because their transcript output has no uniform component.
156
+ - Another extension can replace this wrapper if it overwrites component rendering without composing with the previous renderer.
157
+ - Unsupported renderer shapes fail open rather than affecting tool execution.
158
+
159
+ ## Development
160
+
161
+ Install dependencies and run the complete verification suite with:
162
+
163
+ ```bash
164
+ npm install --legacy-peer-deps
165
+ npm run check
166
+ npm pack --dry-run --json
167
+ ```
168
+
169
+ `npm run check` runs TypeScript validation and the Vitest suite. Tests cover frame width, ANSI output, terminal images, native and AFT rule removal, category colors, icon modes, persisted settings, spinner lifecycle, real `ToolExecutionComponent` integration, render composition, reload idempotence, fail-open behavior, and the no-`registerTool()` contract.
170
+
171
+ `--legacy-peer-deps` avoids an npm 10.9.8 Arborist failure observed while resolving the development peer dependency graph.
172
+
173
+ ## License
174
+
175
+ MIT
package/index.ts ADDED
@@ -0,0 +1,156 @@
1
+ import {
2
+ BashExecutionComponent,
3
+ ToolExecutionComponent,
4
+ type ExtensionAPI,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ import {
8
+ installRenderDecorator,
9
+ isRenderDecoratorEnabled,
10
+ setRenderDecoratorEnabled,
11
+ type InstallStatus,
12
+ } from "./src/render-decorator.js";
13
+ import { formatToolTitle } from "./src/tool-icon.ts";
14
+ import {
15
+ clearToolSpinners,
16
+ toolSpinnerFrame,
17
+ } from "./src/tool-spinner.ts";
18
+ import {
19
+ getIconMode,
20
+ isIconMode,
21
+ loadSettings,
22
+ saveIconMode,
23
+ } from "./src/settings.ts";
24
+ import {
25
+ createCategoryBorderStyle,
26
+ createToolBorderStyle,
27
+ setThemeProvider,
28
+ type BorderStyle,
29
+ } from "./src/tool-category.js";
30
+
31
+ const TOOL_RENDER_KEY = Symbol.for("pi-tools-style:tool-render");
32
+ const SHELL_RENDER_KEY = Symbol.for("pi-tools-style:shell-render");
33
+
34
+ export interface InstallationResult {
35
+ shell: InstallStatus;
36
+ tools: InstallStatus;
37
+ }
38
+
39
+ export function installToolsStyle(): InstallationResult {
40
+ const enabled = process.env.PI_TOOLS_STYLE !== "0";
41
+
42
+ const tools = installRenderDecorator(ToolExecutionComponent, {
43
+ enabled,
44
+ key: TOOL_RENDER_KEY,
45
+ styleBorderFor: toolBorderStyle,
46
+ title: toolTitle,
47
+ trimOuterRules: true,
48
+ });
49
+ const shell = installRenderDecorator(BashExecutionComponent, {
50
+ enabled,
51
+ key: SHELL_RENDER_KEY,
52
+ styleBorderFor: () => createCategoryBorderStyle("execute"),
53
+ title: () => formatToolTitle("shell", getIconMode()),
54
+ trimOuterRules: true,
55
+ });
56
+
57
+ return { shell, tools };
58
+ }
59
+
60
+ export default function toolsStyleExtension(pi: ExtensionAPI): void {
61
+ const installation = installToolsStyle();
62
+ pi.on("session_start", async (_event, context) => {
63
+ clearToolSpinners();
64
+ await loadSettings();
65
+ setThemeProvider(() => context.ui.theme);
66
+ });
67
+ pi.on("session_shutdown", () => {
68
+ clearToolSpinners();
69
+ setThemeProvider(undefined);
70
+ return Promise.resolve();
71
+ });
72
+
73
+ const command = {
74
+ description: "Configure tool boxes and icon mode",
75
+ handler: async (args, context) => {
76
+ const [action = "", value, ...extra] = args
77
+ .trim()
78
+ .toLowerCase()
79
+ .split(/\s+/u);
80
+ const notifyUsage = (): void => {
81
+ context.ui.notify(
82
+ "Usage: /tools-style [on|off|icons ascii|icons nerd-font|icons off]",
83
+ "warning",
84
+ );
85
+ };
86
+
87
+ if (action === "icons") {
88
+ if (!isIconMode(value) || extra.length > 0) {
89
+ notifyUsage();
90
+ return;
91
+ }
92
+
93
+ await saveIconMode(value);
94
+ context.ui.notify(`Tool icons: ${value}.`);
95
+ return;
96
+ }
97
+
98
+ if (
99
+ (action !== "" && action !== "on" && action !== "off") ||
100
+ value !== undefined
101
+ ) {
102
+ notifyUsage();
103
+ return;
104
+ }
105
+
106
+ if (
107
+ installation.tools === "unsupported" ||
108
+ installation.shell === "unsupported"
109
+ ) {
110
+ context.ui.notify(
111
+ "Tool boxes unavailable: incompatible Pi renderer internals.",
112
+ "warning",
113
+ );
114
+ return;
115
+ }
116
+
117
+ const current = isRenderDecoratorEnabled(TOOL_RENDER_KEY);
118
+ const enabled = resolveRequestedState(action, current);
119
+
120
+ setRenderDecoratorEnabled(TOOL_RENDER_KEY, enabled);
121
+ setRenderDecoratorEnabled(SHELL_RENDER_KEY, enabled);
122
+ if (!enabled) clearToolSpinners();
123
+ context.ui.notify(`Tool boxes ${enabled ? "enabled" : "disabled"}.`);
124
+ },
125
+ } satisfies Parameters<ExtensionAPI["registerCommand"]>[1];
126
+
127
+ pi.registerCommand("tools-style", command);
128
+ pi.registerCommand("tstyle", {
129
+ ...command,
130
+ description: "Alias for /tools-style",
131
+ });
132
+ }
133
+
134
+ function resolveRequestedState(requested: string, current: boolean): boolean {
135
+ if (requested === "on") return true;
136
+ if (requested === "off") return false;
137
+ return !current;
138
+ }
139
+
140
+ function toolName(component: ToolExecutionComponent): string {
141
+ const candidate = (component as unknown as { toolName?: unknown }).toolName;
142
+ return typeof candidate === "string" && candidate.length > 0
143
+ ? candidate
144
+ : "tool";
145
+ }
146
+
147
+ function toolTitle(component: ToolExecutionComponent): string {
148
+ const mode = getIconMode();
149
+ const title = formatToolTitle(toolName(component), mode);
150
+ const spinner = toolSpinnerFrame(component, mode);
151
+ return spinner ? `${title} ${spinner}` : title;
152
+ }
153
+
154
+ function toolBorderStyle(component: ToolExecutionComponent): BorderStyle {
155
+ return createToolBorderStyle(toolName(component));
156
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@anthodev/pi-tools-style",
3
+ "version": "0.1.0",
4
+ "description": "Presentation-only boxes for Pi tool calls and shell commands",
5
+ "keywords": ["pi-package", "pi-extension", "tui", "tools"],
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Anthodev/pi-tools-style.git"
11
+ },
12
+ "homepage": "https://github.com/Anthodev/pi-tools-style#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/Anthodev/pi-tools-style/issues"
15
+ },
16
+ "files": [
17
+ "index.ts",
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "test": "vitest run",
24
+ "typecheck": "tsc --noEmit",
25
+ "check": "npm run typecheck && npm test",
26
+ "pack:check": "npm pack --dry-run --json",
27
+ "prepublishOnly": "npm run check"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./index.ts"
32
+ ]
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-coding-agent": "0.84.x",
39
+ "@earendil-works/pi-tui": "0.84.x"
40
+ },
41
+ "devDependencies": {
42
+ "@earendil-works/pi-coding-agent": "0.84.1",
43
+ "@earendil-works/pi-tui": "0.84.1",
44
+ "@types/node": "24.12.0",
45
+ "typescript": "5.9.3",
46
+ "vitest": "4.1.9"
47
+ },
48
+ "engines": {
49
+ "node": ">=22.19.0"
50
+ }
51
+ }
package/src/frame.ts ADDED
@@ -0,0 +1,132 @@
1
+ import {
2
+ stripTerminalSequences,
3
+ truncateToWidth,
4
+ visibleWidth,
5
+ } from "@earendil-works/pi-tui";
6
+
7
+ const RESET = "\u001b[0m";
8
+ const DEFAULT_PADDING = 1;
9
+ const MINIMUM_FRAME_WIDTH = 5;
10
+ const HORIZONTAL_RULE = /^[─━═╌╍┄┅┈┉-]+$/u;
11
+ const TERMINAL_IMAGE_SEQUENCES = [
12
+ "\u001b_G", // Kitty graphics protocol
13
+ "\u001b]1337;File=", // iTerm2 inline images
14
+ "\u001bPq", // Sixel device control string
15
+ "\u0090q", // Eight-bit Sixel device control string
16
+ ] as const;
17
+
18
+ export interface FrameOptions {
19
+ horizontalPadding?: number;
20
+ styleBorder?: (value: string) => string;
21
+ title?: string;
22
+ }
23
+
24
+ export function frameContentWidth(
25
+ width: number,
26
+ horizontalPadding = DEFAULT_PADDING,
27
+ ): number {
28
+ const padding = normalizePadding(horizontalPadding);
29
+ return Math.max(0, Math.floor(width) - 2 - padding * 2);
30
+ }
31
+
32
+ export function frameLines(
33
+ sourceLines: readonly string[],
34
+ width: number,
35
+ options: FrameOptions = {},
36
+ ): string[] {
37
+ const lines = [...sourceLines];
38
+ const outerWidth = Math.floor(width);
39
+ const padding = normalizePadding(options.horizontalPadding);
40
+
41
+ if (
42
+ lines.length === 0 ||
43
+ outerWidth < MINIMUM_FRAME_WIDTH ||
44
+ containsTerminalImage(lines)
45
+ ) {
46
+ return lines;
47
+ }
48
+
49
+ const contentWidth = frameContentWidth(outerWidth, padding);
50
+ if (contentWidth < 1) return lines;
51
+
52
+ const styleBorder = options.styleBorder ?? identity;
53
+ const horizontalPadding = " ".repeat(padding);
54
+ const top = renderTopBorder(outerWidth, options.title, styleBorder);
55
+ const bottom = styleBorder(`╰${"─".repeat(outerWidth - 2)}╯`);
56
+ const body = lines.map((line) => {
57
+ const content = truncateToWidth(line, contentWidth, "");
58
+ const remaining = Math.max(0, contentWidth - visibleWidth(content));
59
+
60
+ const reset = content.includes("\u001b") ? RESET : "";
61
+ return `${styleBorder("│")}${horizontalPadding}${content}${reset}${" ".repeat(
62
+ remaining,
63
+ )}${horizontalPadding}${styleBorder("│")}`;
64
+ });
65
+
66
+ return [top, ...body, bottom];
67
+ }
68
+
69
+ export function containsTerminalImage(lines: readonly string[]): boolean {
70
+ return lines.some((line) =>
71
+ TERMINAL_IMAGE_SEQUENCES.some((sequence) => line.includes(sequence)),
72
+ );
73
+ }
74
+
75
+ export function trimOuterRules(sourceLines: readonly string[]): string[] {
76
+ const lines = [...sourceLines];
77
+
78
+ const firstRuleIndex =
79
+ lines.length > 1 &&
80
+ stripTerminalSequences(lines[0] ?? "").trim() === "" &&
81
+ isHorizontalRule(lines[1] ?? "")
82
+ ? 1
83
+ : 0;
84
+ const lastRuleIndex = lines.length - 1;
85
+
86
+ if (
87
+ firstRuleIndex < lastRuleIndex &&
88
+ isHorizontalRule(lines[firstRuleIndex] ?? "") &&
89
+ isHorizontalRule(lines[lastRuleIndex] ?? "")
90
+ ) {
91
+ return lines.slice(firstRuleIndex + 1, lastRuleIndex);
92
+ }
93
+
94
+ return lines;
95
+ }
96
+
97
+ function renderTopBorder(
98
+ width: number,
99
+ rawTitle: string | undefined,
100
+ styleBorder: (value: string) => string,
101
+ ): string {
102
+ const title = normalizeTitle(rawTitle);
103
+ const maxTitleWidth = Math.max(0, width - 6);
104
+
105
+ if (!title || maxTitleWidth < 1) {
106
+ return styleBorder(`╭${"─".repeat(width - 2)}╮`);
107
+ }
108
+
109
+ const visibleTitle = truncateToWidth(title, maxTitleWidth, "");
110
+ const prefix = `╭─ ${visibleTitle} `;
111
+ const fill = "─".repeat(Math.max(1, width - visibleWidth(prefix) - 1));
112
+ return `${styleBorder(prefix)}${styleBorder(fill)}${styleBorder("╮")}`;
113
+ }
114
+
115
+ function isHorizontalRule(line: string): boolean {
116
+ const visible = stripTerminalSequences(line).trim();
117
+ return visible.length > 0 && HORIZONTAL_RULE.test(visible);
118
+ }
119
+
120
+ function normalizePadding(value: number | undefined): number {
121
+ if (value === undefined || !Number.isFinite(value)) return DEFAULT_PADDING;
122
+ return Math.max(0, Math.floor(value));
123
+ }
124
+
125
+ function normalizeTitle(value: string | undefined): string {
126
+ if (!value) return "";
127
+ return stripTerminalSequences(value).replace(/\s+/gu, " ").trim();
128
+ }
129
+
130
+ function identity(value: string): string {
131
+ return value;
132
+ }
@@ -0,0 +1,155 @@
1
+ import {
2
+ containsTerminalImage,
3
+ frameContentWidth,
4
+ frameLines,
5
+ trimOuterRules,
6
+ type FrameOptions,
7
+ } from "./frame.js";
8
+
9
+ interface Renderable {
10
+ render(width: number): string[];
11
+ }
12
+
13
+ type RenderableConstructor<T extends Renderable = Renderable> = abstract new (
14
+ ...args: never[]
15
+ ) => T;
16
+
17
+ type RenderFunction = (this: Renderable, width: number) => string[];
18
+ type DecoratedPrototype = Renderable & Record<symbol, unknown>;
19
+
20
+ export interface RenderDecoratorConfig<T extends Renderable = Renderable>
21
+ extends Omit<FrameOptions, "title"> {
22
+ enabled?: boolean;
23
+ key: symbol;
24
+ styleBorderFor?: (
25
+ instance: T,
26
+ ) => NonNullable<FrameOptions["styleBorder"]>;
27
+ title?: (instance: T) => string | undefined;
28
+ trimOuterRules?: boolean;
29
+ }
30
+
31
+ export type InstallStatus = "already-installed" | "installed" | "unsupported";
32
+
33
+ interface StoredConfig extends Omit<RenderDecoratorConfig, "enabled"> {
34
+ enabled: boolean;
35
+ }
36
+
37
+ export function installRenderDecorator<T extends Renderable>(
38
+ component: RenderableConstructor<T>,
39
+ config: RenderDecoratorConfig<T>,
40
+ ): InstallStatus {
41
+ const prototype = component.prototype as DecoratedPrototype;
42
+
43
+ if (prototype[config.key] === true) {
44
+ storeConfig(config);
45
+ return "already-installed";
46
+ }
47
+
48
+ const renderOriginal = resolveOriginalRenderer(prototype);
49
+ if (!renderOriginal) return "unsupported";
50
+
51
+ storeConfig(config);
52
+
53
+ Object.defineProperty(prototype, "render", {
54
+ configurable: true,
55
+ value: createDecoratedRenderer(config.key, renderOriginal),
56
+ writable: true,
57
+ });
58
+ Object.defineProperty(prototype, config.key, {
59
+ configurable: false,
60
+ value: true,
61
+ writable: false,
62
+ });
63
+
64
+ return "installed";
65
+ }
66
+
67
+ export function isRenderDecoratorEnabled(key: symbol): boolean {
68
+ return readConfig(key)?.enabled ?? false;
69
+ }
70
+
71
+ export function setRenderDecoratorEnabled(key: symbol, enabled: boolean): void {
72
+ const current = readConfig(key);
73
+ if (current) Reflect.set(globalThis, key, { ...current, enabled });
74
+ }
75
+
76
+ function resolveOriginalRenderer(
77
+ prototype: DecoratedPrototype,
78
+ ): RenderFunction | undefined {
79
+ if (Object.hasOwn(prototype, "render")) {
80
+ const ownRenderer = prototype.render;
81
+ return typeof ownRenderer === "function" ? ownRenderer : undefined;
82
+ }
83
+
84
+ const parent = Object.getPrototypeOf(prototype) as Renderable | null;
85
+ if (typeof parent?.render !== "function") return undefined;
86
+
87
+ return function renderInherited(width: number): string[] {
88
+ const currentParent = Object.getPrototypeOf(prototype) as Renderable | null;
89
+ return typeof currentParent?.render === "function"
90
+ ? currentParent.render.call(this, width)
91
+ : [];
92
+ };
93
+ }
94
+
95
+ function createDecoratedRenderer(
96
+ key: symbol,
97
+ renderOriginal: RenderFunction,
98
+ ): RenderFunction {
99
+ return function decoratedRender(width: number): string[] {
100
+ const current = readConfig(key);
101
+ if (!current?.enabled) return renderOriginal.call(this, width);
102
+
103
+ const innerWidth = frameContentWidth(width, current.horizontalPadding);
104
+ if (innerWidth < 1) return renderOriginal.call(this, width);
105
+
106
+ const rendered = renderOriginal.call(this, innerWidth);
107
+ if (containsTerminalImage(rendered)) {
108
+ return renderOriginal.call(this, width);
109
+ }
110
+
111
+ try {
112
+ return decorateLines(this, rendered, width, current);
113
+ } catch {
114
+ return renderOriginal.call(this, width);
115
+ }
116
+ };
117
+ }
118
+
119
+ function decorateLines(
120
+ instance: Renderable,
121
+ rendered: readonly string[],
122
+ width: number,
123
+ config: StoredConfig,
124
+ ): string[] {
125
+ const lines = config.trimOuterRules
126
+ ? trimOuterRules(rendered)
127
+ : [...rendered];
128
+ const title = config.title?.(instance);
129
+ const options: FrameOptions = {};
130
+
131
+ if (config.horizontalPadding !== undefined) {
132
+ options.horizontalPadding = config.horizontalPadding;
133
+ }
134
+ const styleBorder = config.styleBorderFor?.(instance) ?? config.styleBorder;
135
+ if (styleBorder !== undefined) {
136
+ options.styleBorder = styleBorder;
137
+ }
138
+ if (title) options.title = title;
139
+
140
+ return frameLines(lines, width, options);
141
+ }
142
+
143
+ function storeConfig<T extends Renderable>(
144
+ config: RenderDecoratorConfig<T>,
145
+ ): void {
146
+ const stored: StoredConfig = {
147
+ ...config,
148
+ enabled: config.enabled ?? true,
149
+ } as StoredConfig;
150
+ Reflect.set(globalThis, config.key, stored);
151
+ }
152
+
153
+ function readConfig(key: symbol): StoredConfig | undefined {
154
+ return Reflect.get(globalThis, key) as StoredConfig | undefined;
155
+ }
@@ -0,0 +1,65 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ import type { IconMode } from "./tool-icon.js";
6
+
7
+ interface ToolsStyleSettings {
8
+ iconMode: IconMode;
9
+ }
10
+
11
+ const SETTINGS_KEY = Symbol.for("pi-tools-style:settings");
12
+ const DEFAULT_SETTINGS: Readonly<ToolsStyleSettings> = {
13
+ iconMode: "ascii",
14
+ };
15
+
16
+ export function getIconMode(): IconMode {
17
+ return getSettings().iconMode;
18
+ }
19
+
20
+ export function setIconMode(iconMode: IconMode): void {
21
+ Reflect.set(globalThis, SETTINGS_KEY, {
22
+ iconMode,
23
+ } satisfies ToolsStyleSettings);
24
+ }
25
+
26
+ export async function loadSettings(path = getSettingsPath()): Promise<void> {
27
+ setIconMode(DEFAULT_SETTINGS.iconMode);
28
+
29
+ try {
30
+ const parsed = JSON.parse(await readFile(path, "utf8")) as {
31
+ iconMode?: unknown;
32
+ };
33
+ if (isIconMode(parsed.iconMode)) setIconMode(parsed.iconMode);
34
+ } catch {
35
+ // Missing or malformed user config intentionally falls back to ASCII.
36
+ }
37
+ }
38
+
39
+ export async function saveIconMode(
40
+ iconMode: IconMode,
41
+ path = getSettingsPath(),
42
+ ): Promise<void> {
43
+ await mkdir(dirname(path), { recursive: true });
44
+ await writeFile(path, `${JSON.stringify({ iconMode }, null, 2)}\n`, "utf8");
45
+ setIconMode(iconMode);
46
+ }
47
+
48
+ function getSettingsPath(): string {
49
+ return join(getAgentDir(), "config", "tools-style.json");
50
+ }
51
+
52
+ function getSettings(): ToolsStyleSettings {
53
+ const current = Reflect.get(globalThis, SETTINGS_KEY) as
54
+ | ToolsStyleSettings
55
+ | undefined;
56
+ if (current) return current;
57
+
58
+ const initial = { ...DEFAULT_SETTINGS };
59
+ Reflect.set(globalThis, SETTINGS_KEY, initial);
60
+ return initial;
61
+ }
62
+
63
+ export function isIconMode(value: unknown): value is IconMode {
64
+ return value === "ascii" || value === "nerd-font" || value === "off";
65
+ }
@@ -0,0 +1,163 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type ToolCategory =
4
+ | "execute"
5
+ | "external"
6
+ | "inspect"
7
+ | "interact"
8
+ | "mutate"
9
+ | "orchestrate"
10
+ | "other";
11
+
12
+ export type BorderStyle = (value: string) => string;
13
+ type ThemeProvider = () => Theme;
14
+
15
+ const THEME_PROVIDER_KEY = Symbol.for("pi-tools-style:theme-provider");
16
+ const DIM = "\u001b[2m";
17
+ const RESET_DIM = "\u001b[22m";
18
+
19
+ const CATEGORY_THEME_COLOR: Record<ToolCategory, ThemeColor> = {
20
+ execute: "bashMode",
21
+ external: "syntaxType",
22
+ inspect: "mdLink",
23
+ interact: "accent",
24
+ mutate: "warning",
25
+ orchestrate: "customMessageLabel",
26
+ other: "borderMuted",
27
+ };
28
+
29
+ const EXTERNAL_NAMESPACES = new Set([
30
+ "api",
31
+ "browser",
32
+ "fetch",
33
+ "github",
34
+ "http",
35
+ "jira",
36
+ "linear",
37
+ "mcp",
38
+ "slack",
39
+ "source",
40
+ "web",
41
+ ]);
42
+ const ORCHESTRATION_NAMES = new Set([
43
+ "manage_todo_list",
44
+ "ralph_done",
45
+ "ralph_start",
46
+ "subagent",
47
+ "subagent_supervisor",
48
+ "subagent_wait",
49
+ "todo",
50
+ "workflow",
51
+ "workflow_control",
52
+ ]);
53
+ const INTERACTION_TERMS = new Set([
54
+ "ask",
55
+ "confirm",
56
+ "prompt",
57
+ "question",
58
+ "questionnaire",
59
+ "select",
60
+ ]);
61
+ const EXECUTION_TERMS = new Set([
62
+ "bash",
63
+ "build",
64
+ "command",
65
+ "exec",
66
+ "execute",
67
+ "run",
68
+ "shell",
69
+ "terminal",
70
+ "test",
71
+ ]);
72
+ const MUTATION_TERMS = new Set([
73
+ "add",
74
+ "apply",
75
+ "create",
76
+ "delete",
77
+ "edit",
78
+ "mark",
79
+ "patch",
80
+ "remove",
81
+ "replace",
82
+ "set",
83
+ "undo",
84
+ "update",
85
+ "write",
86
+ ]);
87
+ const INSPECTION_TERMS = new Set([
88
+ "diagnostics",
89
+ "find",
90
+ "get",
91
+ "grep",
92
+ "inspect",
93
+ "lens",
94
+ "list",
95
+ "lsp",
96
+ "query",
97
+ "read",
98
+ "report",
99
+ "resolve",
100
+ "search",
101
+ "show",
102
+ "status",
103
+ "symbol",
104
+ ]);
105
+
106
+ export function classifyTool(toolName: string): ToolCategory {
107
+ const normalized = normalizeToolName(toolName);
108
+ const terms = normalized.split("_").filter(Boolean);
109
+ const namespace = terms[0] ?? "";
110
+
111
+ if (EXTERNAL_NAMESPACES.has(namespace)) return "external";
112
+ if (ORCHESTRATION_NAMES.has(normalized)) return "orchestrate";
113
+ if (terms.some((term) => INTERACTION_TERMS.has(term))) return "interact";
114
+ if (terms.some((term) => EXECUTION_TERMS.has(term))) return "execute";
115
+ if (terms.some((term) => MUTATION_TERMS.has(term))) return "mutate";
116
+ if (terms.some((term) => INSPECTION_TERMS.has(term))) return "inspect";
117
+ return "other";
118
+ }
119
+
120
+ export function createToolBorderStyle(toolName: string): BorderStyle {
121
+ return createCategoryBorderStyle(classifyTool(toolName));
122
+ }
123
+
124
+ export function createCategoryBorderStyle(category: ToolCategory): BorderStyle {
125
+ const theme = readTheme();
126
+ if (!theme) return dimBorder;
127
+
128
+ const color = CATEGORY_THEME_COLOR[category];
129
+ return (value) => theme.fg(color, value);
130
+ }
131
+
132
+ export function setThemeProvider(provider: ThemeProvider | undefined): void {
133
+ if (provider) {
134
+ Reflect.set(globalThis, THEME_PROVIDER_KEY, provider);
135
+ return;
136
+ }
137
+ Reflect.deleteProperty(globalThis, THEME_PROVIDER_KEY);
138
+ }
139
+
140
+ function readTheme(): Theme | undefined {
141
+ const provider = Reflect.get(globalThis, THEME_PROVIDER_KEY) as
142
+ | ThemeProvider
143
+ | undefined;
144
+ if (!provider) return undefined;
145
+
146
+ try {
147
+ return provider();
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ }
152
+
153
+ function normalizeToolName(toolName: string): string {
154
+ return toolName
155
+ .replace(/([a-z\d])([A-Z])/gu, "$1_$2")
156
+ .replace(/[^a-zA-Z\d]+/gu, "_")
157
+ .replace(/^_+|_+$/gu, "")
158
+ .toLowerCase();
159
+ }
160
+
161
+ function dimBorder(value: string): string {
162
+ return `${DIM}${value}${RESET_DIM}`;
163
+ }
@@ -0,0 +1,155 @@
1
+ import { classifyTool, type ToolCategory } from "./tool-category.js";
2
+
3
+ export type IconMode = "ascii" | "nerd-font" | "off";
4
+
5
+ type IconKind =
6
+ | "bug"
7
+ | "download"
8
+ | "edit"
9
+ | "file"
10
+ | "globe"
11
+ | "list"
12
+ | "plug"
13
+ | "question"
14
+ | "save"
15
+ | "search"
16
+ | "sitemap"
17
+ | "terminal"
18
+ | "wrench";
19
+
20
+ const ASCII_ICONS: Record<IconKind, string> = {
21
+ bug: "[D]",
22
+ download: "[v]",
23
+ edit: "[E]",
24
+ file: "[F]",
25
+ globe: "[@]",
26
+ list: "[#]",
27
+ plug: "[M]",
28
+ question: "[!]",
29
+ save: "[W]",
30
+ search: "[?]",
31
+ sitemap: "[*]",
32
+ terminal: "[$]",
33
+ wrench: "[T]",
34
+ };
35
+
36
+ const NERD_FONT_ICONS: Record<IconKind, string> = {
37
+ bug: "", // nf-fa-bug
38
+ download: "", // nf-fa-cloud_download
39
+ edit: "", // nf-fa-edit
40
+ file: "", // nf-fa-file
41
+ globe: "", // nf-fa-globe
42
+ list: "", // nf-fa-list
43
+ plug: "", // nf-fa-plug
44
+ question: "", // nf-fa-question_circle
45
+ save: "", // nf-fa-save
46
+ search: "", // nf-fa-search
47
+ sitemap: "", // nf-fa-sitemap
48
+ terminal: "", // nf-fa-terminal
49
+ wrench: "", // nf-fa-wrench
50
+ };
51
+
52
+ const CATEGORY_ICON: Record<ToolCategory, IconKind> = {
53
+ execute: "terminal",
54
+ external: "globe",
55
+ inspect: "search",
56
+ interact: "question",
57
+ mutate: "edit",
58
+ orchestrate: "sitemap",
59
+ other: "wrench",
60
+ };
61
+
62
+ const DIAGNOSTIC_TERMS = new Set(["diagnostics", "lens", "lsp"]);
63
+ const EDIT_TERMS = new Set(["edit", "patch", "replace"]);
64
+ const QUESTION_TERMS = new Set(["ask", "confirm", "question"]);
65
+ const SEARCH_TERMS = new Set(["find", "grep", "search", "symbol"]);
66
+ const EXECUTION_TERMS = new Set([
67
+ "bash",
68
+ "command",
69
+ "exec",
70
+ "execute",
71
+ "run",
72
+ "shell",
73
+ "terminal",
74
+ ]);
75
+
76
+ export function formatToolTitle(toolName: string, mode: IconMode): string {
77
+ if (mode === "off") return toolName;
78
+ return `${iconForTool(toolName, mode)} | ${toolName}`;
79
+ }
80
+
81
+ function iconForTool(
82
+ toolName: string,
83
+ mode: Exclude<IconMode, "off">,
84
+ ): string {
85
+ const kind = resolveIconKind(toolName);
86
+ return mode === "nerd-font" ? NERD_FONT_ICONS[kind] : ASCII_ICONS[kind];
87
+ }
88
+
89
+ function resolveIconKind(toolName: string): IconKind {
90
+ const normalized = normalizeToolName(toolName);
91
+ const terms = normalized.split("_").filter(Boolean);
92
+ const namespace = terms[0] ?? "";
93
+
94
+ return (
95
+ resolveDocumentIcon(normalized, terms) ??
96
+ resolveExternalIcon(normalized, namespace) ??
97
+ resolveOrchestrationIcon(normalized, namespace) ??
98
+ resolveActionIcon(terms) ??
99
+ CATEGORY_ICON[classifyTool(toolName)]
100
+ );
101
+ }
102
+
103
+ function resolveDocumentIcon(
104
+ normalized: string,
105
+ terms: readonly string[],
106
+ ): IconKind | undefined {
107
+ if (hasAnyTerm(terms, DIAGNOSTIC_TERMS)) return "bug";
108
+ if (normalized === "read" || normalized.startsWith("read_")) return "file";
109
+ if (normalized === "write") return "save";
110
+ if (hasAnyTerm(terms, EDIT_TERMS)) return "edit";
111
+ return undefined;
112
+ }
113
+
114
+ function resolveExternalIcon(
115
+ normalized: string,
116
+ namespace: string,
117
+ ): IconKind | undefined {
118
+ if (namespace === "fetch" || normalized === "get_search_content") {
119
+ return "download";
120
+ }
121
+ if (["web", "browser", "github", "slack"].includes(namespace)) {
122
+ return "globe";
123
+ }
124
+ return namespace === "mcp" ? "plug" : undefined;
125
+ }
126
+
127
+ function resolveOrchestrationIcon(
128
+ normalized: string,
129
+ namespace: string,
130
+ ): IconKind | undefined {
131
+ if (normalized === "manage_todo_list") return "sitemap";
132
+ if (["workflow", "subagent", "ralph"].includes(namespace)) return "sitemap";
133
+ return namespace === "todo" ? "list" : undefined;
134
+ }
135
+
136
+ function resolveActionIcon(terms: readonly string[]): IconKind | undefined {
137
+ if (hasAnyTerm(terms, QUESTION_TERMS)) return "question";
138
+ if (hasAnyTerm(terms, SEARCH_TERMS)) return "search";
139
+ return hasAnyTerm(terms, EXECUTION_TERMS) ? "terminal" : undefined;
140
+ }
141
+
142
+ function hasAnyTerm(
143
+ terms: readonly string[],
144
+ candidates: ReadonlySet<string>,
145
+ ): boolean {
146
+ return terms.some((term) => candidates.has(term));
147
+ }
148
+
149
+ function normalizeToolName(toolName: string): string {
150
+ return toolName
151
+ .replace(/([a-z\d])([A-Z])/gu, "$1_$2")
152
+ .replace(/[^a-zA-Z\d]+/gu, "_")
153
+ .replace(/^_+|_+$/gu, "")
154
+ .toLowerCase();
155
+ }
@@ -0,0 +1,111 @@
1
+ import type { IconMode } from "./tool-icon.js";
2
+
3
+ interface SpinnerUi {
4
+ requestRender?: () => void;
5
+ }
6
+
7
+ interface ToolSpinnerTarget {
8
+ executionStarted?: unknown;
9
+ isPartial?: unknown;
10
+ ui?: SpinnerUi;
11
+ }
12
+
13
+ interface SpinnerRuntime {
14
+ frameIndex: number;
15
+ targets: Map<object, number>;
16
+ timer: NodeJS.Timeout | undefined;
17
+ }
18
+
19
+ const SPINNER_RUNTIME_KEY = Symbol.for("pi-tools-style:tool-spinner");
20
+ const FRAME_INTERVAL_MS = 80;
21
+ const STALE_TARGET_MS = 2_000;
22
+ const ASCII_FRAMES = ["|", "/", "-", "\\"] as const;
23
+ const NERD_FONT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
24
+
25
+ export function toolSpinnerFrame(
26
+ target: object,
27
+ mode: IconMode,
28
+ ): string | undefined {
29
+ const runtime = getRuntime();
30
+ if (!isExecuting(target)) {
31
+ runtime.targets.delete(target);
32
+ stopTickerIfIdle(runtime);
33
+ return undefined;
34
+ }
35
+
36
+ runtime.targets.set(target, Date.now());
37
+ ensureTicker(runtime);
38
+ const frames = mode === "nerd-font" ? NERD_FONT_FRAMES : ASCII_FRAMES;
39
+ return frames[runtime.frameIndex % frames.length];
40
+ }
41
+
42
+ export function clearToolSpinners(): void {
43
+ const runtime = getRuntime();
44
+ if (runtime.timer) clearInterval(runtime.timer);
45
+ runtime.timer = undefined;
46
+ runtime.frameIndex = 0;
47
+ runtime.targets.clear();
48
+ }
49
+
50
+ function ensureTicker(runtime: SpinnerRuntime): void {
51
+ if (runtime.timer) return;
52
+
53
+ runtime.timer = setInterval(() => tick(runtime), FRAME_INTERVAL_MS);
54
+ runtime.timer.unref?.();
55
+ }
56
+
57
+ function tick(runtime: SpinnerRuntime): void {
58
+ runtime.frameIndex += 1;
59
+ const now = Date.now();
60
+ const activeUis = new Set<SpinnerUi>();
61
+
62
+ for (const [target, lastSeenAt] of runtime.targets) {
63
+ if (!isExecuting(target) || now - lastSeenAt > STALE_TARGET_MS) {
64
+ runtime.targets.delete(target);
65
+ continue;
66
+ }
67
+ const ui = readTarget(target).ui;
68
+ if (ui) activeUis.add(ui);
69
+ }
70
+
71
+ for (const ui of activeUis) {
72
+ try {
73
+ ui.requestRender?.();
74
+ } catch {
75
+ // Presentation failure must never affect tool execution.
76
+ }
77
+ }
78
+
79
+ stopTickerIfIdle(runtime);
80
+ }
81
+
82
+ function stopTickerIfIdle(runtime: SpinnerRuntime): void {
83
+ if (runtime.targets.size > 0 || !runtime.timer) return;
84
+ clearInterval(runtime.timer);
85
+ runtime.timer = undefined;
86
+ runtime.frameIndex = 0;
87
+ }
88
+
89
+ function isExecuting(target: object): boolean {
90
+ const fields = readTarget(target);
91
+ return fields.executionStarted === true && fields.isPartial === true;
92
+ }
93
+
94
+ function readTarget(target: object): ToolSpinnerTarget {
95
+ return target as ToolSpinnerTarget;
96
+ }
97
+
98
+ function getRuntime(): SpinnerRuntime {
99
+ const current = Reflect.get(globalThis, SPINNER_RUNTIME_KEY) as
100
+ | SpinnerRuntime
101
+ | undefined;
102
+ if (current) return current;
103
+
104
+ const runtime: SpinnerRuntime = {
105
+ frameIndex: 0,
106
+ targets: new Map(),
107
+ timer: undefined,
108
+ };
109
+ Reflect.set(globalThis, SPINNER_RUNTIME_KEY, runtime);
110
+ return runtime;
111
+ }