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:
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(¬e, "d") else {
+ continue;
+ };
+
+ // Dedup: skip already-processed commands
+ if self.processed_commands.contains(command_id) {
+ continue;
+ }
+
+ let command = session_events::get_tag_value(¬e, "command").unwrap_or("");
+ if command != "spawn_session" {
+ continue;
+ }
+
+ let target = session_events::get_tag_value(¬e, "target_host").unwrap_or("");
+ if target != self.hostname {
+ continue;
+ }
+
+ let cwd = session_events::get_tag_value(¬e, "cwd").unwrap_or("");
+ let backend_str = session_events::get_tag_value(¬e, "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(¬e, "hostname").unwrap_or("").to_string();
+ let cwd = get_tag_value(¬e, "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;