all repos — dingger @ bd1eb9ef9b3b89ff5825eb8bf9b2e978167c9ad0

Watches the AI coding agents in your tmux panes on macOS and tells you which need you, from the menu bar, a floating panel, the status line and notifications; jumps to them, and restores tmux sessions and agents after a crash

show each pi agent's context use: a meter in the panel and search, text in the menu
Igor Soarez igor@soarez.org
Tue, 03 Nov 2026 13:05:57 +0200
commit

bd1eb9ef9b3b89ff5825eb8bf9b2e978167c9ad0

parent

8146d1db419e8bdacd75920da6f3761abf724853

M README.md → README.md

@@ -123,7 +123,8 @@ mark is refused. Use the panel or search for Claude agents.

- **What agents cost** (pi): the menu's top line totals what pi agents spent today, this week, and this month (`$8.86 today · $457 this week · $8.86 this month`); each agent's line adds what its session spent today - (menu, panel, search), and `dingger status` shows both. Periods start at + (at the right edge in the panel and search, after the place in the + menu), and `dingger status` shows both. Periods start at 00:00 UTC (a week on Monday, a month on its 1st). The daemon reads pi's session files (`~/.pi/agent/sessions`, and those of running agents elsewhere) in a thread of its own every 15 seconds, only what was

@@ -133,6 +134,15 @@ own file, and summing every file would count them again (in one month

here, $1,024 of $2,991). The totals include agents since closed or started over. Amounts are what pi prices each reply at (its model configuration); Claude Code records no prices, so it is not counted. +- **Context use** (pi): how full each agent's context window is, the + number pi's own footer shows. The panel and search draw it as a small + meter at the right edge, after the cost (gray; yellow above 70% and red + above 90%, as in pi; the number on hover); the menu adds it as text + (`$0.70 · 10%`), and `dingger status` has a CTX column. The extension + reports it after each message, compaction, model change, and turn; right + after compacting pi does not know it until the next reply, and neither + does dingger (no meter). Agents on an older extension show none until + they reload. - **Forgotten drafts** (pi): an agent with text left in its input box shows `✎` before its name wherever you are not looking at it (where you look, you may be typing it). Idle agents with one come first among idle agents;

@@ -162,7 +172,7 @@

All per-pane state lives in tmux pane options (`@agent_state`, `@dingger_since`, `@dingger_hook`, `@dingger_importance`, `@dingger_extension`, `@dingger_control`, `@dingger_draft`, -`@dingger_cost`), so restarting dingger loses nothing. +`@dingger_cost`, `@dingger_context`), so restarting dingger loses nothing. ### Keeping pi agents on the current extension

@@ -197,7 +207,7 @@ ## Usage

```bash dingger watch # the daemon (run by launchd, see below) -dingger status # agents in priority order, with what they spent +dingger status # agents in priority order, with cost and context use dingger focus [--pane ID] # jump to an agent (default: the top waiter) dingger importance [LEVEL] # an agent's importance: high, normal, low dingger unread [--pane ID] # mark an idle agent unread (done again)

@@ -271,7 +281,8 @@ `/reload`, an extension's blocked event, screen detection without the

extension, restore, reloading an agent on an old extension (and refusing while busy or with a draft), drafts (reported, cleared, kept across a crash and put back by restore), cost (priced replies, counted once across -a real `pi --fork`), and exit. To try a release without installing it: +a real `pi --fork`), context use (pi's footer's number, through a +compaction), and exit. To try a release without installing it: `npm install --prefix /tmp/pi-next @earendil-works/pi-coding-agent@<version>` (through `sfw`), then `tests/pi-live.sh /tmp/pi-next/node_modules/.bin/pi`.
M crates/dinggerd/assets/pi/pi-agent-state.ts → crates/dinggerd/assets/pi/pi-agent-state.ts

@@ -116,6 +116,9 @@ // left behind. Each session's draft is also kept in a file of its own, so

// it comes back when the session does after a crash, a restart, or // `pi --session`. let draft = ""; +// How full the context window is, in percent, as pi's footer shows it; +// "" when unknown (right after compacting, before the next reply). +let context = ""; const draftDir = path.join(path.dirname(socketPath), "drafts"); // Enough to find the agent by it; the file keeps all of it. const reportedDraftChars = 500;

@@ -178,6 +181,7 @@ importance,

extension: extensionVersion, ...(controlListening ? { control: controlPath } : {}), draft: draft.slice(0, reportedDraftChars), + context, seq: Date.now() * 1000, ...(sessionFile ? { agent_session_path: sessionFile } : {}), },

@@ -342,6 +346,26 @@ saveDraft(text);

void report(undefined); } + /** Report how full the context window is, when that changed. */ + function checkContext(ctx: any, quiet = false): void { + let next = ""; + try { + const percent = ctx?.getContextUsage?.()?.percent; + if (typeof percent === "number" && Number.isFinite(percent)) { + next = percent.toFixed(1); + } + } catch { + return; + } + if (next === context) { + return; + } + context = next; + if (!quiet) { + void report(undefined); + } + } + function checkDraftSoon(): void { if (draftTimer) { clearTimeout(draftTimer);

@@ -469,6 +493,8 @@ });

// Each session has its own: /new starts at normal, /resume brings its own back. importance = savedImportance(ctx); announceImportance(); + // The report below carries it. + checkContext(ctx, true); publishState(true); });

@@ -481,6 +507,15 @@ // The prompt left the input box.

checkDraftSoon(); }); + // Each message fills the context (a prompt by pi's estimate, a reply by + // its usage); compacting empties it; another model or branch changes it. + for (const event of ["message_end", "session_compact", "model_select", "session_tree"]) { + pi.on(event, (_event, ctx) => { + latestCtx = ctx; + checkContext(ctx); + }); + } + pi.on("agent_settled", (_event, ctx) => { latestCtx = ctx; if (ctx?.isIdle?.() !== true) {

@@ -488,6 +523,8 @@ return;

} agentActive = false; + // The last reply's usage: message_end comes before pi counts it. + checkContext(ctx); publishState(); // Escape puts queued messages back into the input box. checkDraftSoon();
M crates/dinggerd/src/hooks.rs → crates/dinggerd/src/hooks.rs

@@ -28,7 +28,9 @@ //! `{"method":"agents.reload_outdated"}`, and the watcher reloads each agent

//! on an old extension once it is idle and off screen. //! //! It also carries the `draft` in the agent's input box ("" for none), for -//! marking a draft you left behind; never logged. +//! marking a draft you left behind; never logged. And `context`: how full +//! the agent's context window is, in percent, as pi's footer shows it ("" +//! when unknown, as right after compacting). //! //! Each line is answered with `{"ok":true}`. //!

@@ -74,6 +76,8 @@ extension: Option<String>,

control: Option<String>, /// The draft in the agent's input box, as one line ("" for none). draft: Option<String>, + /// How full its context window is: whole percent ("" when unknown). + context: Option<String>, /// When the last report arrived. at: Option<Instant>, }

@@ -172,6 +176,8 @@ #[serde(default)]

control: Option<String>, #[serde(default)] draft: Option<String>, + #[serde(default)] + context: Option<String>, } fn handle_stream(stream: UnixStream) {

@@ -239,6 +245,7 @@ importance,

extension: params.extension, control: params.control, draft: params.draft, + context: params.context, }; record(params.pane_id, state, reported); wake();

@@ -253,6 +260,7 @@ importance: Option<Importance>,

extension: Option<String>, control: Option<String>, draft: Option<String>, + context: Option<String>, } /// The longest draft kept: enough to find an agent by it.

@@ -297,9 +305,27 @@ pane.control = Some(control);

} if let Some(draft) = reported.draft { pane.draft = Some(draft_line(&draft)); + } + if let Some(context) = reported.context { + pane.context = Some(context_percent(&context)); + } +} + +/// A reported context use as whole percent ("12.6" is "13"); "" for +/// unknown, or anything not a percentage. +fn context_percent(value: &str) -> String { + match value.trim().parse::<f64>() { + Ok(percent) if percent.is_finite() && percent >= 0.0 => format!("{:.0}", percent.min(999.0)), + _ => String::new(), } } +/// How full the pane's agent's context window is ("" when unknown), if its +/// extension reports it. +pub fn context(pane_id: &str) -> Option<String> { + reports().get(pane_id).and_then(|pane| pane.context.clone()) +} + /// Start listening; reports accumulate for the watcher to consume. pub fn spawn_listener() -> Result<(), String> { let path = socket_path();

@@ -438,6 +464,21 @@ record("%t4".into(), None, Reported { importance: Some(Importance::Low), ..Default::default() });

assert_eq!(extension("%t4"), (Some("v1".into()), Some("/s/pi-1.sock".into()))); record("%t4".into(), None, Reported { extension: Some("v2".into()), ..Default::default() }); assert_eq!(extension("%t4").0.as_deref(), Some("v2")); + } + + #[test] + fn context_use_is_kept_as_whole_percent_until_reported_again() { + let context = |text: &str| Reported { context: Some(text.into()), ..Default::default() }; + assert_eq!(super::context("%t6"), None, "an extension that does not report it"); + record("%t6".into(), None, context("12.6")); + assert_eq!(super::context("%t6").as_deref(), Some("13")); + record("%t6".into(), None, Reported::default()); + assert_eq!(super::context("%t6").as_deref(), Some("13"), "kept by a report without one"); + record("%t6".into(), None, context("")); + assert_eq!(super::context("%t6").as_deref(), Some(""), "unknown, after compacting"); + for (value, want) in [("0", "0"), ("0.4", "0"), ("97.5", "98"), ("1e9", "999"), ("-3", ""), ("NaN", ""), ("x", "")] { + assert_eq!(context_percent(value), want, "{value}"); + } } #[test]
M crates/dinggerd/src/main.rs → crates/dinggerd/src/main.rs

@@ -448,7 +448,8 @@ }

/// Every agent pane in herdr's priority order (blocked, done, working, /// idle), oldest-waiting first within a status. ✎ marks a draft left in -/// the agent's input box. COST is what a pi agent spent today (UTC); the +/// the agent's input box. CTX is how full a pi agent's context window is. +/// COST is what a pi agent spent today (UTC); the /// last line totals today, this week, and this month, closed agents included. fn status() -> Result<(), String> { let mut panes: Vec<_> = tmux::list_panes()?

@@ -467,7 +468,7 @@ (watcher::list_order(status, watcher::forgotten_draft(pane), importance, since), pane.display_target.clone())

}); let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " · ", 1); let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5); - println!("{:<8} {:<width$} {:<7} {:>8} NAME", "STATUS", "WHERE", "AGENT", "COST"); + println!("{:<8} {:<width$} {:<7} {:>8} {:>4} NAME", "STATUS", "WHERE", "AGENT", "COST", "CTX"); for pane in &panes { let state = if pane.written_state.is_empty() { "-" } else { &pane.written_state }; let agent = pane.agent.map(detect_core::agent_label).unwrap_or("-");

@@ -478,7 +479,8 @@ importance::Importance::Low => format!("{} (low)", pane.written_name),

importance::Importance::Normal => pane.written_name.clone(), }; let cost = if pane.written_cost.is_empty() { String::new() } else { format!("${}", pane.written_cost) }; - println!("{state:<8} {:<width$} {agent:<7} {cost:>8} {draft}{}", place(pane), name.trim()); + let context = if pane.written_context.is_empty() { String::new() } else { format!("{}%", pane.written_context) }; + println!("{state:<8} {:<width$} {agent:<7} {cost:>8} {context:>4} {draft}{}", place(pane), name.trim()); } let global = |name: &str| tmux::show_global(name).unwrap_or_default(); let spent = [("today", "@dingger_cost_today"), ("this week", "@dingger_cost_week"), ("this month", "@dingger_cost_month")]
M crates/dinggerd/src/tmux.rs → crates/dinggerd/src/tmux.rs

@@ -36,6 +36,8 @@ /// The draft in the agent's input box, as one line.

pub written_draft: String, /// What the agent's session spent today (UTC), in dollars. pub written_cost: String, + /// How full the agent's context window is, in whole percent. + pub written_context: String, }

@@ -121,14 +123,15 @@ "#{@dingger_importance}\t",

"#{@dingger_extension}\t", "#{@dingger_control}\t", "#{@dingger_draft}\t", - "#{@dingger_cost}", + "#{@dingger_cost}\t", + "#{@dingger_context}", ); let out = run_tmux(&["list-panes", "-a", "-F", format])?; let mut panes: Vec<Pane> = Vec::new(); let mut process_tree = None; for line in out.lines() { let fields: Vec<&str> = line.split('\t').collect(); - if fields.len() < 24 { + if fields.len() < 25 { continue; } let attended = fields[4] == "1" && fields[5] != "0";

@@ -175,6 +178,7 @@ written_extension: fields[20].to_string(),

written_control: fields[21].to_string(), written_draft: fields[22].to_string(), written_cost: fields[23].to_string(), + written_context: fields[24].to_string(), }); } Ok(panes)
M crates/dinggerd/src/watcher.rs → crates/dinggerd/src/watcher.rs

@@ -28,6 +28,7 @@ //! - `@dingger_extension`, `@dingger_control` the version of pi's extension

//! and its control socket, for reloading agents on an old one //! - `@dingger_draft` the draft in the agent's input box, as one line //! - `@dingger_cost` what the agent's session spent today (see `cost`) +//! - `@dingger_context` how full its context window is, in whole percent use std::collections::HashMap;

@@ -525,6 +526,12 @@ // The draft goes with the agent too.

match (pane.agent, hooks::draft(&pane.pane_id)) { (Some(_), Some(draft)) => change("@dingger_draft", &pane.written_draft, draft), (None, _) => change("@dingger_draft", &pane.written_draft, String::new()), + _ => {} + } + // And how full its context window is. + match (pane.agent, hooks::context(&pane.pane_id)) { + (Some(_), Some(context)) => change("@dingger_context", &pane.written_context, context), + (None, _) => change("@dingger_context", &pane.written_context, String::new()), _ => {} } if let Err(err) = tmux::set_pane_options(&pane.pane_id, &changes) {
M tests/e2e.sh → tests/e2e.sh

@@ -1144,6 +1144,40 @@ expect "in the total too" "gopt @dingger_cost_today" 2.75

t kill-session -t cost expect "closed agents still count" "gopt @dingger_cost_today" 2.75 10 +# --- 26. how full each agent's context is -------------------------------------- +echo "26. context use" +context_report() { # pane context + python3 - "$HOOK" "$1" "$2" <<'PY' +import json, socket, sys +params = {"pane_id": sys.argv[2], "agent": "pi", "state": "idle", "context": sys.argv[3]} +s = socket.socket(socket.AF_UNIX) +s.connect(sys.argv[1]) +s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode()) +s.close() +PY +} +# A process named pi (found by name, so it can also exit), started in a +# shell that stays. +t new-session -d -s ctx -n x1 -c "$DIR" "/bin/sh" +X1=$(t display -p -t ctx:x1 '#{pane_id}') +t send-keys -t "$X1" "$DIR/rbin/real/pi 100000" Enter +# The daemon polls slowly here; a poke has it look now. +expect "a pi agent" "$DINGGER poke; opt $X1 @agent" pi 80 +context_report "$X1" 78.4 +expect "kept in whole percent" "opt $X1 @dingger_context" 78 50 +expect "the menu: its percentage" "mb --print | grep -F 'ctx · x1' | cut -f3" "ctx · x1 · pi · 78%" +expect "the panel: in its own column" "mb --print-panel --full | grep -F 'ctx · x1' | cut -f3,4" "ctx · x1 · pi 78%" +expect "dingger status: CTX" "$DINGGER status | grep -F 'ctx · x1' | grep -c ' 78% '" 1 +context_report "$X1" "" +expect "unknown (compacted): none" "opt $X1 @dingger_context" "" 50 +expect "nor in the menu" "mb --print | grep -F 'ctx · x1' | cut -f3" "ctx · x1 · pi" +context_report "$X1" 12 +expect "known again" "opt $X1 @dingger_context" 12 50 +t send-keys -t "$X1" C-c +expect "the agent exits" "$DINGGER poke; opt $X1 @agent" "" 80 +expect "its context goes with it" "opt $X1 @dingger_context" "" 50 +t kill-session -t ctx + # Every daemon this run started must stop with it. kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0
M tests/pi-live.sh → tests/pi-live.sh

@@ -16,6 +16,7 @@ PI_BIN=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")

ROOT=$(cd "$(dirname "$0")/.." && pwd) DINGGER="${2:-$ROOT/target/release/dingger}" case "$DINGGER" in /*) ;; *) DINGGER="$PWD/$DINGGER" ;; esac +MENU="$ROOT/target/dingger-menu" D=$(mktemp -d /tmp/dingger-pi-live.XXXXXX) R="dingger-pi-live-$$" PASSES=0; FAILS=0

@@ -50,9 +51,11 @@ # that blocks the way extensions do), one without (screen detection).

for agent in agent agent-plain; do mkdir -p "$D/$agent/extensions" cat >"$D/$agent/models.json" <<EOF -{"providers": {"fake": {"baseUrl": "http://127.0.0.1:$PORT/v1", "api": "openai-completions", "apiKey": "fake", "models": [{"id": "fake-model", "cost": {"input": 100000, "output": 1000000, "cacheRead": 0, "cacheWrite": 0}}]}}} +{"providers": {"fake": {"baseUrl": "http://127.0.0.1:$PORT/v1", "api": "openai-completions", "apiKey": "fake", "models": [{"id": "fake-model", "contextWindow": 400, "cost": {"input": 100000, "output": 1000000, "cacheRead": 0, "cacheWrite": 0}}]}}} EOF - echo '{"defaultProvider": "fake", "defaultModel": "fake-model"}' >"$D/$agent/settings.json" + # A small window, so a few replies fill it measurably; nothing compacts + # on its own, and /compact keeps almost nothing. + echo '{"defaultProvider": "fake", "defaultModel": "fake-model", "compaction": {"enabled": false, "keepRecentTokens": 1}}' >"$D/$agent/settings.json" done PI_CODING_AGENT_DIR="$D/agent" "$DINGGER" install pi >/dev/null cat >"$D/agent/extensions/test-blocked.ts" <<'EOF'

@@ -217,6 +220,36 @@ [ "$FORK" != "$SESSION_FILE" ] && [ "$(replies "$FORK")" -eq $((N + 1)) ] \

&& pass "pi --fork: a new file with the $N replies copied and its own" || fail "fork: $FORK ($(replies "$FORK") replies)" expect "the fork adds only its own reply" "t show -gv @dingger_cost_today 2>/dev/null" "$(python3 -c "print(f'{4*($N+1):.2f}')")" 30 expect "the agent's own spend is unchanged" "t show -pv -t main:agent @dingger_cost 2>/dev/null" "$WANT" +# How full its context is: the number pi's footer shows ("3.3%/400"), in +# an agent of its own (compacting would hide this one's conversation from +# the restore below). +mkdir -p "$D/work3" +t new-window -d -t main: -n ctx -c "$D/work3" +t send-keys -t main:ctx "pi $NO_MCP" Enter +expect "context: an agent of its own" "st ctx" idle 150 +footer() { t capture-pane -p -t main:ctx | grep -oE '(\?|[0-9.]+%)/400' | tail -1 | sed 's|/400||'; } +as_footer() { python3 -c "import sys; f=sys.argv[1]; print('' if f in ('?', '') else round(float(f.rstrip('%'))))" "$(footer)"; } +ctx() { t show -pv -t main:ctx @dingger_context 2>/dev/null; } +same_as_footer() { [ "$(ctx)" = "$(as_footer)" ] && echo same || echo "dingger [$(ctx)] footer [$(footer)]"; } +expect "context: none yet" "footer; ctx" "0.0% +0" 30 +for msg in "slow 1 one" "slow 1 two"; do + t send-keys -t main:ctx -l "$msg" && t send-keys -t main:ctx Enter + expect "and done" "st ctx" done 60 +done +expect "context: after replies, pi's footer" "footer" "3.3%" 30 +expect "context: dingger has pi's number" same_as_footer same 30 +t send-keys -t main:ctx -l "/compact" && t send-keys -t main:ctx Enter +expect "context: after compacting, pi does not know" "footer" "?" 60 +expect "context: nor does dingger" same_as_footer same 30 +expect "the menu shows no percentage" "\"$MENU\" --print | grep -F 'main · ctx' | grep -c '%'" 0 +t send-keys -t main:ctx -l "slow 1 after compacting" && t send-keys -t main:ctx Enter +expect "and done" "st ctx" done 60 +expect "context: known again after a reply" "ctx | grep -cE '^[0-9]+$'" 1 30 +expect "context: pi's number again" same_as_footer same 30 +expect "the menu: the percentage last" "\"$MENU\" --print | grep -F 'main · ctx' | cut -f3 | grep -oE ' [0-9]+%$'" " $(ctx)%" +expect "the panel: cost, then context" "\"$MENU\" --print-panel --full | grep -F 'main · ctx' | cut -f4 | grep -oE '^\\\$[0-9.]+ [0-9]+%$' | grep -c ' $(ctx)%'" 1 +t kill-window -t main:ctx echo "2. screen detection (pi without the extension)" t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter