notedeck

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

commit 2a2bbd468643cc442175cfa395b3ae2a0f261838
parent 1e64c599ae55e41a673c4b0a72ea9dbd1fedb9d6
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 26 Feb 2026 10:42:06 -0800

dave: remote session creation via host picker and spawn commands

Add the ability to create sessions on remote hosts from the phone/local
client. When remote hosts are known (from active sessions or historical
session state events), a host picker overlay appears before the directory
picker, letting the user choose where to create the session.

- Seed per-host recent paths from kind-31988 session state events
- Remote-aware directory picker (no browse button, event-sourced paths)
- Host picker UI with Ctrl+1-9 keyboard shortcuts
- Kind-31989 spawn command events (fire-and-forget to remote hosts)
- Remote session duplication via spawn commands
- Route NewAgent/NewChat through handle_new_chat() for consistent flow

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

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/notedeck_dave/src/session_events.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session_loader.rs | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck_dave/src/ui/directory_picker.rs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Acrates/notedeck_dave/src/ui/host_picker.rs | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 40+++++++++++++++++++++++++++++++---------
6 files changed, 604 insertions(+), 81 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -38,7 +38,7 @@ use notedeck::{ AppContext, AppResponse, DataPath, DataPathType, }; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::string::ToString; use std::sync::Arc; use std::time::Instant; @@ -91,6 +91,13 @@ fn secret_key_bytes(keypair: KeypairUnowned<'_>) -> Option<[u8; 32]> { }) } +/// A pending spawn command waiting to be built and published. +struct PendingSpawnCommand { + target_host: String, + cwd: PathBuf, + backend: BackendType, +} + /// Represents which full-screen overlay (if any) is currently active. /// Data-carrying variants hold the state needed for that step in the /// session-creation flow, replacing scattered `pending_*` fields. @@ -99,6 +106,7 @@ pub enum DaveOverlay { #[default] None, Settings, + HostPicker, DirectoryPicker, /// Backend has been chosen; showing resumable-session list. SessionPicker { @@ -166,6 +174,12 @@ pub struct Dave { /// Local ndb subscription for kind-31988 session state events. /// Fires when new session states are unwrapped from PNS events. session_state_sub: Option<nostrdb::Subscription>, + /// Local ndb subscription for kind-31989 session command events. + session_command_sub: Option<nostrdb::Subscription>, + /// Command UUIDs already processed (dedup for spawn commands). + processed_commands: std::collections::HashSet<String>, + /// Spawn commands waiting to be built+published in update() where secret key is available. + pending_spawn_commands: Vec<PendingSpawnCommand>, /// Permission responses queued for relay publishing (from remote sessions). /// Built and published in the update loop where AppContext is available. pending_perm_responses: Vec<PermissionPublish>, @@ -481,6 +495,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr sessions_restored: false, pns_relay_sub: None, session_state_sub: None, + session_command_sub: None, + processed_commands: std::collections::HashSet::new(), + pending_spawn_commands: Vec::new(), pending_perm_responses: Vec::new(), pending_deletions: Vec::new(), pending_summaries: Vec::new(), @@ -775,15 +792,45 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } return DaveResponse::default(); } + DaveOverlay::HostPicker => { + let has_sessions = !self.session_manager.is_empty(); + let known_hosts = self.known_remote_hosts(); + match ui::host_picker_overlay_ui(&self.hostname, &known_hosts, has_sessions, ui) { + OverlayResult::HostSelected(host) => { + self.directory_picker.target_host = host; + self.active_overlay = DaveOverlay::DirectoryPicker; + } + OverlayResult::Close => {} + _ => { + self.active_overlay = DaveOverlay::HostPicker; + } + } + return DaveResponse::default(); + } DaveOverlay::DirectoryPicker => { let has_sessions = !self.session_manager.is_empty(); match ui::directory_picker_overlay_ui(&mut self.directory_picker, has_sessions, ui) { OverlayResult::DirectorySelected(path) => { - tracing::info!("directory selected: {:?}", path); - self.create_or_pick_backend(path); + if let Some(target_host) = self.directory_picker.target_host.take() { + tracing::info!( + "remote directory selected: {:?} on {}", + path, + target_host + ); + self.queue_spawn_command( + &target_host, + &path, + self.model_config.backend, + ); + } else { + tracing::info!("directory selected: {:?}", path); + self.create_or_pick_backend(path); + } + } + OverlayResult::Close => { + self.directory_picker.target_host = None; } - OverlayResult::Close => {} _ => { self.active_overlay = DaveOverlay::DirectoryPicker; } @@ -975,10 +1022,38 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.create_session_with_cwd(cwd, self.model_config.backend); } AiMode::Agentic => { - // In agentic mode, show the directory picker to select a working directory - self.active_overlay = DaveOverlay::DirectoryPicker; + // If remote hosts are known, show host picker first + if !self.known_remote_hosts().is_empty() { + self.active_overlay = DaveOverlay::HostPicker; + } else { + self.directory_picker.target_host = None; + self.active_overlay = DaveOverlay::DirectoryPicker; + } + } + } + } + + /// Collect remote hostnames from session host_groups and directory picker's + /// event-sourced paths. Excludes the local hostname. + fn known_remote_hosts(&self) -> Vec<String> { + let mut hosts: Vec<String> = Vec::new(); + + // From active session groups + for (hostname, _) in self.session_manager.host_groups() { + if hostname != &self.hostname && !hosts.contains(hostname) { + hosts.push(hostname.clone()); + } + } + + // From event-sourced paths (may include hosts with no active sessions) + for hostname in self.directory_picker.host_recent_paths.keys() { + if hostname != &self.hostname && !hosts.contains(hostname) { + hosts.push(hostname.clone()); } } + + hosts.sort(); + hosts } /// Create a new session with the given cwd (called after directory picker selection) @@ -1019,6 +1094,20 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// Clone the active agent, creating a new session with the same working directory fn clone_active_agent(&mut self) { + let Some(active) = self.session_manager.get_active() else { + return; + }; + + // If the active session is remote, send a spawn command to its host + if active.is_remote() { + if let Some(cwd) = active.cwd().cloned() { + let host = active.details.hostname.clone(); + let backend = active.backend_type; + self.queue_spawn_command(&host, &cwd, backend); + return; + } + } + update::clone_active_agent( &mut self.session_manager, &mut self.directory_picker, @@ -1416,6 +1505,11 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.session_manager.rebuild_host_groups(); + // Seed per-host recent paths from session state events + let host_paths = session_loader::load_recent_paths_by_host(ctx.ndb, &txn); + self.directory_picker + .seed_host_paths(host_paths, &self.hostname); + // Skip the directory picker since we restored sessions self.active_overlay = DaveOverlay::None; } @@ -1554,6 +1648,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr existing_ids.insert(claude_sid.to_string()); + // Track this host+cwd for the directory picker + if !state.cwd.is_empty() { + self.directory_picker + .add_host_path(&state.hostname, PathBuf::from(&state.cwd)); + } + let backend = state .backend .as_deref() @@ -1616,6 +1716,67 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Poll for kind-31989 spawn command events. + /// + /// When a remote device wants to create a session on this host, it publishes + /// a kind-31989 event with `target_host` matching our hostname. We pick it up + /// here and create the session locally. + fn poll_session_command_events(&mut self, ctx: &mut AppContext<'_>) { + let Some(sub) = self.session_command_sub else { + return; + }; + + let note_keys = ctx.ndb.poll_for_notes(sub, 16); + if note_keys.is_empty() { + return; + } + + let txn = match Transaction::new(ctx.ndb) { + Ok(t) => t, + Err(_) => return, + }; + + for key in note_keys { + let Ok(note) = ctx.ndb.get_note_by_key(&txn, key) else { + continue; + }; + + let Some(command_id) = session_events::get_tag_value(&note, "d") else { + continue; + }; + + // Dedup: skip already-processed commands + if self.processed_commands.contains(command_id) { + continue; + } + + let command = session_events::get_tag_value(&note, "command").unwrap_or(""); + if command != "spawn_session" { + continue; + } + + let target = session_events::get_tag_value(&note, "target_host").unwrap_or(""); + if target != self.hostname { + continue; + } + + let cwd = session_events::get_tag_value(&note, "cwd").unwrap_or(""); + let backend_str = session_events::get_tag_value(&note, "backend").unwrap_or(""); + let backend = + BackendType::from_tag_str(backend_str).unwrap_or(self.model_config.backend); + + tracing::info!( + "received spawn command {}: cwd={}, backend={:?}", + command_id, + cwd, + backend + ); + + self.processed_commands.insert(command_id.to_string()); + self.create_session_with_cwd(PathBuf::from(cwd), backend); + } + } + /// Poll for new kind-1988 conversation events. /// /// For remote sessions: process all roles (user, assistant, tool_call, etc.) @@ -1918,8 +2079,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } - /// Create a session with the given cwd, or show the backend picker if - /// multiple agentic backends are available. + /// Queue a spawn command request. The event is built and published in + /// update() where AppContext (and thus the secret key) is available. + fn queue_spawn_command(&mut self, target_host: &str, cwd: &Path, backend: BackendType) { + tracing::info!("queuing spawn command for {} at {:?}", target_host, cwd); + self.pending_spawn_commands.push(PendingSpawnCommand { + target_host: target_host.to_string(), + cwd: cwd.to_path_buf(), + backend, + }); + } + fn create_or_pick_backend(&mut self, cwd: PathBuf) { tracing::info!( "create_or_pick_backend: {} available backends: {:?}", @@ -1989,7 +2159,6 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.show_scene, self.auto_steal_focus, &mut self.home_session, - &mut self.active_overlay, ui.ctx(), ) { KeyActionResult::ToggleView => { @@ -2001,6 +2170,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr KeyActionResult::CloneAgent => { self.clone_active_agent(); } + KeyActionResult::NewAgent => { + self.handle_new_chat(); + } KeyActionResult::DeleteSession(id) => { self.delete_session(id); } @@ -2082,6 +2254,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.auto_steal_focus = new_state; None } + UiActionResult::NewChat => { + self.handle_new_chat(); + None + } UiActionResult::Compact => { if let Some(session) = self.session_manager.get_active() { let session_id = session.id.to_string(); @@ -2456,6 +2632,20 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr tracing::warn!("failed to subscribe for session state events: {:?}", e); } } + + // Local: subscribe in ndb for kind-31989 session command events + let cmd_filter = nostrdb::Filter::new() + .kinds([session_events::AI_SESSION_COMMAND_KIND as u64]) + .build(); + match ctx.ndb.subscribe(&[cmd_filter]) { + Ok(sub) => { + self.session_command_sub = Some(sub); + tracing::info!("subscribed for session command events in ndb"); + } + Err(e) => { + tracing::warn!("failed to subscribe for session command events: {:?}", e); + } + } } } } @@ -2488,6 +2678,9 @@ impl notedeck::App for Dave { // Poll for new session states from PNS-unwrapped relay events self.poll_session_state_events(ctx); + // Poll for spawn commands targeting this host + self.poll_session_command_events(ctx); + // Poll for live conversation events on all sessions. // Returns user messages from remote clients that need backend dispatch. // Only dispatch if the session isn't already streaming a response — @@ -2543,6 +2736,23 @@ impl notedeck::App for Dave { // Build permission response events from remote sessions self.publish_pending_perm_responses(ctx); + // Build spawn command events (need secret key from AppContext) + if !self.pending_spawn_commands.is_empty() { + if let Some(sk) = secret_key_bytes(ctx.accounts.get_selected_account().keypair()) { + for cmd in std::mem::take(&mut self.pending_spawn_commands) { + match session_events::build_spawn_command_event( + &cmd.target_host, + &cmd.cwd.to_string_lossy(), + cmd.backend.as_str(), + &sk, + ) { + Ok(evt) => self.pending_relay_events.push(evt), + Err(e) => tracing::warn!("failed to build spawn command: {:?}", e), + } + } + } + } + // PNS-wrap and publish events to relays let pending = std::mem::take(&mut self.pending_relay_events); let all_events = events_to_publish.iter().chain(pending.iter()); diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -21,6 +21,11 @@ pub const AI_SOURCE_DATA_KIND: u32 = 1989; /// `d` tag = claude_session_id. pub const AI_SESSION_STATE_KIND: u32 = 31988; +/// Nostr event kind for AI session commands (parameterized replaceable, NIP-33). +/// Fire-and-forget commands to create sessions on remote hosts. +/// `d` tag = command UUID. +pub const AI_SESSION_COMMAND_KIND: u32 = 31989; + /// Extract the value of a named tag from a note. pub fn get_tag_value<'a>(note: &'a nostrdb::Note<'a>, tag_name: &str) -> Option<&'a str> { for tag in note.tags() { @@ -767,6 +772,43 @@ pub fn build_session_state_event( finalize_built_event(builder, secret_key, AI_SESSION_STATE_KIND) } +/// Build a kind-31989 spawn command event. +/// +/// This is a fire-and-forget command that tells a remote host to create a new +/// session. The target host discovers the command via its ndb subscription, +/// creates the session locally, and publishes a kind-31988 state event. +pub fn build_spawn_command_event( + target_host: &str, + cwd: &str, + backend: &str, + secret_key: &[u8; 32], +) -> Result<BuiltEvent, EventBuildError> { + let command_id = uuid::Uuid::new_v4().to_string(); + let mut builder = init_note_builder(AI_SESSION_COMMAND_KIND, "", Some(now_secs())); + + builder = builder.start_tag().tag_str("d").tag_str(&command_id); + builder = builder + .start_tag() + .tag_str("command") + .tag_str("spawn_session"); + builder = builder + .start_tag() + .tag_str("target_host") + .tag_str(target_host); + builder = builder.start_tag().tag_str("cwd").tag_str(cwd); + builder = builder.start_tag().tag_str("backend").tag_str(backend); + builder = builder + .start_tag() + .tag_str("t") + .tag_str("ai-session-command"); + builder = builder + .start_tag() + .tag_str("source") + .tag_str("notedeck-dave"); + + finalize_built_event(builder, secret_key, AI_SESSION_COMMAND_KIND) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -10,7 +10,7 @@ use crate::session_events::{get_tag_value, is_conversation_role, AI_CONVERSATION use crate::tools::ToolResponse; use crate::Message; use nostrdb::{Filter, Ndb, NoteKey, Transaction}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; /// Query replaceable events via `ndb.fold`, deduplicating by `d` tag. /// @@ -350,6 +350,64 @@ pub fn latest_valid_session( SessionState::from_note(note, Some(session_id)) } +/// Extract recent working directories grouped by hostname from kind-31988 +/// session state events. +/// +/// Returns up to `MAX_RECENT_PER_HOST` unique paths per hostname, ordered +/// by most recently seen first. Useful for populating the directory picker +/// with previously used paths (both local and remote hosts). +pub fn load_recent_paths_by_host( + ndb: &Ndb, + txn: &Transaction, +) -> HashMap<String, Vec<std::path::PathBuf>> { + use crate::session_events::AI_SESSION_STATE_KIND; + + const MAX_RECENT_PER_HOST: usize = 10; + + let filter = Filter::new().kinds([AI_SESSION_STATE_KIND as u64]).build(); + + let is_valid = |note: &nostrdb::Note| { + if get_tag_value(note, "status") == Some("deleted") { + return false; + } + if note.content().starts_with('{') { + return false; + } + true + }; + + let note_keys = query_replaceable_filtered(ndb, txn, &[filter], is_valid); + + // Collect (hostname, cwd, created_at) triples + let mut entries: Vec<(String, String, u64)> = Vec::new(); + for key in note_keys { + let Ok(note) = ndb.get_note_by_key(txn, key) else { + continue; + }; + let hostname = get_tag_value(&note, "hostname").unwrap_or("").to_string(); + let cwd = get_tag_value(&note, "cwd").unwrap_or("").to_string(); + if cwd.is_empty() { + continue; + } + entries.push((hostname, cwd, note.created_at())); + } + + // Sort by created_at descending (most recent first) + entries.sort_by(|a, b| b.2.cmp(&a.2)); + + // Group by hostname, dedup cwds, cap per host + let mut result: HashMap<String, Vec<std::path::PathBuf>> = HashMap::new(); + for (hostname, cwd, _) in entries { + let paths = result.entry(hostname).or_default(); + let path = std::path::PathBuf::from(&cwd); + if !paths.contains(&path) && paths.len() < MAX_RECENT_PER_HOST { + paths.push(path); + } + } + + result +} + pub(crate) fn truncate(s: &str, max_chars: usize) -> String { if s.chars().count() <= max_chars { s.to_string() diff --git a/crates/notedeck_dave/src/ui/directory_picker.rs b/crates/notedeck_dave/src/ui/directory_picker.rs @@ -1,6 +1,7 @@ use crate::path_utils::abbreviate_path; use crate::ui::keybind_hint::paint_keybind_hint; use egui::{RichText, Vec2}; +use std::collections::HashMap; use std::path::PathBuf; /// Maximum number of recent directories to store @@ -27,6 +28,10 @@ pub struct DirectoryPicker { path_input: String, /// Pending async folder picker result pending_folder_pick: Option<std::sync::mpsc::Receiver<Option<PathBuf>>>, + /// When set, the picker targets a remote host (no browse, event-sourced paths). + pub target_host: Option<String>, + /// Per-host recent paths extracted from kind-31988 session state events. + pub host_recent_paths: HashMap<String, Vec<PathBuf>>, } impl Default for DirectoryPicker { @@ -42,6 +47,8 @@ impl DirectoryPicker { is_open: false, path_input: String::new(), pending_folder_pick: None, + target_host: None, + host_recent_paths: HashMap::new(), } } @@ -57,6 +64,32 @@ impl DirectoryPicker { self.pending_folder_pick = None; } + /// Populate per-host recent paths from session state events. + /// Also seeds `recent_directories` from the local host's paths. + pub fn seed_host_paths(&mut self, paths: HashMap<String, Vec<PathBuf>>, local_hostname: &str) { + // Seed local recent_directories from this host's event-sourced paths + if let Some(local_paths) = paths.get(local_hostname) { + for path in local_paths { + if !self.recent_directories.contains(path) { + self.recent_directories.push(path.clone()); + } + } + self.recent_directories.truncate(MAX_RECENT_DIRECTORIES); + } + self.host_recent_paths = paths; + } + + /// Record a path for a specific host (called when new session state events arrive). + pub fn add_host_path(&mut self, hostname: &str, path: PathBuf) { + let paths = self + .host_recent_paths + .entry(hostname.to_string()) + .or_default(); + paths.retain(|p| p != &path); + paths.insert(0, path); + paths.truncate(MAX_RECENT_DIRECTORIES); + } + /// Add a directory to the recent list pub fn add_recent(&mut self, path: PathBuf) { // Remove if already exists (we'll re-add at front) @@ -105,11 +138,22 @@ impl DirectoryPicker { let mut action = None; let is_narrow = notedeck::ui::is_narrow(ui.ctx()); let ctrl_held = ui.input(|i| i.modifiers.ctrl); + let is_remote = self.target_host.is_some(); + + // Choose which path list to display based on target host + let display_paths: &[PathBuf] = if let Some(ref host) = self.target_host { + self.host_recent_paths + .get(host) + .map(|v| v.as_slice()) + .unwrap_or(&[]) + } else { + &self.recent_directories + }; // Handle keyboard shortcuts for recent directories (Ctrl+1-9) // Only trigger when Ctrl is held to avoid intercepting TextEdit input if ctrl_held { - for (idx, path) in self.recent_directories.iter().take(9).enumerate() { + for (idx, path) in display_paths.iter().take(9).enumerate() { let key = match idx { 0 => egui::Key::Num1, 1 => egui::Key::Num2, @@ -148,7 +192,11 @@ impl DirectoryPicker { } ui.add_space(16.0); } - ui.heading("Select Working Directory"); + if let Some(ref host) = self.target_host { + ui.heading(format!("Select Directory on {}", host)); + } else { + ui.heading("Select Working Directory"); + } }); ui.add_space(16.0); @@ -166,7 +214,7 @@ impl DirectoryPicker { egui::Layout::top_down(egui::Align::LEFT), |ui| { // Recent directories section - if !self.recent_directories.is_empty() { + if !display_paths.is_empty() { ui.label(RichText::new("Recent Directories").strong()); ui.add_space(8.0); @@ -180,9 +228,7 @@ impl DirectoryPicker { egui::ScrollArea::vertical() .max_height(scroll_height) .show(ui, |ui| { - for (idx, path) in - self.recent_directories.clone().iter().enumerate() - { + for (idx, path) in display_paths.iter().enumerate() { let display = abbreviate_path(path); // Full-width button style with larger touch targets on mobile @@ -233,14 +279,16 @@ impl DirectoryPicker { ui.add_space(12.0); } - // Browse button (larger touch target on mobile) - ui.horizontal(|ui| { - let browse_button = - egui::Button::new(RichText::new("Browse...").size(if is_narrow { - 16.0 - } else { - 14.0 - })) + // Browse button — only for local targets (can't browse remote fs) + if !is_remote { + ui.horizontal(|ui| { + let browse_button = egui::Button::new( + RichText::new("Browse...").size(if is_narrow { + 16.0 + } else { + 14.0 + }), + ) .min_size(Vec2::new( if is_narrow { ui.available_width() - 28.0 @@ -250,59 +298,60 @@ impl DirectoryPicker { if is_narrow { 48.0 } else { 32.0 }, )); - let response = ui.add(browse_button); - - // Show keybind hint when Ctrl is held - if ctrl_held { - let hint_center = - response.rect.right_center() + egui::vec2(14.0, 0.0); - paint_keybind_hint(ui, hint_center, "B", 18.0); - } + let response = ui.add(browse_button); + + // Show keybind hint when Ctrl is held + if ctrl_held { + let hint_center = + response.rect.right_center() + egui::vec2(14.0, 0.0); + paint_keybind_hint(ui, hint_center, "B", 18.0); + } + + #[cfg(any( + target_os = "windows", + target_os = "macos", + target_os = "linux" + ))] + if response + .on_hover_text("Open folder picker dialog (B)") + .clicked() + || trigger_browse + { + // Spawn async folder picker + let (tx, rx) = std::sync::mpsc::channel(); + let ctx_clone = ui.ctx().clone(); + std::thread::spawn(move || { + let result = rfd::FileDialog::new().pick_folder(); + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + self.pending_folder_pick = Some(rx); + } + + // On platforms without rfd (e.g., Android), just show the button disabled + #[cfg(not(any( + target_os = "windows", + target_os = "macos", + target_os = "linux" + )))] + { + let _ = response; + let _ = trigger_browse; + } + }); - #[cfg(any( - target_os = "windows", - target_os = "macos", - target_os = "linux" - ))] - if response - .on_hover_text("Open folder picker dialog (B)") - .clicked() - || trigger_browse - { - // Spawn async folder picker - let (tx, rx) = std::sync::mpsc::channel(); - let ctx_clone = ui.ctx().clone(); - std::thread::spawn(move || { - let result = rfd::FileDialog::new().pick_folder(); - let _ = tx.send(result); - ctx_clone.request_repaint(); + if self.pending_folder_pick.is_some() { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Opening dialog..."); }); - self.pending_folder_pick = Some(rx); - } - - // On platforms without rfd (e.g., Android), just show the button disabled - #[cfg(not(any( - target_os = "windows", - target_os = "macos", - target_os = "linux" - )))] - { - let _ = response; - let _ = trigger_browse; } - }); - if self.pending_folder_pick.is_some() { - ui.horizontal(|ui| { - ui.spinner(); - ui.label("Opening dialog..."); - }); + ui.add_space(16.0); } - ui.add_space(16.0); - // Manual path input - ui.label("Or enter path:"); + ui.label("Enter path:"); ui.add_space(4.0); let response = ui.add( @@ -326,8 +375,11 @@ impl DirectoryPicker { || response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - let path = PathBuf::from(&self.path_input); - if path.exists() && path.is_dir() { + let path = PathBuf::from(self.path_input.trim()); + // For remote targets we can't verify the path locally + if !self.path_input.trim().is_empty() + && (is_remote || (path.exists() && path.is_dir())) + { action = Some(DirectoryPickerAction::DirectorySelected(path)); } } diff --git a/crates/notedeck_dave/src/ui/host_picker.rs b/crates/notedeck_dave/src/ui/host_picker.rs @@ -0,0 +1,139 @@ +use crate::ui::keybind_hint::paint_keybind_hint; +use egui::{RichText, Vec2}; + +/// Actions from the host picker overlay. +#[derive(Debug, Clone)] +pub enum HostPickerAction { + /// User picked a host. `None` = local, `Some(hostname)` = remote. + HostSelected(Option<String>), + /// User cancelled. + Cancelled, +} + +/// Render the host picker as a full-panel overlay. +/// +/// `known_hosts` should contain all remote hostnames (not the local one). +/// The local machine is always shown as the first option. +pub fn host_picker_overlay_ui( + ui: &mut egui::Ui, + local_hostname: &str, + known_hosts: &[String], + has_sessions: bool, +) -> Option<HostPickerAction> { + let mut action = None; + let is_narrow = notedeck::ui::is_narrow(ui.ctx()); + let ctrl_held = ui.input(|i| i.modifiers.ctrl); + + // Keyboard shortcuts: Ctrl+1 = local, Ctrl+2..9 = remote hosts + if ctrl_held { + if ui.input(|i| i.key_pressed(egui::Key::Num1)) { + return Some(HostPickerAction::HostSelected(None)); + } + for (idx, host) in known_hosts.iter().take(8).enumerate() { + let key = match idx { + 0 => egui::Key::Num2, + 1 => egui::Key::Num3, + 2 => egui::Key::Num4, + 3 => egui::Key::Num5, + 4 => egui::Key::Num6, + 5 => egui::Key::Num7, + 6 => egui::Key::Num8, + 7 => egui::Key::Num9, + _ => continue, + }; + if ui.input(|i| i.key_pressed(key)) { + return Some(HostPickerAction::HostSelected(Some(host.clone()))); + } + } + } + + egui::Frame::new() + .fill(ui.visuals().panel_fill) + .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20)) + .show(ui, |ui| { + // Header + ui.horizontal(|ui| { + if has_sessions { + if ui.button("< Back").clicked() { + action = Some(HostPickerAction::Cancelled); + } + ui.add_space(16.0); + } + ui.heading("Select Host"); + }); + + ui.add_space(16.0); + + let max_content_width = if is_narrow { + ui.available_width() + } else { + 500.0 + }; + let available_height = ui.available_height(); + + ui.allocate_ui_with_layout( + egui::vec2(max_content_width, available_height), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + let button_height = if is_narrow { 44.0 } else { 32.0 }; + let hint_width = if ctrl_held { 24.0 } else { 0.0 }; + let button_width = ui.available_width() - hint_width - 4.0; + + // Local option + ui.horizontal(|ui| { + let button = egui::Button::new( + RichText::new(format!("{} (local)", local_hostname)).monospace(), + ) + .min_size(Vec2::new(button_width, button_height)) + .fill(ui.visuals().widgets.inactive.weak_bg_fill); + + let response = ui.add(button); + + if ctrl_held { + let hint_center = response.rect.right_center() + + egui::vec2(hint_width / 2.0 + 2.0, 0.0); + paint_keybind_hint(ui, hint_center, "1", 18.0); + } + + if response.clicked() { + action = Some(HostPickerAction::HostSelected(None)); + } + }); + + ui.add_space(4.0); + + // Remote hosts + for (idx, host) in known_hosts.iter().enumerate() { + ui.horizontal(|ui| { + let button = + egui::Button::new(RichText::new(host.as_str()).monospace()) + .min_size(Vec2::new(button_width, button_height)) + .fill(ui.visuals().widgets.inactive.weak_bg_fill); + + let response = ui.add(button); + + if ctrl_held && idx < 8 { + let hint_text = format!("{}", idx + 2); + let hint_center = response.rect.right_center() + + egui::vec2(hint_width / 2.0 + 2.0, 0.0); + paint_keybind_hint(ui, hint_center, &hint_text, 18.0); + } + + if response.clicked() { + action = Some(HostPickerAction::HostSelected(Some(host.clone()))); + } + }); + + ui.add_space(4.0); + } + }, + ); + }); + + // Escape to cancel + if has_sessions && ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) { + action = Some(HostPickerAction::Cancelled); + } + + action +} diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -4,6 +4,7 @@ mod dave; pub mod diff; pub mod directory_picker; mod git_status_ui; +pub mod host_picker; pub mod keybind_hint; pub mod keybindings; pub mod markdown_ui; @@ -18,6 +19,7 @@ mod top_buttons; pub use ask_question::{ask_user_question_summary_ui, ask_user_question_ui}; pub use dave::{DaveAction, DaveResponse, DaveUi}; pub use directory_picker::{DirectoryPicker, DirectoryPickerAction}; +pub use host_picker::HostPickerAction; pub use keybind_hint::{keybind_hint, paint_keybind_hint}; pub use keybindings::{check_keybindings, KeyAction}; pub use scene::{AgentScene, SceneAction, SceneResponse}; @@ -122,6 +124,8 @@ pub enum OverlayResult { BackToDirectoryPicker, /// Apply new settings ApplySettings(DaveSettings), + /// Host was selected. `None` = local, `Some(hostname)` = remote. + HostSelected(Option<String>), } /// Render the settings overlay UI. @@ -196,6 +200,28 @@ pub fn session_picker_overlay_ui( OverlayResult::None } +/// Render the host picker overlay UI. +pub fn host_picker_overlay_ui( + local_hostname: &str, + known_hosts: &[String], + has_sessions: bool, + ui: &mut egui::Ui, +) -> OverlayResult { + if let Some(action) = + host_picker::host_picker_overlay_ui(ui, local_hostname, known_hosts, has_sessions) + { + match action { + HostPickerAction::HostSelected(host) => { + return OverlayResult::HostSelected(host); + } + HostPickerAction::Cancelled => { + return OverlayResult::Close; + } + } + } + OverlayResult::None +} + /// Brand color for a backend type. pub fn backend_color(bt: BackendType) -> egui::Color32 { match bt { @@ -552,6 +578,7 @@ pub enum KeyActionResult { ToggleView, HandleInterrupt, CloneAgent, + NewAgent, DeleteSession(SessionId), SetAutoSteal(bool), /// Permission response needs relay publishing. @@ -569,7 +596,6 @@ pub fn handle_key_action( show_scene: bool, auto_steal_focus: bool, home_session: &mut Option<SessionId>, - active_overlay: &mut DaveOverlay, ctx: &egui::Context, ) -> KeyActionResult { match key_action { @@ -635,10 +661,7 @@ pub fn handle_key_action( update::cycle_prev_agent(session_manager, scene, show_scene); KeyActionResult::None } - KeyAction::NewAgent => { - *active_overlay = DaveOverlay::DirectoryPicker; - KeyActionResult::None - } + KeyAction::NewAgent => KeyActionResult::NewAgent, KeyAction::CloneAgent => KeyActionResult::CloneAgent, KeyAction::Interrupt => KeyActionResult::HandleInterrupt, KeyAction::ToggleView => KeyActionResult::ToggleView, @@ -769,6 +792,8 @@ pub enum UiActionResult { PublishPermissionResponse(update::PermissionPublish), /// Toggle auto-steal focus mode (needs state from DaveApp) ToggleAutoSteal, + /// New chat requested — caller routes through handle_new_chat() + NewChat, /// Trigger manual context compaction Compact, } @@ -786,10 +811,7 @@ pub fn handle_ui_action( match action { DaveAction::ToggleChrome => UiActionResult::AppAction(notedeck::AppAction::ToggleChrome), DaveAction::Note(n) => UiActionResult::AppAction(notedeck::AppAction::Note(n)), - DaveAction::NewChat => { - *active_overlay = DaveOverlay::DirectoryPicker; - UiActionResult::Handled - } + DaveAction::NewChat => UiActionResult::NewChat, DaveAction::Send => UiActionResult::SendAction, DaveAction::ShowSessionList => { *show_session_list = !*show_session_list;