aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md19
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts37
-rw-r--r--crates/dinggerd/src/hooks.rs43
-rw-r--r--crates/dinggerd/src/main.rs8
-rw-r--r--crates/dinggerd/src/tmux.rs8
-rw-r--r--crates/dinggerd/src/watcher.rs7
-rw-r--r--menubar/model.swift44
-rw-r--r--menubar/panel.swift93
-rw-r--r--menubar/search.swift2
-rwxr-xr-xtests/e2e.sh34
-rwxr-xr-xtests/pi-live.sh37
11 files changed, 303 insertions, 29 deletions
diff --git a/README.md b/README.md
index 4b1eeb2..5a2a941 100644
--- a/README.md
+++ b/README.md
@@ -123,7 +123,8 @@ than a poll is still a completion.
- **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 @@ than a poll is still a completion.
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 @@ than a poll is still a completion.
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 @@ session as a custom entry of type `dingger-importance`.
```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 @@ covers identification, the extension's state and session reports, a turn,
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`.
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");
}
diff --git a/menubar/model.swift b/menubar/model.swift
index d0bbd7e..4117ba5 100644
--- a/menubar/model.swift
+++ b/menubar/model.swift
@@ -30,6 +30,9 @@ let mochaText = mocha(205, 214, 244)
let mochaSubtext = mocha(166, 173, 200)
let mochaOverlay = mocha(108, 112, 134)
let mochaBlue = mocha(137, 180, 250)
+let mochaRed = mocha(243, 139, 168)
+let mochaYellow = mocha(249, 226, 175)
+let mochaSurface = mocha(69, 71, 90)
// MARK: herdr's status vocabulary
@@ -99,6 +102,9 @@ struct Row: Equatable {
var attended: Bool = false
/// What its session spent today (UTC), in dollars (`@dingger_cost`).
var cost: Double = 0
+ /// How full its context window is, in percent (`@dingger_context`), as
+ /// pi's footer shows it; nil when unknown.
+ var context: Double? = nil
var location: String { "\(session) · \(window)" }
/// A draft left behind: where the user looks, they may be typing it.
@@ -117,17 +123,41 @@ struct Row: Equatable {
/// forgotten draft.
var markedLabel: String { (importance == .high ? "! " : "") + (forgottenDraft ? "✎ " : "") + label }
- /// Dim second line: where it runs, which harness, whether it is low,
- /// what it spent today, and how long it waits.
+ /// The menu's dim second line: where it runs, which harness, whether
+ /// it is low, how long it waits, what it spent today, and how full its
+ /// context is.
var subtitle: String {
+ var parts = [place]
+ if cost >= 0.005 { parts.append(dollars(cost)) }
+ if let context { parts.append(contextText(context)) }
+ return parts.joined(separator: " · ")
+ }
+
+ /// The panel's second line, before its cost and context columns.
+ var place: String {
var parts = [location, agent]
if importance == .low { parts.append("low") }
- if cost >= 0.005 { parts.append(dollars(cost)) }
if waiting, since != Int.max {
parts.append("\(status.rawValue) \(elapsed(sinceMs: since))")
}
return parts.joined(separator: " · ")
}
+
+ /// The panel's row as text: its columns, then cost and context.
+ var panelText: String {
+ let right = [cost >= 0.005 ? dollars(cost) : nil, context.map(contextText)].compactMap { $0 }
+ return "\(status.symbol) \(status.rawValue)\t\(markedLabel)\t\(place)"
+ + (right.isEmpty ? "" : "\t" + right.joined(separator: " "))
+ }
+}
+
+/// "10%".
+func contextText(_ value: Double) -> String { "\(Int(value.rounded()))%" }
+
+/// The context meter's color: as pi's footer, yellow above 70% and red
+/// above 90%; nil below.
+func contextWarning(_ value: Double) -> NSColor? {
+ value > 90 ? mochaRed : value > 70 ? mochaYellow : nil
}
/// "$0.42", "$19.85"; whole dollars from $100: "$1,967".
@@ -326,19 +356,19 @@ func fetchRows() -> [Row] {
let format = [
"#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}",
"#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}", "#{pane_current_path}",
- "#{@dingger_importance}", "#{window_active}", "#{session_attached}", "#{@dingger_draft}", "#{@dingger_cost}",
+ "#{@dingger_importance}", "#{window_active}", "#{session_attached}", "#{@dingger_draft}", "#{@dingger_cost}", "#{@dingger_context}",
].joined(separator: "\t")
guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] }
let lines = out.split(separator: "\n").map {
$0.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
}
// Looked at in any of its sessions, as the daemon counts it.
- let attended = Set(lines.filter { $0.count >= 15 && $0[11] == "1" && $0[12] != "0" }.map { $0[0] })
+ let attended = Set(lines.filter { $0.count >= 16 && $0[11] == "1" && $0[12] != "0" }.map { $0[0] })
// A pane in several sessions (a session group, a linked window) is
// listed once, under its first session, as the daemon counts it.
var listed = Set<String>()
let rows: [Row] = lines.compactMap { f in
- guard f.count >= 15, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil }
+ guard f.count >= 16, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil }
let status = Status(rawValue: f[2]) ?? .unknown
guard status != .unknown else { return nil }
let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6]
@@ -346,7 +376,7 @@ func fetchRows() -> [Row] {
session: f[5], window: window, since: Int(f[3]) ?? Int.max,
path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9],
importance: Importance(option: f[10]), draft: f[13], attended: attended.contains(f[0]),
- cost: Double(f[14]) ?? 0)
+ cost: Double(f[14]) ?? 0, context: Double(f[15]))
}
return rows.sorted(by: listedBefore)
}
diff --git a/menubar/panel.swift b/menubar/panel.swift
index 21b4644..5347ceb 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -163,7 +163,7 @@ struct PanelContent {
}
for (index, row) in rows.enumerated() {
if index == separatorBefore { lines.append("---") }
- lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)")
+ lines.append(row.panelText)
}
if overflow > 0 { lines.append("+\(overflow) more waiting") }
if showsSummary {
@@ -291,8 +291,10 @@ final class ActionMenuItem: NSMenuItem {
@objc private func run() { handler() }
}
-/// One agent row: colored symbol and name, then a dim subtitle. Right-click
-/// offers "Mark as unread" for an idle agent.
+/// One agent row: colored symbol and name, then a dim line with where it
+/// runs, and at its right edge today's cost and a context meter, each in a
+/// fixed column so they line up down the list. Right-click offers "Mark as
+/// unread" for an idle agent.
final class PanelRowView: ClickView {
private let selected: Bool
private let contextMenu: NSMenu?
@@ -311,10 +313,35 @@ final class PanelRowView: ClickView {
self.onClick = { onClick(row.paneId) }
let title = singleLine(NSTextField(
labelWithAttributedString: titleText ?? rowTitle(row, primary: mochaText, secondary: mochaSubtext)))
- let subtitleLabel = NSTextField(labelWithString: row.subtitle)
- subtitleLabel.font = .systemFont(ofSize: 11)
- subtitleLabel.textColor = mochaSubtext
- let subtitle = singleLine(subtitleLabel)
+ let placeLabel = NSTextField(labelWithString: row.place)
+ placeLabel.font = .systemFont(ofSize: 11)
+ placeLabel.textColor = mochaSubtext
+ let place = singleLine(placeLabel)
+ let cost = NSTextField(labelWithString: row.cost >= 0.005 ? dollars(row.cost) : "")
+ cost.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular)
+ cost.textColor = mochaSubtext
+ cost.alignment = .right
+ let meter = ContextMeter(percent: row.context)
+ // The meter at the right edge, the cost before it; the place takes
+ // what is left and truncates.
+ let subtitle = NSView()
+ for view in [place, cost, meter] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ subtitle.addSubview(view)
+ }
+ NSLayoutConstraint.activate([
+ meter.trailingAnchor.constraint(equalTo: subtitle.trailingAnchor),
+ meter.centerYAnchor.constraint(equalTo: subtitle.centerYAnchor),
+ meter.widthAnchor.constraint(equalToConstant: ContextMeter.size.width),
+ meter.heightAnchor.constraint(equalToConstant: ContextMeter.size.height),
+ cost.trailingAnchor.constraint(equalTo: meter.leadingAnchor, constant: -6),
+ cost.widthAnchor.constraint(equalToConstant: 52),
+ cost.firstBaselineAnchor.constraint(equalTo: place.firstBaselineAnchor),
+ place.leadingAnchor.constraint(equalTo: subtitle.leadingAnchor),
+ place.trailingAnchor.constraint(lessThanOrEqualTo: cost.leadingAnchor, constant: -6),
+ place.topAnchor.constraint(equalTo: subtitle.topAnchor),
+ place.bottomAnchor.constraint(equalTo: subtitle.bottomAnchor),
+ ])
let stack = NSStackView(views: [title, subtitle])
stack.orientation = .vertical
stack.alignment = .leading
@@ -327,7 +354,7 @@ final class PanelRowView: ClickView {
stack.topAnchor.constraint(equalTo: topAnchor, constant: 5),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5),
title.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor),
- subtitle.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor),
+ subtitle.widthAnchor.constraint(equalTo: stack.widthAnchor),
])
toolTip = row.name.isEmpty ? nil : row.name
}
@@ -343,6 +370,38 @@ final class PanelRowView: ClickView {
}
}
+/// How full an agent's context window is: a small bar, gray, then yellow
+/// above 70% and red above 90% (as pi's footer); nothing when unknown.
+final class ContextMeter: NSView {
+ static let size = NSSize(width: 44, height: 5)
+ let percent: Double?
+
+ init(percent: Double?) {
+ self.percent = percent
+ super.init(frame: NSRect(origin: .zero, size: Self.size))
+ translatesAutoresizingMaskIntoConstraints = false
+ toolTip = percent.map { "\(contextText($0)) of context" }
+ }
+
+ required init?(coder: NSCoder) { fatalError("not used") }
+
+ /// The filled part: at least a dot, at most the whole bar.
+ static func fill(_ percent: Double, width: CGFloat) -> CGFloat {
+ max(size.height, min(width, width * CGFloat(percent) / 100))
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ guard let percent else { return }
+ let radius = bounds.height / 2
+ mochaSurface.setFill()
+ NSBezierPath(roundedRect: bounds, xRadius: radius, yRadius: radius).fill()
+ (contextWarning(percent) ?? mochaOverlay.blended(withFraction: 0.35, of: mochaSubtext) ?? mochaOverlay).setFill()
+ var filled = bounds
+ filled.size.width = Self.fill(percent, width: bounds.width)
+ NSBezierPath(roundedRect: filled, xRadius: radius, yRadius: radius).fill()
+ }
+}
+
/// The restore offer: "↺ Restore 9 sessions · 22 agents", what it was.
private final class OfferView: ClickView {
init(title: String, detail: String, onClick: @escaping () -> Void) {
@@ -718,12 +777,28 @@ func panelSelftest() -> [String] {
check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"),
"toggle offered only when allowed, labelled by layout")
+ // Context use.
+ var filling = row("f", .idle)
+ filling.cost = 3.12
+ filling.context = 78.4
+ check(filling.subtitle == "s · f · pi · $3.12 · 78%", "context: the menu's text, after the cost")
+ check(filling.place == "s · f · pi" && filling.panelText.hasSuffix("\ts · f · pi\t$3.12 78%"),
+ "context: the panel's columns")
+ check(contextWarning(70) == nil && contextWarning(70.5) == mochaYellow && contextWarning(90.5) == mochaRed,
+ "context: pi's colors, above 70% and 90%")
+ check(ContextMeter.fill(0, width: 44) == 5 && ContextMeter.fill(50, width: 44) == 22
+ && ContextMeter.fill(150, width: 44) == 44, "context: the bar's fill")
+ filling.context = nil
+ filling.cost = 0
+ check(filling.subtitle == "s · f · pi" && !filling.panelText.contains("%"), "context: unknown, nothing")
+
// Spend.
check(dollars(0.42) == "$0.42" && dollars(19.849) == "$19.85" && dollars(99.994) == "$99.99"
&& dollars(99.996) == "$100" && dollars(1967.14) == "$1,967", "spend: cents below $100, then whole dollars")
var spender = row("s", .done)
spender.cost = 19.85
- check(spender.subtitle.hasPrefix("s · s · pi · $19.85 · done "), "spend: today's in the subtitle, before the wait")
+ check(spender.subtitle.hasPrefix("s · s · pi · done ") && spender.subtitle.hasSuffix(" · $19.85"),
+ "spend: today's in the menu's subtitle, after the wait")
spender.cost = 0.004
check(!spender.subtitle.contains("$"), "spend: nothing spent, nothing shown")
check(Spend(today: 8.86, week: 456.87, month: 8.86).line == "$8.86 today · $457 this week · $8.86 this month"
diff --git a/menubar/search.swift b/menubar/search.swift
index ed9adaa..d1e745e 100644
--- a/menubar/search.swift
+++ b/menubar/search.swift
@@ -180,7 +180,7 @@ struct SearchState: Equatable {
for match in visible {
let row = match.row
let marker = row.paneId == selected ? "›" : " "
- lines.append("\(marker) \(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)")
+ lines.append("\(marker) \(row.panelText)")
}
lines.append(footer)
return lines.joined(separator: "\n")
diff --git a/tests/e2e.sh b/tests/e2e.sh
index e5b5865..b1709bc 100755
--- a/tests/e2e.sh
+++ b/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
diff --git a/tests/pi-live.sh b/tests/pi-live.sh
index 95c5885..6083be4 100755
--- a/tests/pi-live.sh
+++ b/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 @@ PORT=$(cat "$D/port")
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=$(ls -t "$(dirname "$SESSION_FILE")"/*.jsonl | head -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