commit ff67e8736df6694a0c38aacf9485a3c877528164
parent e8a3fb4eb9e5355b76297e94627ead4f90dabb76
Author: William Casarin <jb55@jb55.com>
Date: Tue, 17 Feb 2026 13:38:35 -0800
add remote-only backend for lite client mode
Devices without API keys now use a no-op RemoteOnlyBackend instead of
falling back to a trial OpenAI key. Session restore from ndb runs
regardless of backend type, so remote agentic sessions are visible
alongside local chat sessions. The session list splits into "Agents"
and "Chats" sections, and UI elements (CWD, status bar, scene view)
use per-session ai_mode instead of a global setting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
13 files changed, 319 insertions(+), 302 deletions(-)
diff --git a/crates/enostr/src/pns.rs b/crates/enostr/src/pns.rs
@@ -55,8 +55,7 @@ pub fn derive_pns_keys(device_key: &[u8; 32]) -> PnsKeys {
/// Returns base64-encoded NIP-44 v2 ciphertext suitable for the
/// `content` field of a kind-1080 event.
pub fn encrypt(conversation_key: &ConversationKey, inner_json: &str) -> Result<String, PnsError> {
- let payload =
- v2::encrypt_to_bytes(conversation_key, inner_json).map_err(PnsError::Encrypt)?;
+ let payload = v2::encrypt_to_bytes(conversation_key, inner_json).map_err(PnsError::Encrypt)?;
Ok(BASE64.encode(payload))
}
diff --git a/crates/notedeck_dave/src/backend/mod.rs b/crates/notedeck_dave/src/backend/mod.rs
@@ -1,9 +1,11 @@
mod claude;
mod openai;
+mod remote;
mod session_info;
mod tool_summary;
mod traits;
pub use claude::ClaudeBackend;
pub use openai::OpenAiBackend;
+pub use remote::RemoteOnlyBackend;
pub use traits::{AiBackend, BackendType};
diff --git a/crates/notedeck_dave/src/backend/remote.rs b/crates/notedeck_dave/src/backend/remote.rs
@@ -0,0 +1,43 @@
+use crate::messages::DaveApiResponse;
+use crate::tools::Tool;
+use claude_agent_sdk_rs::PermissionMode;
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::mpsc;
+use std::sync::Arc;
+
+use super::AiBackend;
+
+/// A no-op backend for devices without API keys.
+///
+/// Allows creating local Chat sessions (input is ignored) while viewing
+/// and controlling remote Agentic sessions discovered from ndb/relays.
+pub struct RemoteOnlyBackend;
+
+impl AiBackend for RemoteOnlyBackend {
+ fn stream_request(
+ &self,
+ _messages: Vec<crate::Message>,
+ _tools: Arc<HashMap<String, Tool>>,
+ _model: String,
+ _user_id: String,
+ _session_id: String,
+ _cwd: Option<PathBuf>,
+ _resume_session_id: Option<String>,
+ _ctx: egui::Context,
+ ) -> (
+ mpsc::Receiver<DaveApiResponse>,
+ Option<tokio::task::JoinHandle<()>>,
+ ) {
+ // Return a closed channel — no local AI processing
+ let (_tx, rx) = mpsc::channel();
+ (rx, None)
+ }
+
+ fn cleanup_session(&self, _session_id: String) {}
+
+ fn interrupt_session(&self, _session_id: String, _ctx: egui::Context) {}
+
+ fn set_permission_mode(&self, _session_id: String, _mode: PermissionMode, _ctx: egui::Context) {
+ }
+}
diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs
@@ -11,6 +11,8 @@ use std::sync::Arc;
pub enum BackendType {
OpenAI,
Claude,
+ /// No local AI — only view/control remote agentic sessions from ndb
+ Remote,
}
/// Trait for AI backend implementations
diff --git a/crates/notedeck_dave/src/config.rs b/crates/notedeck_dave/src/config.rs
@@ -110,7 +110,7 @@ impl DaveSettings {
/// Create settings from an existing ModelConfig (preserves env var values)
pub fn from_model_config(config: &ModelConfig) -> Self {
let provider = match config.backend {
- BackendType::OpenAI => AiProvider::OpenAI,
+ BackendType::OpenAI | BackendType::Remote => AiProvider::OpenAI,
BackendType::Claude => AiProvider::Anthropic,
};
@@ -182,11 +182,13 @@ impl Default for ModelConfig {
}
}
} else {
- // Auto-detect: prefer Claude if key is available, otherwise OpenAI
+ // Auto-detect: prefer Claude if key is available, then OpenAI, then Remote
if anthropic_api_key.is_some() {
BackendType::Claude
- } else {
+ } else if api_key.is_some() {
BackendType::OpenAI
+ } else {
+ BackendType::Remote
}
};
@@ -203,6 +205,7 @@ impl Default for ModelConfig {
.unwrap_or_else(|| match backend {
BackendType::OpenAI => "gpt-4o".to_string(),
BackendType::Claude => "claude-sonnet-4.5".to_string(),
+ BackendType::Remote => String::new(),
});
ModelConfig {
@@ -220,7 +223,7 @@ impl ModelConfig {
pub fn ai_mode(&self) -> AiMode {
match self.backend {
BackendType::Claude => AiMode::Agentic,
- BackendType::OpenAI => AiMode::Chat,
+ BackendType::OpenAI | BackendType::Remote => AiMode::Chat,
}
}
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -24,7 +24,7 @@ mod update;
mod vec3;
use agent_status::AgentStatus;
-use backend::{AiBackend, BackendType, ClaudeBackend, OpenAiBackend};
+use backend::{AiBackend, BackendType, ClaudeBackend, OpenAiBackend, RemoteOnlyBackend};
use chrono::{Duration, Local};
use egui_wgpu::RenderState;
use enostr::KeypairUnowned;
@@ -171,11 +171,7 @@ struct PendingMessageLoad {
///
/// ndb's `process_pns` will unwrap it internally, making the inner
/// event queryable. This ensures 1080 events exist in ndb for relay sync.
-fn pns_ingest(
- ndb: &nostrdb::Ndb,
- event_json: &str,
- secret_key: &[u8; 32],
-) {
+fn pns_ingest(ndb: &nostrdb::Ndb, event_json: &str, secret_key: &[u8; 32]) {
let pns_keys = enostr::pns::derive_pns_keys(secret_key);
match session_events::wrap_pns(event_json, &pns_keys) {
Ok(pns_json) => {
@@ -292,6 +288,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.expect("Claude backend requires ANTHROPIC_API_KEY or CLAUDE_API_KEY");
Box::new(ClaudeBackend::new(api_key.clone()))
}
+ BackendType::Remote => Box::new(RemoteOnlyBackend),
};
let avatar = render_state.map(DaveAvatar::new);
@@ -406,7 +403,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
match res {
DaveApiResponse::Failed(ref err) => {
if let Some(sk) = &secret_key {
- if let Some(evt) = ingest_live_event(session, app_ctx.ndb, sk, err, "error", None) {
+ if let Some(evt) =
+ ingest_live_event(session, app_ctx.ndb, sk, err, "error", None)
+ {
events_to_publish.push(evt);
}
}
@@ -490,15 +489,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
pns_ingest(app_ctx.ndb, &evt.note_json, sk);
// Store note_id for linking responses
if let Some(agentic) = &mut session.agentic {
- agentic.perm_request_note_ids.insert(
- pending.request.id,
- evt.note_id,
- );
+ agentic
+ .perm_request_note_ids
+ .insert(pending.request.id, evt.note_id);
}
events_to_publish.push(evt);
}
Err(e) => {
- tracing::warn!("failed to build permission request event: {}", e);
+ tracing::warn!(
+ "failed to build permission request event: {}",
+ e
+ );
}
}
}
@@ -522,8 +523,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
// Generate live event for tool result
if let Some(sk) = &secret_key {
- let content =
- format!("{}: {}", result.tool_name, result.summary);
+ let content = format!("{}: {}", result.tool_name, result.summary);
if let Some(evt) = ingest_live_event(
session,
app_ctx.ndb,
@@ -723,8 +723,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
} => {
let claude_session_id = session_id.clone();
let sid = self.create_resumed_session_with_cwd(cwd, session_id, title);
- self.pending_archive_convert =
- Some((file_path, sid, claude_session_id));
+ self.pending_archive_convert = Some((file_path, sid, claude_session_id));
self.session_picker.close();
self.active_overlay = DaveOverlay::None;
}
@@ -801,7 +800,6 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
&self.model_config,
is_interrupt_pending,
self.auto_steal_focus,
- self.ai_mode,
app_ctx,
ui,
);
@@ -834,7 +832,6 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
&self.model_config,
is_interrupt_pending,
self.auto_steal_focus,
- self.ai_mode,
self.show_session_list,
app_ctx,
ui,
@@ -1006,10 +1003,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
let content = note.content();
let (allowed, message) = match serde_json::from_str::<serde_json::Value>(content) {
Ok(v) => {
- let decision = v
- .get("decision")
- .and_then(|d| d.as_str())
- .unwrap_or("deny");
+ let decision = v.get("decision").and_then(|d| d.as_str()).unwrap_or("deny");
let msg = v
.get("message")
.and_then(|m| m.as_str())
@@ -1046,10 +1040,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
if sender.send(response).is_err() {
- tracing::warn!(
- "failed to send remote permission response for {}",
- perm_id
- );
+ tracing::warn!("failed to send remote permission response for {}", perm_id);
} else {
tracing::info!(
"remote permission response for {}: {}",
@@ -1095,11 +1086,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
&sk,
) {
Ok(evt) => {
- tracing::info!(
- "publishing session state: {} -> {}",
- claude_sid,
- status,
- );
+ tracing::info!("publishing session state: {} -> {}", claude_sid, status,);
pns_ingest(ctx.ndb, &evt.note_json, &sk);
self.pending_relay_events.push(evt);
}
@@ -1236,11 +1223,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
);
// Load conversation history from kind-1988 events
- let loaded = session_loader::load_session_messages(
- ctx.ndb,
- &txn,
- &state.claude_session_id,
- );
+ let loaded =
+ session_loader::load_session_messages(ctx.ndb, &txn, &state.claude_session_id);
if let Some(session) = self.session_manager.get_mut(dave_sid) {
tracing::info!(
@@ -1258,18 +1242,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
let is_remote = session.is_remote();
if let Some(agentic) = &mut session.agentic {
- if let (Some(root), Some(last)) =
- (loaded.root_note_id, loaded.last_note_id)
- {
+ if let (Some(root), Some(last)) = (loaded.root_note_id, loaded.last_note_id) {
agentic.live_threading.seed(root, last, loaded.event_count);
}
// Load permission state and dedup set from events
agentic.responded_perm_ids = loaded.responded_perm_ids;
- agentic.perm_request_note_ids.extend(loaded.perm_request_note_ids);
+ agentic
+ .perm_request_note_ids
+ .extend(loaded.perm_request_note_ids);
agentic.seen_note_ids = loaded.note_ids;
// Set remote status from state event
- agentic.remote_status =
- AgentStatus::from_status_str(&state.status);
+ agentic.remote_status = AgentStatus::from_status_str(&state.status);
// Set up live conversation subscription for remote sessions
if is_remote && agentic.live_conversation_sub.is_none() {
@@ -1350,9 +1333,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.session_manager
.iter()
.filter(|s| {
- s.agentic
- .as_ref()
- .and_then(|a| a.event_session_id())
+ s.agentic.as_ref().and_then(|a| a.event_session_id())
== Some(claude_sid)
})
.map(|s| s.id)
@@ -1385,7 +1366,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
continue;
}
- let title = session_events::get_tag_value(¬e, "title").unwrap_or("Untitled").to_string();
+ let title = session_events::get_tag_value(¬e, "title")
+ .unwrap_or("Untitled")
+ .to_string();
let cwd_str = session_events::get_tag_value(¬e, "cwd").unwrap_or("");
let cwd = std::path::PathBuf::from(cwd_str);
@@ -1403,11 +1386,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
);
// Load any conversation history that arrived with it
- let loaded = session_loader::load_session_messages(
- ctx.ndb,
- &txn,
- claude_sid,
- );
+ let loaded = session_loader::load_session_messages(ctx.ndb, &txn, claude_sid);
if let Some(session) = self.session_manager.get_mut(dave_sid) {
if !loaded.messages.is_empty() {
@@ -1426,14 +1405,14 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
let is_remote = session.is_remote();
if let Some(agentic) = &mut session.agentic {
- if let (Some(root), Some(last)) =
- (loaded.root_note_id, loaded.last_note_id)
- {
+ if let (Some(root), Some(last)) = (loaded.root_note_id, loaded.last_note_id) {
agentic.live_threading.seed(root, last, loaded.event_count);
}
// Load permission state and dedup set
agentic.responded_perm_ids = loaded.responded_perm_ids;
- agentic.perm_request_note_ids.extend(loaded.perm_request_note_ids);
+ agentic
+ .perm_request_note_ids
+ .extend(loaded.perm_request_note_ids);
agentic.seen_note_ids = loaded.note_ids;
// Set remote status
agentic.remote_status = AgentStatus::from_status_str(status_str);
@@ -1522,22 +1501,16 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
match role {
Some("user") => {
- session
- .chat
- .push(Message::User(content.to_string()));
+ session.chat.push(Message::User(content.to_string()));
}
Some("assistant") => {
session.chat.push(Message::Assistant(
- crate::messages::AssistantMessage::from_text(
- content.to_string(),
- ),
+ crate::messages::AssistantMessage::from_text(content.to_string()),
));
}
Some("tool_call") => {
session.chat.push(Message::Assistant(
- crate::messages::AssistantMessage::from_text(
- content.to_string(),
- ),
+ crate::messages::AssistantMessage::from_text(content.to_string()),
));
}
Some("tool_result") => {
@@ -1547,16 +1520,15 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
} else {
content.to_string()
};
- session.chat.push(Message::ToolResult(
- crate::messages::ToolResult {
+ session
+ .chat
+ .push(Message::ToolResult(crate::messages::ToolResult {
tool_name: "tool".to_string(),
summary,
- },
- ));
+ }));
}
Some("permission_request") => {
- if let Ok(content_json) =
- serde_json::from_str::<serde_json::Value>(content)
+ if let Ok(content_json) = serde_json::from_str::<serde_json::Value>(content)
{
let tool_name = content_json["tool_name"]
.as_str()
@@ -1566,25 +1538,19 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.get("tool_input")
.cloned()
.unwrap_or(serde_json::Value::Null);
- let perm_id =
- session_events::get_tag_value(note, "perm-id")
- .and_then(|s| uuid::Uuid::parse_str(s).ok())
- .unwrap_or_else(uuid::Uuid::new_v4);
+ let perm_id = session_events::get_tag_value(note, "perm-id")
+ .and_then(|s| uuid::Uuid::parse_str(s).ok())
+ .unwrap_or_else(uuid::Uuid::new_v4);
// Check if we already responded
- let response =
- if agentic.responded_perm_ids.contains(&perm_id) {
- Some(
- crate::messages::PermissionResponseType::Allowed,
- )
- } else {
- None
- };
+ let response = if agentic.responded_perm_ids.contains(&perm_id) {
+ Some(crate::messages::PermissionResponseType::Allowed)
+ } else {
+ None
+ };
// Store the note ID for linking responses
- agentic
- .perm_request_note_ids
- .insert(perm_id, *note.id());
+ agentic.perm_request_note_ids.insert(perm_id, *note.id());
session.chat.push(Message::PermissionRequest(
crate::messages::PermissionRequest {
@@ -1600,9 +1566,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
Some("permission_response") => {
// Track that this permission was responded to
- if let Some(perm_id_str) =
- session_events::get_tag_value(note, "perm-id")
- {
+ if let Some(perm_id_str) = session_events::get_tag_value(note, "perm-id") {
if let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) {
agentic.responded_perm_ids.insert(perm_id);
// Update the matching PermissionRequest in chat
@@ -1806,7 +1770,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
// Generate live event for user message
if let Some(sk) = secret_key_bytes(app_ctx.accounts.get_selected_account().keypair()) {
- if let Some(evt) = ingest_live_event(session, app_ctx.ndb, &sk, &user_text, "user", None) {
+ if let Some(evt) =
+ ingest_live_event(session, app_ctx.ndb, &sk, &user_text, "user", None)
+ {
self.pending_relay_events.push(evt);
}
}
@@ -1871,7 +1837,7 @@ impl notedeck::App for Dave {
self.poll_ipc_commands();
// One-time initialization on first update
- if !self.sessions_restored && self.ai_mode == AiMode::Agentic {
+ if !self.sessions_restored {
self.sessions_restored = true;
// Process any PNS-wrapped events already in ndb
@@ -1891,14 +1857,8 @@ impl notedeck::App for Dave {
// Subscribe to PNS events on relays for session discovery from other devices.
// Also subscribe locally in ndb for kind-31988 session state events
// so we detect new sessions appearing after PNS unwrapping.
- if let Some(sk) = ctx
- .accounts
- .get_selected_account()
- .keypair()
- .secret_key
- {
- let pns_keys =
- enostr::pns::derive_pns_keys(&sk.secret_bytes());
+ if let Some(sk) = ctx.accounts.get_selected_account().keypair().secret_key {
+ let pns_keys = enostr::pns::derive_pns_keys(&sk.secret_bytes());
// Ensure the PNS relay is in the pool
let egui_ctx = ui.ctx().clone();
@@ -1968,66 +1928,61 @@ impl notedeck::App for Dave {
claude_sid
);
let loaded_txn = Transaction::new(ctx.ndb).expect("txn");
- let loaded = session_loader::load_session_messages(
- ctx.ndb,
- &loaded_txn,
- &claude_sid,
- );
+ let loaded =
+ session_loader::load_session_messages(ctx.ndb, &loaded_txn, &claude_sid);
if let Some(session) = self.session_manager.get_mut(dave_sid) {
tracing::info!("loaded {} messages into chat UI", loaded.messages.len());
session.chat = loaded.messages;
if let Some(agentic) = &mut session.agentic {
- if let (Some(root), Some(last)) =
- (loaded.root_note_id, loaded.last_note_id)
+ if let (Some(root), Some(last)) = (loaded.root_note_id, loaded.last_note_id)
{
agentic.live_threading.seed(root, last, loaded.event_count);
}
- agentic.perm_request_note_ids.extend(loaded.perm_request_note_ids);
+ agentic
+ .perm_request_note_ids
+ .extend(loaded.perm_request_note_ids);
}
}
- } else {
- if let Some(secret_bytes) = secret_key_bytes(ctx.accounts.get_selected_account().keypair()) {
- // Subscribe for 1988 events BEFORE ingesting so we catch them
- let sub_filter = nostrdb::Filter::new()
- .kinds([session_events::AI_CONVERSATION_KIND as u64])
- .tags([claude_sid.as_str()], 'd')
- .build();
-
- match ctx.ndb.subscribe(&[sub_filter]) {
- Ok(sub) => {
- match session_converter::convert_session_to_events(
- &file_path,
- ctx.ndb,
- &secret_bytes,
- ) {
- Ok(note_ids) => {
- tracing::info!(
- "archived session: {} events from {}, awaiting indexing",
- note_ids.len(),
- file_path.display()
- );
- self.pending_message_load = Some(PendingMessageLoad {
- sub,
- dave_session_id: dave_sid,
- claude_session_id: claude_sid,
- });
- }
- Err(e) => {
- tracing::error!("archive conversion failed: {}", e);
- }
+ } else if let Some(secret_bytes) =
+ secret_key_bytes(ctx.accounts.get_selected_account().keypair())
+ {
+ // Subscribe for 1988 events BEFORE ingesting so we catch them
+ let sub_filter = nostrdb::Filter::new()
+ .kinds([session_events::AI_CONVERSATION_KIND as u64])
+ .tags([claude_sid.as_str()], 'd')
+ .build();
+
+ match ctx.ndb.subscribe(&[sub_filter]) {
+ Ok(sub) => {
+ match session_converter::convert_session_to_events(
+ &file_path,
+ ctx.ndb,
+ &secret_bytes,
+ ) {
+ Ok(note_ids) => {
+ tracing::info!(
+ "archived session: {} events from {}, awaiting indexing",
+ note_ids.len(),
+ file_path.display()
+ );
+ self.pending_message_load = Some(PendingMessageLoad {
+ sub,
+ dave_session_id: dave_sid,
+ claude_session_id: claude_sid,
+ });
+ }
+ Err(e) => {
+ tracing::error!("archive conversion failed: {}", e);
}
- }
- Err(e) => {
- tracing::error!(
- "failed to subscribe for archive events: {:?}",
- e
- );
}
}
- } else {
- tracing::warn!("no secret key available for archive conversion");
+ Err(e) => {
+ tracing::error!("failed to subscribe for archive events: {:?}", e);
+ }
}
+ } else {
+ tracing::warn!("no secret key available for archive conversion");
}
}
@@ -2048,12 +2003,13 @@ impl notedeck::App for Dave {
// Seed live threading from archive events so new events
// thread as replies to the existing conversation.
if let Some(agentic) = &mut session.agentic {
- if let (Some(root), Some(last)) =
- (loaded.root_note_id, loaded.last_note_id)
+ if let (Some(root), Some(last)) = (loaded.root_note_id, loaded.last_note_id)
{
agentic.live_threading.seed(root, last, loaded.event_count);
}
- agentic.perm_request_note_ids.extend(loaded.perm_request_note_ids);
+ agentic
+ .perm_request_note_ids
+ .extend(loaded.perm_request_note_ids);
}
}
self.pending_message_load = None;
@@ -2069,12 +2025,17 @@ impl notedeck::App for Dave {
.and_then(|s| s.agentic.as_ref())
.map(|a| a.permission_message_state != crate::session::PermissionMessageState::None)
.unwrap_or(false);
+ let active_ai_mode = self
+ .session_manager
+ .get_active()
+ .map(|s| s.ai_mode)
+ .unwrap_or(self.ai_mode);
if let Some(key_action) = check_keybindings(
ui.ctx(),
has_pending_permission,
has_pending_question,
in_tentative_state,
- self.ai_mode,
+ active_ai_mode,
) {
self.handle_key_action(key_action, ui);
}
@@ -2091,21 +2052,14 @@ impl notedeck::App for Dave {
// 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());
- if let Some(sk) = ctx
- .accounts
- .get_selected_account()
- .keypair()
- .secret_key
- {
+ if let Some(sk) = ctx.accounts.get_selected_account().keypair().secret_key {
let pns_keys = enostr::pns::derive_pns_keys(&sk.secret_bytes());
for event in all_events {
match session_events::wrap_pns(&event.note_json, &pns_keys) {
- Ok(pns_json) => {
- match enostr::ClientMessage::event_json(pns_json) {
- Ok(msg) => ctx.pool.send_to(&msg, PNS_RELAY_URL),
- Err(e) => tracing::warn!("failed to build relay message: {:?}", e),
- }
- }
+ Ok(pns_json) => match enostr::ClientMessage::event_json(pns_json) {
+ Ok(msg) => ctx.pool.send_to(&msg, PNS_RELAY_URL),
+ Err(e) => tracing::warn!("failed to build relay message: {:?}", e),
+ },
Err(e) => tracing::warn!("failed to PNS-wrap event: {}", e),
}
}
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -263,14 +263,10 @@ impl ChatSession {
pub fn has_pending_permissions(&self) -> bool {
if self.is_remote() {
// Remote: check for unresponded PermissionRequest messages in chat
- let responded = self
- .agentic
- .as_ref()
- .map(|a| &a.responded_perm_ids);
+ let responded = self.agentic.as_ref().map(|a| &a.responded_perm_ids);
return self.chat.iter().any(|msg| {
if let Message::PermissionRequest(req) = msg {
- req.response.is_none()
- && responded.map_or(true, |ids| !ids.contains(&req.id))
+ req.response.is_none() && responded.is_none_or(|ids| !ids.contains(&req.id))
} else {
false
}
diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs
@@ -391,6 +391,7 @@ fn build_source_data_event(
/// assistant message.
///
/// `tool_id`: The tool use/result ID for tool_call and tool_result events.
+#[allow(clippy::too_many_arguments)]
fn build_single_event(
line: Option<&JsonlLine>,
content: &str,
@@ -542,14 +543,8 @@ pub fn build_permission_request_event(
builder = builder.start_tag().tag_str("d").tag_str(session_id);
// Permission-specific tags
- builder = builder
- .start_tag()
- .tag_str("perm-id")
- .tag_str(&perm_id_str);
- builder = builder
- .start_tag()
- .tag_str("tool-name")
- .tag_str(tool_name);
+ builder = builder.start_tag().tag_str("perm-id").tag_str(&perm_id_str);
+ builder = builder.start_tag().tag_str("tool-name").tag_str(tool_name);
builder = builder
.start_tag()
.tag_str("role")
@@ -560,14 +555,8 @@ pub fn build_permission_request_event(
.tag_str("notedeck-dave");
// Discoverability
- builder = builder
- .start_tag()
- .tag_str("t")
- .tag_str("ai-conversation");
- builder = builder
- .start_tag()
- .tag_str("t")
- .tag_str("ai-permission");
+ builder = builder.start_tag().tag_str("t").tag_str("ai-conversation");
+ builder = builder.start_tag().tag_str("t").tag_str("ai-permission");
finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND)
}
@@ -602,16 +591,10 @@ pub fn build_permission_response_event(
builder = builder.start_tag().tag_str("d").tag_str(session_id);
// Link to the request event
- builder = builder
- .start_tag()
- .tag_str("e")
- .tag_id(request_note_id);
+ builder = builder.start_tag().tag_str("e").tag_id(request_note_id);
// Permission-specific tags
- builder = builder
- .start_tag()
- .tag_str("perm-id")
- .tag_str(&perm_id_str);
+ builder = builder.start_tag().tag_str("perm-id").tag_str(&perm_id_str);
builder = builder
.start_tag()
.tag_str("role")
@@ -622,14 +605,8 @@ pub fn build_permission_response_event(
.tag_str("notedeck-dave");
// Discoverability
- builder = builder
- .start_tag()
- .tag_str("t")
- .tag_str("ai-conversation");
- builder = builder
- .start_tag()
- .tag_str("t")
- .tag_str("ai-permission");
+ builder = builder.start_tag().tag_str("t").tag_str("ai-conversation");
+ builder = builder.start_tag().tag_str("t").tag_str("ai-permission");
finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND)
}
@@ -657,14 +634,8 @@ pub fn build_session_state_event(
builder = builder.start_tag().tag_str("status").tag_str(status);
// Discoverability
- builder = builder
- .start_tag()
- .tag_str("t")
- .tag_str("ai-session-state");
- builder = builder
- .start_tag()
- .tag_str("t")
- .tag_str("ai-conversation");
+ builder = builder.start_tag().tag_str("t").tag_str("ai-session-state");
+ builder = builder.start_tag().tag_str("t").tag_str("ai-conversation");
builder = builder
.start_tag()
.tag_str("source")
@@ -809,7 +780,10 @@ mod tests {
assert!(!events[0].note_json.contains("source-data"));
// 1989 event should have source-data with raw paths preserved
- let sd_event = events.iter().find(|e| e.kind == AI_SOURCE_DATA_KIND).unwrap();
+ let sd_event = events
+ .iter()
+ .find(|e| e.kind == AI_SOURCE_DATA_KIND)
+ .unwrap();
assert!(sd_event.note_json.contains("source-data"));
assert!(sd_event.note_json.contains("/Users/jb55/dev/notedeck"));
}
@@ -1028,14 +1002,9 @@ mod tests {
let tool_input = serde_json::json!({"command": "rm -rf /tmp/test"});
let sk = test_secret_key();
- let event = build_permission_request_event(
- &perm_id,
- "Bash",
- &tool_input,
- "sess-perm-test",
- &sk,
- )
- .unwrap();
+ let event =
+ build_permission_request_event(&perm_id, "Bash", &tool_input, "sess-perm-test", &sk)
+ .unwrap();
assert_eq!(event.kind, AI_CONVERSATION_KIND);
diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs
@@ -18,11 +18,7 @@ use std::collections::HashSet;
/// `created_at` for each unique `d` tag value.
///
/// Returns a Vec of `NoteKey`s for the winning notes (one per unique d-tag).
-pub fn query_replaceable(
- ndb: &Ndb,
- txn: &Transaction,
- filters: &[Filter],
-) -> Vec<NoteKey> {
+pub fn query_replaceable(ndb: &Ndb, txn: &Transaction, filters: &[Filter]) -> Vec<NoteKey> {
query_replaceable_filtered(ndb, txn, filters, |_| true)
}
@@ -47,7 +43,7 @@ pub fn query_replaceable_filtered(
return acc;
};
- let created_at = note.created_at() as u64;
+ let created_at = note.created_at();
if let Some((existing_ts, _)) = acc.get(d_tag) {
if created_at <= *existing_ts {
@@ -56,7 +52,10 @@ pub fn query_replaceable_filtered(
}
if predicate(¬e) {
- acc.insert(d_tag.to_string(), (created_at, note.key().expect("note key")));
+ acc.insert(
+ d_tag.to_string(),
+ (created_at, note.key().expect("note key")),
+ );
} else {
// Latest revision rejected — remove any older revision we kept
acc.remove(d_tag);
@@ -275,7 +274,9 @@ pub fn load_session_states(ndb: &Ndb, txn: &Transaction) -> Vec<SessionState> {
states.push(SessionState {
claude_session_id: claude_session_id.to_string(),
- title: get_tag_value(¬e, "title").unwrap_or("Untitled").to_string(),
+ title: get_tag_value(¬e, "title")
+ .unwrap_or("Untitled")
+ .to_string(),
cwd: get_tag_value(¬e, "cwd").unwrap_or("").to_string(),
status: get_tag_value(¬e, "status").unwrap_or("idle").to_string(),
});
diff --git a/crates/notedeck_dave/src/ui/markdown_ui.rs b/crates/notedeck_dave/src/ui/markdown_ui.rs
@@ -275,9 +275,7 @@ fn render_table(headers: &[Span], rows: &[Vec<Span>], theme: &MdTheme, buffer: &
// Use first header's byte offset as id_salt so multiple tables don't clash
let salt = headers.first().map_or(0, |h| h.start);
- let mut builder = TableBuilder::new(ui)
- .id_salt(salt)
- .vscroll(false);
+ let mut builder = TableBuilder::new(ui).id_salt(salt).vscroll(false);
for _ in 0..num_cols {
builder = builder.column(Column::auto().resizable(true));
}
diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -319,7 +319,6 @@ pub fn desktop_ui(
model_config: &ModelConfig,
is_interrupt_pending: bool,
auto_steal_focus: bool,
- ai_mode: AiMode,
app_ctx: &mut notedeck::AppContext,
ui: &mut egui::Ui,
) -> (DaveResponse, Option<SessionListAction>, bool) {
@@ -341,7 +340,11 @@ pub fn desktop_ui(
.fill(ui.visuals().faint_bg_color)
.inner_margin(egui::Margin::symmetric(8, 12))
.show(ui, |ui| {
- if ai_mode == AiMode::Agentic {
+ let has_agentic = session_manager
+ .sessions_ordered()
+ .iter()
+ .any(|s| s.ai_mode == AiMode::Agentic);
+ if has_agentic {
ui.horizontal(|ui| {
if ui
.button("Scene View")
@@ -356,7 +359,7 @@ pub fn desktop_ui(
});
ui.separator();
}
- SessionListUi::new(session_manager, focus_queue, ctrl_held, ai_mode).ui(ui)
+ SessionListUi::new(session_manager, focus_queue, ctrl_held).ui(ui)
})
.inner
})
@@ -365,8 +368,13 @@ pub fn desktop_ui(
let chat_response = ui
.allocate_new_ui(egui::UiBuilder::new().max_rect(chat_rect), |ui| {
if let Some(session) = session_manager.get_active_mut() {
- build_dave_ui(session, model_config, is_interrupt_pending, auto_steal_focus)
- .ui(app_ctx, ui)
+ build_dave_ui(
+ session,
+ model_config,
+ is_interrupt_pending,
+ auto_steal_focus,
+ )
+ .ui(app_ctx, ui)
} else {
DaveResponse::default()
}
@@ -384,7 +392,6 @@ pub fn narrow_ui(
model_config: &ModelConfig,
is_interrupt_pending: bool,
auto_steal_focus: bool,
- ai_mode: AiMode,
show_session_list: bool,
app_ctx: &mut notedeck::AppContext,
ui: &mut egui::Ui,
@@ -395,14 +402,18 @@ pub fn narrow_ui(
.fill(ui.visuals().faint_bg_color)
.inner_margin(egui::Margin::symmetric(8, 12))
.show(ui, |ui| {
- SessionListUi::new(session_manager, focus_queue, ctrl_held, ai_mode).ui(ui)
+ SessionListUi::new(session_manager, focus_queue, ctrl_held).ui(ui)
})
.inner;
(DaveResponse::default(), session_action)
} else if let Some(session) = session_manager.get_active_mut() {
- let response =
- build_dave_ui(session, model_config, is_interrupt_pending, auto_steal_focus)
- .ui(app_ctx, ui);
+ let response = build_dave_ui(
+ session,
+ model_config,
+ is_interrupt_pending,
+ auto_steal_focus,
+ )
+ .ui(app_ctx, ui);
(response, None)
} else {
(DaveResponse::default(), None)
@@ -711,8 +722,7 @@ pub fn handle_ui_action(
request_id,
response,
} => {
- let result =
- update::handle_permission_response(session_manager, request_id, response);
+ let result = update::handle_permission_response(session_manager, request_id, response);
if let update::PermissionResponseResult::NeedsRelayPublish {
perm_id,
allowed,
diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs
@@ -22,7 +22,6 @@ pub struct SessionListUi<'a> {
session_manager: &'a SessionManager,
focus_queue: &'a FocusQueue,
ctrl_held: bool,
- ai_mode: AiMode,
}
impl<'a> SessionListUi<'a> {
@@ -30,13 +29,11 @@ impl<'a> SessionListUi<'a> {
session_manager: &'a SessionManager,
focus_queue: &'a FocusQueue,
ctrl_held: bool,
- ai_mode: AiMode,
) -> Self {
SessionListUi {
session_manager,
focus_queue,
ctrl_held,
- ai_mode,
}
}
@@ -66,15 +63,9 @@ impl<'a> SessionListUi<'a> {
fn header_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> {
let mut action = None;
- // Header text and tooltip depend on mode
- let (header_text, new_tooltip) = match self.ai_mode {
- AiMode::Chat => ("Chats", "New Chat"),
- AiMode::Agentic => ("Agents", "New Agent"),
- };
-
ui.horizontal(|ui| {
ui.add_space(4.0);
- ui.label(egui::RichText::new(header_text).size(18.0).strong());
+ ui.label(egui::RichText::new("Sessions").size(18.0).strong());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let icon = app_images::new_message_image()
@@ -84,7 +75,7 @@ impl<'a> SessionListUi<'a> {
if ui
.add(icon)
.on_hover_cursor(egui::CursorIcon::PointingHand)
- .on_hover_text(new_tooltip)
+ .on_hover_text("New Chat")
.clicked()
{
action = Some(SessionListAction::NewSession);
@@ -98,49 +89,95 @@ impl<'a> SessionListUi<'a> {
fn sessions_list_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> {
let mut action = None;
let active_id = self.session_manager.active_id();
-
- for (index, session) in self.session_manager.sessions_ordered().iter().enumerate() {
- let is_active = Some(session.id) == active_id;
- // Show keyboard shortcut hint for first 9 sessions (1-9 keys), only when Ctrl held
- let shortcut_hint = if self.ctrl_held && index < 9 {
- Some(index + 1)
- } else {
- None
- };
-
- // Check if this session is in the focus queue
- let queue_priority = self.focus_queue.get_session_priority(session.id);
-
- // Get cwd from agentic data, fallback to empty path for Chat mode
- let empty_path = PathBuf::new();
- let cwd = session.cwd().unwrap_or(&empty_path);
-
- let response = self.session_item_ui(
- ui,
- &session.title,
- cwd,
- is_active,
- shortcut_hint,
- session.status(),
- queue_priority,
+ let sessions = self.session_manager.sessions_ordered();
+
+ // Split into agents and chats
+ let agents: Vec<_> = sessions
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| s.ai_mode == AiMode::Agentic)
+ .collect();
+ let chats: Vec<_> = sessions
+ .iter()
+ .enumerate()
+ .filter(|(_, s)| s.ai_mode == AiMode::Chat)
+ .collect();
+
+ // Agents section
+ if !agents.is_empty() {
+ ui.label(
+ egui::RichText::new("Agents")
+ .size(12.0)
+ .color(ui.visuals().weak_text_color()),
);
-
- if response.clicked() {
- action = Some(SessionListAction::SwitchTo(session.id));
+ ui.add_space(4.0);
+ for (index, session) in &agents {
+ if let Some(a) = self.render_session_item(ui, session, *index, active_id) {
+ action = Some(a);
+ }
}
+ ui.add_space(8.0);
+ }
- // Right-click context menu for delete
- response.context_menu(|ui| {
- if ui.button("Delete").clicked() {
- action = Some(SessionListAction::Delete(session.id));
- ui.close_menu();
+ // Chats section
+ if !chats.is_empty() {
+ ui.label(
+ egui::RichText::new("Chats")
+ .size(12.0)
+ .color(ui.visuals().weak_text_color()),
+ );
+ ui.add_space(4.0);
+ for (index, session) in &chats {
+ if let Some(a) = self.render_session_item(ui, session, *index, active_id) {
+ action = Some(a);
}
- });
+ }
}
action
}
+ fn render_session_item(
+ &self,
+ ui: &mut egui::Ui,
+ session: &crate::session::ChatSession,
+ index: usize,
+ active_id: Option<SessionId>,
+ ) -> Option<SessionListAction> {
+ let is_active = Some(session.id) == active_id;
+ let shortcut_hint = if self.ctrl_held && index < 9 {
+ Some(index + 1)
+ } else {
+ None
+ };
+ let queue_priority = self.focus_queue.get_session_priority(session.id);
+ let empty_path = PathBuf::new();
+ let cwd = session.cwd().unwrap_or(&empty_path);
+
+ let response = self.session_item_ui(
+ ui,
+ &session.title,
+ cwd,
+ is_active,
+ shortcut_hint,
+ session.status(),
+ queue_priority,
+ session.ai_mode,
+ );
+
+ let mut action = None;
+ if response.clicked() {
+ action = Some(SessionListAction::SwitchTo(session.id));
+ }
+ response.context_menu(|ui| {
+ if ui.button("Delete").clicked() {
+ action = Some(SessionListAction::Delete(session.id));
+ ui.close_menu();
+ }
+ });
+ action
+ }
+
#[allow(clippy::too_many_arguments)]
fn session_item_ui(
&self,
@@ -151,11 +188,12 @@ impl<'a> SessionListUi<'a> {
shortcut_hint: Option<usize>,
status: AgentStatus,
queue_priority: Option<FocusPriority>,
+ session_ai_mode: AiMode,
) -> egui::Response {
- // In Chat mode: shorter height (no CWD), no status bar
- // In Agentic mode: taller height with CWD and status bar
- let show_cwd = self.ai_mode == AiMode::Agentic;
- let show_status_bar = self.ai_mode == AiMode::Agentic;
+ // Per-session: Chat sessions get shorter height (no CWD), no status bar
+ // Agentic sessions get taller height with CWD and status bar
+ let show_cwd = session_ai_mode == AiMode::Agentic;
+ let show_status_bar = session_ai_mode == AiMode::Agentic;
let item_height = if show_cwd { 48.0 } else { 32.0 };
let desired_size = egui::vec2(ui.available_width(), item_height);
diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs
@@ -141,15 +141,10 @@ pub fn first_pending_permission(session_manager: &SessionManager) -> Option<uuid
let session = session_manager.get_active()?;
if session.is_remote() {
// Remote: find first unresponded PermissionRequest in chat
- let responded = session
- .agentic
- .as_ref()
- .map(|a| &a.responded_perm_ids);
+ let responded = session.agentic.as_ref().map(|a| &a.responded_perm_ids);
for msg in &session.chat {
if let Message::PermissionRequest(req) = msg {
- if req.response.is_none()
- && responded.map_or(true, |ids| !ids.contains(&req.id))
- {
+ if req.response.is_none() && responded.is_none_or(|ids| !ids.contains(&req.id)) {
return Some(req.id);
}
}
@@ -482,7 +477,11 @@ pub fn cycle_prev_agent(
show_scene: bool,
) {
cycle_agent(session_manager, scene, show_scene, |idx, len| {
- if idx == 0 { len - 1 } else { idx - 1 }
+ if idx == 0 {
+ len - 1
+ } else {
+ idx - 1
+ }
});
}
@@ -613,7 +612,10 @@ pub fn process_auto_steal_focus(
focus_queue.set_cursor(idx);
if let Some(entry) = focus_queue.current() {
switch_and_focus_session(session_manager, scene, show_scene, entry.session_id);
- tracing::debug!("Auto-steal: switched to Done session {:?}", entry.session_id);
+ tracing::debug!(
+ "Auto-steal: switched to Done session {:?}",
+ entry.session_id
+ );
return true;
}
}