notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
Mcrates/notedeck_dave/src/backend/claude.rs | 24++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 11+++++++++++
Mcrates/notedeck_dave/src/messages.rs | 23+++++++++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 3+++
Mcrates/notedeck_dave/src/ui/dave.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/notedeck_dave/src/ui/mod.rs | 7++++++-
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 {