Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Animated thinking/working/tool indicators for pi coding agent.

## Structure

- `animations.ts` — single-file extension: 21 animations + `/animation` command + AssistantMessageComponent patch
- `animations.ts` — integrated extension: 21 animations + `/animation` + `/spinner` commands + AssistantMessageComponent patch
- `explorations/` — standalone demos (`bun run explorations/XX-name.ts`)
- `tmux-demo.sh` — launch all demos in tmux

Expand All @@ -14,6 +14,7 @@ Animated thinking/working/tool indicators for pi coding agent.
pi -e ./animations.ts
/animation showcase # browse all
/animation fire3 # set all states
/spinner claude # Claude spinner frames
```

## Architecture
Expand All @@ -22,5 +23,5 @@ pi -e ./animations.ts
- **3-line**: `setWidget("anim-multi", lines)` for multi-line
- **Thinking label**: monkey-patch `AssistantMessageComponent.updateContent()`
- **State priority**: thinking > tool > working
- **Config**: `~/.pi/agent/extensions/pi-tui-animations.json`
- **Config**: `~/.pi/agent/extensions/pi-tui-animations.json` (animations + spinner)
- **AnimationFn**: `(frame, width, phase?) => string | string[]`
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# pi-animations

Animated thinking, working, and tool-execution indicators for [pi coding agent](https://github.com/badlogic/pi-mono).
Animated thinking, working, and tool-execution indicators for [pi coding agent](https://github.com/badlogic/pi-mono), plus a configurable working spinner.

Replace pi's default spinner with 21 terminal animations — from demoscene fire to Matrix rain to Pac-Man — all rendered inline with ANSI true color and Nerd Font glyphs.
Replace pi's default spinner with 21 terminal animations — from demoscene fire to Matrix rain to Pac-Man — and manage the inline spinner frames from the same extension.

## Demo

Expand Down Expand Up @@ -57,7 +57,7 @@ Or via git:

## Usage

Everything is under the `/animation` command:
Everything is under `/animation` and `/spinner`:

```
/animation Show status, list all animations, and help
Expand All @@ -72,8 +72,11 @@ Everything is under the `/animation` command:
/animation random Random animation each time
/animation on Enable animations
/animation off Disable animations
/spinner ... Frames: claude|braille|pulse|dot|star|none
```

`/spinner` controls the loader icon.

### Example config

```
Expand Down Expand Up @@ -159,7 +162,7 @@ Configurable via `/animation width`.

### Persistence

All settings are saved to `~/.pi/agent/extensions/pi-tui-animations.json` and restored on startup:
All animation + spinner settings are saved to `~/.pi/agent/extensions/pi-tui-animations.json` and restored on startup:

```json
{
Expand Down
145 changes: 142 additions & 3 deletions animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,20 @@
* /animation tool:<name> Set tool only
* /animation width full|default|<n>
* /animation on|off|random
* /spinner Configure working spinner frames
*/

import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { AssistantMessageComponent, getAgentDir } from "@mariozechner/pi-coding-agent";
import { Text, matchesKey } from "@mariozechner/pi-tui";
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
FRAME_PRESETS,
formatFrames,
getFrameConfig,
type FramePreset,
} from "./spinner-data.js";

const rgb = (r: number, g: number, b: number) => `\x1b[38;2;${r};${g};${b}m`;
const bold = "\x1b[1m";
Expand Down Expand Up @@ -66,6 +73,17 @@ const PHASE_LABELS: Record<AnimPhase, string> = {
tool: "Running",
};

interface SpinnerConfig {
frames: FramePreset | "custom";
customFrames?: string[];
frameIntervalMs: number;
}

const DEFAULT_SPINNER_CONFIG: SpinnerConfig = {
frames: "claude",
frameIntervalMs: 150,
};

// ─── 02 Neural Pulse ─────────────────────────────────────────────
const neuralPulse: AnimationFn = (f, w) => {
const N = Math.min(14, Math.floor(w / 4));
Expand Down Expand Up @@ -545,6 +563,7 @@ interface AnimConfig {
width?: "full" | "default" | number;
randomMode?: boolean;
enabled?: boolean;
workingSpinner?: Partial<SpinnerConfig>;
}

function getConfigPath(): string {
Expand Down Expand Up @@ -573,6 +592,25 @@ function resolveWidth(w: "full" | "default" | number | undefined): number {
return Math.max(10, Math.min(w, (process.stdout.columns || 80) - 4));
}

function getRawSpinnerFrames(config: SpinnerConfig): string[] {
if (config.frames === "custom" && config.customFrames && config.customFrames.length > 0) {
return config.customFrames;
}
return getFrameConfig(config.frames).frames;
}

function colorizeFrames(frames: string[], ctx: ExtensionContext): string[] {
return frames.map((f) => (f ? ctx.ui.theme.fg("accent", f) : f));
}

function describeFramePreset(config: SpinnerConfig): string {
if (config.frames === "custom") {
return `custom [${formatFrames(config.customFrames ?? [])}] @${config.frameIntervalMs}ms`;
}
const preset = FRAME_PRESETS[config.frames as FramePreset];
return `${config.frames} [${formatFrames(preset?.frames ?? [])}] @${preset?.intervalMs ?? config.frameIntervalMs}ms`;
}

interface AnimState {
workingAnim: string;
thinkingAnim: string;
Expand All @@ -588,6 +626,8 @@ interface AnimState {
isThinking: boolean;
isToolRunning: boolean;
currentWorkingCtx: ExtensionContext | null;
spinner: SpinnerConfig;
spinnerCtx: ExtensionContext | null;
}

function getState(): AnimState {
Expand Down Expand Up @@ -655,6 +695,11 @@ export default function (pi: ExtensionAPI) {
isThinking: false,
isToolRunning: false,
currentWorkingCtx: null,
spinner: {
...DEFAULT_SPINNER_CONFIG,
...(cfg.workingSpinner && typeof cfg.workingSpinner === "object" ? cfg.workingSpinner : {}),
},
spinnerCtx: null,
};
(globalThis as any)[STATE_KEY] = state;
ensurePatch();
Expand All @@ -667,9 +712,17 @@ export default function (pi: ExtensionAPI) {
width: state.width,
randomMode: state.randomMode,
enabled: state.enabled,
workingSpinner: state.spinner,
});
}

function applySpinnerIndicator(ctx: ExtensionContext) {
state.spinnerCtx = ctx;
const raw = getRawSpinnerFrames(state.spinner);
const colored = colorizeFrames(raw, ctx);
ctx.ui.setWorkingIndicator({ frames: colored, intervalMs: state.spinner.frameIntervalMs });
}

// ─── Working animation ───────────────────────────────────────
let lastAnimLines = 0; // track if we need to switch between message/widget

Expand Down Expand Up @@ -750,10 +803,18 @@ export default function (pi: ExtensionAPI) {
// ─── Events ──────────────────────────────────────────────────
pi.on("session_start", async (_e, ctx) => {
state.theme = ctx.ui.theme;
applySpinnerIndicator(ctx);
ctx.ui.setWorkingMessage();
});

pi.on("agent_start", async (_e, ctx) => {
startWorkingAnimation(ctx);
applySpinnerIndicator(ctx);
if (state.enabled) {
startWorkingAnimation(ctx);
} else {
stopWorkingAnimation(ctx);
ctx.ui.setWorkingMessage();
}
});

pi.on("agent_end", async (_e, ctx) => {
Expand Down Expand Up @@ -917,10 +978,11 @@ export default function (pi: ExtensionAPI) {
const status = state.enabled
? `Working: ${state.workingAnim} • Thinking: ${state.thinkingAnim} • Tool: ${state.toolAnim} • Width: ${state.width}${state.randomMode ? " • (random)" : ""}`
: "Animations disabled";
const spinner = `Spinner: ${describeFramePreset(state.spinner)}`;
const list = ANIMATIONS.map(a =>
` ${a.name.padEnd(20)} [${a.category.padEnd(8)} ${a.lines}L] ${a.description}`
).join("\n");
ctx.ui.notify(`${status}\n\nAnimations:\n${list}\n\nUsage:\n /animation showcase Browse & pick\n /animation <name> Set all states\n /animation working:<name> Set working only\n /animation thinking:<name> Set thinking only\n /animation tool:<name> Set tool only\n /animation width full|default|<n>\n /animation on|off|random`, "info");
ctx.ui.notify(`${status}\n${spinner}\n\nAnimations:\n${list}\n\nUsage:\n /animation showcase Browse & pick\n /animation <name> Set all states\n /animation working:<name> Set working only\n /animation thinking:<name> Set thinking only\n /animation tool:<name> Set tool only\n /animation width full|default|<n>\n /animation on|off|random\n /spinner ... Manage spinner frames`, "info");
return;
}

Expand All @@ -933,7 +995,7 @@ export default function (pi: ExtensionAPI) {
// ── on/off/random ──
if (arg === "off") {
state.enabled = false;
stopWorkingAnimation();
stopWorkingAnimation(ctx);
stopThinkingTicker();
ctx.ui.setWorkingMessage();
persistConfig();
Expand Down Expand Up @@ -1005,4 +1067,81 @@ export default function (pi: ExtensionAPI) {
ctx.ui.notify(msg, "info");
},
});

// ─── Spinner command ────────────────────────────────────────
pi.registerCommand("spinner", {
description: "Configure working spinner frames.",
getArgumentCompletions: (prefix: string) => {
const items = [
{ value: "claude", label: "claude", description: "· ✢ ✳ ✶ ✻ ✽" },
{ value: "braille", label: "braille", description: "⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏" },
{ value: "pulse", label: "pulse", description: "· • ● •" },
{ value: "dot", label: "dot", description: "● (static)" },
{ value: "star", label: "star", description: "✧ ★ ✦ ✶ ✹" },
{ value: "none", label: "none", description: "Hide indicator" },
{ value: "frames ", label: "frames", description: "Custom comma-separated frames" },
{ value: "interval ", label: "interval", description: "Set frame interval in ms" },
];
const first = prefix.split(/\s+/)[0]?.toLowerCase() ?? "";
if (first === "frames" || first === "interval") return null;
const filtered = prefix ? items.filter((i) => i.value.startsWith(prefix.toLowerCase())) : items;
return filtered.length > 0 ? filtered : null;
},
handler: async (args, ctx) => {
const trimmed = args.trim();
if (!trimmed) {
ctx.ui.notify(`Frames: ${describeFramePreset(state.spinner)}`, "info");
return;
}

const parts = trimmed.split(/\s+/);
const sub = parts[0]!.toLowerCase();

if (sub === "frames" && parts.length > 1) {
const frameList = parts.slice(1).join("").split(",").map((s) => s.trim()).filter(Boolean);
if (frameList.length === 0) {
ctx.ui.notify("Usage: /spinner frames f1,f2,f3,...", "error");
return;
}
state.spinner.frames = "custom";
state.spinner.customFrames = frameList;
applySpinnerIndicator(ctx);
persistConfig();
ctx.ui.notify(`Custom frames set: ${formatFrames(frameList)}`, "success");
return;
}

if (sub === "interval" && parts.length > 1) {
const n = parseInt(parts[1]!, 10);
if (isNaN(n) || n < 0) {
ctx.ui.notify("Usage: /spinner interval <ms> (>= 0)", "error");
return;
}
state.spinner.frameIntervalMs = n;
applySpinnerIndicator(ctx);
persistConfig();
ctx.ui.notify(`Frame interval set to ${n}ms`, "success");
return;
}

const validPresets: FramePreset[] = ["claude", "braille", "pulse", "dot", "star", "none"];
const match = validPresets.find((p) => p === sub);
if (match) {
state.spinner.frames = match;
state.spinner.frameIntervalMs = FRAME_PRESETS[match].intervalMs;
delete state.spinner.customFrames;
applySpinnerIndicator(ctx);
persistConfig();
const label = match === "none" ? "hidden" : match;
ctx.ui.notify(`Spinner frames: ${label}`, "success");
return;
}

ctx.ui.notify(
"Usage: /spinner [claude|braille|pulse|dot|star|none|frames f1,f2,...|interval <ms>]",
"error",
);
},
});

}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "pi-animations",
"version": "0.1.3",
"description": "Animated thinking/working/tool indicators for the Pi coding agent — 21 terminal animations with ANSI true color and Nerd Font glyphs",
"description": "Animated thinking/working/tool indicators and configurable spinner frames for the Pi coding agent — 21 terminal animations with ANSI true color and Nerd Font glyphs",
"license": "MIT",
"author": "arpagon",
"repository": {
Expand All @@ -24,6 +24,7 @@
],
"files": [
"animations.ts",
"spinner-data.ts",
"README.md",
"LICENSE"
],
Expand Down
66 changes: 66 additions & 0 deletions spinner-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Spinner data module
*
* Frame presets for the integrated pi-animations + spinner extension.
*/

// ─── Types ────────────────────────────────────────────────────────

export type FramePreset = "claude" | "braille" | "pulse" | "dot" | "star" | "none";

export interface FrameConfig {
frames: string[];
intervalMs: number;
}

// ─── Frame Presets ────────────────────────────────────────────────

type FramePresets = Record<FramePreset, FrameConfig>;

export const FRAME_PRESETS: FramePresets = {
/** Claude Code's bespoke 6-frame asterisk/star sequence */
claude: {
frames: ["·", "✢", "✳", "✶", "✻", "✽"],
intervalMs: 100,
},
/** Standard Braille dots (pi's default) */
braille: {
frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
intervalMs: 80,
},
/** Simple pulse animation */
pulse: {
frames: ["·", "•", "●", "•"],
intervalMs: 120,
},
/** Static dot — no animation */
dot: {
frames: ["●"],
intervalMs: 0,
},
/** Star / sparkle variants */
star: {
frames: ["✧", "★", "✦", "✶", "✹"],
intervalMs: 100,
},
/** Hidden — no indicator shown */
none: {
frames: [],
intervalMs: 0,
},
};

// ─── Helpers ──────────────────────────────────────────────────────

/** Pretty-print a frame list for display */
export function formatFrames(frames: string[]): string {
if (frames.length === 0) return "(none)";
if (frames.length === 1) return frames[0]!;
return frames.join(" ");
}

/** Get frame preset by key, fallback to braille */
export function getFrameConfig(key: string): FrameConfig {
const preset = FRAME_PRESETS[key as FramePreset];
return preset ?? FRAME_PRESETS.braille;
}