@zenspc/pi-spinner

Customize pi's streaming spinner animation and rotate through a user-defined message list.

Packages

Package details

extension

Install @zenspc/pi-spinner from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@zenspc/pi-spinner
Package
@zenspc/pi-spinner
Version
0.2.0
Published
Aug 13, 2026
Downloads
222/mo · 148/wk
Author
ph03nix
License
MIT
Types
extension
Size
73.8 KB
Dependencies
0 dependencies · 1 peer
Pi manifest JSON
{
  "extensions": [
    "./src/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@zenspc/pi-spinner

Replaces pi's default "Working..." loader text and braille spinner with a user-chosen animation preset and a rotating message list.

Install

pi install npm:@zenspc/pi-spinner

Local development from this monorepo:

pi -e ./packages/pi-spinner
pi install ./packages/pi-spinner

Quick start

  1. Run /spinner inside pi to open the customization TUI.
  2. Pick an animation preset, edit your message list, set the cycle interval, and save (to global or project). You can also edit custom frames and the frame interval from the same menu. An empty frames editor clears the override so the preset shows again. Cycle order can be random or sequential. A built-in message pack (default, calm, dry) replaces the current list when you pick it in the TUI. Activity messages are off by default; turn them on from the TUI to briefly show the current tool.
  3. Next time pi streams a response, the loader uses your new animation and rotates through your messages.

If you never customize anything, the extension uses pi's built-in defaults: braille spinner, "Working..." text, no rotation. You can opt out by running /spinner-reset and the loader returns to pi's default.

Commands

Command Description
/spinner Open the TUI
/spinner status Show merged config + paths
/spinner help Usage
/spinner <preset> Set preset (including hidden, dot)
/spinner pack <name> Replace messages with a built-in pack
/spinner random / /spinner sequential Set cycle order
/spinner rotate Same as /spinner-rotate
/spinner reset Same as /spinner-reset (both files)
/spinner-reset [global|project] Scoped or full reset

Slash mutations save to the global file unless the verb is a scoped reset. Project overrides still win on next load if present.

Built-in animation presets

Name Description
braille Pi's default 10-frame braille spinner
dots Dim-to-accent pulse: · • ● •
arrows Eight arrows spinning around the compass
bars 12 bars growing and shrinking like a VU meter
progress Five-frame progress bar that fills and resets
rainbow Dots cycling through the full theme color palette
minimal Static muted ellipsis, no animation
dot Single static accent dot
hidden No glyph; the working message still shows. Compaction and retry loaders stay on pi's built-in styling.

Config files

The extension loads (and merges) two optional JSON config files:

Path Scope
~/.pi/agent/extensions/spinner.json Global, applies to all projects
<project>/.pi/spinner.json Project-local, overrides global

Merge order: built-in defaults < global < project. So a project file with just { "preset": "rainbow" } keeps your global messages and overrides only the preset.

Schema

{
	// Animation preset name. One of: braille, dots, arrows, bars, progress, rainbow, minimal, dot, hidden.
	// Ignored if `customFrames` is non-empty.
	"preset": "dots",

	// Message list, one entry per line in the TUI editor. One is shown at a time
	// while the agent is working; the cycler rotates through them on a timer.
	// Max 50 messages; each message is capped at 120 characters after sanitization.
	"messages": [
		"Thinking...",
		"Pondering...",
		"Brewing ideas...",
	],

	// Last picked built-in pack name. One of: default, calm, dry.
	// Picking a pack in the TUI replaces `messages` with that pack.
	// A JSON file that sets only `messagePack` does not rewrite `messages` on load.
	// The `messages` key still wins; otherwise the default list remains.
	"messagePack": "default",

	// How often (ms) to switch to the next message. Clamped to [1500, 15000].
	"cycleIntervalMs": 5000,

	// Order used when picking the next message.
	// `random` (default) shuffles and avoids an immediate repeat.
	// `sequential` walks the list and wraps.
	"cycleMode": "random",

	// Optional raw animation frames. When non-empty, this overrides `preset`.
	// Each frame is up to 8 characters; max 32 frames.
	// Editable from /spinner; an empty frames editor clears the override.
	"customFrames": ["⠋", "⠙", "⠹", "⠸"],
	// Frame interval (ms) for `customFrames`. Clamped to [50, 2000]. Default 100.
	"customIntervalMs": 80,

	// When true, the working message briefly shows the current tool
	// (basename / first token only) while it runs. The cycler resumes
	// after the tool ends. Default false. TUI only.
	"activityMessages": false,

	// When true, the current rotating message is also used as pi's
	// Ctrl+T hidden-thinking label (raw, un-themed). Off restores pi's
	// default on session shutdown. Default false.
	"syncThinkingLabel": false
}

Example: minimal global override

{
	"preset": "rainbow",
	"cycleIntervalMs": 3000
}

Example: project-local custom messages

.pi/spinner.json in your repo:

{
	"messages": [
		"Compiling...",
		"Running tests...",
		"Formatting diff...",
	]
}

How it works

  • On session_start, the extension reads and merges the config files, calls ctx.ui.setWorkingIndicator(...) with themed frames, and starts a MessageCycler that calls ctx.ui.setWorkingMessage(...) on a timer.
  • If activityMessages is on, tool_execution_start overrides the working message with a sanitized one-liner for the current tool (basename or first command token only). The cycler resumes after tool_execution_end, and leftover overrides are cleared on agent_end / agent_settled. Off by default; TUI only; activity text is never written back to spinner.json.
  • If syncThinkingLabel is on, the current rotating message is also used as pi's Ctrl+T hidden-thinking label (raw, un-themed). Pi's default label is restored on session_shutdown (or /spinner-reset).
  • Both APIs already persist across loader recreations inside a session, so the animation and current message survive between agent turns without extra work.
  • On session_shutdown (e.g. /new, /resume, /fork, /reload, or exit), the cycler is stopped and pi's default "Working..." text is restored.
  • In non-TUI modes (rpc, json, print), the underlying APIs are no-ops, and the extension short-circuits its session_start work, so it never spins a timer in those modes.

Limitations

  • The custom loader is only visible in interactive TUI mode, consistent with pi's own loading UI. RPC/print/JSON runs ignore it.
  • Custom animation frames are rendered verbatim; the extension wraps them in theme.fg("accent", ...) for the built-in presets, so theme changes (light/dark) are honored automatically. If you supply customFrames, they also use the accent color.
  • The editor that opens for message and custom-frame editing uses pi's standard input editor, so familiar shortcuts work.
  • Custom frames are also editable from /spinner. An empty frames editor clears the override so the preset is active again.

Security notes

Config files are untrusted input (especially <project>/.pi/spinner.json from a cloned repo).

Hardening applied at the config boundary:

  • File size capped at 100 KB; larger files are ignored.
  • Only regular files are read or overwritten (symlinks/dirs/devices are refused).
  • Writes are atomic (temp + rename) with mode 0o600; parent dirs are created as 0o700.
  • Keys are allowlisted; unknown fields (including customized runtime state) are never persisted.
  • Preset names must match a built-in; unknown names are dropped.
  • cycleMode must be random or sequential; unknown values are dropped.
  • messagePack must be default, calm, or dry; unknown values are dropped.
  • Messages and frames are stripped of ANSI/control characters before they reach the TUI.
  • Tool args used for activity messages are sanitized the same way as config messages before they reach the TUI (ANSI/control stripped, then capped at 40 characters). Only a basename or the first command token is shown; full paths and command lines are not.
  • Message count (50), message length (120), frame count (32), and frame length (8) are hard-capped.
  • Intervals are clamped to documented ranges.

This package does not touch the network, credentials, or the model context. It only changes the local loader animation and text in TUI mode.

Source

src/index.ts
src/command.ts
src/constants.ts
src/presets.ts
src/config.ts
src/activity.ts
src/cycler.ts
src/ui.ts