@nakido-official/pi-footer

A configurable footer extension for pi

Packages

Package details

extension

Install @nakido-official/pi-footer from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@nakido-official/pi-footer
Package
@nakido-official/pi-footer
Version
0.8.0
Published
Oct 21, 2026
Downloads
340/mo · 13/wk
Author
nakido-official
License
MIT
Types
extension
Size
121.3 KB
Dependencies
0 dependencies · 2 peers
Pi manifest JSON
{
  "extensions": [
    "./index.ts"
  ]
}

Security note

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

README

pi-footer

A configurable pi footer extension: extension statuses, a context-window progress bar, session usage, and model state on as many lines as you configure.

building · tests · 2 warnings                                                                       MCP:2
███████░░░ 71.3%/200k #12 • ↑1.2M ↓34k CH92.1% $0.421 • ~/.p/a/pi-footer (main *)   ollama/qwen2.5-coder:7b • high

Every footer line is a {"left": …, "right": …} entry in layout.lines; both sides are template strings naming the items to render, the separator between them, which runs of text are colored ({color dim}…{endcolor}) and which are conditional ({if git}…{endif}). This README is the reference for that syntax; lib/config.ts holds the parser and the validation rules, and config.schema.json gives editor completion.

Install

pi install npm:@nakido-official/pi-footer          # personal (~/.pi/agent/settings.json)
pi install npm:@nakido-official/pi-footer --local  # project (.pi/settings.json, needs project trust)

Try it for a single run without installing anything:

pi -e npm:@nakido-official/pi-footer

Already cloned this repo? Load the workspace copy instead:

pi -e ./packages/pi-footer

Inside a running session, /reload re-reads the extension source and the configuration.

Configuration

The extension reads the user config and falls back to the bundled default:

Role Path
User config <agent-dir>/extension-settings/pi-footer.json (usually ~/.pi/agent/…)
Bundled default config.default.json, shipped inside the package next to the extension

Project-local configuration is deliberately ignored, so a repository can never change how your footer looks.

On a first run the user config does not exist yet, so the extension writes it as a byte-for-byte copy of the bundled default. After that the file is yours: it is read, and never rewritten. Delete it and the next session seeds it again from the bundled default.

{
	"$schema": "https://cdn.jsdelivr.net/npm/@nakido-official/pi-footer/config.schema.json",
	"layout": {
		"lines": [
			{
				"left": "{status}",
				"right": "{color dim}MCP:{mcp}{endcolor}"
			},
			{
				"left": "{color text}{context-bar} {context-percent}/{context-window} #{turns}{if tokens-in,tokens-out,cache-hit,cost} {separator} {if tokens-in}↑{tokens-in}{endif}{if tokens-out} ↓{tokens-out}{endif}{if cache-hit} CH{cache-hit}{endif}{if cost} ${cost}{endif}{endif} {separator} {cwd}{if git} ({git}){endif}{endcolor}",
				"right": "{color dim}{model}{if thinking} {separator} {thinking}{endif}{endcolor}"
			}
		],
		"separator": "{color dim}•{endcolor}"
	},
	"widgets": {
		"context": {
			"adaptive": {
				"enabled": true,
				"items": {
					"context-bar": true,
					"context-percent": true,
					"context-window": true
				},
				"warnAt": 70,
				"errorAt": 90,
				"warnColor": "warning",
				"errorColor": "error"
			}
		},
		"cwd": {
			"abbreviate": true
		},
		"git": {
			"pollIntervalMs": 5000,
			"dirtySuffix": " *",
			"adaptive": {
				"enabled": true,
				"cleanColor": "success",
				"dirtyColor": "warning"
			}
		},
		"mcp": {
			"pollIntervalMs": 5000
		}
	}
}

That is config.default.json verbatim — exactly what a fresh install writes out — so the footer looks the same whether the file exists or not. $schema is optional and only used by your editor; so is every other key, which falls back to its value in the bundled default.

layout

lines is the footer, top to bottom. Each entry is one line with a left-aligned and a right-aligned template, both optional — an omitted side is the empty string — and the right side stays right-aligned on the same row. The list is complete: removing an entry removes that line, and "lines": [] renders no footer at all.

A line whose items all render empty is dropped. The bundled layout drops its first line only when {status} is empty and MCP:{mcp} is removed from it: {mcp} always renders a number (0 when nothing is connected), so as shipped that line is always present. That is the general rule — the footer never guesses which half of a line matters.

separator applies to every template on every line; there are no per-line overrides.

"lines": [
	{ "left": "{status}" },
	{
		"left": "{color text}{context-bar} {context-percent}/{context-window} {separator} {cwd}{endcolor}",
		"right": "{color dim}{model} {separator} {thinking} {separator} #{turns}{endcolor}"
	}
]
  • {item} inserts an item, listed below. It paints nothing on its own: an item is colored by the {color …} block around it, and one outside any block renders in the terminal's own foreground. Whether the context items and git recolor themselves is widgets.
  • {separator} is replaced by layout.separator textually, so one key re-punctuates the whole footer — and the separator may carry {color …} tags of its own.
  • {color name} … {endcolor} paints the run between them and {if items} … {endif} renders it only while its condition holds. Tags must be closed in order.
  • {{ and }} render literal braces.
  • Everything else is literal text, rendered as-is and uncolored unless a {color …} block covers it — so [{cwd}], ctx:, (sub) all work.

A template is parsed while the config is validated, so an unknown placeholder, an unknown item in {if …}, an unknown color, an unterminated {, an unclosed tag or a closing tag that does not match the open one rejects the whole config with a warning naming every problem and listing what is available. There is no per-placeholder color syntax: {git:dim} names a color and is rejected with a pointer at the block that replaces it.

Items that have nothing to display — no extension status, outside a git repository, thinking level off, a usage number the session has not reported yet — render the empty string, never a placeholder like -. That matches pi's built-in footer, which pushes the whole ↑<count> chunk or nothing, and it lets the template decide what to do with the punctuation around the value. (mcp always renders, as 0 when nothing is connected: an idle MCP setup is a real value, not a missing one.)

Nothing else disappears on its own. Literal text and {separator} are unconditional; a separator stays where you put it, so {cwd} {separator} {git} outside a repository renders ~/.p/pi-footer • — dangling, by design, because there is no implicit grouping to outsmart you. To make the separator vanish with the value, put both inside the same {if …}:

{cwd}{if git} {separator} ({git}){endif}

The bundled layout guards its first separator with {if tokens-in,tokens-out,cache-hit,cost} — the whole usage run — so a fresh session shows #0 • ~/x with one bullet, and a session with usage shows #0 • ↑1.2M ↓34k CH92.1% $0.421 • ~/x (main *) with the same number of bullets.

Whitespace runs in a rendered line are normalized to a single space and both ends are trimmed, so a vanished {if …} leaves no double gap behind, and the spacing may live in the template or in layout.separator ("{color dim} • {endcolor}") as long as it is written in one place.

Item Renders as Notes
status building · tests Extension statuses set with ctx.ui.setStatus, sorted by key and sanitized; inserted as-is with no color, empty while none is reported.
context-bar ███████░░░ 10-cell context bar; recolors with the gauge when widgets.context applies.
context-percent 71.3% Reported context usage; ? when the provider reports no percentage.
context-window 200k Size of the context window.
tokens-in 1.2M, empty Cumulative input tokens; empty while the total is zero. The bundled layout writes the ↑ itself, in its own block, so the item is the bare number and the arrow disappears with it.
tokens-out 34k, empty Cumulative output tokens; empty while the total is zero. The bundled layout writes the ↓ itself, in its own block.
cache-hit 92.1%, empty Cache hit rate of the latest request; empty until the session reports a cache read or write. The bundled layout writes the CH prefix itself.
cost 0.421, 0.000 (sub), empty Session cost in dollars — the bundled layout writes the $ itself, so the item is the bare number with (sub) appended for subscription-backed providers; empty while the cost is zero and no subscription applies.
cwd ~/.p/a/pi-footer Working directory: ~ for home, other segments shortened to one character unless widgets.cwd.abbreviate is false (~/.pi/agent/source/pi-footer).
git main, main * Branch name, plus widgets.git.dirtySuffix (bundled " *") while the working tree has changes; clean and dirty colors also come from widgets.git.
mcp 2 Number of connected MCP servers, inferred from the tool registry (see widgets.mcp); 0 when MCP is idle.
model ollama/qwen2.5-coder:7b Provider and model id, verbatim — the id is never trimmed at a colon.
thinking high Thinking level, empty when off.
turns 12 Number of user turns in the session. The bundled layout writes the # itself, so the item is the bare number.

Any item may appear more than once, and the same item may appear on both sides, though there is rarely a reason to.

Colors

{color name} … {endcolor} paints the run between the two tags in one pi theme color, and that is the only way to color anything. There is no default color: text and items outside a color block are not painted at all and take the terminal's own foreground, so nothing is colored by accident and no placeholder repeats a color its block already set.

The bundled layout names {color text} around the whole left template, {color dim} around the right one, and one more inside layout.separator so the bullets stay dim against the text left side:

"left":  "{color text}{cwd} {separator} {if git}({git}){endif}{endcolor}"
"separator": "{color dim}•{endcolor}"
What Color
literal text, {item}, {separator} none outside a block — the terminal's default foreground
anything inside {color name} … {endcolor} name
inside a nested {color other} other, and name comes back at its {endcolor}
{status} none — inserted as-is, even inside a color block
context items and git with adaptive coloring on the widget's color replaces the block's

{status} is the one item a {color …} block cannot paint: the text comes from other extensions and may already carry styling, so wrapping it changes nothing.

Every pi theme foreground token is accepted, so the useful ones are text, dim, muted, accent, success, warning, error, border, borderAccent and borderMuted, and the thinking/markdown ones work too. A name that is not such a token is a configuration error — theme.fg() throws on an unknown one, so the config is rejected while it is read instead of breaking a render, and the warning lists every color the extension accepts.

Conditions

{if items} … {endif} renders the run between the tags only while its condition holds. The condition is a comma separated list of items and holds while at least one of them has something to show; nesting adds an and, and a closed tag restores whatever the outer tag said:

Template Renders as
{if git}({git}){endif} (main) with a branch, nothing at all without one
{if tokens-in}↑{tokens-in}{endif} ↑1.2M after the first response, nothing before it
{if cache-hit}CH{cache-hit}{endif} CH92.1% once a cache read or write is reported
{if tokens-in,tokens-out}Σ{tokens-in}/{tokens-out}{endif} the pair, as soon as either number exists
{if cost}${cost}{endif} $0.421 once the session has spent something
{if git} {separator} ({git}){endif} a bullet and the branch, or nothing at all
{if status}── {status}{endif} a frame around the extension statuses, gone while none is set
{if git}{if thinking}{color accent}⌁{endcolor}{endif}{endif} nesting: both must hold
  • A condition asks only whether an item has content — never how large a value is. Every threshold stays in widgets.
  • Both tags are balanced and ordered: {endif} with a {color …} still open is reported as "cannot close … (expected {endcolor})", and an unclosed tag names its own closer.
  • Nothing is grouped implicitly. Text and {separator} outside {if …} are unconditional, so a condition that should take punctuation with it has to own that punctuation.
  • {if} takes no color: colors come from a surrounding {color …} block, so the two concerns stay separate. {if git,contxt} is rejected like any other unknown item.

Escaping is unchanged: {{ and }} are literal braces, so {{color dim}} renders the text {color dim}.

layout.separator

Substituted wherever a template says {separator}, so one key re-punctuates the whole layout. It is itself a template, which is how the separator names its own color once instead of at every use — the bundled value is:

"separator": "{color dim}•{endcolor}"

Substitution is textual and happens before parsing, so the tags the separator brings take effect where the placeholder sat:

  • {color text}{cwd} {separator} {git}{endcolor} renders the bullet in dim and everything else in text — the block around the placeholder is restored after the separator's {endcolor};
  • a separator without tags, "•", takes the color of the block around it;
  • {{ and }} still escape, so {{{separator}}} renders a literal { and } around it;
  • the separator may not contain {separator} itself; that is rejected while the config is read.

Spacing is written in one place — either " • " here or spaces around the placeholder in the template, never both: whitespace runs in the rendered line are normalized to a single space. The separator is never inserted automatically, and it is not a group boundary: one written outside an {if …} is always rendered, even when the item next to it is empty (see Conditions for how to guard it). Set it to "" to render nothing at all.

widgets

Everything about how the footer looks is in layout; widgets is behavior, one entry per widget that has any. Each entry is optional in a user config and inherits missing fields from the bundled default, so {"widgets": {"mcp": {"pollIntervalMs": 0}}} turns off the MCP timer and changes nothing else.

widgets.context

The three context items share one gauge, so they share one set of thresholds and colors, but each can be switched off on its own. While adaptive.enabled is true:

  • the color its {color …} block names is the calm band — inside {color accent}, the bar stays accent below warnAt;
  • from warnAt on, the item switches to warnColor;
  • from errorAt on, it switches to errorColor;
  • a provider that reports no percentage stays calm.

Both thresholds are absolute percentages of the context window (the number {context-percent} displays), between 0 and 100, with warnAt less than errorAt.

adaptive.enabled: false turns the whole group off, and no single item can switch itself back on. Every entry in adaptive.items defaults to true, so {"context-percent": false} keeps only the percentage in the layout color.

The bundled 70/90 bands are absolute, so they do not follow the window size. pi compacts at (window − reserveTokens) / window — 87.2 % for a 128k window — so the default errorAt of 90 is only reached on larger windows or with a changed reserveTokens; set it below the compaction trigger (below ≈ 87 for a 128k window) if the error color should show before compaction.

widgets.cwd

{cwd} always writes your home directory as ~; abbreviate (bundled true) decides what happens to the directories in between:

abbreviate /home/me/.pi/agent/source/pi-footer
true ~/.p/a/s/pi-footer
false ~/.pi/agent/source/pi-footer

The last segment — the directory you are actually in — is never shortened, and the flag applies outside home in the same way (/srv/apps/deploy renders as /s/a/deploy, or unchanged with false). ~ for home is not configurable: it is the one shortening that always helps, and every path under home stays shorter than the full one either way.

widgets.git

pollIntervalMs (bundled 5000, minimum 250) is how often the working tree is re-checked, and dirtySuffix (bundled " *") is appended to the branch name verbatim while it has changes: " *" renders main *, "*" renders main*, and "" hides the marker. The leading space is part of the value, not added by the renderer.

adaptive decides the colors: cleanColor while the working tree is clean, dirtyColor while it has changes — applied to the branch name and the suffix together. While adaptive.enabled is true it replaces the color the layout gave {git}; with adaptive off and an empty suffix, a dirty tree looks exactly like a clean one.

pi exposes the git branch to extensions but not the working-tree state, so the extension runs git status --porcelain itself. Untracked-but-not-ignored files count as dirty, and .gitignore is respected. That check is asynchronous and is never run more than once at a time:

  • right after the footer is installed,
  • after every tool call and every turn,
  • when the branch changes,
  • and, unless pollIntervalMs is 0, on a timer.

The footer only re-renders when the answer actually changes. Set 0 if you never want the timer — the event-driven checks remain. The check only runs at all while the layout mentions git — as a {git} item or as the condition of a block — so dropping that placeholder also removes the extra git processes.

Outside a repository, without a git binary, or on a timeout, the check stays quiet and the branch renders without a marker.

widgets.mcp

pollIntervalMs (bundled 5000, minimum 250) is how often the tool registry is re-read to see which MCP servers are connected. pi exposes neither MCP connection state nor an MCP connection event to extensions: pi.getMcpServers() lists only servers an extension registered itself, and the built-in MCP extension never reports a status. The one signal left is the tool registry — a connected server registers its tools as mcp__<server>__<tool> — so the extension reads pi.getAllTools() and takes the distinct mcp__<server> namespaces as the connected server list.

That read is in-process and cheap, and it runs:

  • right after the footer is installed,
  • after every tool call and every turn,
  • and, unless pollIntervalMs is 0, on a timer.

The footer only re-renders when the server list actually changes, and the registry is only read while the layout mentions mcp — as an item or as an {if …} condition. Set 0 if you never want the timer — the event-driven reads remain.

Because the signal is the tool registry, {mcp} answers "how many servers are usable right now", not "which ones failed": a server that offers no tools (resources only), one that is still connecting, and one that failed all look the same — absent. The item is therefore a plain count, rendered in the color of the {color …} block around it, and cannot show a per-server error. An idle setup renders 0, which is a real value: to hide it you need a numeric condition, which the grammar deliberately does not have.

Extension statuses

Values set with ctx.ui.setStatus(key, text) are one item, {status}: sorted by key, sanitized (newlines and tabs become spaces), joined with a single space, and empty when no status is set. The bundled first line puts it on the left and the live MCP count on the right, so that line is dropped only if you remove MCP:{mcp} from it too. {status} works as an {if …} condition as well: {if status}── {status}{endif} frames the statuses only while some extension has something to say.

{status} is inserted as-is and never painted: a {color …} block around it changes nothing, and there is no per-placeholder color syntax left to try. This is the one item the footer does not put in a theme color, because the text comes from other extensions and may already carry styling. Move it, duplicate it, put other items beside it, or drop it from lines — like every other item it only renders where a template names it.

Missing, empty, and invalid configuration

The config is read at session start and whenever /footer on reinstalls the footer. There are no defaults in code: every fallback below is the bundled config.default.json.

Situation What happens
File absent It is created from the bundled default, which is then rendered.
Creation fails (read-only home, no permission) One warning naming the path and the OS error; the bundled default is rendered.
File is empty or whitespace-only One warning; the bundled default is rendered.
File is {}, or only $schema One warning that it sets no configuration keys; the bundled default is rendered.
File is not valid JSON One warning carrying the parse error; the bundled default is rendered.
Unknown key, unknown placeholder, unknown color, wrong type One warning naming the file and every problem; the bundled default is rendered.
The bundled default is missing or invalid One warning, then the extension fails loudly.

An existing file is never rewritten — not when it is empty, invalid, or full of typos, and not to "repair" it. A broken file therefore keeps warning once per process (warnings are deduplicated) until you edit or delete it. Nothing is ever partially applied: one bad key sends the whole file back to the bundled default, because a half-honored layout is harder to debug than a loud warning.

Multiple problems are joined into that one warning with ; :

pi-footer: /home/me/.pi/agent/extension-settings/pi-footer.json: invalid configuration — "layout.lines[1].left": unknown placeholder "{contxt}" (available: status, context-bar, context-percent, context-window, tokens-in, tokens-out, cache-hit, cost, cwd, git, mcp, model, thinking, turns, separator; tags: "{color <theme color>}" … "{endcolor}", "{if <item>[,<item>]}" … "{endif}"); using /home/me/.pi/extensions/pi-footer/config.default.json

Because the bundled default is the only layout the extension knows, a missing or corrupt config.default.json leaves nothing to render: the problem is reported and then thrown, which pi surfaces as an extension error while its own footer stays in place. That is a packaging bug, not something to paper over with a guessed layout.

Commands

Command Effect
/footer Toggle the footer (same as /footer toggle)
/footer toggle Flip the current state
/footer on Show the bar and re-read the configuration file
/footer off Hide it and restore pi's built-in footer

The switch lives in process memory only: it survives session changes (/new, /resume, /fork) and /reload, and is discarded when pi exits. Nothing is written to project or global config. The command is a no-op outside the terminal UI (RPC, JSON, print) and reports that instead.

Tests

npm test runs node --test over the pure modules in lib/:

  • test/config.test.ts — seeding, fallback, inheritance, every diagnostic, and the template parser (colors, conditions, {separator} substitution, escaping);
  • test/statusline.test.ts — composeLayout, hjoin, and the formatting helpers;
  • test/cache-stats.test.ts — turn counting, the cache-hit formula, token formatting;
  • test/mcp-status.test.ts — deriving servers from the tool registry;
  • test/git-status.test.ts — the dirty check against throwaway repositories, skipped without git.

The tests load real config files from a temporary directory and run against the bundled config.default.json, so they exercise the same paths a session does. Nothing starts pi itself.

Design notes

  • Only the footer is replaced; every other pi UI surface is untouched. The right-hand block of a line keeps priority when the terminal is narrow: both blocks are truncated from their right end, so the progress bar and the model name stay visible.
  • Templates are parsed during config validation, so the renderer only walks tokens and never touches the template grammar. A token arrives carrying the color it must paint with and the conditions it must meet — {separator} has been substituted and the tags are gone. This is also the only place that checks theme colors: theme.fg() throws on an unknown one, which must not happen mid-render.
  • Items and colors are separate decisions at render time: every item produces a bare string, "" meaning "nothing to show", and the token's color paints it. A condition and the token it guards therefore cannot disagree, and one value per item per render is computed once.
  • Composing one side is a pure function (composeLayout): a token whose condition fails becomes an empty piece — the same thing an item without a value is — then whitespace is tidied on plain text and what is left is painted. There is no grouping pass: a separator is ordinary text, so what the template writes is what appears.
  • ctx.ui.setStatus stores the text and requests a render, so the footer reads the live status map during render(); there is no status polling timer.
  • Work is skipped when the layout does not ask for it: session entries are only scanned when the layout shows {tokens-in}, {tokens-out}, {cache-hit}, {cost} or {turns}, git is only run when it mentions git (as an item or condition), and the tool registry is only read when it mentions mcp.
  • Token formatting and the cache-hit formula mirror pi's built-in footer, but cache read/write counts are dropped because the context items already report that side of the story. Missing values are empty rather than -, and the ↑/↓/CH/$ prefixes live in the layout, inside conditions that vanish with the number — so an untouched session looks like pi's own footer rather than a row of dashes.
  • MCP connection state is not exposed to extensions and fires no event, so {mcp} is inferred from the tool registry's mcp__<server> namespaces and kept fresh by polling. The extension deliberately never handles mcp_servers_change: doing so would mark it as the extension that connects registered MCP servers, taking that job away from the built-in MCP support.
  • There are no built-in defaults in code. config.default.json is both what a first run writes out and what an unusable config falls back to, so the file you edit and the fallback can never drift apart.
  • lib/ has no pi imports: lib/config.ts is schema, template parsing, validation, seeding, and file loading with caller-supplied paths, lib/statusline.ts is pure formatting, lib/git-status.ts wraps the git status call, and lib/mcp-status.ts turns a tool list into the connected-server list. That is also what npm test covers — see Tests.

Requirements

  • pi >= 1.0
  • Node >= 22.19
  • git on PATH for the dirty marker; without it the branch still renders

Both runtime packages (@earendil-works/pi-coding-agent, @earendil-works/pi-tui) are provided by pi and declared as peerDependencies. The extension ships as TypeScript and is loaded through jiti — there is no build step.

License

MIT © nakido