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:
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