Statusline: replacing the default footer
Everything you’ve built this module runs invisibly - a blocked tool call, a ctx.abort(), a PLAN.md reread, none of it shows up anywhere unless you go looking for it in the transcript. stash sessions run long: a re-extraction batch, a schema migration, an afternoon of search-index debugging. You want to glance down and see, without asking, which model is answering right now and how much of the context window it’s already spent - the same way Module 2 taught you to inspect that live, just always visible instead of one command away.
Pi’s default footer already covers the basics - cwd, session name, token usage (↑ input / ↓ output / R cache-read / W cache-write), cost, context-window percentage, and the active model. What you’re building here isn’t a footer from nothing - it’s a replacement, swapped in with the same ctx.ui.setFooter() call the default uses internally, so you can track something the stock version doesn’t: a running tool tally, say, or a stash-specific number. Building one is the smallest extension surface in this whole module - and worth starting there before you reach for anything fancier.
The minimal footer
Section titled “The minimal footer”The whole contract is one method: ctx.ui.setFooter(), called once from session_start, handed a factory that returns { render, invalidate, dispose }:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";import { truncateToWidth } from "@earendil-works/pi-tui";
export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { ctx.ui.setFooter((_tui, _theme, _footerData) => ({ render(width: number) { const pct = Math.round(ctx.getContextUsage()?.percent ?? 0); const filled = Math.round(pct / 10); const bar = "#".repeat(filled) + "-".repeat(10 - filled); const line = `${ctx.model?.id ?? "no model"} [${bar}] ${pct}%`; return [truncateToWidth(line, width)]; }, invalidate() {}, dispose() {}, })); });}That’s the entire pattern: render(width) returns an array of rows (plain strings here - one row, in this case), invalidate() tells Pi to redraw, dispose() cleans up if the extension unloads. Calling setFooter at all replaces the default footer for the rest of the session - there’s no “extend, don’t replace” mode, so anything from the stock footer you still want (cwd, cost, context %) has to be reproduced in your own render. Want the chrome gone entirely for a distraction-free run? Return [] from render - an empty array is a footer that takes up no space at all. Same contract, zero rows.
A richer version: tool tally and cost, without a running counter
Section titled “A richer version: tool tally and cost, without a running counter”The stock footer already gives you model, tokens, cost, and context percentage. What it doesn’t give you is a per-tool tally for this session - how many times bash ran versus edit, say. That’s a reasonable thing to add, and it’s also where the two gotchas in this whole extension surface live.
Gotcha one: don’t accumulate cost and tokens, recompute them. The tempting move is a running total that increments as events fire. Don’t - an incremental counter drifts the moment you /fork a session, because the fork inherits history the counter never saw. The pattern tilldone used for task state applies here too: on every render, walk ctx.sessionManager.getBranch() fresh and sum each assistant message’s usage.input / usage.output / usage.cost.total from scratch. Slightly more work per render; correct after every fork, branch, or resume with no extra bookkeeping. footerData.onBranchChange() gives you a subscription to redraw the moment the active branch changes - say, the instant you /fork mid-session - and returns the unsubscribe function your dispose() should call.
Gotcha two: tally tool calls on the right event. You want tool_execution_end, not tool_call - tool_call fires before a call runs (and could still get blocked by your damage-control gate), while tool_execution_end fires once the result is actually in:
// .pi/extensions/statusline-rich.ts (excerpt)const toolCounts: Record<string, number> = {};
pi.on("tool_execution_end", async (event) => { toolCounts[event.toolName] = (toolCounts[event.toolName] ?? 0) + 1;});Describe the combined version to Pi rather than assembling it by hand: “Extend my statusline extension to also show total cost, total tokens, and a per-tool call count for this session - recompute cost and tokens from ctx.sessionManager.getBranch() on every render instead of accumulating them, and tally tool counts on tool_execution_end.” When it comes back, verify the two gotchas held: fork the session and confirm cost/tokens don’t double or reset oddly, and check the tool tally actually updates as calls complete rather than as they’re issued.
Two ways to build the widget, and when either matters
Section titled “Two ways to build the widget, and when either matters”The raw-string-row style above is the whole footer contract laid bare - good for a first extension, and genuinely all you need for something this simple. The alternative, once a footer gets more visually involved (multiple lines, colored segments, a progress bar that isn’t just #/-), is component composition: instantiate a Text component from @earendil-works/pi-tui once, outside render(), and mutate it with .setText() on each call instead of rebuilding a string from scratch. Neither is more “correct” - the string-row style is faster to write and read; the component style pays off once you’re managing enough visual state that plain string concatenation gets unwieldy. Start with strings; reach for components when the strings start fighting you.
You now have four pieces built on hooks you already knew how to use before this module started: a tool_call gate for safety, another for task sequencing, a before_agent_start injection for planning, and a setFooter for visibility - three of them filling gaps Pi left open on purpose, the fourth replacing a default Pi already ships with one that tracks what you actually care about on stash. That’s the whole trade Pi is making explicit - nothing is missing by accident, and nothing you add back or swap out is hidden from you either.
TypeScript got you this far, but not every problem in this module needed code - the plan-mode lesson was a markdown file and a habit, not an API. That’s worth taking further: some of what you build for stash should be a procedure, not a program. Next: skills & packages.