diff options
| author | Igor Soarez <igor@soarez.org> | 2026-11-03 13:05:57 +0200 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-11-03 13:05:57 +0200 |
| commit | bd1eb9ef9b3b89ff5825eb8bf9b2e978167c9ad0 (patch) | |
| tree | ac3ea2a1f0f2243c692ba5e5fab251df6c9f077b /crates | |
| parent | 8146d1db419e8bdacd75920da6f3761abf724853 (diff) | |
show each pi agent's context use: a meter in the panel and search, text in the menu
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/dinggerd/assets/pi/pi-agent-state.ts | 37 | ||||
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 43 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 8 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 8 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 7 |
5 files changed, 97 insertions, 6 deletions
diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts index e4e357d..fc70638 100644 --- a/crates/dinggerd/assets/pi/pi-agent-state.ts +++ b/crates/dinggerd/assets/pi/pi-agent-state.ts @@ -116,6 +116,9 @@ function savedImportance(ctx: any): Importance { // 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 @@ async function report(state: AgentState | undefined, message?: string): Promise< extension: extensionVersion, ...(controlListening ? { control: controlPath } : {}), draft: draft.slice(0, reportedDraftChars), + context, seq: Date.now() * 1000, ...(sessionFile ? { agent_session_path: sessionFile } : {}), }, @@ -342,6 +346,26 @@ export default function (pi) { 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 @@ export default function (pi) { // 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 @@ export default function (pi) { 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 @@ export default function (pi) { } 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(); diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index 1718ff8..82fa7bd 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -28,7 +28,9 @@ //! 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 @@ struct PaneReports { 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 @@ struct HookParams { control: Option<String>, #[serde(default)] draft: Option<String>, + #[serde(default)] + context: Option<String>, } fn handle_stream(stream: UnixStream) { @@ -239,6 +245,7 @@ fn handle_stream(stream: UnixStream) { extension: params.extension, control: params.control, draft: params.draft, + context: params.context, }; record(params.pane_id, state, reported); wake(); @@ -253,6 +260,7 @@ struct Reported { extension: Option<String>, control: Option<String>, draft: Option<String>, + context: Option<String>, } /// The longest draft kept: enough to find an agent by it. @@ -298,6 +306,24 @@ fn record(pane_id: String, state: Option<AgentState>, reported: Reported) { 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. @@ -441,6 +467,21 @@ mod tests { } #[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] fn a_draft_is_kept_as_one_short_line_until_reported_again() { let draft = |text: &str| Reported { draft: Some(text.into()), ..Default::default() }; record("%t5".into(), Some(AgentState::Idle), Reported::default()); diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 99c10a7..935da7d 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -448,7 +448,8 @@ fn mark_unread(pane: Option<String>) -> Result<(), String> { /// 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 @@ fn status() -> Result<(), String> { }); 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 @@ fn status() -> Result<(), String> { 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")] diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index 136260e..80adbb5 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -36,6 +36,8 @@ pub struct Pane { 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 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { "#{@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 @@ pub fn list_panes() -> Result<Vec<Pane>, 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) diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 1aec88e..77b9259 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -28,6 +28,7 @@ //! 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; @@ -527,6 +528,12 @@ impl Watcher { (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) { warn!(target = %pane.display_target, %err, "failed to write pane options"); } |
