@nakido-official/pi-footer0.8.0
A configurable footer extension for pi
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
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 iswidgets.{separator}is replaced bylayout.separatortextually, 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 indimand everything else intext— 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 staysaccentbelowwarnAt; - from
warnAton, the item switches towarnColor; - from
errorAton, it switches toerrorColor; - 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
pollIntervalMsis0, 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
pollIntervalMsis0, 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 withoutgit.
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.setStatusstores the text and requests a render, so the footer reads the live status map duringrender(); 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},gitis only run when it mentionsgit(as an item or condition), and the tool registry is only read when it mentionsmcp. - 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'smcp__<server>namespaces and kept fresh by polling. The extension deliberately never handlesmcp_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.jsonis 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.tsis schema, template parsing, validation, seeding, and file loading with caller-supplied paths,lib/statusline.tsis pure formatting,lib/git-status.tswraps thegit statuscall, andlib/mcp-status.tsturns a tool list into the connected-server list. That is also whatnpm testcovers — see Tests.
Requirements
- pi
>= 1.0 - Node
>= 22.19 gitonPATHfor 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