notedeck

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

commit 16e2d3839d86dbc20b7185ce121064c9a40feeab
parent e22a1030de64335124ed26565fdf739321ae3efa
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 26 Feb 2026 12:18:36 -0800

Merge branches 'people-lists' and 'dave'

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 2+-
Mcrates/notedeck_dave/src/session.rs | 16++++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 3++-
Mcrates/notedeck_dave/src/ui/session_list.rs | 11+++++++----
Mcrates/notedeck_dave/src/update.rs | 6+++---
6 files changed, 67 insertions(+), 9 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -2260,7 +2260,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } UiActionResult::Compact => { if let Some(session) = self.session_manager.get_active() { - let session_id = session.id.to_string(); + let session_id = format!("dave-session-{}", session.id); if let Some(rx) = get_backend(&self.backends, bt) .compact_session(session_id, ui.ctx().clone()) { diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::mpsc::Receiver; +use std::time::Instant; use crate::agent_status::AgentStatus; use crate::backend::BackendType; @@ -383,6 +384,8 @@ pub struct ChatSession { pub details: SessionDetails, /// Which backend this session uses (Claude, Codex, etc.) pub backend_type: BackendType, + /// When the last AI response token was received (for "5m ago" display) + pub last_activity: Option<Instant>, } impl Drop for ChatSession { @@ -428,6 +431,7 @@ impl ChatSession { .unwrap_or_default(), }, backend_type, + last_activity: None, } } @@ -833,6 +837,17 @@ impl SessionManager { &self.chat_ids } + /// Session IDs in visual/display order (host groups then chats). + /// Keybinding numbers (Ctrl+1-9) map to this order. + pub fn visual_order(&self) -> Vec<SessionId> { + let mut ids = Vec::new(); + for (_, group_ids) in &self.host_groups { + ids.extend_from_slice(group_ids); + } + ids.extend_from_slice(&self.chat_ids); + ids + } + /// Get a session's index in the recency-ordered list (for keyboard shortcuts). pub fn session_index(&self, id: SessionId) -> Option<usize> { self.order.iter().position(|&oid| oid == id) @@ -894,6 +909,7 @@ impl ChatSession { pub fn append_token(&mut self, token: &str) { // Content arrived — transition AwaitingResponse → Streaming. self.dispatch_state.backend_responded(); + self.last_activity = Some(Instant::now()); // Fast path: last message is the active assistant response if let Some(Message::Assistant(msg)) = self.chat.last_mut() { diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -74,6 +74,8 @@ pub struct DaveUi<'a> { backend_type: BackendType, /// Current permission mode (Default, Plan, AcceptEdits) permission_mode: PermissionMode, + /// When the last AI response token was received + last_activity: Option<std::time::Instant>, } /// The response the app generates. The response contains an optional @@ -191,9 +193,15 @@ impl<'a> DaveUi<'a> { dispatch_state: crate::session::DispatchState::default(), backend_type: BackendType::Remote, permission_mode: PermissionMode::Default, + last_activity: None, } } + pub fn last_activity(mut self, instant: Option<std::time::Instant>) -> Self { + self.last_activity = instant; + self + } + pub fn backend_type(mut self, bt: BackendType) -> Self { self.backend_type = bt; self @@ -384,6 +392,7 @@ impl<'a> DaveUi<'a> { auto_steal_focus, self.usage, self.context_window, + self.last_activity, ui, ) }) @@ -1410,6 +1419,20 @@ fn add_msg_link(ui: &mut egui::Ui, shift_held: bool, action: &mut Option<DaveAct } } +/// Format an Instant as a relative time string (e.g. "just now", "3m ago"). +fn format_relative_time(instant: std::time::Instant) -> String { + let elapsed = instant.elapsed().as_secs(); + if elapsed < 60 { + "just now".to_string() + } else if elapsed < 3600 { + format!("{}m ago", elapsed / 60) + } else if elapsed < 86400 { + format!("{}h ago", elapsed / 3600) + } else { + format!("{}d ago", elapsed / 86400) + } +} + /// Renders the status bar containing git status and toggle badges. fn status_bar_ui( mut git_status: Option<&mut GitStatusCache>, @@ -1418,6 +1441,7 @@ fn status_bar_ui( auto_steal_focus: bool, usage: Option<&crate::messages::UsageInfo>, context_window: u64, + last_activity: Option<std::time::Instant>, ui: &mut egui::Ui, ) -> Option<DaveAction> { let snapshot = git_status @@ -1440,6 +1464,13 @@ fn status_bar_ui( None }; if is_agentic { + if let Some(instant) = last_activity { + ui.label( + egui::RichText::new(format_relative_time(instant)) + .size(10.0) + .color(ui.visuals().weak_text_color()), + ); + } usage_bar_ui(usage, context_window, ui); } badge_action @@ -1449,6 +1480,13 @@ fn status_bar_ui( // No git status (remote session) - just show badges and usage ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let badge_action = toggle_badges_ui(ui, permission_mode, auto_steal_focus); + if let Some(instant) = last_activity { + ui.label( + egui::RichText::new(format_relative_time(instant)) + .size(10.0) + .color(ui.visuals().weak_text_color()), + ); + } usage_bar_ui(usage, context_window, ui); badge_action }) diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -69,7 +69,8 @@ fn build_dave_ui<'a>( .is_remote(is_remote) .dispatch_state(session.dispatch_state) .details(&session.details) - .backend_type(session.backend_type); + .backend_type(session.backend_type) + .last_activity(session.last_activity); if let Some(agentic) = &mut session.agentic { let model = agentic diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs @@ -92,6 +92,7 @@ impl<'a> SessionListUi<'a> { fn sessions_list_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> { let mut action = None; let active_id = self.session_manager.active_id(); + let mut visual_index: usize = 0; // Agents grouped by hostname (pre-computed, no per-frame allocation) for (hostname, ids) in self.session_manager.host_groups() { @@ -108,10 +109,11 @@ impl<'a> SessionListUi<'a> { ui.add_space(4.0); for &id in ids { if let Some(session) = self.session_manager.get(id) { - let index = self.session_manager.session_index(id).unwrap_or(0); - if let Some(a) = self.render_session_item(ui, session, index, active_id) { + if let Some(a) = self.render_session_item(ui, session, visual_index, active_id) + { action = Some(a); } + visual_index += 1; } } ui.add_space(8.0); @@ -128,10 +130,11 @@ impl<'a> SessionListUi<'a> { ui.add_space(4.0); for &id in chat_ids { if let Some(session) = self.session_manager.get(id) { - let index = self.session_manager.session_index(id).unwrap_or(0); - if let Some(a) = self.render_session_item(ui, session, index, active_id) { + if let Some(a) = self.render_session_item(ui, session, visual_index, active_id) + { action = Some(a); } + visual_index += 1; } } } diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs @@ -406,14 +406,14 @@ pub fn switch_and_focus_session( } } -/// Switch to agent by index in the ordered list (0-indexed). +/// Switch to agent by index in the visual display order (0-indexed). pub fn switch_to_agent_by_index( session_manager: &mut SessionManager, scene: &mut AgentScene, show_scene: bool, index: usize, ) { - let ids = session_manager.session_ids(); + let ids = session_manager.visual_order(); if let Some(&id) = ids.get(index) { switch_and_focus_session(session_manager, scene, show_scene, id); } @@ -426,7 +426,7 @@ fn cycle_agent( show_scene: bool, index_fn: impl FnOnce(usize, usize) -> usize, ) { - let ids = session_manager.session_ids(); + let ids = session_manager.visual_order(); if ids.is_empty() { return; }