notedeck

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

commit 15b8656d1f1d66d23fc7e5f4603d097d7ff98ad6
parent cbf42c7f4bdfc10d2a81dd0b87cc921d94c7378d
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 19 Feb 2026 10:19:09 -0800

Merge work from various agents

Diffstat:
Mcrates/notedeck/src/persist/settings_handler.rs | 15+++++++++++++++
Mcrates/notedeck_columns/src/app.rs | 12+++++++++++-
Mcrates/notedeck_columns/src/nav.rs | 27++++++++++++++++++++++++++-
Mcrates/notedeck_columns/src/route.rs | 13+++++++++++++
Mcrates/notedeck_columns/src/ui/column/header.rs | 1+
Mcrates/notedeck_columns/src/ui/mod.rs | 1+
Acrates/notedeck_columns/src/ui/welcome.rs | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 12++++++------
Mcrates/notedeck_dave/src/session.rs | 30++++++++++++++++++++++--------
Mcrates/notedeck_dave/src/ui/dave.rs | 50++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/notedeck_dave/src/ui/mod.rs | 5+++--
Mcrates/notedeck_dave/src/ui/scene.rs | 4++--
Mcrates/notedeck_dave/src/ui/session_list.rs | 8++++----
Mcrates/notedeck_dave/src/update.rs | 14++++++++------
14 files changed, 277 insertions(+), 36 deletions(-)

diff --git a/crates/notedeck/src/persist/settings_handler.rs b/crates/notedeck/src/persist/settings_handler.rs @@ -42,6 +42,8 @@ pub struct Settings { pub animate_nav_transitions: bool, pub max_hashtags_per_note: usize, #[serde(default)] + pub welcome_completed: bool, + #[serde(default)] pub tos_accepted: bool, #[serde(default)] pub tos_accepted_at: Option<u64>, @@ -70,6 +72,7 @@ impl Default for Settings { note_body_font_size: DEFAULT_NOTE_BODY_FONT_SIZE, animate_nav_transitions: default_animate_nav_transitions(), max_hashtags_per_note: DEFAULT_MAX_HASHTAGS_PER_NOTE, + welcome_completed: false, tos_accepted: false, tos_accepted_at: None, tos_version: default_tos_version(), @@ -295,6 +298,18 @@ impl SettingsHandler { .unwrap_or(DEFAULT_MAX_HASHTAGS_PER_NOTE) } + pub fn welcome_completed(&self) -> bool { + self.current_settings + .as_ref() + .map(|s| s.welcome_completed) + .unwrap_or(false) + } + + pub fn complete_welcome(&mut self) { + self.get_settings_mut().welcome_completed = true; + self.try_save_settings(); + } + pub fn tos_accepted(&self) -> bool { self.current_settings .as_ref() diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -256,7 +256,16 @@ fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Con warn!("update_damus init: {err}"); } - if is_compiled_as_mobile() && !app_ctx.settings.tos_accepted() { + if !app_ctx.settings.welcome_completed() { + let split = + egui_nav::Split::PercentFromTop(egui_nav::Percent::new(40).expect("40 <= 100")); + if let Some(col) = damus + .decks_cache + .selected_column_mut(app_ctx.i18n, app_ctx.accounts) + { + col.sheet_router.route_to(Route::Welcome, split); + } + } else if is_compiled_as_mobile() && !app_ctx.settings.tos_accepted() { damus .columns_mut(app_ctx.i18n, app_ctx.accounts) .get_selected_router() @@ -844,6 +853,7 @@ fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool { Route::Following(_) => false, Route::FollowedBy(_) => false, Route::TosAcceptance => false, + Route::Welcome => false, Route::Report(_) => false, } } diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -1,5 +1,5 @@ use crate::{ - accounts::{render_accounts_route, AccountsAction, AccountsResponse}, + accounts::{render_accounts_route, AccountsAction, AccountsResponse, AccountsRoute}, app::{get_active_columns_mut, get_decks_mut}, column::ColumnsAction, deck_state::DeckState, @@ -1054,6 +1054,31 @@ fn render_nav_body( DragResponse::none() } + Route::Welcome => { + let resp = ui::welcome::WelcomeView::new(ctx.i18n).show(ui); + + if let Some(welcome_resp) = resp { + ctx.settings.complete_welcome(); + match welcome_resp { + ui::welcome::WelcomeResponse::CreateAccount => { + app.columns_mut(ctx.i18n, ctx.accounts) + .column_mut(col) + .router_mut() + .route_to(Route::Accounts(AccountsRoute::Onboarding)); + } + ui::welcome::WelcomeResponse::Login => { + app.columns_mut(ctx.i18n, ctx.accounts) + .column_mut(col) + .router_mut() + .route_to(Route::Accounts(AccountsRoute::AddAccount)); + } + ui::welcome::WelcomeResponse::Browse => {} + } + return DragResponse::output(Some(RenderNavAction::Back)); + } + + DragResponse::none() + } Route::Wallet(wallet_type) => { let state = match wallet_type { notedeck::WalletType::Auto => 's: { diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs @@ -39,6 +39,7 @@ pub enum Route { Following(Pubkey), FollowedBy(Pubkey), TosAcceptance, + Welcome, Report(ReportTarget), } @@ -158,6 +159,9 @@ impl Route { Route::TosAcceptance => { writer.write_token("tos"); } + Route::Welcome => { + writer.write_token("welcome"); + } Route::Report(target) => { writer.write_token("report"); writer.write_token(&target.pubkey.hex()); @@ -310,6 +314,12 @@ impl Route { }, |p| { p.parse_all(|p| { + p.parse_token("welcome")?; + Ok(Route::Welcome) + }) + }, + |p| { + p.parse_all(|p| { p.parse_token("report")?; let pubkey = Pubkey::from_hex(p.pull_token()?) .map_err(|_| ParseError::HexDecodeFailed)?; @@ -448,6 +458,9 @@ impl Route { "Terms of Service", "Column title for TOS acceptance screen" )), + Route::Welcome => { + ColumnTitle::formatted(tr!(i18n, "Welcome", "Column title for welcome screen")) + } Route::Report(_) => { ColumnTitle::formatted(tr!(i18n, "Report", "Column title for report screen")) } diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -549,6 +549,7 @@ impl<'a> NavTitle<'a> { Route::Following(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), Route::FollowedBy(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), Route::TosAcceptance => None, + Route::Welcome => None, Route::Report(_) => None, } } diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs @@ -23,6 +23,7 @@ pub mod timeline; pub mod toolbar; pub mod tos; pub mod wallet; +pub mod welcome; pub mod widgets; pub use accounts::AccountsView; diff --git a/crates/notedeck_columns/src/ui/welcome.rs b/crates/notedeck_columns/src/ui/welcome.rs @@ -0,0 +1,121 @@ +use egui::{vec2, Button, Label, Layout, RichText}; +use notedeck::{tr, Localization, NotedeckTextStyle}; +use notedeck_ui::padding; + +pub enum WelcomeResponse { + CreateAccount, + Login, + Browse, +} + +pub struct WelcomeView<'a> { + i18n: &'a mut Localization, +} + +impl<'a> WelcomeView<'a> { + pub fn new(i18n: &'a mut Localization) -> Self { + Self { i18n } + } + + pub fn show(&mut self, ui: &mut egui::Ui) -> Option<WelcomeResponse> { + let mut response = None; + + padding(16.0, ui, |ui| { + ui.spacing_mut().item_spacing = vec2(0.0, 16.0); + + ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { + ui.add_space(48.0); + + ui.add(Label::new( + RichText::new(tr!( + self.i18n, + "Welcome to Notedeck", + "Welcome screen title" + )) + .text_style(NotedeckTextStyle::Heading2.text_style()), + )); + + ui.add_space(8.0); + + let max_width: f32 = 400.0; + ui.allocate_ui(vec2(max_width.min(ui.available_width()), 0.0), |ui| { + ui.add( + Label::new( + RichText::new(tr!( + self.i18n, + "Notedeck is a client for Nostr, an open protocol for decentralized social networking. Unlike traditional platforms, no single company controls your feed, your identity, or your data. Your account is a cryptographic key pair \u{2014} no emails, no passwords, no phone numbers.", + "Welcome screen body text explaining what Notedeck and Nostr are" + )) + .text_style(NotedeckTextStyle::Body.text_style()), + ) + .wrap(), + ); + }); + + ui.add_space(24.0); + + let button_size = vec2(200.0, 40.0); + let font_size = + notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body); + + if ui + .add( + Button::new( + RichText::new(tr!( + self.i18n, + "Create Account", + "Button to create a new Nostr account" + )) + .size(font_size), + ) + .fill(notedeck_ui::colors::PINK) + .min_size(button_size), + ) + .clicked() + { + response = Some(WelcomeResponse::CreateAccount); + } + + ui.add_space(4.0); + + if ui + .add( + Button::new( + RichText::new(tr!( + self.i18n, + "I have a Nostr key", + "Button for existing Nostr users to log in with their key" + )) + .size(font_size), + ) + .min_size(button_size), + ) + .clicked() + { + response = Some(WelcomeResponse::Login); + } + + ui.add_space(4.0); + + if ui + .add( + Button::new( + RichText::new(tr!( + self.i18n, + "Just browsing", + "Button to dismiss welcome and browse the app without an account" + )) + .size(font_size), + ) + .frame(false), + ) + .clicked() + { + response = Some(WelcomeResponse::Browse); + } + }); + }); + + response + } +} diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -347,7 +347,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Create a default session with current directory let sid = manager.new_session(std::env::current_dir().unwrap_or_default(), ai_mode); if let Some(session) = manager.get_mut(sid) { - session.hostname = hostname.clone(); + session.details.hostname = hostname.clone(); } (manager, DaveOverlay::None) } @@ -1094,7 +1094,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Focus on new session if let Some(session) = self.session_manager.get_mut(id) { - session.hostname = self.hostname.clone(); + session.details.hostname = self.hostname.clone(); session.focus_requested = true; if self.show_scene { self.scene.select(id); @@ -1257,7 +1257,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr queue_built_event( session_events::build_session_state_event( &claude_sid, - &session.title, + &session.details.title, &cwd, status, &self.hostname, @@ -1412,7 +1412,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Local sessions use the current machine's hostname; // remote sessions use what was stored in the event. - session.hostname = if is_remote { + session.details.hostname = if is_remote { state.hostname.clone() } else { self.hostname.clone() @@ -1585,7 +1585,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let loaded = session_loader::load_session_messages(ctx.ndb, &txn, claude_sid); if let Some(session) = self.session_manager.get_mut(dave_sid) { - session.hostname = hostname; + session.details.hostname = hostname; if !loaded.messages.is_empty() { tracing::info!( "loaded {} messages for discovered session", @@ -1828,7 +1828,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(claude_sid) = agentic.event_session_id() { self.pending_deletions.push(DeletedSessionInfo { claude_session_id: claude_sid.to_string(), - title: session.title.clone(), + title: session.details.title.clone(), cwd: agentic.cwd.to_string_lossy().to_string(), }); } diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -27,6 +27,13 @@ pub enum SessionSource { Remote, } +/// Session metadata for display in chat headers +pub struct SessionDetails { + pub title: String, + pub hostname: String, + pub cwd: Option<PathBuf>, +} + /// State for permission response with message #[derive(Default, Clone, Copy, PartialEq)] pub enum PermissionMessageState { @@ -263,7 +270,6 @@ impl AgenticSessionData { /// A single chat session with Dave pub struct ChatSession { pub id: SessionId, - pub title: String, pub chat: Vec<Message>, pub input: String, pub incoming_tokens: Option<Receiver<DaveApiResponse>>, @@ -282,8 +288,8 @@ pub struct ChatSession { pub agentic: Option<AgenticSessionData>, /// Whether this session is local (has a Claude process) or remote (relay-only). pub source: SessionSource, - /// Hostname of the machine where this session originated. - pub hostname: String, + /// Session metadata for display (title, hostname, cwd) + pub details: SessionDetails, } impl Drop for ChatSession { @@ -296,6 +302,11 @@ impl Drop for ChatSession { impl ChatSession { pub fn new(id: SessionId, cwd: PathBuf, ai_mode: AiMode) -> Self { + let details_cwd = if ai_mode == AiMode::Agentic { + Some(cwd.clone()) + } else { + None + }; let agentic = match ai_mode { AiMode::Agentic => Some(AgenticSessionData::new(id, cwd)), AiMode::Chat => None, @@ -303,7 +314,6 @@ impl ChatSession { ChatSession { id, - title: "New Chat".to_string(), chat: vec![], input: String::new(), incoming_tokens: None, @@ -314,7 +324,11 @@ impl ChatSession { ai_mode, agentic, source: SessionSource::Local, - hostname: String::new(), + details: SessionDetails { + title: "New Chat".to_string(), + hostname: String::new(), + cwd: details_cwd, + }, } } @@ -330,7 +344,7 @@ impl ChatSession { if let Some(ref mut agentic) = session.agentic { agentic.resume_session_id = Some(resume_session_id); } - session.title = title; + session.details.title = title; session } @@ -430,8 +444,8 @@ impl ChatSession { } else { title }; - if new_title != self.title { - self.title = new_title; + if new_title != self.details.title { + self.details.title = new_title; self.state_dirty = true; } break; diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -13,7 +13,7 @@ use crate::{ PermissionResponse, PermissionResponseType, QuestionAnswer, SubagentInfo, SubagentStatus, ToolResult, }, - session::{PermissionMessageState, SessionId}, + session::{PermissionMessageState, SessionDetails, SessionId}, tools::{PresentNotesCall, ToolCall, ToolCalls, ToolResponse}, }; use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers}; @@ -52,6 +52,8 @@ pub struct DaveUi<'a> { git_status: Option<&'a mut GitStatusCache>, /// Whether this is a remote session (no local Claude process) is_remote: bool, + /// Session details for header display + details: Option<&'a SessionDetails>, } /// The response the app generates. The response contains an optional @@ -159,9 +161,15 @@ impl<'a> DaveUi<'a> { ai_mode, git_status: None, is_remote: false, + details: None, } } + pub fn details(mut self, details: &'a SessionDetails) -> Self { + self.details = Some(details); + self + } + pub fn permission_message_state(mut self, state: PermissionMessageState) -> Self { self.permission_message_state = state; self @@ -324,7 +332,13 @@ impl<'a> DaveUi<'a> { .show(ui, |ui| { self.chat_frame(ui.ctx()) .show(ui, |ui| { - ui.vertical(|ui| self.render_chat(app_ctx, ui)).inner + ui.vertical(|ui| { + if let Some(details) = self.details { + session_header_ui(ui, details); + } + self.render_chat(app_ctx, ui) + }) + .inner }) .inner }) @@ -1226,13 +1240,11 @@ fn status_bar_ui( // Right-aligned section: badges then refresh ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let action = if is_agentic { + if is_agentic { toggle_badges_ui(ui, plan_mode_active, auto_steal_focus) } else { None - }; - - action + } }) .inner } else if is_agentic { @@ -1301,3 +1313,29 @@ fn toggle_badges_ui( action } + +fn session_header_ui(ui: &mut egui::Ui, details: &SessionDetails) { + ui.horizontal(|ui| { + ui.heading(&details.title); + }); + + if let Some(cwd) = &details.cwd { + let cwd_display = super::path_utils::abbreviate_path(cwd); + let display_text = if details.hostname.is_empty() { + cwd_display + } else { + format!("{}:{}", details.hostname, cwd_display) + }; + ui.add( + egui::Label::new( + egui::RichText::new(display_text) + .monospace() + .size(11.0) + .weak(), + ) + .wrap_mode(egui::TextWrapMode::Truncate), + ); + } + + ui.separator(); +} diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -64,7 +64,8 @@ fn build_dave_ui<'a>( .has_pending_permission(has_pending_permission) .plan_mode_active(plan_mode_active) .auto_steal_focus(auto_steal_focus) - .is_remote(is_remote); + .is_remote(is_remote) + .details(&session.details); if let Some(agentic) = &mut session.agentic { ui_builder = ui_builder @@ -263,7 +264,7 @@ pub fn scene_ui( .show(ui, |ui| { if let Some(selected_id) = scene.primary_selection() { if let Some(session) = session_manager.get_mut(selected_id) { - ui.heading(&session.title); + ui.heading(&session.details.title); ui.separator(); let response = build_dave_ui( diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs @@ -165,7 +165,7 @@ impl AgentScene { let keybind_number = keybind_idx + 1; // 1-indexed for display let position = agentic.scene_position; let status = session.status(); - let title = &session.title; + let title = &session.details.title; let is_selected = selected_ids.contains(&id); let queue_priority = focus_queue.get_session_priority(id); @@ -411,7 +411,7 @@ impl AgentScene { ); // Cwd label (monospace, weak+small) - let cwd_text = cwd.to_string_lossy(); + let cwd_text = super::path_utils::abbreviate_path(cwd); let cwd_pos = center + Vec2::new(0.0, agent_radius + 38.0); painter.text( cwd_pos, diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs @@ -156,9 +156,9 @@ impl<'a> SessionListUi<'a> { let response = self.session_item_ui( ui, - &session.title, + &session.details.title, cwd, - &session.hostname, + &session.details.hostname, is_active, shortcut_hint, session.status(), @@ -298,9 +298,9 @@ impl<'a> SessionListUi<'a> { /// Draw cwd text (monospace, weak+small) with clipping. /// Shows "hostname:cwd" when hostname is non-empty. fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, hostname: &str, pos: egui::Pos2, max_width: f32) { - let cwd_str = cwd_path.to_string_lossy(); + let cwd_str = super::path_utils::abbreviate_path(cwd_path); let display_text = if hostname.is_empty() { - cwd_str.to_string() + cwd_str } else { format!("{}:{}", hostname, cwd_str) }; diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs @@ -574,14 +574,16 @@ pub fn process_auto_steal_focus( tracing::debug!("Auto-steal: saved home session {:?}", home_session); } - // Jump to first Done item + // Jump to first Done item and clear it from the queue if let Some(idx) = focus_queue.first_done_index() { focus_queue.set_cursor(idx); if let Some(entry) = focus_queue.current() { - switch_and_focus_session(session_manager, scene, show_scene, entry.session_id); + let sid = entry.session_id; + switch_and_focus_session(session_manager, scene, show_scene, sid); + focus_queue.dequeue(sid); tracing::debug!( - "Auto-steal: switched to Done session {:?}", - entry.session_id + "Auto-steal: switched to Done session {:?} and cleared indicator", + sid ); return true; } @@ -868,7 +870,7 @@ pub fn create_session_with_cwd( let id = session_manager.new_session(cwd, ai_mode); if let Some(session) = session_manager.get_mut(id) { - session.hostname = hostname.to_string(); + session.details.hostname = hostname.to_string(); session.focus_requested = true; if show_scene { scene.select(id); @@ -897,7 +899,7 @@ pub fn create_resumed_session_with_cwd( let id = session_manager.new_resumed_session(cwd, resume_session_id, title, ai_mode); if let Some(session) = session_manager.get_mut(id) { - session.hostname = hostname.to_string(); + session.details.hostname = hostname.to_string(); session.focus_requested = true; if show_scene { scene.select(id);