commit b14c124c4e6224a9e6b7b56c0f76d66e26962bc1
parent e483cfd9e23dc0bf92b19ce19ed92c3848ec0607
Author: William Casarin <jb55@jb55.com>
Date: Tue, 24 Feb 2026 11:38:24 -0800
dave: add context window usage bar to status bar
Extract usage metrics (tokens, cost) from Claude's Result message
and display a fill bar in the status bar to the left of the PLAN/AUTO
badges. Bar color transitions green → yellow → red as context fills.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
6 files changed, 165 insertions(+), 5 deletions(-)
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -479,6 +479,30 @@ async fn session_actor(
.unwrap_or_else(|| "Unknown error".to_string());
let _ = response_tx.send(DaveApiResponse::Failed(error_text));
}
+
+ // Extract usage metrics
+ let (input_tokens, output_tokens) = result_msg
+ .usage
+ .as_ref()
+ .map(|u| {
+ let inp = u.get("input_tokens")
+ .and_then(|v| v.as_u64())
+ .unwrap_or(0);
+ let out = u.get("output_tokens")
+ .and_then(|v| v.as_u64())
+ .unwrap_or(0);
+ (inp, out)
+ })
+ .unwrap_or((0, 0));
+
+ let usage_info = crate::messages::UsageInfo {
+ input_tokens,
+ output_tokens,
+ cost_usd: result_msg.total_cost_usd,
+ num_turns: result_msg.num_turns,
+ };
+ let _ = response_tx.send(DaveApiResponse::QueryComplete(usage_info));
+
stream_done = true;
}
ClaudeMessage::User(user_msg) => {
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -856,6 +856,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
session.chat.push(Message::CompactionComplete(info));
}
+
+ DaveApiResponse::QueryComplete(info) => {
+ if let Some(agentic) = &mut session.agentic {
+ agentic.usage.input_tokens = info.input_tokens;
+ agentic.usage.output_tokens = info.output_tokens;
+ agentic.usage.num_turns = info.num_turns;
+ if let Some(cost) = info.cost_usd {
+ agentic.usage.cost_usd = Some(cost);
+ }
+ }
+ }
}
}
diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs
@@ -328,6 +328,27 @@ pub struct CompactionInfo {
pub pre_tokens: u64,
}
+/// Usage metrics from a completed query's Result message
+#[derive(Debug, Clone, Default)]
+pub struct UsageInfo {
+ pub input_tokens: u64,
+ pub output_tokens: u64,
+ pub cost_usd: Option<f64>,
+ pub num_turns: u32,
+}
+
+impl UsageInfo {
+ pub fn total_tokens(&self) -> u64 {
+ self.input_tokens + self.output_tokens
+ }
+}
+
+/// Get context window size for a model name.
+/// All current Claude models have 200K context.
+pub fn context_window_for_model(_model: Option<&str>) -> u64 {
+ 200_000
+}
+
/// The ai backends response. Since we are using streaming APIs these are
/// represented as individual tokens or tool calls
pub enum DaveApiResponse {
@@ -356,6 +377,8 @@ pub enum DaveApiResponse {
CompactionStarted,
/// Conversation compaction completed with token info
CompactionComplete(CompactionInfo),
+ /// Query completed with usage metrics
+ QueryComplete(UsageInfo),
}
impl Message {
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -210,6 +210,8 @@ pub struct AgenticSessionData {
pub seen_note_ids: HashSet<[u8; 32]>,
/// Tracks the "Compact & Approve" lifecycle.
pub compact_and_proceed: CompactAndProceedState,
+ /// Accumulated usage metrics across queries in this session.
+ pub usage: crate::messages::UsageInfo,
}
impl AgenticSessionData {
@@ -243,6 +245,7 @@ impl AgenticSessionData {
live_conversation_sub: None,
seen_note_ids: HashSet::new(),
compact_and_proceed: CompactAndProceedState::Idle,
+ usage: Default::default(),
}
}
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -63,6 +63,10 @@ pub struct DaveUi<'a> {
/// Color for the notification dot on the mobile hamburger icon,
/// derived from FocusPriority of the next focus queue entry.
status_dot_color: Option<egui::Color32>,
+ /// Usage metrics for the current session (tokens, cost)
+ usage: Option<&'a crate::messages::UsageInfo>,
+ /// Context window size for the current model
+ context_window: u64,
}
/// The response the app generates. The response contains an optional
@@ -173,6 +177,8 @@ impl<'a> DaveUi<'a> {
git_status: None,
details: None,
status_dot_color: None,
+ usage: None,
+ context_window: crate::messages::context_window_for_model(None),
}
}
@@ -248,6 +254,12 @@ impl<'a> DaveUi<'a> {
self
}
+ pub fn usage(mut self, usage: &'a crate::messages::UsageInfo, model: Option<&str>) -> Self {
+ self.usage = Some(usage);
+ self.context_window = crate::messages::context_window_for_model(model);
+ self
+ }
+
fn chat_margin(&self, ctx: &egui::Context) -> i8 {
if self.flags.contains(DaveUiFlags::Compact) || notedeck::ui::is_narrow(ctx) {
8
@@ -348,6 +360,8 @@ impl<'a> DaveUi<'a> {
is_agentic,
plan_mode_active,
auto_steal_focus,
+ self.usage,
+ self.context_window,
ui,
)
})
@@ -1281,6 +1295,8 @@ fn status_bar_ui(
is_agentic: bool,
plan_mode_active: bool,
auto_steal_focus: bool,
+ usage: Option<&crate::messages::UsageInfo>,
+ context_window: u64,
ui: &mut egui::Ui,
) -> Option<DaveAction> {
let snapshot = git_status
@@ -1295,19 +1311,25 @@ fn status_bar_ui(
if let Some(git_status) = git_status.as_deref_mut() {
git_status_ui::git_status_content_ui(git_status, &snapshot, ui);
- // Right-aligned section: badges then refresh
+ // Right-aligned section: usage bar, badges, then refresh
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- if is_agentic {
+ let badge_action = if is_agentic {
toggle_badges_ui(ui, plan_mode_active, auto_steal_focus)
} else {
None
+ };
+ if is_agentic {
+ usage_bar_ui(usage, context_window, ui);
}
+ badge_action
})
.inner
} else if is_agentic {
- // No git status (remote session) - just show badges
+ // No git status (remote session) - just show badges and usage
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- toggle_badges_ui(ui, plan_mode_active, auto_steal_focus)
+ let badge_action = toggle_badges_ui(ui, plan_mode_active, auto_steal_focus);
+ usage_bar_ui(usage, context_window, ui);
+ badge_action
})
.inner
} else {
@@ -1325,6 +1347,78 @@ fn status_bar_ui(
.inner
}
+/// Format a token count in a compact human-readable form (e.g. "45K", "1.2M")
+fn format_tokens(tokens: u64) -> String {
+ if tokens >= 1_000_000 {
+ format!("{:.1}M", tokens as f64 / 1_000_000.0)
+ } else if tokens >= 1_000 {
+ format!("{}K", tokens / 1_000)
+ } else {
+ tokens.to_string()
+ }
+}
+
+/// Renders the usage fill bar showing context window consumption.
+fn usage_bar_ui(
+ usage: Option<&crate::messages::UsageInfo>,
+ context_window: u64,
+ ui: &mut egui::Ui,
+) {
+ let total = usage.map(|u| u.total_tokens()).unwrap_or(0);
+ if total == 0 {
+ return;
+ }
+ let usage = usage.unwrap();
+ let fraction = (total as f64 / context_window as f64).min(1.0) as f32;
+
+ // Color based on fill level: green → yellow → red
+ let bar_color = if fraction < 0.5 {
+ egui::Color32::from_rgb(100, 180, 100)
+ } else if fraction < 0.8 {
+ egui::Color32::from_rgb(200, 180, 60)
+ } else {
+ egui::Color32::from_rgb(200, 80, 80)
+ };
+
+ let weak = ui.visuals().weak_text_color();
+
+ // Cost label
+ if let Some(cost) = usage.cost_usd {
+ if cost > 0.0 {
+ ui.add(egui::Label::new(
+ egui::RichText::new(format!("${:.2}", cost))
+ .size(10.0)
+ .color(weak),
+ ));
+ }
+ }
+
+ // Token count label
+ ui.add(egui::Label::new(
+ egui::RichText::new(format!(
+ "{} / {}",
+ format_tokens(total),
+ format_tokens(context_window)
+ ))
+ .size(10.0)
+ .color(weak),
+ ));
+
+ // Fill bar
+ let bar_width = 60.0;
+ let bar_height = 8.0;
+ let (rect, _) = ui.allocate_exact_size(egui::vec2(bar_width, bar_height), egui::Sense::hover());
+ let painter = ui.painter_at(rect);
+
+ // Background
+ painter.rect_filled(rect, 3.0, ui.visuals().faint_bg_color);
+
+ // Fill
+ let fill_rect =
+ egui::Rect::from_min_size(rect.min, egui::vec2(bar_width * fraction, bar_height));
+ painter.rect_filled(fill_rect, 3.0, bar_color);
+}
+
/// Render clickable PLAN and AUTO toggle badges. Returns an action if clicked.
fn toggle_badges_ui(
ui: &mut egui::Ui,
diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -67,11 +67,16 @@ fn build_dave_ui<'a>(
.details(&session.details);
if let Some(agentic) = &mut session.agentic {
+ let model = agentic
+ .session_info
+ .as_ref()
+ .and_then(|si| si.model.as_deref());
ui_builder = ui_builder
.permission_message_state(agentic.permission_message_state)
.question_answers(&mut agentic.question_answers)
.question_index(&mut agentic.question_index)
- .is_compacting(agentic.is_compacting);
+ .is_compacting(agentic.is_compacting)
+ .usage(&agentic.usage, model);
// Only show git status for local sessions
if !is_remote {