notedeck

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

commit 9dbf828b2ca9b0a7b642e64818c3735e8331588e
parent 23c27ffb988062c1e6b9e01db9ea55a96f3b89af
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 17 Feb 2026 15:46:01 -0800

add source hostname to session state events and session list UI

Sessions now include a hostname tag in kind-31988 events, parsed on
restore, and displayed as hostname:cwd in the session list sidebar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
MCargo.lock | 13++++++++++++-
Mcrates/notedeck_dave/Cargo.toml | 1+
Mcrates/notedeck_dave/src/lib.rs | 35++++++++++++++++++++++++++++++++---
Mcrates/notedeck_dave/src/session.rs | 3+++
Mcrates/notedeck_dave/src/session_events.rs | 4++++
Mcrates/notedeck_dave/src/session_loader.rs | 2++
Mcrates/notedeck_dave/src/ui/session_list.rs | 20++++++++++++++------
Mcrates/notedeck_dave/src/update.rs | 6++++++
8 files changed, 74 insertions(+), 10 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2417,6 +2417,16 @@ dependencies = [ ] [[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.0.7", + "windows-link 0.2.1", +] + +[[package]] name = "getrandom" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4163,6 +4173,7 @@ dependencies = [ "egui_extras", "enostr", "futures", + "gethostname 1.1.0", "hex", "md-stream", "nostrdb", @@ -8397,7 +8408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" dependencies = [ "as-raw-xcb-connection", - "gethostname", + "gethostname 0.4.3", "libc", "libloading", "once_cell", diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml @@ -30,6 +30,7 @@ egui_extras = { workspace = true } md-stream = { workspace = true } similar = "2" dirs = "5" +gethostname = "1" [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] rfd = { workspace = true } diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -141,6 +141,8 @@ pub struct Dave { /// Sessions pending deletion state event publication. /// Populated in delete_session(), drained in the update loop where AppContext is available. pending_deletions: Vec<DeletedSessionInfo>, + /// Local machine hostname, included in session state events. + hostname: String, } /// A permission response queued for relay publishing. @@ -306,13 +308,20 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Create IPC listener for external spawn-agent commands let ipc_listener = ipc::create_listener(ctx); + let hostname = gethostname::gethostname() + .to_string_lossy() + .into_owned(); + // In Chat mode, create a default session immediately and skip directory picker // In Agentic mode, show directory picker on startup let (session_manager, active_overlay) = match ai_mode { AiMode::Chat => { let mut manager = SessionManager::new(); // Create a default session with current directory - manager.new_session(std::env::current_dir().unwrap_or_default(), ai_mode); + let sid = manager.new_session(std::env::current_dir().unwrap_or_default(), ai_mode); + if let Some(session) = manager.get_mut(sid) { + session.hostname = hostname.clone(); + } (manager, DaveOverlay::None) } AiMode::Agentic => (SessionManager::new(), DaveOverlay::DirectoryPicker), @@ -346,6 +355,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session_state_sub: None, pending_perm_responses: Vec::new(), pending_deletions: Vec::new(), + hostname, } } @@ -894,6 +904,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.show_scene, self.ai_mode, cwd, + &self.hostname, ); } @@ -913,6 +924,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr cwd, resume_session_id, title, + &self.hostname, ) } @@ -924,6 +936,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &mut self.scene, self.show_scene, self.ai_mode, + &self.hostname, ); } @@ -943,6 +956,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Focus on new session if let Some(session) = self.session_manager.get_mut(id) { + session.hostname = self.hostname.clone(); session.focus_requested = true; if self.show_scene { self.scene.select(id); @@ -1107,6 +1121,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &session.title, &cwd, status, + &self.hostname, &sk, ) { Ok(evt) => { @@ -1140,6 +1155,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &info.title, &info.cwd, "deleted", + &self.hostname, &sk, ) { Ok(evt) => { @@ -1265,6 +1281,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } let is_remote = session.is_remote(); + // Local sessions use the current machine's hostname; + // remote sessions use what was stored in the event. + session.hostname = if is_remote { + state.hostname.clone() + } else { + self.hostname.clone() + }; + if let Some(agentic) = &mut session.agentic { if let (Some(root), Some(last)) = (loaded.root_note_id, loaded.last_note_id) { agentic.live_threading.seed(root, last, loaded.event_count); @@ -1395,11 +1419,15 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .to_string(); let cwd_str = session_events::get_tag_value(&note, "cwd").unwrap_or(""); let cwd = std::path::PathBuf::from(cwd_str); + let hostname = session_events::get_tag_value(&note, "hostname") + .unwrap_or("") + .to_string(); tracing::info!( - "discovered new session from relay: '{}' ({})", + "discovered new session from relay: '{}' ({}) on {}", title, - claude_sid + claude_sid, + hostname, ); existing_ids.insert(claude_sid.to_string()); @@ -1415,6 +1443,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let loaded = session_loader::load_session_messages(ctx.ndb, &txn, claude_sid); if let Some(session) = self.session_manager.get_mut(dave_sid) { + session.hostname = hostname; if !loaded.messages.is_empty() { tracing::info!( "loaded {} messages for discovered session", diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -184,6 +184,8 @@ pub struct ChatSession { pub agentic: Option<AgenticSessionData>, /// Whether this session is local (has a Claude process) or remote (relay-only). pub source: SessionSource, + /// Hostname of the machine where this session originated. + pub hostname: String, } impl Drop for ChatSession { @@ -214,6 +216,7 @@ impl ChatSession { ai_mode, agentic, source: SessionSource::Local, + hostname: String::new(), } } diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -621,6 +621,7 @@ pub fn build_session_state_event( title: &str, cwd: &str, status: &str, + hostname: &str, secret_key: &[u8; 32], ) -> Result<BuiltEvent, EventBuildError> { let mut builder = init_note_builder(AI_SESSION_STATE_KIND, "", Some(now_secs())); @@ -632,6 +633,7 @@ pub fn build_session_state_event( builder = builder.start_tag().tag_str("title").tag_str(title); builder = builder.start_tag().tag_str("cwd").tag_str(cwd); builder = builder.start_tag().tag_str("status").tag_str(status); + builder = builder.start_tag().tag_str("hostname").tag_str(hostname); // Discoverability builder = builder.start_tag().tag_str("t").tag_str("ai-session-state"); @@ -1081,6 +1083,7 @@ mod tests { "Fix the login bug", "/tmp/project", "working", + "my-laptop", &sk, ) .unwrap(); @@ -1099,6 +1102,7 @@ mod tests { assert!(json.contains("Fix the login bug")); assert!(json.contains("working")); assert!(json.contains("/tmp/project")); + assert!(json.contains(r#""hostname","my-laptop"#)); } #[test] diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -234,6 +234,7 @@ pub struct SessionState { pub title: String, pub cwd: String, pub status: String, + pub hostname: String, } /// Load all session states from kind-31988 events in ndb. @@ -279,6 +280,7 @@ pub fn load_session_states(ndb: &Ndb, txn: &Transaction) -> Vec<SessionState> { .to_string(), cwd: get_tag_value(&note, "cwd").unwrap_or("").to_string(), status: get_tag_value(&note, "status").unwrap_or("idle").to_string(), + hostname: get_tag_value(&note, "hostname").unwrap_or("").to_string(), }); } diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs @@ -158,6 +158,7 @@ impl<'a> SessionListUi<'a> { ui, &session.title, cwd, + &session.hostname, is_active, shortcut_hint, session.status(), @@ -184,6 +185,7 @@ impl<'a> SessionListUi<'a> { ui: &mut egui::Ui, title: &str, cwd: &Path, + hostname: &str, is_active: bool, shortcut_hint: Option<usize>, status: AgentStatus, @@ -286,22 +288,28 @@ impl<'a> SessionListUi<'a> { // Draw cwd below title - only in Agentic mode if show_cwd { let cwd_pos = rect.left_center() + egui::vec2(text_start_x, 7.0); - cwd_ui(ui, cwd, cwd_pos, max_text_width); + cwd_ui(ui, cwd, hostname, cwd_pos, max_text_width); } response } } -/// Draw cwd text (monospace, weak+small) with clipping -fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, pos: egui::Pos2, max_width: f32) { - let cwd_text = cwd_path.to_string_lossy(); +/// Draw cwd text (monospace, weak+small) with clipping. +/// Shows "hostname:cwd" when hostname is non-empty. +fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, hostname: &str, pos: egui::Pos2, max_width: f32) { + let cwd_str = cwd_path.to_string_lossy(); + let display_text = if hostname.is_empty() { + cwd_str.to_string() + } else { + format!("{}:{}", hostname, cwd_str) + }; let cwd_font = egui::FontId::monospace(10.0); let cwd_color = ui.visuals().weak_text_color(); let cwd_galley = ui .painter() - .layout_no_wrap(cwd_text.to_string(), cwd_font.clone(), cwd_color); + .layout_no_wrap(display_text.clone(), cwd_font.clone(), cwd_color); if cwd_galley.size().x > max_width { let clip_rect = egui::Rect::from_min_size( @@ -317,7 +325,7 @@ fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, pos: egui::Pos2, max_width: f32) { ui.painter().text( pos, egui::Align2::LEFT_CENTER, - &cwd_text, + &display_text, cwd_font, cwd_color, ); diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs @@ -895,11 +895,13 @@ pub fn create_session_with_cwd( show_scene: bool, ai_mode: AiMode, cwd: PathBuf, + hostname: &str, ) -> SessionId { directory_picker.add_recent(cwd.clone()); let id = session_manager.new_session(cwd, ai_mode); if let Some(session) = session_manager.get_mut(id) { + session.hostname = hostname.to_string(); session.focus_requested = true; if show_scene { scene.select(id); @@ -922,11 +924,13 @@ pub fn create_resumed_session_with_cwd( cwd: PathBuf, resume_session_id: String, title: String, + hostname: &str, ) -> SessionId { directory_picker.add_recent(cwd.clone()); let id = session_manager.new_resumed_session(cwd, resume_session_id, title, ai_mode); if let Some(session) = session_manager.get_mut(id) { + session.hostname = hostname.to_string(); session.focus_requested = true; if show_scene { scene.select(id); @@ -945,6 +949,7 @@ pub fn clone_active_agent( scene: &mut AgentScene, show_scene: bool, ai_mode: AiMode, + hostname: &str, ) -> Option<SessionId> { let cwd = session_manager .get_active() @@ -956,6 +961,7 @@ pub fn clone_active_agent( show_scene, ai_mode, cwd, + hostname, )) }