notedeck

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

commit 1bdf6fa5fca1817fa604a1202ff2279a302db971
parent 72105d384dedcc7a08cb239a1205b9abba1c3195
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 28 Jan 2026 21:40:03 -0800

feat(dave): require cwd selection when creating new sessions

Add a directory picker modal that appears when creating new sessions.
Users must select a working directory before a session is created,
improving the UX for launching the app from GUI launchers where no
project directory context exists.

Changes:
- Add DirectoryPicker UI with recent directories, browse button, and
  path input
- Make session.cwd non-optional (PathBuf instead of Option<PathBuf>)
- Update SessionManager::new_session() to require cwd parameter
- Auto-open picker on startup and when last session is deleted
- /cd command now adds to recent directories list
- Update scene and session list views to always show cwd

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

Diffstat:
M.gitignore | 1+
MCargo.lock | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/notedeck_dave/Cargo.toml | 2++
Mcrates/notedeck_dave/src/lib.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/notedeck_dave/src/session.rs | 27+++++++++++----------------
Acrates/notedeck_dave/src/ui/directory_picker.rs | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 2++
Mcrates/notedeck_dave/src/ui/scene.rs | 24+++++++++++-------------
Mcrates/notedeck_dave/src/ui/session_list.rs | 18++++++++----------
Mtodos.txt | 2+-
10 files changed, 532 insertions(+), 67 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +crates/notedeck_dave/.planning/ .buildcmd TODO.bak android-config.json diff --git a/Cargo.lock b/Cargo.lock @@ -260,6 +260,24 @@ dependencies = [ [[package]] name = "ashpd" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd884d7c72877a94102c3715f3b1cd09ff4fac28221add3e57cfbe25c236d093" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.8.5", + "serde", + "serde_repr", + "url", + "zbus 4.4.0", +] + +[[package]] +name = "ashpd" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" @@ -274,7 +292,7 @@ dependencies = [ "serde", "serde_repr", "url", - "zbus", + "zbus 5.7.1", ] [[package]] @@ -1658,7 +1676,7 @@ dependencies = [ "objc2-foundation 0.3.1", "parking_lot", "percent-encoding", - "pollster", + "pollster 0.4.0", "profiling", "raw-window-handle", "static_assertions", @@ -3802,6 +3820,19 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" @@ -4092,7 +4123,7 @@ dependencies = [ "profiling", "puffin", "puffin_egui", - "rfd", + "rfd 0.15.3", "rmpv", "robius-open", "security-framework 2.11.1", @@ -4123,6 +4154,7 @@ dependencies = [ "chrono", "claude-agent-sdk-rs", "dashmap", + "dirs", "eframe", "egui", "egui-wgpu", @@ -4134,6 +4166,7 @@ dependencies = [ "notedeck", "notedeck_ui", "rand 0.9.2", + "rfd 0.14.1", "serde", "serde_json", "sha2", @@ -4314,6 +4347,17 @@ dependencies = [ ] [[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] name = "objc-sys" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4587,6 +4631,15 @@ dependencies = [ ] [[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] name = "object" version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4953,6 +5006,12 @@ dependencies = [ [[package]] name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + +[[package]] +name = "pollster" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" @@ -5553,11 +5612,34 @@ dependencies = [ [[package]] name = "rfd" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25a73a7337fc24366edfca76ec521f51877b114e42dab584008209cca6719251" +dependencies = [ + "ashpd 0.8.1", + "block", + "dispatch", + "js-sys", + "log", + "objc", + "objc-foundation", + "objc_id", + "pollster 0.3.0", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.48.0", +] + +[[package]] +name = "rfd" version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80c844748fdc82aae252ee4594a89b6e7ebef1063de7951545564cbc4e57075d" dependencies = [ - "ashpd", + "ashpd 0.11.0", "block2 0.6.1", "dispatch2 0.2.0", "js-sys", @@ -5566,7 +5648,7 @@ dependencies = [ "objc2-app-kit 0.3.1", "objc2-core-foundation", "objc2-foundation 0.3.1", - "pollster", + "pollster 0.4.0", "raw-window-handle", "urlencoding", "wasm-bindgen", @@ -8341,6 +8423,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" [[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] name = "xkbcommon-dl" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -8403,6 +8495,44 @@ dependencies = [ [[package]] name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" version = "5.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3a7c7cee313d044fca3f48fa782cb750c79e4ca76ba7bc7718cd4024cdf6f68" @@ -8421,7 +8551,7 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "nix 0.30.1", "ordered-stream", "serde", "serde_repr", @@ -8429,9 +8559,22 @@ dependencies = [ "uds_windows", "windows-sys 0.59.0", "winnow", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.7.1", + "zbus_names 4.2.0", + "zvariant 5.5.3", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", + "zvariant_utils 2.1.0", ] [[package]] @@ -8444,9 +8587,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.104", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.2.0", + "zvariant 5.5.3", + "zvariant_utils 3.2.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -8458,7 +8612,7 @@ dependencies = [ "serde", "static_assertions", "winnow", - "zvariant", + "zvariant 5.5.3", ] [[package]] @@ -8613,6 +8767,20 @@ dependencies = [ [[package]] name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "url", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" version = "5.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d30786f75e393ee63a21de4f9074d4c038d52c5b1bb4471f955db249f9dffb1" @@ -8622,8 +8790,21 @@ dependencies = [ "serde", "url", "winnow", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.5.3", + "zvariant_utils 3.2.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", + "zvariant_utils 2.1.0", ] [[package]] @@ -8636,7 +8817,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.104", - "zvariant_utils", + "zvariant_utils 3.2.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", ] [[package]] diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml @@ -28,6 +28,8 @@ dashmap = "6" #reqwest = "0.12.15" egui_extras = { workspace = true } similar = "2" +rfd = "0.14" +dirs = "5" [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -39,8 +39,9 @@ pub use tools::{ ToolResponses, }; pub use ui::{ - check_keybindings, AgentScene, DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, KeyAction, - SceneAction, SceneResponse, SessionListAction, SessionListUi, SettingsPanelAction, + check_keybindings, AgentScene, DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, + DirectoryPicker, DirectoryPickerAction, KeyAction, SceneAction, SceneResponse, + SessionListAction, SessionListUi, SettingsPanelAction, }; pub use vec3::Vec3; @@ -73,6 +74,8 @@ pub struct Dave { auto_steal_focus: bool, /// The session ID to return to after processing all NeedsInput items home_session: Option<SessionId>, + /// Directory picker for selecting working directory when creating sessions + directory_picker: DirectoryPicker, } /// Calculate an anonymous user_id from a keypair @@ -145,6 +148,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let settings = DaveSettings::from_model_config(&model_config); + let mut directory_picker = DirectoryPicker::new(); + // Auto-open the picker on startup since there are no sessions + directory_picker.open(); + Dave { backend, avatar, @@ -160,6 +167,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr focus_queue: FocusQueue::new(), auto_steal_focus: false, home_session: None, + directory_picker, } } @@ -614,7 +622,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(action) = session_action { match action { SessionListAction::NewSession => { - self.session_manager.new_session(); + self.handle_new_chat(); self.show_session_list = false; } SessionListAction::SwitchTo(id) => { @@ -658,7 +666,16 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } fn handle_new_chat(&mut self) { - let id = self.session_manager.new_session(); + // Open the directory picker instead of creating session directly + self.directory_picker.open(); + } + + /// Create a new session with the given cwd (called after directory picker selection) + fn create_session_with_cwd(&mut self, cwd: PathBuf) { + // Add to recent directories + self.directory_picker.add_recent(cwd.clone()); + + let id = self.session_manager.new_session(cwd); // Request focus on the new session's input if let Some(session) = self.session_manager.get_mut(id) { session.focus_requested = true; @@ -678,6 +695,11 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Clean up backend resources (e.g., close persistent connections) let session_id = format!("dave-session-{}", id); self.backend.cleanup_session(session_id); + + // If no sessions remain, open the directory picker for a new session + if self.session_manager.is_empty() { + self.directory_picker.open(); + } } } @@ -1265,28 +1287,44 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// Handle a user send action triggered by the ui fn handle_user_send(&mut self, app_ctx: &AppContext, ui: &egui::Ui) { - if let Some(session) = self.session_manager.get_active_mut() { + // Check for /cd command first + let cd_result = if let Some(session) = self.session_manager.get_active_mut() { let input = session.input.trim().to_string(); - - // Handle /cd command if input.starts_with("/cd ") { let path_str = input.strip_prefix("/cd ").unwrap().trim(); let path = PathBuf::from(path_str); + session.input.clear(); if path.exists() && path.is_dir() { - session.cwd = Some(path.clone()); + session.cwd = path.clone(); session.chat.push(Message::System(format!( "Working directory set to: {}", path.display() ))); + Some(Ok(path)) } else { session .chat .push(Message::Error(format!("Invalid directory: {}", path_str))); + Some(Err(())) } - session.input.clear(); - return; + } else { + None } + } else { + None + }; + + // If /cd command was processed, add to recent directories + if let Some(Ok(path)) = cd_result { + self.directory_picker.add_recent(path); + return; + } else if cd_result.is_some() { + // Error case - already handled above + return; + } + // Normal message handling + if let Some(session) = self.session_manager.get_active_mut() { session.chat.push(Message::User(session.input.clone())); session.input.clear(); session.update_title_from_last_message(); @@ -1302,7 +1340,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let user_id = calculate_user_id(app_ctx.accounts.get_selected_account().keypair()); let session_id = format!("dave-session-{}", session.id); let messages = session.chat.clone(); - let cwd = session.cwd.clone(); + let cwd = Some(session.cwd.clone()); let tools = self.tools.clone(); let model_name = self.model_config.model().to_owned(); let ctx = ctx.clone(); @@ -1341,6 +1379,21 @@ impl notedeck::App for Dave { } } + // Render directory picker and handle its actions + if let Some(picker_action) = self.directory_picker.ui(ui.ctx()) { + match picker_action { + DirectoryPickerAction::DirectorySelected(path) => { + self.create_session_with_cwd(path); + } + DirectoryPickerAction::Cancelled => { + // Picker closed without selection, nothing to do + } + DirectoryPickerAction::BrowseRequested => { + // Handled internally by the picker + } + } + } + // Handle global keybindings (when no text input has focus) let has_pending_permission = self.first_pending_permission().is_some(); let has_pending_question = self.has_pending_question(); diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -51,7 +51,7 @@ pub struct ChatSession { /// Current question index for multi-question AskUserQuestion (keyed by request UUID) pub question_index: HashMap<Uuid, usize>, /// Working directory for claude-code subprocess - pub cwd: Option<PathBuf>, + pub cwd: PathBuf, /// Session info from Claude Code CLI (tools, model, agents, etc.) pub session_info: Option<SessionInfo>, /// Indices of subagent messages in chat (keyed by task_id) @@ -71,7 +71,7 @@ impl Drop for ChatSession { } impl ChatSession { - pub fn new(id: SessionId) -> Self { + pub fn new(id: SessionId, cwd: PathBuf) -> Self { // Arrange sessions in a grid pattern let col = (id as i32 - 1) % 4; let row = (id as i32 - 1) / 4; @@ -93,7 +93,7 @@ impl ChatSession { permission_message_state: PermissionMessageState::None, question_answers: HashMap::new(), question_index: HashMap::new(), - cwd: None, + cwd, session_info: None, subagent_indices: HashMap::new(), is_compacting: false, @@ -203,23 +203,20 @@ impl Default for SessionManager { impl SessionManager { pub fn new() -> Self { - let mut manager = SessionManager { + SessionManager { sessions: HashMap::new(), order: Vec::new(), active: None, next_id: 1, - }; - // Start with one session - manager.new_session(); - manager + } } - /// Create a new session and make it active - pub fn new_session(&mut self) -> SessionId { + /// Create a new session with the given cwd and make it active + pub fn new_session(&mut self, cwd: PathBuf) -> SessionId { let id = self.next_id; self.next_id += 1; - let session = ChatSession::new(id); + let session = ChatSession::new(id, cwd); self.sessions.insert(id, session); self.order.insert(0, id); // Most recent first self.active = Some(id); @@ -253,6 +250,9 @@ impl SessionManager { } /// Delete a session + /// Returns true if the session was deleted, false if it didn't exist. + /// If the last session is deleted, active will be None and the caller + /// should open the directory picker to create a new session. pub fn delete_session(&mut self, id: SessionId) -> bool { if self.sessions.remove(&id).is_some() { self.order.retain(|&x| x != id); @@ -260,11 +260,6 @@ impl SessionManager { // If we deleted the active session, switch to another if self.active == Some(id) { self.active = self.order.first().copied(); - - // If no sessions left, create a new one - if self.active.is_none() { - self.new_session(); - } } true } else { diff --git a/crates/notedeck_dave/src/ui/directory_picker.rs b/crates/notedeck_dave/src/ui/directory_picker.rs @@ -0,0 +1,224 @@ +use egui::{Align, Color32, Layout, RichText, Vec2}; +use std::path::PathBuf; + +/// Maximum number of recent directories to store +const MAX_RECENT_DIRECTORIES: usize = 10; + +/// Actions that can be triggered from the directory picker +#[derive(Debug, Clone)] +pub enum DirectoryPickerAction { + /// User selected a directory + DirectorySelected(PathBuf), + /// User cancelled the picker + Cancelled, + /// User requested to browse for a directory (opens native dialog) + BrowseRequested, +} + +/// State for the directory picker modal +pub struct DirectoryPicker { + /// List of recently used directories + pub recent_directories: Vec<PathBuf>, + /// Whether the picker is currently open + pub is_open: bool, + /// Text input for manual path entry + path_input: String, + /// Pending async folder picker result + pending_folder_pick: Option<std::sync::mpsc::Receiver<Option<PathBuf>>>, +} + +impl Default for DirectoryPicker { + fn default() -> Self { + Self::new() + } +} + +impl DirectoryPicker { + pub fn new() -> Self { + Self { + recent_directories: Vec::new(), + is_open: false, + path_input: String::new(), + pending_folder_pick: None, + } + } + + /// Open the picker + pub fn open(&mut self) { + self.is_open = true; + self.path_input.clear(); + } + + /// Close the picker + pub fn close(&mut self) { + self.is_open = false; + self.pending_folder_pick = None; + } + + /// 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) + self.recent_directories.retain(|p| p != &path); + // Add to front + self.recent_directories.insert(0, path); + // Trim to max size + self.recent_directories.truncate(MAX_RECENT_DIRECTORIES); + } + + /// Check for pending folder picker result + fn check_pending_pick(&mut self) -> Option<PathBuf> { + if let Some(rx) = &self.pending_folder_pick { + match rx.try_recv() { + Ok(Some(path)) => { + self.pending_folder_pick = None; + return Some(path); + } + Ok(None) => { + // User cancelled the dialog + self.pending_folder_pick = None; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.pending_folder_pick = None; + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + // Still waiting + } + } + } + None + } + + /// Render the directory picker UI + /// Returns an action if one was triggered + pub fn ui(&mut self, ctx: &egui::Context) -> Option<DirectoryPickerAction> { + if !self.is_open { + return None; + } + + // Check for pending folder pick result first + if let Some(path) = self.check_pending_pick() { + self.close(); + return Some(DirectoryPickerAction::DirectorySelected(path)); + } + + let mut action = None; + + egui::Window::new("Select Working Directory") + .collapsible(false) + .resizable(true) + .default_width(400.0) + .anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO) + .show(ctx, |ui| { + ui.add_space(8.0); + + // Recent directories section + if !self.recent_directories.is_empty() { + ui.label(RichText::new("Recent Directories").strong()); + ui.add_space(4.0); + + egui::ScrollArea::vertical() + .max_height(200.0) + .show(ui, |ui| { + for path in &self.recent_directories.clone() { + let display = abbreviate_path(path); + let response = ui.add( + egui::Button::new(RichText::new(&display).monospace()) + .min_size(Vec2::new(ui.available_width(), 28.0)) + .fill(Color32::TRANSPARENT), + ); + + if response + .on_hover_text(path.display().to_string()) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + action = Some(DirectoryPickerAction::DirectorySelected( + path.clone(), + )); + } + } + }); + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + } + + // Browse button + ui.horizontal(|ui| { + if ui + .button(RichText::new("Browse...").size(14.0)) + .on_hover_text("Open folder picker dialog") + .clicked() + { + // Spawn async folder picker + let (tx, rx) = std::sync::mpsc::channel(); + let ctx_clone = 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); + } + + if self.pending_folder_pick.is_some() { + ui.spinner(); + ui.label("Opening dialog..."); + } + }); + + ui.add_space(8.0); + + // Manual path input + ui.label("Or enter path:"); + ui.horizontal(|ui| { + let response = ui.add( + egui::TextEdit::singleline(&mut self.path_input) + .hint_text("/path/to/project") + .desired_width(ui.available_width() - 50.0), + ); + + if ui.button("Go").clicked() + || 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() { + action = Some(DirectoryPickerAction::DirectorySelected(path)); + } + } + }); + + ui.add_space(12.0); + + // Cancel button + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("Cancel").clicked() { + action = Some(DirectoryPickerAction::Cancelled); + } + }); + }); + + // Handle Escape key to cancel + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + action = Some(DirectoryPickerAction::Cancelled); + } + + // Close picker if action taken + if action.is_some() { + self.close(); + } + + action + } +} + +/// Abbreviate a path for display (e.g., replace home dir with ~) +fn abbreviate_path(path: &PathBuf) -> String { + if let Some(home) = dirs::home_dir() { + if let Ok(relative) = path.strip_prefix(&home) { + return format!("~/{}", relative.display()); + } + } + path.display().to_string() +} diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -2,6 +2,7 @@ mod ask_question; pub mod badge; mod dave; pub mod diff; +pub mod directory_picker; pub mod keybind_hint; pub mod keybindings; mod pill; @@ -13,6 +14,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 keybind_hint::{keybind_hint, paint_keybind_hint}; pub use keybindings::{check_keybindings, KeyAction}; pub use scene::{AgentScene, SceneAction, SceneResponse}; diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs @@ -170,7 +170,7 @@ impl AgentScene { position, status, title, - session.cwd.as_deref(), + &session.cwd, is_selected, ctrl_held, queue_priority, @@ -306,7 +306,7 @@ impl AgentScene { position: Vec2, status: AgentStatus, title: &str, - cwd: Option<&Path>, + cwd: &Path, is_selected: bool, show_keybinding: bool, queue_priority: Option<FocusPriority>, @@ -388,17 +388,15 @@ impl AgentScene { ); // Cwd label (monospace, weak+small) - if let Some(cwd_path) = cwd { - let cwd_text = cwd_path.to_string_lossy(); - let cwd_pos = center + Vec2::new(0.0, agent_radius + 38.0); - painter.text( - cwd_pos, - egui::Align2::CENTER_TOP, - &cwd_text, - egui::FontId::monospace(8.0), - ui.visuals().weak_text_color(), - ); - } + let cwd_text = cwd.to_string_lossy(); + let cwd_pos = center + Vec2::new(0.0, agent_radius + 38.0); + painter.text( + cwd_pos, + egui::Align2::CENTER_TOP, + &cwd_text, + egui::FontId::monospace(8.0), + ui.visuals().weak_text_color(), + ); response } diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs @@ -104,7 +104,7 @@ impl<'a> SessionListUi<'a> { let response = self.session_item_ui( ui, &session.title, - session.cwd.as_deref(), + &session.cwd, is_active, shortcut_hint, session.status(), @@ -131,14 +131,14 @@ impl<'a> SessionListUi<'a> { &self, ui: &mut egui::Ui, title: &str, - cwd: Option<&Path>, + cwd: &Path, is_active: bool, shortcut_hint: Option<usize>, status: AgentStatus, queue_priority: Option<FocusPriority>, ) -> egui::Response { - // Taller height when cwd is present to fit both lines - let item_height = if cwd.is_some() { 48.0 } else { 36.0 }; + // Always use taller height since cwd is always present + let item_height = 48.0; let desired_size = egui::vec2(ui.available_width(), item_height); let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click()); let hover_text = format!("Ctrl+{} to switch", shortcut_hint.unwrap_or(0)); @@ -191,8 +191,8 @@ impl<'a> SessionListUi<'a> { right_offset }; - // Calculate text position - offset title upward when cwd is present - let title_y_offset = if cwd.is_some() { -7.0 } else { 0.0 }; + // Calculate text position - offset title upward since cwd is always present + let title_y_offset = -7.0; let text_pos = rect.left_center() + egui::vec2(text_start_x, title_y_offset); let max_text_width = rect.width() - text_start_x - text_end_x; @@ -225,10 +225,8 @@ impl<'a> SessionListUi<'a> { } // Draw cwd below title - if let Some(cwd_path) = cwd { - let cwd_pos = rect.left_center() + egui::vec2(text_start_x, 7.0); - cwd_ui(ui, cwd_path, cwd_pos, max_text_width); - } + let cwd_pos = rect.left_center() + egui::vec2(text_start_x, 7.0); + cwd_ui(ui, cwd, cwd_pos, max_text_width); response } diff --git a/todos.txt b/todos.txt @@ -1,4 +1,4 @@ -- [ ] in crates/notedeck_dave, when an agent steals focus from another agent to ask a question, i want it to focus back to where it was +- [x] in crates/notedeck_dave, when an agent steals focus from another agent to ask a question, i want it to focus back to where it was - [ ] make crates/notedeck_dave/src/lib.rs smaller