diff --git a/Cargo.lock b/Cargo.lock index de2a434ab..8dd3234a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1845,16 +1845,19 @@ checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" name = "enostr" version = "0.3.0" dependencies = [ + "base64 0.22.1", "bech32", "ewebsock", "hashbrown 0.15.4", "hex", + "hkdf", "mio", "nostr 0.37.0", "nostrdb", "serde", "serde_derive", "serde_json", + "sha2", "thiserror 2.0.12", "tokenator", "tokio", @@ -2413,6 +2416,16 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.0.7", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -2730,6 +2743,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -3929,7 +3951,7 @@ dependencies = [ [[package]] name = "nostrdb" version = "0.9.0" -source = "git+https://github.com/damus-io/nostrdb-rs?rev=34738d2894d841ac44b1c46e0334a7cf2ca09b34#34738d2894d841ac44b1c46e0334a7cf2ca09b34" +source = "git+https://github.com/damus-io/nostrdb-rs?rev=9aeecd3c4576be0b34df87c26e334e87a39d57e5#9aeecd3c4576be0b34df87c26e334e87a39d57e5" dependencies = [ "bindgen 0.69.5", "cc", @@ -4151,6 +4173,7 @@ dependencies = [ "egui_extras", "enostr", "futures", + "gethostname 1.1.0", "hex", "md-stream", "nostrdb", @@ -4164,6 +4187,7 @@ dependencies = [ "serde_json", "sha2", "similar", + "tempfile", "tokio", "tracing", "uuid", @@ -8384,7 +8408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" dependencies = [ "as-raw-xcb-connection", - "gethostname", + "gethostname 0.4.3", "libc", "libloading", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index e01a2df07..eef2ddff2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ md5 = "0.7.0" nostr = { version = "0.37.0", default-features = false, features = ["std", "nip44", "nip49"] } nwc = "0.39.0" mio = { version = "1.0.3", features = ["os-poll", "net"] } -nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "34738d2894d841ac44b1c46e0334a7cf2ca09b34" } +nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "9aeecd3c4576be0b34df87c26e334e87a39d57e5" } #nostrdb = "0.6.1" notedeck = { path = "crates/notedeck" } notedeck_chrome = { path = "crates/notedeck_chrome" } @@ -97,6 +97,7 @@ url = "2.5.2" urlencoding = "2.1.3" uuid = { version = "1.10.0", features = ["v4"] } sha2 = "0.10.8" +hkdf = "0.12.4" bincode = "1.3.3" mime_guess = "2.0.5" pretty_assertions = "1.4.1" diff --git a/crates/enostr/Cargo.toml b/crates/enostr/Cargo.toml index 274d62fe6..fd3791ed7 100644 --- a/crates/enostr/Cargo.toml +++ b/crates/enostr/Cargo.toml @@ -20,4 +20,7 @@ url = { workspace = true } mio = { workspace = true } tokio = { workspace = true } tokenator = { workspace = true } -hashbrown = { workspace = true } \ No newline at end of file +hashbrown = { workspace = true } +hkdf = { workspace = true } +sha2 = { workspace = true } +base64 = { workspace = true } \ No newline at end of file diff --git a/crates/enostr/src/lib.rs b/crates/enostr/src/lib.rs index 985768f97..ffec292d0 100644 --- a/crates/enostr/src/lib.rs +++ b/crates/enostr/src/lib.rs @@ -3,6 +3,7 @@ mod error; mod filter; mod keypair; mod note; +pub mod pns; mod profile; mod pubkey; mod relay; diff --git a/crates/enostr/src/pns.rs b/crates/enostr/src/pns.rs new file mode 100644 index 000000000..914bff0a8 --- /dev/null +++ b/crates/enostr/src/pns.rs @@ -0,0 +1,229 @@ +//! NIP-PNS: Private Note Storage +//! +//! Deterministic key derivation and encryption for storing private nostr +//! events on relays. Only the owner of the device key can publish and +//! decrypt PNS events (kind 1080). +//! +//! Key derivation: +//! pns_key = hkdf_extract(ikm=device_key, salt="nip-pns") +//! pns_keypair = derive_secp256k1_keypair(pns_key) +//! pns_nip44_key = hkdf_extract(ikm=pns_key, salt="nip44-v2") + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use hkdf::Hkdf; +use nostr::nips::nip44::v2::{self, ConversationKey}; +use sha2::Sha256; + +use crate::{FullKeypair, Pubkey}; + +/// Kind number for PNS events. +pub const PNS_KIND: u32 = 1080; + +/// Salt used for deriving pns_key from the device key. +const PNS_SALT: &[u8] = b"nip-pns"; + +/// Salt used for deriving the NIP-44 symmetric key from pns_key. +const NIP44_SALT: &[u8] = b"nip44-v2"; + +/// Derived PNS keys — everything needed to create and decrypt PNS events. +pub struct PnsKeys { + /// Keypair for signing kind-1080 events (derived from pns_key). + pub keypair: FullKeypair, + /// NIP-44 conversation key for encrypting/decrypting content. + pub conversation_key: ConversationKey, +} + +/// Derive all PNS keys from a device secret key. +/// +/// This is deterministic: the same device key always produces the same +/// PNS keypair and encryption key. +pub fn derive_pns_keys(device_key: &[u8; 32]) -> PnsKeys { + let pns_key = hkdf_extract(device_key, PNS_SALT); + let keypair = keypair_from_bytes(&pns_key); + let nip44_key = hkdf_extract(&pns_key, NIP44_SALT); + let conversation_key = ConversationKey::new(nip44_key); + + PnsKeys { + keypair, + conversation_key, + } +} + +/// Encrypt an inner event JSON string for PNS storage. +/// +/// 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 { + let payload = v2::encrypt_to_bytes(conversation_key, inner_json).map_err(PnsError::Encrypt)?; + Ok(BASE64.encode(payload)) +} + +/// Decrypt a PNS event's content field back to the inner event JSON. +/// +/// Takes base64-encoded NIP-44 v2 ciphertext from a kind-1080 event. +pub fn decrypt(conversation_key: &ConversationKey, content: &str) -> Result { + let payload = BASE64.decode(content).map_err(PnsError::Base64)?; + let plaintext = v2::decrypt_to_bytes(conversation_key, &payload).map_err(PnsError::Decrypt)?; + String::from_utf8(plaintext).map_err(PnsError::Utf8) +} + +/// HMAC-SHA256(key=salt, msg=ikm) → 32-byte key. +/// +/// This matches the nostrdb C implementation which uses raw HMAC-SHA256 +/// (i.e. HKDF-Extract only, without HKDF-Expand). +fn hkdf_extract(ikm: &[u8; 32], salt: &[u8]) -> [u8; 32] { + let (prk, _) = Hkdf::::extract(Some(salt), ikm); + let mut out = [0u8; 32]; + out.copy_from_slice(&prk); + out +} + +/// Derive a secp256k1 keypair from 32 bytes of key material. +fn keypair_from_bytes(key: &[u8; 32]) -> FullKeypair { + let secret_key = + nostr::SecretKey::from_slice(key).expect("32 bytes of HKDF output is a valid secret key"); + let (xopk, _) = secret_key.x_only_public_key(&nostr::SECP256K1); + FullKeypair { + pubkey: Pubkey::new(xopk.serialize()), + secret_key, + } +} + +#[derive(Debug)] +pub enum PnsError { + Encrypt(nostr::nips::nip44::Error), + Decrypt(nostr::nips::nip44::Error), + Base64(base64::DecodeError), + Utf8(std::string::FromUtf8Error), +} + +impl std::fmt::Display for PnsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PnsError::Encrypt(e) => write!(f, "PNS encrypt failed: {e}"), + PnsError::Decrypt(e) => write!(f, "PNS decrypt failed: {e}"), + PnsError::Base64(e) => write!(f, "PNS base64 decode failed: {e}"), + PnsError::Utf8(e) => write!(f, "PNS decrypted content is not UTF-8: {e}"), + } + } +} + +impl std::error::Error for PnsError {} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_device_key() -> [u8; 32] { + // Deterministic test key + let mut key = [0u8; 32]; + key[0] = 0x01; + key[31] = 0xff; + key + } + + #[test] + fn test_derive_pns_keys_deterministic() { + let dk = test_device_key(); + let keys1 = derive_pns_keys(&dk); + let keys2 = derive_pns_keys(&dk); + + assert_eq!(keys1.keypair.pubkey, keys2.keypair.pubkey); + assert_eq!( + keys1.conversation_key.as_bytes(), + keys2.conversation_key.as_bytes() + ); + } + + #[test] + fn test_pns_pubkey_differs_from_device_pubkey() { + let dk = test_device_key(); + let pns = derive_pns_keys(&dk); + + // Device pubkey + let device_sk = nostr::SecretKey::from_slice(&dk).unwrap(); + let (device_xopk, _) = device_sk.x_only_public_key(&nostr::SECP256K1); + let device_pubkey = Pubkey::new(device_xopk.serialize()); + + // PNS pubkey should be different (derived via HKDF) + assert_ne!(pns.keypair.pubkey, device_pubkey); + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let dk = test_device_key(); + let keys = derive_pns_keys(&dk); + + let inner = r#"{"kind":1,"pubkey":"abc","content":"hello","tags":[],"created_at":0}"#; + let encrypted = encrypt(&keys.conversation_key, inner).unwrap(); + + // Should be base64 + assert!(BASE64.decode(&encrypted).is_ok()); + + let decrypted = decrypt(&keys.conversation_key, &encrypted).unwrap(); + assert_eq!(decrypted, inner); + } + + #[test] + fn test_different_keys_cannot_decrypt() { + let dk1 = test_device_key(); + let mut dk2 = test_device_key(); + dk2[0] = 0x02; + + let keys1 = derive_pns_keys(&dk1); + let keys2 = derive_pns_keys(&dk2); + + let inner = r#"{"content":"secret"}"#; + let encrypted = encrypt(&keys1.conversation_key, inner).unwrap(); + + // Different key should fail to decrypt + assert!(decrypt(&keys2.conversation_key, &encrypted).is_err()); + } + + #[test] + fn test_matches_nostrdb_c_test_vector() { + // Same device key as nostrdb's test_pns_unwrap in test.c: + // unsigned char device_sec[32] = {0,...,0,2}; + let mut device_key = [0u8; 32]; + device_key[31] = 0x02; + + let keys = derive_pns_keys(&device_key); + + // The C test expects PNS pubkey: + // "fa22d53e9d38ca7af1e66dcf88f5fb2444368df6bd16580b5827c8cfbc622d4e" + let expected_pns_pubkey = + "fa22d53e9d38ca7af1e66dcf88f5fb2444368df6bd16580b5827c8cfbc622d4e"; + let actual_pns_pubkey = hex::encode(keys.keypair.pubkey.bytes()); + assert_eq!( + actual_pns_pubkey, expected_pns_pubkey, + "PNS pubkey must match nostrdb C implementation" + ); + + // Also verify device pubkey matches (sanity check): + // c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5 + let device_sk = nostr::SecretKey::from_slice(&device_key).unwrap(); + let (device_xopk, _) = device_sk.x_only_public_key(&nostr::SECP256K1); + let expected_device_pubkey = + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + assert_eq!(hex::encode(device_xopk.serialize()), expected_device_pubkey); + } + + #[test] + fn test_encrypt_produces_different_ciphertext() { + // NIP-44 uses random nonce, so encrypting same plaintext twice + // should produce different ciphertext + let dk = test_device_key(); + let keys = derive_pns_keys(&dk); + + let inner = r#"{"content":"hello"}"#; + let enc1 = encrypt(&keys.conversation_key, inner).unwrap(); + let enc2 = encrypt(&keys.conversation_key, inner).unwrap(); + + assert_ne!(enc1, enc2); + + // But both should decrypt to the same thing + assert_eq!(decrypt(&keys.conversation_key, &enc1).unwrap(), inner); + assert_eq!(decrypt(&keys.conversation_key, &enc2).unwrap(), inner); + } +} diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs index b80cc526d..a34389548 100644 --- a/crates/notedeck/src/app.rs +++ b/crates/notedeck/src/app.rs @@ -220,7 +220,13 @@ impl Notedeck { let settings = SettingsHandler::new(&path).load(); - let config = Config::new().set_ingester_threads(2).set_mapsize(map_size); + let config = Config::new() + .set_ingester_threads(2) + .set_mapsize(map_size) + .set_sub_callback({ + let ctx = ctx.clone(); + move |_| ctx.request_repaint() + }); let keystore = if parsed_args.options.contains(NotedeckOptions::UseKeystore) { let keys_path = path.path(DataPathType::Keys); diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml index bf1077595..356b97388 100644 --- a/crates/notedeck_dave/Cargo.toml +++ b/crates/notedeck_dave/Cargo.toml @@ -30,6 +30,7 @@ egui_extras = { workspace = true } md-stream = { workspace = true } similar = "2" dirs = "5" +gethostname = "1" [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] rfd = { workspace = true } @@ -40,6 +41,7 @@ objc2-app-kit = { version = "0.3.1", features = ["NSApplication", "NSResponder", [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } +tempfile = { workspace = true } [[bin]] name = "notedeck-spawn" diff --git a/crates/notedeck_dave/src/agent_status.rs b/crates/notedeck_dave/src/agent_status.rs index 55f608f61..d48fcb0e2 100644 --- a/crates/notedeck_dave/src/agent_status.rs +++ b/crates/notedeck_dave/src/agent_status.rs @@ -36,4 +36,27 @@ impl AgentStatus { AgentStatus::Done => "Done", } } + + /// Get the status as a lowercase string for serialization (nostr events). + pub fn as_str(&self) -> &'static str { + match self { + AgentStatus::Idle => "idle", + AgentStatus::Working => "working", + AgentStatus::NeedsInput => "needs_input", + AgentStatus::Error => "error", + AgentStatus::Done => "done", + } + } + + /// Parse a status string from a nostr event (kind-31988 content). + pub fn from_status_str(s: &str) -> Option { + match s { + "idle" => Some(AgentStatus::Idle), + "working" => Some(AgentStatus::Working), + "needs_input" => Some(AgentStatus::NeedsInput), + "error" => Some(AgentStatus::Error), + "done" => Some(AgentStatus::Done), + _ => None, + } + } } diff --git a/crates/notedeck_dave/src/auto_accept.rs b/crates/notedeck_dave/src/auto_accept.rs index 236b614fb..70cefc9d1 100644 --- a/crates/notedeck_dave/src/auto_accept.rs +++ b/crates/notedeck_dave/src/auto_accept.rs @@ -110,6 +110,7 @@ impl Default for AutoAcceptRules { "gh release view".into(), // Beads issue tracker "bd".into(), + "beads list".into(), ], }, AutoAcceptRule::ReadOnlyTool { diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs index c8395993b..576187c9d 100644 --- a/crates/notedeck_dave/src/backend/claude.rs +++ b/crates/notedeck_dave/src/backend/claude.rs @@ -555,9 +555,9 @@ async fn session_actor( } } Some(Err(err)) => { - tracing::error!("Claude stream error: {}", err); - let _ = response_tx.send(DaveApiResponse::Failed(err.to_string())); - stream_done = true; + // Non-fatal: unknown message types (e.g. rate_limit_event) + // cause deserialization errors but the stream continues. + tracing::warn!("Claude stream message skipped: {}", err); } None => { stream_done = true; @@ -620,24 +620,28 @@ impl AiBackend for ClaudeBackend { ) { let (response_tx, response_rx) = mpsc::channel(); - // Determine if this is the first message in the session - let is_first_message = messages - .iter() - .filter(|m| matches!(m, Message::User(_))) - .count() - == 1; - - // For first message, send full prompt; for continuation, just the latest message - let prompt = if is_first_message { - Self::messages_to_prompt(&messages) - } else { + // For resumed sessions, always send just the latest message since + // Claude Code already has the full conversation context via --resume. + // For new sessions, send full prompt on the first message. + let prompt = if resume_session_id.is_some() { Self::get_latest_user_message(&messages) + } else { + let is_first_message = messages + .iter() + .filter(|m| matches!(m, Message::User(_))) + .count() + == 1; + if is_first_message { + Self::messages_to_prompt(&messages) + } else { + Self::get_latest_user_message(&messages) + } }; tracing::debug!( - "Sending request to Claude Code: session={}, is_first={}, prompt length: {}, preview: {:?}", + "Sending request to Claude Code: session={}, resumed={}, prompt length: {}, preview: {:?}", session_id, - is_first_message, + resume_session_id.is_some(), prompt.len(), &prompt[..prompt.len().min(100)] ); diff --git a/crates/notedeck_dave/src/backend/mod.rs b/crates/notedeck_dave/src/backend/mod.rs index 328cd6daa..1e7df6e21 100644 --- 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 new file mode 100644 index 000000000..7f1dad050 --- /dev/null +++ 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, + _tools: Arc>, + _model: String, + _user_id: String, + _session_id: String, + _cwd: Option, + _resume_session_id: Option, + _ctx: egui::Context, + ) -> ( + mpsc::Receiver, + Option>, + ) { + // 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 index ba04710c2..962d0d53d 100644 --- 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 index cea62ddc6..bf2a8dd23 100644 --- 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/focus_queue.rs b/crates/notedeck_dave/src/focus_queue.rs index 1948448c1..640f1bb73 100644 --- a/crates/notedeck_dave/src/focus_queue.rs +++ b/crates/notedeck_dave/src/focus_queue.rs @@ -255,6 +255,20 @@ impl FocusQueue { .any(|e| e.priority == FocusPriority::NeedsInput) } + /// Find the first entry with Done priority and return its index + pub fn first_done_index(&self) -> Option { + self.entries + .iter() + .position(|e| e.priority == FocusPriority::Done) + } + + /// Check if there are any Done items in the queue + pub fn has_done(&self) -> bool { + self.entries + .iter() + .any(|e| e.priority == FocusPriority::Done) + } + pub fn ui_info(&self) -> Option<(usize, usize, FocusPriority)> { let entry = self.current()?; Some((self.current_position()?, self.len(), entry.priority)) diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs index d4fba27bb..52eaaf72b 100644 --- a/crates/notedeck_dave/src/lib.rs +++ b/crates/notedeck_dave/src/lib.rs @@ -9,21 +9,28 @@ pub(crate) mod git_status; pub mod ipc; pub(crate) mod mesh; mod messages; +mod path_normalize; mod quaternion; pub mod session; +pub mod session_converter; pub mod session_discovery; +pub mod session_events; +pub mod session_jsonl; +pub mod session_loader; +pub mod session_reconstructor; mod tools; mod ui; mod update; mod vec3; -use backend::{AiBackend, BackendType, ClaudeBackend, OpenAiBackend}; +use agent_status::AgentStatus; +use backend::{AiBackend, BackendType, ClaudeBackend, OpenAiBackend, RemoteOnlyBackend}; use chrono::{Duration, Local}; use egui_wgpu::RenderState; use enostr::KeypairUnowned; use focus_queue::FocusQueue; -use nostrdb::Transaction; -use notedeck::{ui::is_narrow, AppAction, AppContext, AppResponse}; +use nostrdb::{Subscription, Transaction}; +use notedeck::{try_process_events_core, ui::is_narrow, AppAction, AppContext, AppResponse}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::string::ToString; @@ -51,6 +58,19 @@ pub use ui::{ }; pub use vec3::Vec3; +/// Relay URL used for PNS event publishing and subscription. +/// TODO: make this configurable in the UI +const PNS_RELAY_URL: &str = "ws://relay.jb55.com/"; + +/// Extract a 32-byte secret key from a keypair. +fn secret_key_bytes(keypair: KeypairUnowned<'_>) -> Option<[u8; 32]> { + keypair.secret_key.map(|sk| { + sk.as_secret_bytes() + .try_into() + .expect("secret key is 32 bytes") + }) +} + /// Represents which full-screen overlay (if any) is currently active #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DaveOverlay { @@ -100,6 +120,134 @@ pub struct Dave { active_overlay: DaveOverlay, /// IPC listener for external spawn-agent commands ipc_listener: Option, + /// Pending archive conversion: (jsonl_path, dave_session_id, claude_session_id). + /// Set when resuming a session; processed in update() where AppContext is available. + pending_archive_convert: Option<(std::path::PathBuf, SessionId, String)>, + /// Waiting for ndb to finish indexing 1988 events so we can load messages. + pending_message_load: Option, + /// Events waiting to be published to relays (queued from non-pool contexts). + pending_relay_events: Vec, + /// Whether sessions have been restored from ndb on startup. + sessions_restored: bool, + /// Remote relay subscription ID for PNS events (kind-1080). + /// Used to discover session state events from other devices. + pns_relay_sub: Option, + /// Local ndb subscription for kind-31988 session state events. + /// Fires when new session states are unwrapped from PNS events. + session_state_sub: Option, + /// Permission responses queued for relay publishing (from remote sessions). + /// Built and published in the update loop where AppContext is available. + pending_perm_responses: Vec, + /// Sessions pending deletion state event publication. + /// Populated in delete_session(), drained in the update loop where AppContext is available. + pending_deletions: Vec, + /// Local machine hostname, included in session state events. + hostname: String, +} + +use update::PermissionPublish; + +/// Info captured from a session before deletion, for publishing a "deleted" state event. +struct DeletedSessionInfo { + claude_session_id: String, + title: String, + cwd: String, +} + +/// Subscription waiting for ndb to index 1988 conversation events. +struct PendingMessageLoad { + /// ndb subscription for kind-1988 events matching the session + sub: Subscription, + /// Dave's internal session ID + dave_session_id: SessionId, + /// Claude session ID (the `d` tag value) + claude_session_id: String, +} + +/// PNS-wrap an event and ingest the 1080 wrapper into ndb. +/// +/// 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]) { + let pns_keys = enostr::pns::derive_pns_keys(secret_key); + match session_events::wrap_pns(event_json, &pns_keys) { + Ok(pns_json) => { + // wrap_pns returns bare {…} JSON; use relay format + // ["EVENT", "subid", {…}] so ndb triggers PNS unwrapping + let wrapped = format!("[\"EVENT\", \"_pns\", {}]", pns_json); + if let Err(e) = ndb.process_event(&wrapped) { + tracing::warn!("failed to ingest PNS event: {:?}", e); + } + } + Err(e) => { + tracing::warn!("failed to PNS-wrap for local ingest: {}", e); + } + } +} + +/// Ingest a freshly-built event: PNS-wrap into local ndb and push to the +/// relay publish queue. Logs on success with `event_desc` and on failure. +/// Returns `true` if the event was queued successfully. +fn queue_built_event( + result: Result, + event_desc: &str, + ndb: &nostrdb::Ndb, + sk: &[u8; 32], + queue: &mut Vec, +) -> bool { + match result { + Ok(evt) => { + tracing::info!("{}", event_desc); + pns_ingest(ndb, &evt.note_json, sk); + queue.push(evt); + true + } + Err(e) => { + tracing::error!("failed to build event ({}): {}", event_desc, e); + false + } + } +} + +/// Build and ingest a live kind-1988 event into ndb (via PNS wrapping). +/// +/// Extracts cwd and session ID from the session's agentic data, +/// builds the event, PNS-wraps and ingests it, and returns the event +/// for relay publishing. +fn ingest_live_event( + session: &mut ChatSession, + ndb: &nostrdb::Ndb, + secret_key: &[u8; 32], + content: &str, + role: &str, + tool_id: Option<&str>, + tool_name: Option<&str>, +) -> Option { + let agentic = session.agentic.as_mut()?; + let session_id = agentic.event_session_id().map(|s| s.to_string())?; + let cwd = agentic.cwd.to_str(); + + match session_events::build_live_event( + content, + role, + &session_id, + cwd, + tool_id, + tool_name, + &mut agentic.live_threading, + secret_key, + ) { + Ok(event) => { + // Mark as seen so we don't double-process when it echoes back from the relay + agentic.seen_note_ids.insert(event.note_id); + pns_ingest(ndb, &event.note_json, secret_key); + Some(event) + } + Err(e) => { + tracing::warn!("failed to build live event: {}", e); + None + } + } } /// Calculate an anonymous user_id from a keypair @@ -165,6 +313,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); @@ -180,13 +329,18 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Create IPC listener for external spawn-agent commands let ipc_listener = ipc::create_listener(ctx); + let hostname = gethostname::gethostname().to_string_lossy().into_owned(); + // In Chat mode, create a default session immediately and skip directory picker // In Agentic mode, show directory picker on startup let (session_manager, active_overlay) = match ai_mode { AiMode::Chat => { let mut manager = SessionManager::new(); // Create a default session with current directory - manager.new_session(std::env::current_dir().unwrap_or_default(), ai_mode); + 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(); + } (manager, DaveOverlay::None) } AiMode::Agentic => (SessionManager::new(), DaveOverlay::DirectoryPicker), @@ -212,6 +366,15 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session_picker: SessionPicker::new(), active_overlay, ipc_listener, + pending_archive_convert: None, + pending_message_load: None, + pending_relay_events: Vec::new(), + sessions_restored: false, + pns_relay_sub: None, + session_state_sub: None, + pending_perm_responses: Vec::new(), + pending_deletions: Vec::new(), + hostname, } } @@ -226,13 +389,20 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.settings = settings; } - /// Process incoming tokens from the ai backend for ALL sessions - /// Returns a set of session IDs that need to send tool responses - fn process_events(&mut self, app_ctx: &AppContext) -> HashSet { + /// Process incoming tokens from the ai backend for ALL sessions. + /// Returns (sessions needing tool responses, events to publish to relays). + fn process_events( + &mut self, + app_ctx: &AppContext, + ) -> (HashSet, Vec) { // Track which sessions need to send tool responses let mut needs_send: HashSet = HashSet::new(); + let mut events_to_publish: Vec = Vec::new(); let active_id = self.session_manager.active_id(); + // Extract secret key once for live event generation + let secret_key = secret_key_bytes(app_ctx.accounts.get_selected_account().keypair()); + // Get all session IDs to process let session_ids = self.session_manager.session_ids(); @@ -262,7 +432,22 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr }; match res { - DaveApiResponse::Failed(err) => session.chat.push(Message::Error(err)), + 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, + None, + ) { + events_to_publish.push(evt); + } + } + session.chat.push(Message::Error(err.to_string())); + } DaveApiResponse::Token(token) => match session.chat.last_mut() { Some(Message::Assistant(msg)) => msg.push_token(&token), @@ -320,10 +505,49 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr pending.request.tool_input ); + // Build and publish a proper permission request event + // with perm-id, tool-name tags for remote clients + if let Some(sk) = &secret_key { + let event_session_id = session + .agentic + .as_ref() + .and_then(|a| a.event_session_id().map(|s| s.to_string())); + + if let Some(sid) = event_session_id { + match session_events::build_permission_request_event( + &pending.request.id, + &pending.request.tool_name, + &pending.request.tool_input, + &sid, + sk, + ) { + Ok(evt) => { + // PNS-wrap and ingest into local ndb + pns_ingest(app_ctx.ndb, &evt.note_json, sk); + // Store note_id for linking responses + if let Some(agentic) = &mut session.agentic { + agentic + .permissions + .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 + ); + } + } + } + } + // Store the response sender for later (agentic only) if let Some(agentic) = &mut session.agentic { agentic - .pending_permissions + .permissions + .pending .insert(pending.request.id, pending.response_tx); } @@ -335,6 +559,23 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr DaveApiResponse::ToolResult(result) => { tracing::debug!("Tool result: {} - {}", result.tool_name, result.summary); + + // Generate live event for tool result + if let Some(sk) = &secret_key { + let content = format!("{}: {}", result.tool_name, result.summary); + if let Some(evt) = ingest_live_event( + session, + app_ctx.ndb, + sk, + &content, + "tool_result", + None, + Some(result.tool_name.as_str()), + ) { + events_to_publish.push(evt); + } + } + // Invalidate git status after file-modifying tools. // tool_name is a String from the Claude SDK, no enum available. if matches!(result.tool_name.as_str(), "Bash" | "Write" | "Edit") { @@ -352,9 +593,59 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr info.tools.len(), info.agents.len() ); + // Set up subscriptions when we learn the claude session ID if let Some(agentic) = &mut session.agentic { + if let Some(ref csid) = info.claude_session_id { + // Permission response subscription (filtered to ai-permission tag) + if agentic.perm_response_sub.is_none() { + let filter = nostrdb::Filter::new() + .kinds([session_events::AI_CONVERSATION_KIND as u64]) + .tags([csid.as_str()], 'd') + .tags(["ai-permission"], 't') + .build(); + match app_ctx.ndb.subscribe(&[filter]) { + Ok(sub) => { + tracing::info!( + "subscribed for remote permission responses (session {})", + csid + ); + agentic.perm_response_sub = Some(sub); + } + Err(e) => { + tracing::warn!( + "failed to subscribe for permission responses: {:?}", + e + ); + } + } + } + // Conversation subscription for incoming remote user messages + if agentic.live_conversation_sub.is_none() { + let filter = nostrdb::Filter::new() + .kinds([session_events::AI_CONVERSATION_KIND as u64]) + .tags([csid.as_str()], 'd') + .build(); + match app_ctx.ndb.subscribe(&[filter]) { + Ok(sub) => { + tracing::info!( + "subscribed for conversation events (session {})", + csid + ); + agentic.live_conversation_sub = Some(sub); + } + Err(e) => { + tracing::warn!( + "failed to subscribe for conversation events: {:?}", + e + ); + } + } + } + } agentic.session_info = Some(info); } + // Persist initial session state now that we know the claude_session_id + session.state_dirty = true; } DaveApiResponse::SubagentSpawned(subagent) => { @@ -412,8 +703,36 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(Message::Assistant(msg)) = session.chat.last_mut() { msg.finalize(); } + + // Generate live event for the finalized assistant message + if let Some(sk) = &secret_key { + if let Some(Message::Assistant(msg)) = session.chat.last() { + let text = msg.text().to_string(); + if !text.is_empty() { + if let Some(evt) = ingest_live_event( + session, + app_ctx.ndb, + sk, + &text, + "assistant", + None, + None, + ) { + events_to_publish.push(evt); + } + } + } + } + session.task_handle = None; // Don't restore incoming_tokens - leave it None + + // If chat ends with a user message, there's an + // unanswered remote message that arrived while we + // were streaming. Queue it for dispatch. + if session.needs_redispatch_after_stream_end() { + needs_send.insert(session_id); + } } } _ => { @@ -425,7 +744,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } - needs_send + (needs_send, events_to_publish) } fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { @@ -470,8 +789,11 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr cwd, session_id, title, + file_path, } => { - self.create_resumed_session_with_cwd(cwd, session_id, title); + 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.session_picker.close(); self.active_overlay = DaveOverlay::None; } @@ -548,7 +870,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, ); @@ -581,7 +902,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, @@ -620,6 +940,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.show_scene, self.ai_mode, cwd, + &self.hostname, ); } @@ -629,7 +950,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr cwd: PathBuf, resume_session_id: String, title: String, - ) { + ) -> SessionId { update::create_resumed_session_with_cwd( &mut self.session_manager, &mut self.directory_picker, @@ -639,7 +960,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr cwd, resume_session_id, title, - ); + &self.hostname, + ) } /// Clone the active agent, creating a new session with the same working directory @@ -650,6 +972,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr &mut self.scene, self.show_scene, self.ai_mode, + &self.hostname, ); } @@ -669,6 +992,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.focus_requested = true; if self.show_scene { self.scene.select(id); @@ -694,8 +1018,720 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } + /// Poll for remote permission responses arriving via nostr relays. + /// + /// Remote clients (phone) publish kind-1988 events with + /// `role=permission_response` and a `perm-id` tag. We poll each + /// session's subscription and route matching responses through the + /// existing oneshot channel, racing with the local UI. + fn poll_remote_permission_responses(&mut self, ndb: &nostrdb::Ndb) { + let session_ids = self.session_manager.session_ids(); + for session_id in session_ids { + let Some(session) = self.session_manager.get_mut(session_id) else { + continue; + }; + // Only local sessions poll for remote responses + if session.is_remote() { + continue; + } + let Some(agentic) = &mut session.agentic else { + continue; + }; + let Some(sub) = agentic.perm_response_sub else { + continue; + }; + + // Poll for new notes (non-blocking) + let note_keys = ndb.poll_for_notes(sub, 64); + if note_keys.is_empty() { + continue; + } + + let txn = match Transaction::new(ndb) { + Ok(txn) => txn, + Err(_) => continue, + }; + + for key in note_keys { + let Ok(note) = ndb.get_note_by_key(&txn, key) else { + continue; + }; + + // Only process permission_response events + let role = session_events::get_tag_value(¬e, "role"); + if role != Some("permission_response") { + continue; + } + + // Extract perm-id + let Some(perm_id_str) = session_events::get_tag_value(¬e, "perm-id") else { + tracing::warn!("permission_response event missing perm-id tag"); + continue; + }; + let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) else { + tracing::warn!("invalid perm-id UUID: {}", perm_id_str); + continue; + }; + + // Parse the content to determine allow/deny + let content = note.content(); + let (allowed, message) = match serde_json::from_str::(content) { + Ok(v) => { + 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()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + (decision == "allow", msg) + } + Err(_) => (false, None), + }; + + // Route through the existing oneshot channel (first-response-wins) + if let Some(sender) = agentic.permissions.pending.remove(&perm_id) { + let response = if allowed { + PermissionResponse::Allow { message } + } else { + PermissionResponse::Deny { + reason: message.unwrap_or_else(|| "Denied by remote".to_string()), + } + }; + + // Mark in UI + let response_type = if allowed { + crate::messages::PermissionResponseType::Allowed + } else { + crate::messages::PermissionResponseType::Denied + }; + for msg in &mut session.chat { + if let Message::PermissionRequest(req) = msg { + if req.id == perm_id { + req.response = Some(response_type); + break; + } + } + } + + if sender.send(response).is_err() { + tracing::warn!("failed to send remote permission response for {}", perm_id); + } else { + tracing::info!( + "remote permission response for {}: {}", + perm_id, + if allowed { "allowed" } else { "denied" } + ); + } + } + // If sender not found, either local UI already responded or + // this is a stale event — just ignore it silently. + } + } + } + + /// Publish kind-31988 state events for sessions whose status changed. + fn publish_dirty_session_states(&mut self, ctx: &mut AppContext<'_>) { + let Some(sk) = secret_key_bytes(ctx.accounts.get_selected_account().keypair()) else { + return; + }; + + for session in self.session_manager.iter_mut() { + if !session.state_dirty || session.is_remote() { + continue; + } + + let Some(agentic) = &session.agentic else { + continue; + }; + + let Some(claude_sid) = agentic.event_session_id() else { + continue; + }; + let claude_sid = claude_sid.to_string(); + + let cwd = agentic.cwd.to_string_lossy(); + let status = session.status().as_str(); + + queue_built_event( + session_events::build_session_state_event( + &claude_sid, + &session.title, + &cwd, + status, + &self.hostname, + &sk, + ), + &format!("publishing session state: {} -> {}", claude_sid, status), + ctx.ndb, + &sk, + &mut self.pending_relay_events, + ); + + session.state_dirty = false; + } + } + + /// Publish "deleted" state events for sessions that were deleted. + /// Called in the update loop where AppContext is available. + fn publish_pending_deletions(&mut self, ctx: &mut AppContext<'_>) { + if self.pending_deletions.is_empty() { + return; + } + + let Some(sk) = secret_key_bytes(ctx.accounts.get_selected_account().keypair()) else { + return; + }; + + for info in std::mem::take(&mut self.pending_deletions) { + queue_built_event( + session_events::build_session_state_event( + &info.claude_session_id, + &info.title, + &info.cwd, + "deleted", + &self.hostname, + &sk, + ), + &format!( + "publishing deleted session state: {}", + info.claude_session_id + ), + ctx.ndb, + &sk, + &mut self.pending_relay_events, + ); + } + } + + /// Build and queue permission response events from remote sessions. + /// Called in the update loop where AppContext is available. + fn publish_pending_perm_responses(&mut self, ctx: &AppContext<'_>) { + if self.pending_perm_responses.is_empty() { + return; + } + + let Some(sk) = secret_key_bytes(ctx.accounts.get_selected_account().keypair()) else { + tracing::warn!("no secret key for publishing permission responses"); + self.pending_perm_responses.clear(); + return; + }; + + let pending = std::mem::take(&mut self.pending_perm_responses); + + // Get session info from the active session + let session = match self.session_manager.get_active() { + Some(s) => s, + None => return, + }; + let agentic = match &session.agentic { + Some(a) => a, + None => return, + }; + let session_id = match agentic.event_session_id() { + Some(id) => id.to_string(), + None => return, + }; + + for resp in pending { + let request_note_id = match agentic.permissions.request_note_ids.get(&resp.perm_id) { + Some(id) => id, + None => { + tracing::warn!("no request note_id for perm_id {}", resp.perm_id); + continue; + } + }; + + queue_built_event( + session_events::build_permission_response_event( + &resp.perm_id, + request_note_id, + resp.allowed, + resp.message.as_deref(), + &session_id, + &sk, + ), + &format!( + "queued remote permission response for {} ({})", + resp.perm_id, + if resp.allowed { "allow" } else { "deny" } + ), + ctx.ndb, + &sk, + &mut self.pending_relay_events, + ); + } + } + + /// Restore sessions from kind-31988 state events in ndb. + /// Called once on first `update()`. + fn restore_sessions_from_ndb(&mut self, ctx: &mut AppContext<'_>) { + let txn = match Transaction::new(ctx.ndb) { + Ok(t) => t, + Err(e) => { + tracing::error!("failed to open txn for session restore: {:?}", e); + return; + } + }; + + let states = session_loader::load_session_states(ctx.ndb, &txn); + if states.is_empty() { + return; + } + + tracing::info!("restoring {} sessions from ndb", states.len()); + + for state in &states { + let cwd = std::path::PathBuf::from(&state.cwd); + let dave_sid = self.session_manager.new_resumed_session( + cwd, + state.claude_session_id.clone(), + state.title.clone(), + AiMode::Agentic, + ); + + // Load conversation history from kind-1988 events + 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!( + "restored session '{}': {} messages", + state.title, + loaded.messages.len(), + ); + session.chat = loaded.messages; + + // Determine if this is a remote session (cwd doesn't exist locally) + let cwd = std::path::PathBuf::from(&state.cwd); + if !cwd.exists() { + session.source = session::SessionSource::Remote; + } + let is_remote = session.is_remote(); + + // Local sessions use the current machine's hostname; + // remote sessions use what was stored in the event. + session.hostname = if is_remote { + state.hostname.clone() + } else { + self.hostname.clone() + }; + + if let Some(agentic) = &mut session.agentic { + 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.permissions.merge_loaded( + loaded.permissions.responded, + loaded.permissions.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_ts = state.created_at; + + // Set up live conversation subscription so we can + // receive messages from remote clients (e.g. phone) + // even before the local backend is started. + if agentic.live_conversation_sub.is_none() { + let conv_filter = nostrdb::Filter::new() + .kinds([session_events::AI_CONVERSATION_KIND as u64]) + .tags([state.claude_session_id.as_str()], 'd') + .build(); + match ctx.ndb.subscribe(&[conv_filter]) { + Ok(sub) => { + agentic.live_conversation_sub = Some(sub); + tracing::info!( + "subscribed for live conversation events for session '{}'", + state.title, + ); + } + Err(e) => { + tracing::warn!( + "failed to subscribe for conversation events: {:?}", + e, + ); + } + } + } + } + } + } + + // Skip the directory picker since we restored sessions + self.active_overlay = DaveOverlay::None; + } + + /// Poll for new kind-31988 session state events from the ndb subscription. + /// + /// When PNS events arrive from relays and get unwrapped, new session state + /// events may appear. This detects them and creates sessions we don't already have. + fn poll_session_state_events(&mut self, ctx: &mut AppContext<'_>) { + let Some(sub) = self.session_state_sub else { + return; + }; + + let note_keys = ctx.ndb.poll_for_notes(sub, 32); + if note_keys.is_empty() { + return; + } + + let txn = match Transaction::new(ctx.ndb) { + Ok(t) => t, + Err(_) => return, + }; + + // Collect existing claude session IDs to avoid duplicates + let mut existing_ids: std::collections::HashSet = self + .session_manager + .iter() + .filter_map(|s| { + s.agentic + .as_ref() + .and_then(|a| a.event_session_id().map(|id| id.to_string())) + }) + .collect(); + + for key in note_keys { + let Ok(note) = ctx.ndb.get_note_by_key(&txn, key) else { + continue; + }; + + let Some(claude_sid) = session_events::get_tag_value(¬e, "d") else { + continue; + }; + + let status_str = session_events::get_tag_value(¬e, "status").unwrap_or("idle"); + + // Skip deleted sessions entirely — don't create or keep them + if status_str == "deleted" { + // If we have this session locally, remove it (only if this + // event is newer than the last state we applied). + if existing_ids.contains(claude_sid) { + let ts = note.created_at(); + let to_delete: Vec = self + .session_manager + .iter() + .filter(|s| { + s.agentic.as_ref().is_some_and(|a| { + a.event_session_id() == Some(claude_sid) && ts > a.remote_status_ts + }) + }) + .map(|s| s.id) + .collect(); + for id in to_delete { + update::delete_session( + &mut self.session_manager, + &mut self.focus_queue, + self.backend.as_ref(), + &mut self.directory_picker, + id, + ); + } + } + continue; + } + + // Update remote_status for existing remote sessions, but only + // if this event is newer than the one we already applied. + // Multiple revisions of the same replaceable event can arrive + // out of order (e.g. after a relay reconnect). + if existing_ids.contains(claude_sid) { + let ts = note.created_at(); + let new_status = AgentStatus::from_status_str(status_str); + for session in self.session_manager.iter_mut() { + if session.is_remote() { + if let Some(agentic) = &mut session.agentic { + if agentic.event_session_id() == Some(claude_sid) + && ts > agentic.remote_status_ts + { + agentic.remote_status = new_status; + agentic.remote_status_ts = ts; + } + } + } + } + continue; + } + + 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); + let hostname = session_events::get_tag_value(¬e, "hostname") + .unwrap_or("") + .to_string(); + + tracing::info!( + "discovered new session from relay: '{}' ({}) on {}", + title, + claude_sid, + hostname, + ); + + existing_ids.insert(claude_sid.to_string()); + + let dave_sid = self.session_manager.new_resumed_session( + cwd, + claude_sid.to_string(), + title.clone(), + AiMode::Agentic, + ); + + // Load any conversation history that arrived with it + 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; + if !loaded.messages.is_empty() { + tracing::info!( + "loaded {} messages for discovered session", + loaded.messages.len() + ); + session.chat = loaded.messages; + } + + // Determine if this is a remote session + let cwd_path = std::path::PathBuf::from(cwd_str); + if !cwd_path.exists() { + session.source = session::SessionSource::Remote; + } + + if let Some(agentic) = &mut session.agentic { + 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.permissions.merge_loaded( + loaded.permissions.responded, + loaded.permissions.request_note_ids, + ); + agentic.seen_note_ids = loaded.note_ids; + // Set remote status + agentic.remote_status = AgentStatus::from_status_str(status_str); + agentic.remote_status_ts = note.created_at(); + + // Set up live conversation subscription so we can + // receive messages from remote clients (e.g. phone) + // even before the local backend is started. + if agentic.live_conversation_sub.is_none() { + let conv_filter = nostrdb::Filter::new() + .kinds([session_events::AI_CONVERSATION_KIND as u64]) + .tags([claude_sid], 'd') + .build(); + match ctx.ndb.subscribe(&[conv_filter]) { + Ok(sub) => { + agentic.live_conversation_sub = Some(sub); + tracing::info!( + "subscribed for live conversation events for session '{}'", + &title, + ); + } + Err(e) => { + tracing::warn!( + "failed to subscribe for conversation events: {:?}", + e, + ); + } + } + } + } + } + + // If we were showing the directory picker, switch to showing sessions + if matches!(self.active_overlay, DaveOverlay::DirectoryPicker) { + self.active_overlay = DaveOverlay::None; + } + } + } + + /// Poll for new kind-1988 conversation events. + /// + /// For remote sessions: process all roles (user, assistant, tool_call, etc.) + /// to keep the phone UI in sync with the desktop's conversation. + /// + /// For local sessions: only process `role=user` messages arriving from + /// remote clients (phone), collecting them for backend dispatch. + fn poll_remote_conversation_events(&mut self, ndb: &nostrdb::Ndb) -> Vec<(SessionId, String)> { + let mut remote_user_messages: Vec<(SessionId, String)> = Vec::new(); + let session_ids = self.session_manager.session_ids(); + for session_id in session_ids { + let Some(session) = self.session_manager.get_mut(session_id) else { + continue; + }; + let is_remote = session.is_remote(); + + // Get sub without holding agentic borrow + let sub = match session + .agentic + .as_ref() + .and_then(|a| a.live_conversation_sub) + { + Some(s) => s, + None => continue, + }; + + let note_keys = ndb.poll_for_notes(sub, 128); + if note_keys.is_empty() { + continue; + } + + let txn = match Transaction::new(ndb) { + Ok(txn) => txn, + Err(_) => continue, + }; + + // Collect and sort by created_at to process in order + let mut notes: Vec<_> = note_keys + .iter() + .filter_map(|key| ndb.get_note_by_key(&txn, *key).ok()) + .collect(); + notes.sort_by_key(|n| n.created_at()); + + for note in ¬es { + // Skip events we've already processed (dedup) + let note_id = *note.id(); + let dominated = session + .agentic + .as_mut() + .map(|a| !a.seen_note_ids.insert(note_id)) + .unwrap_or(true); + if dominated { + continue; + } + + let content = note.content(); + let role = session_events::get_tag_value(note, "role"); + + // Local sessions: only process incoming user messages from remote clients + if !is_remote { + if role == Some("user") { + tracing::info!("received remote user message for local session"); + session.chat.push(Message::User(content.to_string())); + session.update_title_from_last_message(); + remote_user_messages.push((session_id, content.to_string())); + } + continue; + } + + let Some(agentic) = &mut session.agentic else { + continue; + }; + + match role { + Some("user") => { + session.chat.push(Message::User(content.to_string())); + } + Some("assistant") => { + session.chat.push(Message::Assistant( + crate::messages::AssistantMessage::from_text(content.to_string()), + )); + } + Some("tool_call") => { + session.chat.push(Message::Assistant( + crate::messages::AssistantMessage::from_text(content.to_string()), + )); + } + Some("tool_result") => { + let summary = if content.chars().count() > 100 { + let truncated: String = content.chars().take(100).collect(); + format!("{}...", truncated) + } else { + content.to_string() + }; + let tool_name = session_events::get_tag_value(note, "tool-name") + .unwrap_or("tool") + .to_string(); + session + .chat + .push(Message::ToolResult(crate::messages::ToolResult { + tool_name, + summary, + })); + } + Some("permission_request") => { + if let Ok(content_json) = serde_json::from_str::(content) + { + let tool_name = content_json["tool_name"] + .as_str() + .unwrap_or("unknown") + .to_string(); + let tool_input = content_json + .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); + + // Check if we already responded + let response = if agentic.permissions.responded.contains(&perm_id) { + Some(crate::messages::PermissionResponseType::Allowed) + } else { + None + }; + + // Store the note ID for linking responses + agentic + .permissions + .request_note_ids + .insert(perm_id, *note.id()); + + session.chat.push(Message::PermissionRequest( + crate::messages::PermissionRequest { + id: perm_id, + tool_name, + tool_input, + response, + answer_summary: None, + cached_plan: None, + }, + )); + } + } + 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 Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) { + agentic.permissions.responded.insert(perm_id); + // Update the matching PermissionRequest in chat + for msg in session.chat.iter_mut() { + if let Message::PermissionRequest(req) = msg { + if req.id == perm_id && req.response.is_none() { + req.response = Some( + crate::messages::PermissionResponseType::Allowed, + ); + } + } + } + } + } + } + _ => { + // Skip progress, queue-operation, etc. + } + } + } + } + remote_user_messages + } + /// Delete a session and clean up backend resources fn delete_session(&mut self, id: SessionId) { + // Capture session info before deletion so we can publish a "deleted" state event + if let Some(session) = self.session_manager.get(id) { + if let Some(agentic) = &session.agentic { + 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(), + cwd: agentic.cwd.to_string_lossy().to_string(), + }); + } + } + } + update::delete_session( &mut self.session_manager, &mut self.focus_queue, @@ -765,6 +1801,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr KeyActionResult::SetAutoSteal(new_state) => { self.auto_steal_focus = new_state; } + KeyActionResult::PublishPermissionResponse(publish) => { + self.pending_perm_responses.push(publish); + } KeyActionResult::None => {} } } @@ -775,6 +1814,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr SendActionResult::SendMessage => { self.handle_user_send(ctx, ui); } + SendActionResult::NeedsRelayPublish(publish) => { + self.pending_perm_responses.push(publish); + } SendActionResult::Handled => {} } } @@ -799,6 +1841,21 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.handle_send_action(ctx, ui); None } + UiActionResult::PublishPermissionResponse(publish) => { + self.pending_perm_responses.push(publish); + None + } + UiActionResult::ToggleAutoSteal => { + let new_state = crate::update::toggle_auto_steal( + &mut self.session_manager, + &mut self.scene, + self.show_scene, + self.auto_steal_focus, + &mut self.home_session, + ); + self.auto_steal_focus = new_state; + None + } UiActionResult::Handled => None, } } @@ -822,9 +1879,25 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // Normal message handling if let Some(session) = self.session_manager.get_active_mut() { - session.chat.push(Message::User(session.input.clone())); + let user_text = session.input.clone(); session.input.clear(); + + // 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, None) + { + self.pending_relay_events.push(evt); + } + } + + session.chat.push(Message::User(user_text)); session.update_title_from_last_message(); + + // Remote sessions: publish user message to relay but don't send to local backend + if session.is_remote() { + return; + } } self.send_user_message(app_ctx, ui.ctx()); } @@ -874,12 +1947,228 @@ impl notedeck::App for Dave { fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { let mut app_action: Option = None; + // Process relay events into ndb (needed when dave is the active app). + // Re-send PNS subscription when the relay (re)connects. + let pns_sub_id = self.pns_relay_sub.clone(); + try_process_events_core(ctx, ui.ctx(), |app_ctx, ev| { + if let enostr::RelayEvent::Opened = (&ev.event).into() { + if ev.relay == PNS_RELAY_URL { + if let Some(sub_id) = &pns_sub_id { + if let Some(sk) = + app_ctx.accounts.get_selected_account().keypair().secret_key + { + let pns_keys = enostr::pns::derive_pns_keys(&sk.secret_bytes()); + let pns_filter = nostrdb::Filter::new() + .kinds([enostr::pns::PNS_KIND as u64]) + .authors([pns_keys.keypair.pubkey.bytes()]) + .build(); + let req = enostr::ClientMessage::req(sub_id.clone(), vec![pns_filter]); + app_ctx.pool.send_to(&req, PNS_RELAY_URL); + tracing::info!("re-subscribed for PNS events after relay reconnect"); + } + } + } + } + }); + // Poll for external spawn-agent commands via IPC self.poll_ipc_commands(); + // One-time initialization on first update + if !self.sessions_restored { + self.sessions_restored = true; + + // Process any PNS-wrapped events already in ndb + let pns_ndb = ctx.ndb.clone(); + if let Err(e) = std::thread::Builder::new() + .name("process_pns".into()) + .spawn(move || { + let txn = Transaction::new(&pns_ndb).expect("txn"); + pns_ndb.process_pns(&txn); + }) + { + tracing::error!("failed to spawn process_pns thread: {e}"); + } + + self.restore_sessions_from_ndb(ctx); + + // 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()); + + // Ensure the PNS relay is in the pool + let egui_ctx = ui.ctx().clone(); + let wakeup = move || egui_ctx.request_repaint(); + if let Err(e) = ctx.pool.add_url(PNS_RELAY_URL.to_string(), wakeup) { + tracing::warn!("failed to add PNS relay {}: {:?}", PNS_RELAY_URL, e); + } + + // Remote: subscribe on PNS relay for kind-1080 authored by our PNS pubkey + let pns_filter = nostrdb::Filter::new() + .kinds([enostr::pns::PNS_KIND as u64]) + .authors([pns_keys.keypair.pubkey.bytes()]) + .build(); + let sub_id = uuid::Uuid::new_v4().to_string(); + let req = enostr::ClientMessage::req(sub_id.clone(), vec![pns_filter]); + ctx.pool.send_to(&req, PNS_RELAY_URL); + self.pns_relay_sub = Some(sub_id); + tracing::info!("subscribed for PNS events on {}", PNS_RELAY_URL); + + // Local: subscribe in ndb for kind-31988 session state events + let state_filter = nostrdb::Filter::new() + .kinds([session_events::AI_SESSION_STATE_KIND as u64]) + .tags(["ai-session-state"], 't') + .build(); + match ctx.ndb.subscribe(&[state_filter]) { + Ok(sub) => { + self.session_state_sub = Some(sub); + tracing::info!("subscribed for session state events in ndb"); + } + Err(e) => { + tracing::warn!("failed to subscribe for session state events: {:?}", e); + } + } + } + } + // Poll for external editor completion update::poll_editor_job(&mut self.session_manager); + // Poll for new session states from PNS-unwrapped relay events + self.poll_session_state_events(ctx); + + // Poll for live conversation events on all sessions. + // Returns user messages from remote clients that need backend dispatch. + // Only dispatch if the session isn't already streaming a response — + // the message is already in chat, so it will be included when the + // current stream finishes and we re-dispatch. + let remote_user_msgs = self.poll_remote_conversation_events(ctx.ndb); + for (sid, _msg) in remote_user_msgs { + let should_dispatch = self + .session_manager + .get(sid) + .is_some_and(|s| s.should_dispatch_remote_message()); + if should_dispatch { + self.send_user_message_for(sid, ctx, ui.ctx()); + } + } + + // Process pending archive conversion (JSONL → nostr events) + if let Some((file_path, dave_sid, claude_sid)) = self.pending_archive_convert.take() { + // Check if events already exist for this session in ndb + let txn = Transaction::new(ctx.ndb).expect("txn"); + let filter = nostrdb::Filter::new() + .kinds([session_events::AI_CONVERSATION_KIND as u64]) + .tags([claude_sid.as_str()], 'd') + .limit(1) + .build(); + let already_exists = ctx + .ndb + .query(&txn, &[filter], 1) + .map(|r| !r.is_empty()) + .unwrap_or(false); + drop(txn); + + if already_exists { + // Events already in ndb (from previous conversion or live events). + // Skip archive conversion and load directly. + tracing::info!( + "session {} already has events in ndb, skipping archive conversion", + claude_sid + ); + let loaded_txn = Transaction::new(ctx.ndb).expect("txn"); + 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) + { + agentic.live_threading.seed(root, last, loaded.event_count); + } + agentic + .permissions + .request_note_ids + .extend(loaded.permissions.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); + } + } + } + Err(e) => { + tracing::error!("failed to subscribe for archive events: {:?}", e); + } + } + } else { + tracing::warn!("no secret key available for archive conversion"); + } + } + + // Poll pending message load — wait for ndb to index 1988 events + if let Some(pending) = &self.pending_message_load { + let notes = ctx.ndb.poll_for_notes(pending.sub, 4096); + if !notes.is_empty() { + let txn = Transaction::new(ctx.ndb).expect("txn"); + let loaded = session_loader::load_session_messages( + ctx.ndb, + &txn, + &pending.claude_session_id, + ); + if let Some(session) = self.session_manager.get_mut(pending.dave_session_id) { + tracing::info!("loaded {} messages into chat UI", loaded.messages.len()); + session.chat = loaded.messages; + + // 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) + { + agentic.live_threading.seed(root, last, loaded.event_count); + } + agentic + .permissions + .request_note_ids + .extend(loaded.permissions.request_note_ids); + } + } + self.pending_message_load = None; + } + } + // 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(); @@ -889,12 +2178,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); } @@ -903,10 +2197,37 @@ impl notedeck::App for Dave { self.check_interrupt_timeout(); // Process incoming AI responses for all sessions - let sessions_needing_send = self.process_events(ctx); + let (sessions_needing_send, events_to_publish) = self.process_events(ctx); + + // Build permission response events from remote sessions + self.publish_pending_perm_responses(ctx); + + // 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 { + 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), + }, + Err(e) => tracing::warn!("failed to PNS-wrap event: {}", e), + } + } + } + + // Poll for remote permission responses from relay events. + // These arrive as kind-1988 events with role=permission_response, + // published by phone/remote clients. First-response-wins with local UI. + self.poll_remote_permission_responses(ctx.ndb); - // Poll git status for all agentic sessions + // Poll git status for local agentic sessions for session in self.session_manager.iter_mut() { + if session.is_remote() { + continue; + } if let Some(agentic) = &mut session.agentic { agentic.git_status.poll(); agentic.git_status.maybe_auto_refresh(); @@ -916,17 +2237,29 @@ impl notedeck::App for Dave { // Update all session statuses after processing events self.session_manager.update_all_statuses(); + // Publish kind-31988 state events for sessions whose status changed + self.publish_dirty_session_states(ctx); + + // Publish "deleted" state events for recently deleted sessions + self.publish_pending_deletions(ctx); + // Update focus queue based on status changes let status_iter = self.session_manager.iter().map(|s| (s.id, s.status())); self.focus_queue.update_from_statuses(status_iter); + // Suppress auto-steal while the user is typing (non-empty input) + let user_is_typing = self + .session_manager + .get_active() + .is_some_and(|s| !s.input.is_empty()); + // Process auto-steal focus mode let stole_focus = update::process_auto_steal_focus( &mut self.session_manager, &mut self.focus_queue, &mut self.scene, self.show_scene, - self.auto_steal_focus, + self.auto_steal_focus && !user_is_typing, &mut self.home_session, ); diff --git a/crates/notedeck_dave/src/path_normalize.rs b/crates/notedeck_dave/src/path_normalize.rs new file mode 100644 index 000000000..c6f2db411 --- /dev/null +++ b/crates/notedeck_dave/src/path_normalize.rs @@ -0,0 +1,141 @@ +//! Path normalization for JSONL source-data. +//! +//! When storing JSONL lines in nostr events, absolute paths are converted to +//! relative (using the session's `cwd` as base). On reconstruction, relative +//! paths are re-expanded using the local machine's working directory. +//! +//! This operates on the raw JSON string via string replacement — paths can +//! appear anywhere in tool inputs/outputs, so structural replacement would +//! miss nested occurrences. + +/// Replace all occurrences of `cwd` prefix in absolute paths with relative paths. +/// +/// Not currently used (Phase 1 stores raw paths), kept for future Phase 2. +#[allow(dead_code)] +/// +/// For example, with cwd = "/Users/jb55/dev/notedeck": +/// "/Users/jb55/dev/notedeck/src/main.rs" → "src/main.rs" +/// "/Users/jb55/dev/notedeck" → "." +pub fn normalize_paths(json: &str, cwd: &str) -> String { + if cwd.is_empty() { + return json.to_string(); + } + + // Ensure cwd doesn't have a trailing slash for consistent matching + let cwd = cwd.strip_suffix('/').unwrap_or(cwd); + + // Replace "cwd/" prefix first (subpaths), then bare "cwd" (exact match) + let with_slash = format!("{}/", cwd); + let result = json.replace(&with_slash, ""); + + // Replace bare cwd (e.g. the cwd field itself) with "." + result.replace(cwd, ".") +} + +/// Re-expand relative paths back to absolute using the given local cwd. +/// +/// Reverses `normalize_paths`: the cwd field "." becomes the local cwd, +/// and relative paths get the cwd prefix prepended. +/// +/// Note: This is not perfectly inverse — it will also expand any unrelated +/// "." occurrences that happen to match. In practice, the cwd field is the +/// main target, and relative paths in tool inputs/outputs are the rest. +/// +/// Not currently used (Phase 1 stores raw paths), kept for future Phase 2. +#[allow(dead_code)] +pub fn denormalize_paths(json: &str, local_cwd: &str) -> String { + if local_cwd.is_empty() { + return json.to_string(); + } + + let local_cwd = local_cwd.strip_suffix('/').unwrap_or(local_cwd); + + // We need to be careful about ordering here. We want to: + // 1. Replace "." (bare cwd reference) with the local cwd + // 2. Re-expand relative paths that were stripped of the cwd prefix + // + // But since normalized JSON has paths like "src/main.rs" (no prefix), + // we can't blindly prefix all bare paths. Instead, we reverse the + // exact transformations that normalize_paths applied: + // + // The normalize step replaced: + // "{cwd}/" → "" (paths become relative) + // "{cwd}" → "." (bare cwd references) + // + // So to reverse, we need context-aware replacement. The safest approach + // is to look for patterns that were likely produced by normalization: + // - JSON string values that are exactly "." → local_cwd + // - Relative paths in known field positions + // + // For now, we do simple string replacement which handles the most + // common case (the "cwd" field). Full path reconstruction for tool + // inputs/outputs would need the original field structure. + + // Replace "\"cwd\":\".\"" with the local cwd + let result = json.replace("\"cwd\":\".\"", &format!("\"cwd\":\"{}\"", local_cwd)); + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_absolute_paths() { + let json = + r#"{"cwd":"/Users/jb55/dev/notedeck","file":"/Users/jb55/dev/notedeck/src/main.rs"}"#; + let normalized = normalize_paths(json, "/Users/jb55/dev/notedeck"); + assert_eq!(normalized, r#"{"cwd":".","file":"src/main.rs"}"#); + } + + #[test] + fn test_normalize_with_trailing_slash() { + // cwd with trailing slash is stripped; the cwd value in JSON + // still contains the trailing slash so it becomes "" + "/" = "/" + // after replacing the base. In practice JSONL cwd values don't + // have trailing slashes. + let json = r#"{"cwd":"/tmp/project","file":"/tmp/project/lib.rs"}"#; + let normalized = normalize_paths(json, "/tmp/project/"); + assert_eq!(normalized, r#"{"cwd":".","file":"lib.rs"}"#); + } + + #[test] + fn test_normalize_empty_cwd() { + let json = r#"{"file":"/some/path"}"#; + let normalized = normalize_paths(json, ""); + assert_eq!(normalized, json); + } + + #[test] + fn test_normalize_no_matching_paths() { + let json = r#"{"file":"/other/path/file.rs"}"#; + let normalized = normalize_paths(json, "/Users/jb55/dev/notedeck"); + assert_eq!(normalized, json); + } + + #[test] + fn test_normalize_multiple_occurrences() { + let json = + r#"{"old":"/Users/jb55/dev/notedeck/a.rs","new":"/Users/jb55/dev/notedeck/b.rs"}"#; + let normalized = normalize_paths(json, "/Users/jb55/dev/notedeck"); + assert_eq!(normalized, r#"{"old":"a.rs","new":"b.rs"}"#); + } + + #[test] + fn test_denormalize_cwd_field() { + let json = r#"{"cwd":"."}"#; + let denormalized = denormalize_paths(json, "/Users/jb55/dev/notedeck"); + assert_eq!(denormalized, r#"{"cwd":"/Users/jb55/dev/notedeck"}"#); + } + + #[test] + fn test_normalize_roundtrip_cwd() { + let original_cwd = "/Users/jb55/dev/notedeck"; + let json = r#"{"cwd":"/Users/jb55/dev/notedeck"}"#; + let normalized = normalize_paths(json, original_cwd); + assert_eq!(normalized, r#"{"cwd":"."}"#); + let denormalized = denormalize_paths(&normalized, original_cwd); + assert_eq!(denormalized, json); + } +} diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs index f6082407a..bdc714a7f 100644 --- a/crates/notedeck_dave/src/session.rs +++ b/crates/notedeck_dave/src/session.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::mpsc::Receiver; @@ -6,8 +6,10 @@ use crate::agent_status::AgentStatus; use crate::config::AiMode; use crate::git_status::GitStatusCache; use crate::messages::{ - CompactionInfo, PermissionResponse, QuestionAnswer, SessionInfo, SubagentStatus, + AnswerSummary, CompactionInfo, PermissionResponse, PermissionResponseType, QuestionAnswer, + SessionInfo, SubagentStatus, }; +use crate::session_events::ThreadingState; use crate::{DaveApiResponse, Message}; use claude_agent_sdk_rs::PermissionMode; use tokio::sync::oneshot; @@ -15,6 +17,16 @@ use uuid::Uuid; pub type SessionId = u32; +/// Whether this session runs locally or is observed remotely via relays. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SessionSource { + /// Local Claude process running on this machine. + #[default] + Local, + /// Remote session observed via relay events (no local process). + Remote, +} + /// State for permission response with message #[derive(Default, Clone, Copy, PartialEq)] pub enum PermissionMessageState { @@ -26,10 +38,97 @@ pub enum PermissionMessageState { TentativeDeny, } +/// Consolidated permission tracking for a session. +/// +/// Bundles the local oneshot channels (for local sessions), the note-ID +/// mapping (for linking relay responses), and the already-responded set +/// (for remote sessions) into a single struct. +pub struct PermissionTracker { + /// Local oneshot senders waiting for the user to allow/deny. + pub pending: HashMap>, + /// Maps permission-request UUID → nostr note ID of the published request. + pub request_note_ids: HashMap, + /// Permission UUIDs that have already been responded to. + pub responded: HashSet, +} + +impl PermissionTracker { + pub fn new() -> Self { + Self { + pending: HashMap::new(), + request_note_ids: HashMap::new(), + responded: HashSet::new(), + } + } + + /// Whether there are unresolved local permission requests. + pub fn has_pending(&self) -> bool { + !self.pending.is_empty() + } + + /// Resolve a permission request. This is the ONLY place resolution state + /// is updated — both `handle_permission_response` and + /// `handle_question_response` funnel through here. + pub fn resolve( + &mut self, + chat: &mut [Message], + request_id: Uuid, + response_type: PermissionResponseType, + answer_summary: Option, + is_remote: bool, + oneshot_response: Option, + ) { + // 1. Update the PermissionRequest message in chat + for msg in chat.iter_mut() { + if let Message::PermissionRequest(req) = msg { + if req.id == request_id { + req.response = Some(response_type); + if answer_summary.is_some() { + req.answer_summary = answer_summary; + } + break; + } + } + } + + // 2. Update PermissionTracker state + if is_remote { + self.responded.insert(request_id); + } else if let Some(response) = oneshot_response { + if let Some(sender) = self.pending.remove(&request_id) { + if sender.send(response).is_err() { + tracing::error!( + "failed to send permission response for request {}", + request_id + ); + } + } else { + tracing::warn!("no pending permission found for request {}", request_id); + } + } + } + + /// Merge loaded permission state from restored events. + pub fn merge_loaded( + &mut self, + responded: HashSet, + request_note_ids: HashMap, + ) { + self.responded = responded; + self.request_note_ids.extend(request_note_ids); + } +} + +impl Default for PermissionTracker { + fn default() -> Self { + Self::new() + } +} + /// Agentic-mode specific session data (Claude backend only) pub struct AgenticSessionData { - /// Pending permission requests waiting for user response - pub pending_permissions: HashMap>, + /// Permission state (pending channels, note IDs, responded set) + pub permissions: PermissionTracker, /// Position in the RTS scene (in scene coordinates) pub scene_position: egui::Vec2, /// Permission mode for Claude (Default or Plan) @@ -55,6 +154,24 @@ pub struct AgenticSessionData { pub resume_session_id: Option, /// Git status cache for this session's working directory pub git_status: GitStatusCache, + /// Threading state for live kind-1988 event generation. + pub live_threading: ThreadingState, + /// Subscription for remote permission response events (kind-1988, t=ai-permission). + /// Set up once when the session's claude_session_id becomes known. + pub perm_response_sub: Option, + /// Status as reported by the remote desktop's kind-31988 event. + /// Only meaningful when session source is Remote. + pub remote_status: Option, + /// Timestamp of the kind-31988 event that last set `remote_status`. + /// Used to ignore older replaceable event revisions that arrive out of order. + pub remote_status_ts: u64, + /// Subscription for live kind-1988 conversation events from relays. + /// Used by remote sessions to receive new messages in real-time. + pub live_conversation_sub: Option, + /// Note IDs we've already processed from live conversation polling. + /// Prevents duplicate messages when events are loaded during restore + /// and then appear again via the subscription. + pub seen_note_ids: HashSet<[u8; 32]>, } impl AgenticSessionData { @@ -68,7 +185,7 @@ impl AgenticSessionData { let git_status = GitStatusCache::new(cwd.clone()); AgenticSessionData { - pending_permissions: HashMap::new(), + permissions: PermissionTracker::new(), scene_position: egui::Vec2::new(x, y), permission_mode: PermissionMode::Default, permission_message_state: PermissionMessageState::None, @@ -81,9 +198,25 @@ impl AgenticSessionData { last_compaction: None, resume_session_id: None, git_status, + live_threading: ThreadingState::new(), + perm_response_sub: None, + remote_status: None, + remote_status_ts: 0, + live_conversation_sub: None, + seen_note_ids: HashSet::new(), } } + /// Get the session ID to use for live kind-1988 events. + /// + /// Prefers claude_session_id from SessionInfo, falls back to resume_session_id. + pub fn event_session_id(&self) -> Option<&str> { + self.session_info + .as_ref() + .and_then(|i| i.claude_session_id.as_deref()) + .or(self.resume_session_id.as_deref()) + } + /// Update a subagent's output (appending new content, keeping only the tail) pub fn update_subagent_output( &mut self, @@ -126,12 +259,18 @@ pub struct ChatSession { pub task_handle: Option>, /// Cached status for the agent (derived from session state) cached_status: AgentStatus, + /// Set when cached_status changes, cleared after publishing state event + pub state_dirty: bool, /// Whether this session's input should be focused on the next frame pub focus_requested: bool, /// AI interaction mode for this session (Chat vs Agentic) pub ai_mode: AiMode, /// Agentic-mode specific data (None in Chat mode) pub agentic: Option, + /// 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, } impl Drop for ChatSession { @@ -157,9 +296,12 @@ impl ChatSession { incoming_tokens: None, task_handle: None, cached_status: AgentStatus::Idle, + state_dirty: false, focus_requested: false, ai_mode, agentic, + source: SessionSource::Local, + hostname: String::new(), } } @@ -200,11 +342,28 @@ impl ChatSession { self.agentic.is_some() } + /// Check if this is a remote session (observed via relay, no local process) + pub fn is_remote(&self) -> bool { + self.source == SessionSource::Remote + } + /// Check if session has pending permission requests 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.permissions.responded); + return self.chat.iter().any(|msg| { + if let Message::PermissionRequest(req) = msg { + req.response.is_none() && responded.is_none_or(|ids| !ids.contains(&req.id)) + } else { + false + } + }); + } + // Local: check oneshot senders self.agentic .as_ref() - .is_some_and(|a| !a.pending_permissions.is_empty()) + .is_some_and(|a| a.permissions.has_pending()) } /// Check if session is in plan mode @@ -243,11 +402,15 @@ impl ChatSession { }; // Use first ~30 chars of last message as title let title: String = text.chars().take(30).collect(); - self.title = if text.len() > 30 { + let new_title = if text.len() > 30 { format!("{}...", title) } else { title }; + if new_title != self.title { + self.title = new_title; + self.state_dirty = true; + } break; } } @@ -257,13 +420,31 @@ impl ChatSession { self.cached_status } - /// Update the cached status based on current session state + /// Update the cached status based on current session state. + /// Sets `state_dirty` when the status actually changes. pub fn update_status(&mut self) { - self.cached_status = self.derive_status(); + let new_status = self.derive_status(); + if new_status != self.cached_status { + self.cached_status = new_status; + self.state_dirty = true; + } } /// Derive status from the current session state fn derive_status(&self) -> AgentStatus { + // Remote sessions derive status from the kind-31988 state event, + // but override to NeedsInput if there are unresponded permission requests. + if self.is_remote() { + if self.has_pending_permissions() { + return AgentStatus::NeedsInput; + } + return self + .agentic + .as_ref() + .and_then(|a| a.remote_status) + .unwrap_or(AgentStatus::Idle); + } + // Check for pending permission requests (needs input) - agentic only if self.has_pending_permissions() { return AgentStatus::NeedsInput; @@ -478,3 +659,137 @@ impl SessionManager { self.order.clone() } } + +impl ChatSession { + /// Whether the session is actively streaming a response from the backend. + pub fn is_streaming(&self) -> bool { + self.incoming_tokens.is_some() + } + + /// Whether the session has an unanswered user message at the end of the + /// chat that needs to be dispatched to the backend. + pub fn has_pending_user_message(&self) -> bool { + matches!(self.chat.last(), Some(Message::User(_))) + } + + /// Whether a newly arrived remote user message should be dispatched to + /// the backend right now. Returns false if the session is already + /// streaming — the message is already in chat and will be picked up + /// when the current stream finishes. + pub fn should_dispatch_remote_message(&self) -> bool { + !self.is_streaming() && self.has_pending_user_message() + } + + /// Whether the session needs a re-dispatch after a stream ends. + /// This catches user messages that arrived while we were streaming. + pub fn needs_redispatch_after_stream_end(&self) -> bool { + !self.is_streaming() && self.has_pending_user_message() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AiMode; + use crate::messages::AssistantMessage; + use std::sync::mpsc; + + fn test_session() -> ChatSession { + ChatSession::new(1, PathBuf::from("/tmp"), AiMode::Agentic) + } + + #[test] + fn dispatch_when_idle_with_user_message() { + let mut session = test_session(); + session.chat.push(Message::User("hello".into())); + assert!(session.should_dispatch_remote_message()); + } + + #[test] + fn no_dispatch_while_streaming() { + let mut session = test_session(); + session.chat.push(Message::User("hello".into())); + + // Start streaming + let (_tx, rx) = mpsc::channel::(); + session.incoming_tokens = Some(rx); + + // New user message arrives while streaming + session.chat.push(Message::User("another".into())); + assert!(!session.should_dispatch_remote_message()); + } + + #[test] + fn redispatch_after_stream_ends_with_pending_user_message() { + let mut session = test_session(); + session.chat.push(Message::User("msg1".into())); + + // Start streaming + let (tx, rx) = mpsc::channel::(); + session.incoming_tokens = Some(rx); + + // Assistant responds, then more user messages arrive + session + .chat + .push(Message::Assistant(AssistantMessage::from_text( + "response".into(), + ))); + session.chat.push(Message::User("msg2".into())); + + // Stream ends + drop(tx); + session.incoming_tokens = None; + + assert!(session.needs_redispatch_after_stream_end()); + } + + #[test] + fn no_redispatch_when_assistant_is_last() { + let mut session = test_session(); + session.chat.push(Message::User("hello".into())); + + let (tx, rx) = mpsc::channel::(); + session.incoming_tokens = Some(rx); + + session + .chat + .push(Message::Assistant(AssistantMessage::from_text( + "done".into(), + ))); + + drop(tx); + session.incoming_tokens = None; + + assert!(!session.needs_redispatch_after_stream_end()); + } + + /// The key bug scenario: multiple remote messages arrive across frames + /// while streaming. None should trigger dispatch. After stream ends, + /// the last pending message should trigger redispatch. + #[test] + fn multiple_remote_messages_while_streaming() { + let mut session = test_session(); + + // First message — dispatched normally + session.chat.push(Message::User("msg1".into())); + assert!(session.should_dispatch_remote_message()); + + // Backend starts streaming + let (tx, rx) = mpsc::channel::(); + session.incoming_tokens = Some(rx); + + // Messages arrive one per frame while streaming + session.chat.push(Message::User("msg2".into())); + assert!(!session.should_dispatch_remote_message()); + + session.chat.push(Message::User("msg3".into())); + assert!(!session.should_dispatch_remote_message()); + + // Stream ends + drop(tx); + session.incoming_tokens = None; + + // Should redispatch — there are unanswered user messages + assert!(session.needs_redispatch_after_stream_end()); + } +} diff --git a/crates/notedeck_dave/src/session_converter.rs b/crates/notedeck_dave/src/session_converter.rs new file mode 100644 index 000000000..ec7750eab --- /dev/null +++ b/crates/notedeck_dave/src/session_converter.rs @@ -0,0 +1,72 @@ +//! Orchestrates converting a claude-code session JSONL file into nostr events. +//! +//! Reads the JSONL file line-by-line, builds kind-1988 nostr events with +//! proper threading, and ingests them into the local nostr database. + +use crate::session_events::{self, BuiltEvent, ThreadingState}; +use crate::session_jsonl::JsonlLine; +use nostrdb::{IngestMetadata, Ndb}; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +/// Convert a session JSONL file into nostr events and ingest them locally. +/// +/// Returns the ordered list of note IDs for the ingested events. +pub fn convert_session_to_events( + jsonl_path: &Path, + ndb: &Ndb, + secret_key: &[u8; 32], +) -> Result, ConvertError> { + let file = File::open(jsonl_path).map_err(ConvertError::Io)?; + let reader = BufReader::new(file); + + let mut threading = ThreadingState::new(); + let mut note_ids = Vec::new(); + + for (line_num, line) in reader.lines().enumerate() { + let line = line.map_err(ConvertError::Io)?; + if line.trim().is_empty() { + continue; + } + + let parsed = JsonlLine::parse(&line) + .map_err(|e| ConvertError::Parse(format!("line {}: {}", line_num + 1, e)))?; + + let events = session_events::build_events(&parsed, &mut threading, secret_key) + .map_err(|e| ConvertError::Build(format!("line {}: {}", line_num + 1, e)))?; + + for event in events { + ingest_event(ndb, &event)?; + note_ids.push(event.note_id); + } + } + + Ok(note_ids) +} + +/// Ingest a single built event into the local ndb. +fn ingest_event(ndb: &Ndb, event: &BuiltEvent) -> Result<(), ConvertError> { + ndb.process_event_with(&event.to_event_json(), IngestMetadata::new().client(true)) + .map_err(|e| ConvertError::Ingest(format!("{:?}", e)))?; + Ok(()) +} + +#[derive(Debug)] +pub enum ConvertError { + Io(std::io::Error), + Parse(String), + Build(String), + Ingest(String), +} + +impl std::fmt::Display for ConvertError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConvertError::Io(e) => write!(f, "IO error: {}", e), + ConvertError::Parse(e) => write!(f, "parse error: {}", e), + ConvertError::Build(e) => write!(f, "build error: {}", e), + ConvertError::Ingest(e) => write!(f, "ingest error: {}", e), + } + } +} diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs new file mode 100644 index 000000000..430bd02ef --- /dev/null +++ b/crates/notedeck_dave/src/session_events.rs @@ -0,0 +1,1156 @@ +//! Convert parsed JSONL lines into kind-1988 nostr events. +//! +//! Each JSONL line becomes one or more nostr events. Assistant messages with +//! mixed content (text + tool_use blocks) are split into separate events. +//! Events are threaded using NIP-10 `e` tags with root/reply markers. + +use crate::session_jsonl::{self, ContentBlock, JsonlLine}; +use nostrdb::{NoteBuildOptions, NoteBuilder}; +use std::collections::HashMap; + +/// Nostr event kind for AI conversation notes. +pub const AI_CONVERSATION_KIND: u32 = 1988; + +/// Nostr event kind for source-data companion events (archive). +/// Each 1989 event carries the raw JSONL for one line, linked to the +/// corresponding 1988 event via an `e` tag. +pub const AI_SOURCE_DATA_KIND: u32 = 1989; + +/// Nostr event kind for AI session state (parameterized replaceable, NIP-33). +/// One event per session, auto-replaced by nostrdb on update. +/// `d` tag = claude_session_id. +pub const AI_SESSION_STATE_KIND: u32 = 31988; + +/// Extract the value of a named tag from a note. +pub fn get_tag_value<'a>(note: &'a nostrdb::Note<'a>, tag_name: &str) -> Option<&'a str> { + for tag in note.tags() { + if tag.count() < 2 { + continue; + } + let Some(name) = tag.get_str(0) else { + continue; + }; + if name != tag_name { + continue; + } + return tag.get_str(1); + } + None +} + +/// A built nostr event ready for ingestion and relay publishing. +#[derive(Debug)] +pub struct BuiltEvent { + /// The bare event JSON `{…}` — for relay publishing and ndb ingestion. + pub note_json: String, + /// The 32-byte note ID (from the signed event). + pub note_id: [u8; 32], + /// The nostr event kind (1988 or 1989). + pub kind: u32, +} + +impl BuiltEvent { + /// Format as `["EVENT", {…}]` for ndb ingestion via `process_event_with`. + pub fn to_event_json(&self) -> String { + format!("[\"EVENT\", {}]", self.note_json) + } +} + +/// Wrap an inner event in a kind-1080 PNS envelope for relay publishing. +/// +/// Encrypts the inner event JSON with the PNS conversation key and signs +/// the outer event with the PNS keypair. Returns the kind-1080 event JSON. +pub fn wrap_pns( + inner_json: &str, + pns_keys: &enostr::pns::PnsKeys, +) -> Result { + let ciphertext = enostr::pns::encrypt(&pns_keys.conversation_key, inner_json) + .map_err(|e| EventBuildError::Serialize(format!("PNS encrypt: {e}")))?; + + let pns_secret = pns_keys.keypair.secret_key.secret_bytes(); + let builder = init_note_builder(enostr::pns::PNS_KIND, &ciphertext, Some(now_secs())); + let event = finalize_built_event(builder, &pns_secret, enostr::pns::PNS_KIND)?; + Ok(event.note_json) +} + +/// Maintains threading state across a session's events. +pub struct ThreadingState { + /// Maps JSONL uuid → nostr note ID (32 bytes). + uuid_to_note_id: HashMap, + /// The note ID of the first event in the session (root). + root_note_id: Option<[u8; 32]>, + /// The note ID of the most recently built event. + last_note_id: Option<[u8; 32]>, + /// Monotonic sequence counter for unambiguous ordering. + seq: u32, + /// Last seen session ID (carried forward for lines that lack it). + session_id: Option, + /// Last seen timestamp in seconds (carried forward for lines that lack it). + last_timestamp: Option, +} + +impl Default for ThreadingState { + fn default() -> Self { + Self::new() + } +} + +impl ThreadingState { + pub fn new() -> Self { + Self { + uuid_to_note_id: HashMap::new(), + root_note_id: None, + last_note_id: None, + seq: 0, + session_id: None, + last_timestamp: None, + } + } + + /// The current sequence number. + pub fn seq(&self) -> u32 { + self.seq + } + + /// Update session context from a JSONL line (session_id, timestamp). + fn update_context(&mut self, line: &JsonlLine) { + if let Some(sid) = line.session_id() { + self.session_id = Some(sid.to_string()); + } + if let Some(ts) = line.timestamp_secs() { + self.last_timestamp = Some(ts); + } + } + + /// Get the session ID for the current line, falling back to the last seen. + fn session_id_for(&self, line: &JsonlLine) -> Option { + line.session_id() + .map(|s| s.to_string()) + .or_else(|| self.session_id.clone()) + } + + /// Get the timestamp for the current line, falling back to the last seen. + fn timestamp_for(&self, line: &JsonlLine) -> Option { + line.timestamp_secs().or(self.last_timestamp) + } + + /// Seed threading state from existing events (e.g. loaded from ndb). + /// + /// Sets root and last note IDs so that subsequent live events + /// thread correctly as replies to the existing conversation. + pub fn seed(&mut self, root_note_id: [u8; 32], last_note_id: [u8; 32], event_count: u32) { + self.root_note_id = Some(root_note_id); + self.last_note_id = Some(last_note_id); + self.seq = event_count; + } + + /// Record a built event's note ID, associated with a JSONL uuid. + /// + /// `can_be_root`: if true, this event may become the conversation root. + /// Metadata events (queue-operation, progress, etc.) should pass false + /// so they don't become the root of the threading chain. + pub fn record(&mut self, uuid: Option<&str>, note_id: [u8; 32], can_be_root: bool) { + if can_be_root && self.root_note_id.is_none() { + self.root_note_id = Some(note_id); + } + if let Some(uuid) = uuid { + self.uuid_to_note_id.insert(uuid.to_string(), note_id); + } + self.last_note_id = Some(note_id); + self.seq += 1; + } +} + +/// Get the current Unix timestamp in seconds. +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Initialize a NoteBuilder with kind, content, and optional timestamp. +fn init_note_builder(kind: u32, content: &str, timestamp: Option) -> NoteBuilder<'_> { + let mut builder = NoteBuilder::new() + .kind(kind) + .content(content) + .options(NoteBuildOptions::default()); + + if let Some(ts) = timestamp { + builder = builder.created_at(ts); + } + + builder +} + +/// Sign, build, and serialize a NoteBuilder into a BuiltEvent. +fn finalize_built_event( + builder: NoteBuilder, + secret_key: &[u8; 32], + kind: u32, +) -> Result { + let note = builder + .sign(secret_key) + .build() + .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?; + + let note_id: [u8; 32] = *note.id(); + + let note_json = note + .json() + .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?; + + Ok(BuiltEvent { + note_json, + note_id, + kind, + }) +} + +/// Whether a role represents a conversation message (not metadata). +pub fn is_conversation_role(role: &str) -> bool { + matches!(role, "user" | "assistant" | "tool_call" | "tool_result") +} + +/// Build nostr events from a single JSONL line. +/// +/// Returns one or more events. Assistant messages with mixed content blocks +/// (text + tool_use) are split into multiple events, one per block. +/// +/// `secret_key` is the 32-byte secret key for signing events. +pub fn build_events( + line: &JsonlLine, + threading: &mut ThreadingState, + secret_key: &[u8; 32], +) -> Result, EventBuildError> { + // Resolve session_id and timestamp with fallback to last seen values, + // then update context for subsequent lines. + let session_id = threading.session_id_for(line); + let timestamp = threading.timestamp_for(line); + threading.update_context(line); + + let msg = line.message(); + let is_assistant = line.line_type() == Some("assistant"); + + // Check if this is an assistant message with multiple content blocks + // that should be split into separate events + let blocks: Vec> = if is_assistant { + msg.as_ref().map(|m| m.content_blocks()).unwrap_or_default() + } else { + vec![] + }; + + let should_split = is_assistant && blocks.len() > 1; + + let mut events = if should_split { + // Build one event per content block + let total = blocks.len(); + let mut events = Vec::with_capacity(total); + for (i, block) in blocks.iter().enumerate() { + let content = session_jsonl::display_content_for_block(block); + let role = match block { + ContentBlock::Text(_) => "assistant", + ContentBlock::ToolUse { .. } => "tool_call", + ContentBlock::ToolResult { .. } => "tool_result", + }; + let tool_id = match block { + ContentBlock::ToolUse { id, .. } => Some(*id), + ContentBlock::ToolResult { tool_use_id, .. } => Some(*tool_use_id), + _ => None, + }; + let tool_name = match block { + ContentBlock::ToolUse { name, .. } => Some(*name), + ContentBlock::ToolResult { tool_use_id, .. } => { + // Look up tool name from a prior ToolUse block with matching id + blocks.iter().find_map(|b| match b { + ContentBlock::ToolUse { id, name, .. } if *id == *tool_use_id => { + Some(*name) + } + _ => None, + }) + } + _ => None, + }; + + let event = build_single_event( + Some(line), + &content, + role, + "claude-code", + Some((i, total)), + tool_id, + tool_name, + session_id.as_deref(), + None, + timestamp, + threading, + secret_key, + )?; + threading.record(line.uuid(), event.note_id, is_conversation_role(role)); + events.push(event); + } + events + } else { + // Single event for the line + let content = session_jsonl::extract_display_content(line); + let role = line.role().unwrap_or("unknown"); + + // Extract tool_id and tool_name from single-block messages + let (tool_id, tool_name) = msg + .as_ref() + .and_then(|m| { + let blocks = m.content_blocks(); + if blocks.len() == 1 { + match &blocks[0] { + ContentBlock::ToolUse { id, name, .. } => { + Some((id.to_string(), Some(name.to_string()))) + } + ContentBlock::ToolResult { tool_use_id, .. } => { + Some((tool_use_id.to_string(), None)) + } + _ => None, + } + } else { + None + } + }) + .map_or((None, None), |(id, name)| (Some(id), name)); + + let event = build_single_event( + Some(line), + &content, + role, + "claude-code", + None, + tool_id.as_deref(), + tool_name.as_deref(), + session_id.as_deref(), + None, + timestamp, + threading, + secret_key, + )?; + threading.record(line.uuid(), event.note_id, is_conversation_role(role)); + vec![event] + }; + + // Build a kind-1989 source-data companion event linked to the first 1988 event. + let first_note_id = events[0].note_id; + let source_data_event = build_source_data_event( + line, + &first_note_id, + threading.seq() - 1, + session_id.as_deref(), + timestamp, + secret_key, + )?; + events.push(source_data_event); + + Ok(events) +} + +#[derive(Debug)] +pub enum EventBuildError { + Build(String), + Serialize(String), +} + +impl std::fmt::Display for EventBuildError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EventBuildError::Build(e) => write!(f, "failed to build note: {}", e), + EventBuildError::Serialize(e) => write!(f, "failed to serialize event: {}", e), + } + } +} + +/// Build a kind-1989 source-data companion event. +/// +/// Contains the raw JSONL line and links to the corresponding 1988 event. +/// Does NOT participate in threading (no root/reply, no seq increment). +fn build_source_data_event( + line: &JsonlLine, + conversation_note_id: &[u8; 32], + seq: u32, + session_id: Option<&str>, + timestamp: Option, + secret_key: &[u8; 32], +) -> Result { + let raw_json = line.to_json(); + let seq_str = seq.to_string(); + + let mut builder = init_note_builder(AI_SOURCE_DATA_KIND, "", timestamp); + + // Link to the corresponding 1988 event + builder = builder + .start_tag() + .tag_str("e") + .tag_id(conversation_note_id); + + if let Some(session_id) = session_id { + builder = builder.start_tag().tag_str("d").tag_str(session_id); + } + + // Same seq as the first 1988 event from this line + builder = builder.start_tag().tag_str("seq").tag_str(&seq_str); + + // The raw JSONL data + builder = builder + .start_tag() + .tag_str("source-data") + .tag_str(&raw_json); + + finalize_built_event(builder, secret_key, AI_SOURCE_DATA_KIND) +} + +/// Build a single kind-1988 nostr event. +/// +/// When `line` is provided (archive path), extracts slug, version, model, +/// line_type, and cwd from the JSONL line. When `None` (live path), only +/// uses the explicitly passed parameters. +/// +/// `split_index`: `Some((i, total))` when this event is part of a split +/// assistant message. +/// +/// `tool_id`: The tool use/result ID for tool_call and tool_result events. +/// +/// `tool_name`: The tool name (e.g. "Bash", "Read") for tool_call and tool_result events. +#[allow(clippy::too_many_arguments)] +fn build_single_event( + line: Option<&JsonlLine>, + content: &str, + role: &str, + source: &str, + split_index: Option<(usize, usize)>, + tool_id: Option<&str>, + tool_name: Option<&str>, + session_id: Option<&str>, + cwd: Option<&str>, + timestamp: Option, + threading: &ThreadingState, + secret_key: &[u8; 32], +) -> Result { + let mut builder = init_note_builder(AI_CONVERSATION_KIND, content, timestamp); + + // -- Session identity tags -- + if let Some(session_id) = session_id { + builder = builder.start_tag().tag_str("d").tag_str(session_id); + } + if let Some(slug) = line.and_then(|l| l.slug()) { + builder = builder.start_tag().tag_str("session-slug").tag_str(slug); + } + + // -- Threading tags (NIP-10) -- + if let Some(root_id) = threading.root_note_id { + builder = builder + .start_tag() + .tag_str("e") + .tag_id(&root_id) + .tag_str("") + .tag_str("root"); + } + if let Some(reply_id) = threading.last_note_id { + builder = builder + .start_tag() + .tag_str("e") + .tag_id(&reply_id) + .tag_str("") + .tag_str("reply"); + } + + // -- Sequence number (monotonic, for unambiguous ordering) -- + let seq_str = threading.seq.to_string(); + builder = builder.start_tag().tag_str("seq").tag_str(&seq_str); + + // -- Message metadata tags -- + builder = builder.start_tag().tag_str("source").tag_str(source); + + if let Some(version) = line.and_then(|l| l.version()) { + builder = builder + .start_tag() + .tag_str("source-version") + .tag_str(version); + } + + builder = builder.start_tag().tag_str("role").tag_str(role); + + // Model tag (for assistant messages) + if let Some(model) = line.and_then(|l| l.message()).and_then(|m| m.model()) { + builder = builder.start_tag().tag_str("model").tag_str(model); + } + + if let Some(line_type) = line.and_then(|l| l.line_type()) { + builder = builder.start_tag().tag_str("turn-type").tag_str(line_type); + } + + // -- CWD tag -- + let resolved_cwd = cwd.or_else(|| line.and_then(|l| l.cwd())); + if let Some(cwd) = resolved_cwd { + builder = builder.start_tag().tag_str("cwd").tag_str(cwd); + } + + // -- Split tag (for split assistant messages) -- + if let Some((i, total)) = split_index { + let split_str = format!("{}/{}", i, total); + builder = builder.start_tag().tag_str("split").tag_str(&split_str); + } + + // -- Tool ID tag -- + if let Some(tid) = tool_id { + builder = builder.start_tag().tag_str("tool-id").tag_str(tid); + } + + // -- Tool name tag -- + if let Some(tn) = tool_name { + builder = builder.start_tag().tag_str("tool-name").tag_str(tn); + } + + // -- Discoverability -- + builder = builder.start_tag().tag_str("t").tag_str("ai-conversation"); + + finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND) +} + +/// Build a kind-1988 event for a live conversation message. +/// +/// Unlike `build_events()` which works from JSONL lines, this builds directly +/// from role + content strings. No kind-1989 source-data events are created. +/// +/// Calls `threading.record()` internally. +#[allow(clippy::too_many_arguments)] +pub fn build_live_event( + content: &str, + role: &str, + session_id: &str, + cwd: Option<&str>, + tool_id: Option<&str>, + tool_name: Option<&str>, + threading: &mut ThreadingState, + secret_key: &[u8; 32], +) -> Result { + let event = build_single_event( + None, + content, + role, + "notedeck-dave", + None, + tool_id, + tool_name, + Some(session_id), + cwd, + Some(now_secs()), + threading, + secret_key, + )?; + + threading.record(None, event.note_id, true); + Ok(event) +} + +/// Build a kind-1988 permission request event. +/// +/// Published to relays so remote clients (phone) can see pending permission +/// requests and respond. Tags include `perm-id` (UUID), `tool-name`, and +/// `t: ai-permission` for filtering. +/// +/// Does NOT participate in threading — permission events are ancillary. +pub fn build_permission_request_event( + perm_id: &uuid::Uuid, + tool_name: &str, + tool_input: &serde_json::Value, + session_id: &str, + secret_key: &[u8; 32], +) -> Result { + // Content is a JSON summary for display on remote clients + let content = serde_json::json!({ + "tool_name": tool_name, + "tool_input": tool_input, + }) + .to_string(); + + let perm_id_str = perm_id.to_string(); + + let mut builder = init_note_builder(AI_CONVERSATION_KIND, &content, Some(now_secs())); + + // Session identity + 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("role") + .tag_str("permission_request"); + builder = builder + .start_tag() + .tag_str("source") + .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"); + + finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND) +} + +/// Build a kind-1988 permission response event. +/// +/// Published by remote clients (phone) to allow/deny a permission request. +/// The desktop subscribes for these and routes them through the existing +/// oneshot channel, racing with the local UI. +/// +/// Tags include `perm-id` (matching the request), `e` tag linking to the +/// request event, and `t: ai-permission` for filtering. +pub fn build_permission_response_event( + perm_id: &uuid::Uuid, + request_note_id: &[u8; 32], + allowed: bool, + message: Option<&str>, + session_id: &str, + secret_key: &[u8; 32], +) -> Result { + let content = serde_json::json!({ + "decision": if allowed { "allow" } else { "deny" }, + "message": message.unwrap_or(""), + }) + .to_string(); + + let perm_id_str = perm_id.to_string(); + + let mut builder = init_note_builder(AI_CONVERSATION_KIND, &content, Some(now_secs())); + + // Session identity + 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); + + // Permission-specific tags + builder = builder.start_tag().tag_str("perm-id").tag_str(&perm_id_str); + builder = builder + .start_tag() + .tag_str("role") + .tag_str("permission_response"); + builder = builder + .start_tag() + .tag_str("source") + .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"); + + finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND) +} + +/// Build a kind-31988 session state event (parameterized replaceable). +/// +/// Published on every status change so remote clients and startup restore +/// can discover active sessions. nostrdb auto-replaces older versions +/// with same (kind, pubkey, d-tag). +pub fn build_session_state_event( + claude_session_id: &str, + title: &str, + cwd: &str, + status: &str, + hostname: &str, + secret_key: &[u8; 32], +) -> Result { + let mut builder = init_note_builder(AI_SESSION_STATE_KIND, "", Some(now_secs())); + + // Session identity (makes this a parameterized replaceable event) + builder = builder.start_tag().tag_str("d").tag_str(claude_session_id); + + // Session metadata as tags + builder = builder.start_tag().tag_str("title").tag_str(title); + builder = builder.start_tag().tag_str("cwd").tag_str(cwd); + builder = builder.start_tag().tag_str("status").tag_str(status); + builder = builder.start_tag().tag_str("hostname").tag_str(hostname); + + // 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("source") + .tag_str("notedeck-dave"); + + finalize_built_event(builder, secret_key, AI_SESSION_STATE_KIND) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test secret key (32 bytes, not for real use) + fn test_secret_key() -> [u8; 32] { + let mut key = [0u8; 32]; + key[0] = 1; // non-zero so signing works + key + } + + #[test] + fn test_build_user_text_event() { + let line = JsonlLine::parse( + r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"sess1","timestamp":"2026-02-09T20:43:35.675Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"user","content":"Human: hello world\n\n"}}"#, + ) + .unwrap(); + + let mut threading = ThreadingState::new(); + let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); + + // 1 conversation event (1988) + 1 source-data event (1989) + assert_eq!(events.len(), 2); + assert_eq!(events[0].kind, AI_CONVERSATION_KIND); + assert_eq!(events[1].kind, AI_SOURCE_DATA_KIND); + assert!(threading.root_note_id.is_some()); + assert_eq!(threading.root_note_id, Some(events[0].note_id)); + + // 1988 event has kind and tags but NO source-data + let json = &events[0].note_json; + assert!(json.contains("1988")); + assert!(json.contains("source")); + assert!(json.contains("claude-code")); + assert!(json.contains("role")); + assert!(json.contains("user")); + assert!(!json.contains("source-data")); + + // 1989 event has source-data + assert!(events[1].note_json.contains("source-data")); + } + + #[test] + fn test_build_assistant_text_event() { + let line = JsonlLine::parse( + r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"sess1","timestamp":"2026-02-09T20:43:38.421Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"I can help with that."}]}}"#, + ) + .unwrap(); + + let mut threading = ThreadingState::new(); + // Simulate a prior event + threading.root_note_id = Some([1u8; 32]); + threading.last_note_id = Some([1u8; 32]); + + let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); + // 1 conversation (1988) + 1 source-data (1989) + assert_eq!(events.len(), 2); + assert_eq!(events[0].kind, AI_CONVERSATION_KIND); + assert_eq!(events[1].kind, AI_SOURCE_DATA_KIND); + + let json = &events[0].note_json; + assert!(json.contains("assistant")); + assert!(json.contains("claude-opus-4-5-20251101")); // model tag + } + + #[test] + fn test_build_split_assistant_mixed_content() { + let line = JsonlLine::parse( + r#"{"type":"assistant","uuid":"u3","sessionId":"sess1","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Let me check."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/tmp/test.rs"}}]}}"#, + ) + .unwrap(); + + let mut threading = ThreadingState::new(); + let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); + + // 2 conversation events (1988) + 1 source-data (1989) + assert_eq!(events.len(), 3); + assert_eq!(events[0].kind, AI_CONVERSATION_KIND); + assert_eq!(events[1].kind, AI_CONVERSATION_KIND); + assert_eq!(events[2].kind, AI_SOURCE_DATA_KIND); + + // All should have unique note IDs + assert_ne!(events[0].note_id, events[1].note_id); + assert_ne!(events[0].note_id, events[2].note_id); + } + + #[test] + fn test_threading_chain() { + let lines = vec![ + r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"hello"}}"#, + r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:01Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#, + r#"{"type":"user","uuid":"u3","parentUuid":"u2","sessionId":"s","timestamp":"2026-02-09T20:00:02Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"bye"}}"#, + ]; + + let mut threading = ThreadingState::new(); + let sk = test_secret_key(); + let mut all_events = vec![]; + + for line_str in &lines { + let line = JsonlLine::parse(line_str).unwrap(); + let events = build_events(&line, &mut threading, &sk).unwrap(); + all_events.extend(events); + } + + // 3 lines × (1 conversation + 1 source-data) = 6 events + assert_eq!(all_events.len(), 6); + + // Filter to only 1988 events for threading checks + let conv_events: Vec<_> = all_events + .iter() + .filter(|e| e.kind == AI_CONVERSATION_KIND) + .collect(); + assert_eq!(conv_events.len(), 3); + + // First event should be root (no e tags) + // Subsequent events should reference root + previous + assert!(!conv_events[0].note_json.contains("root")); + assert!(conv_events[1].note_json.contains("root")); + assert!(conv_events[1].note_json.contains("reply")); + assert!(conv_events[2].note_json.contains("root")); + assert!(conv_events[2].note_json.contains("reply")); + } + + #[test] + fn test_source_data_preserves_raw_json() { + let line = JsonlLine::parse( + r#"{"type":"user","uuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/Users/jb55/dev/notedeck","version":"2.0.64","message":{"role":"user","content":"check /Users/jb55/dev/notedeck/src/main.rs"}}"#, + ) + .unwrap(); + + let mut threading = ThreadingState::new(); + let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); + + // 1988 event should NOT have source-data + 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(); + assert!(sd_event.note_json.contains("source-data")); + assert!(sd_event.note_json.contains("/Users/jb55/dev/notedeck")); + } + + #[test] + fn test_queue_operation_event() { + let line = JsonlLine::parse( + r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-02-09T20:43:35.669Z","sessionId":"sess1"}"#, + ) + .unwrap(); + + let mut threading = ThreadingState::new(); + let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); + // 1 conversation (1988) + 1 source-data (1989) + assert_eq!(events.len(), 2); + + let json = &events[0].note_json; + assert!(json.contains("queue-operation")); + } + + #[test] + fn test_seq_counter_increments() { + let lines = vec![ + r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"hello"}}"#, + r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:01Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#, + ]; + + let mut threading = ThreadingState::new(); + let sk = test_secret_key(); + + assert_eq!(threading.seq(), 0); + + let line = JsonlLine::parse(lines[0]).unwrap(); + let events = build_events(&line, &mut threading, &sk).unwrap(); + // 1 conversation + 1 source-data + assert_eq!(events.len(), 2); + assert_eq!(threading.seq(), 1); + // First 1988 event should have seq=0 + assert!(events[0].note_json.contains(r#""seq","0"#)); + // 1989 event should also have seq=0 (matches its 1988 event) + assert!(events[1].note_json.contains(r#""seq","0"#)); + + let line = JsonlLine::parse(lines[1]).unwrap(); + let events = build_events(&line, &mut threading, &sk).unwrap(); + assert_eq!(events.len(), 2); + assert_eq!(threading.seq(), 2); + // Second 1988 event should have seq=1 + assert!(events[0].note_json.contains(r#""seq","1"#)); + } + + #[test] + fn test_split_tags_and_source_data() { + let line = JsonlLine::parse( + r#"{"type":"assistant","uuid":"u3","sessionId":"sess1","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Let me check."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/tmp/test.rs"}}]}}"#, + ) + .unwrap(); + + let mut threading = ThreadingState::new(); + let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); + // 2 conversation (1988) + 1 source-data (1989) + assert_eq!(events.len(), 3); + + // First event (text): split 0/2, NO source-data (moved to 1989) + assert!(events[0].note_json.contains(r#""split","0/2"#)); + assert!(!events[0].note_json.contains("source-data")); + + // Second event (tool_call): split 1/2, NO source-data, has tool-id + assert!(events[1].note_json.contains(r#""split","1/2"#)); + assert!(!events[1].note_json.contains("source-data")); + assert!(events[1].note_json.contains(r#""tool-id","t1"#)); + + // Third event (1989): has source-data + assert_eq!(events[2].kind, AI_SOURCE_DATA_KIND); + assert!(events[2].note_json.contains("source-data")); + } + + #[test] + fn test_cwd_tag() { + let line = JsonlLine::parse( + r#"{"type":"user","uuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/Users/jb55/dev/notedeck","version":"2.0.64","message":{"role":"user","content":"hello"}}"#, + ) + .unwrap(); + + let mut threading = ThreadingState::new(); + let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); + + assert!(events[0] + .note_json + .contains(r#""cwd","/Users/jb55/dev/notedeck"#)); + } + + #[test] + fn test_tool_result_has_tool_id() { + let line = JsonlLine::parse( + r#"{"type":"user","uuid":"u4","parentUuid":"u3","cwd":"/tmp","sessionId":"s","version":"2.0.64","timestamp":"2026-02-09T20:00:03Z","message":{"role":"user","content":[{"tool_use_id":"toolu_abc","type":"tool_result","content":"file contents"}]}}"#, + ) + .unwrap(); + + let mut threading = ThreadingState::new(); + let events = build_events(&line, &mut threading, &test_secret_key()).unwrap(); + // 1 conversation + 1 source-data + assert_eq!(events.len(), 2); + assert!(events[0].note_json.contains(r#""tool-id","toolu_abc"#)); + } + + #[tokio::test] + async fn test_full_roundtrip() { + use crate::session_reconstructor; + use nostrdb::{Config, IngestMetadata, Ndb, Transaction}; + use serde_json::Value; + use tempfile::TempDir; + + // Sample JSONL lines covering different message types + let jsonl_lines = vec![ + r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-02-09T20:00:00Z","sessionId":"roundtrip-test"}"#, + r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:01Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"user","content":"Human: hello world\n\n"}}"#, + r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:02Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Let me check that file."},{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/tmp/project/main.rs"}}]}}"#, + r#"{"type":"user","uuid":"u3","parentUuid":"u2","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:03Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"fn main() {}"}]}}"#, + r#"{"type":"assistant","uuid":"u4","parentUuid":"u3","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:04Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"That's a simple main function."}]}}"#, + ]; + + // Set up ndb + let tmp_dir = TempDir::new().unwrap(); + let ndb = Ndb::new(tmp_dir.path().to_str().unwrap(), &Config::new()).unwrap(); + + // Build and ingest events one at a time, waiting for each + let sk = test_secret_key(); + let mut threading = ThreadingState::new(); + let mut total_events = 0; + + let filter = nostrdb::Filter::new() + .kinds([AI_CONVERSATION_KIND as u64, AI_SOURCE_DATA_KIND as u64]) + .build(); + + for line_str in &jsonl_lines { + let line = JsonlLine::parse(line_str).unwrap(); + let events = build_events(&line, &mut threading, &sk).unwrap(); + for event in &events { + let sub_id = ndb.subscribe(&[filter.clone()]).unwrap(); + ndb.process_event_with(&event.to_event_json(), IngestMetadata::new().client(true)) + .expect("ingest failed"); + let _keys = ndb.wait_for_notes(sub_id, 1).await.unwrap(); + total_events += 1; + } + } + + // Each JSONL line produces N conversation events + 1 source-data event. + // Line 1 (queue-op): 1 conv + 1 sd = 2 + // Line 2 (user): 1 conv + 1 sd = 2 + // Line 3 (assistant split): 2 conv + 1 sd = 3 + // Line 4 (user tool_result): 1 conv + 1 sd = 2 + // Line 5 (assistant): 1 conv + 1 sd = 2 + // Total: 11 + assert_eq!(total_events, 11); + + // Reconstruct JSONL from ndb + let txn = Transaction::new(&ndb).unwrap(); + let reconstructed = + session_reconstructor::reconstruct_jsonl_lines(&ndb, &txn, "roundtrip-test").unwrap(); + + // Should get back one JSONL line per original line + assert_eq!( + reconstructed.len(), + jsonl_lines.len(), + "expected {} lines, got {}", + jsonl_lines.len(), + reconstructed.len() + ); + + // Compare each line as serde_json::Value for order-independent equality + for (i, (original, reconstructed)) in + jsonl_lines.iter().zip(reconstructed.iter()).enumerate() + { + let orig_val: Value = serde_json::from_str(original).unwrap(); + let recon_val: Value = serde_json::from_str(reconstructed).unwrap(); + assert_eq!( + orig_val, recon_val, + "line {} mismatch.\noriginal: {}\nreconstructed: {}", + i, original, reconstructed + ); + } + } + + #[test] + fn test_file_history_snapshot_inherits_context() { + // file-history-snapshot lines lack sessionId and top-level timestamp. + // They should inherit session_id from a prior line and get timestamp + // from snapshot.timestamp. + let lines = vec![ + r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"ctx-test","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"hello"}}"#, + r#"{"type":"file-history-snapshot","messageId":"abc","snapshot":{"messageId":"abc","trackedFileBackups":{},"timestamp":"2026-02-11T01:29:31.555Z"},"isSnapshotUpdate":false}"#, + ]; + + let mut threading = ThreadingState::new(); + let sk = test_secret_key(); + + // First line sets context + let line = JsonlLine::parse(lines[0]).unwrap(); + let events = build_events(&line, &mut threading, &sk).unwrap(); + assert!(events[0].note_json.contains(r#""d","ctx-test"#)); + + // Second line (file-history-snapshot) should inherit session_id + let line = JsonlLine::parse(lines[1]).unwrap(); + assert!(line.session_id().is_none()); // no top-level sessionId + let events = build_events(&line, &mut threading, &sk).unwrap(); + + // 1988 event should have inherited d tag + assert!(events[0].note_json.contains(r#""d","ctx-test"#)); + // Should have snapshot timestamp (1770773371), not the user's + assert!(events[0].note_json.contains(r#""created_at":1770773371"#)); + } + + #[test] + fn test_build_permission_request_event() { + let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + 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(); + + assert_eq!(event.kind, AI_CONVERSATION_KIND); + + let json = &event.note_json; + // Has permission-specific tags + assert!(json.contains(r#""perm-id","550e8400-e29b-41d4-a716-446655440000"#)); + assert!(json.contains(r#""tool-name","Bash"#)); + assert!(json.contains(r#""role","permission_request"#)); + // Has session identity + assert!(json.contains(r#""d","sess-perm-test"#)); + // Has discoverability tags + assert!(json.contains(r#""t","ai-conversation"#)); + assert!(json.contains(r#""t","ai-permission"#)); + // Content has tool info + assert!(json.contains("rm -rf")); + } + + #[test] + fn test_build_permission_response_event() { + let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let request_note_id = [42u8; 32]; + let sk = test_secret_key(); + + // Test allow response + let event = build_permission_response_event( + &perm_id, + &request_note_id, + true, + Some("looks safe"), + "sess-perm-test", + &sk, + ) + .unwrap(); + + assert_eq!(event.kind, AI_CONVERSATION_KIND); + + let json = &event.note_json; + assert!(json.contains(r#""perm-id","550e8400-e29b-41d4-a716-446655440000"#)); + assert!(json.contains(r#""role","permission_response"#)); + assert!(json.contains(r#""d","sess-perm-test"#)); + assert!(json.contains("allow")); + assert!(json.contains("looks safe")); + // Has e tag linking to request + assert!(json.contains(r#""e""#)); + } + + #[test] + fn test_permission_response_deny() { + let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let request_note_id = [42u8; 32]; + let sk = test_secret_key(); + + let event = build_permission_response_event( + &perm_id, + &request_note_id, + false, + Some("too dangerous"), + "sess-perm-test", + &sk, + ) + .unwrap(); + + let json = &event.note_json; + assert!(json.contains("deny")); + assert!(json.contains("too dangerous")); + } + + #[test] + fn test_build_session_state_event() { + let sk = test_secret_key(); + + let event = build_session_state_event( + "sess-state-test", + "Fix the login bug", + "/tmp/project", + "working", + "my-laptop", + &sk, + ) + .unwrap(); + + assert_eq!(event.kind, AI_SESSION_STATE_KIND); + + let json = &event.note_json; + // Kind 31988 (parameterized replaceable) + assert!(json.contains("31988")); + // Has d tag for replacement + assert!(json.contains(r#""d","sess-state-test"#)); + // Has discoverability tags + assert!(json.contains(r#""t","ai-session-state"#)); + assert!(json.contains(r#""t","ai-conversation"#)); + // Content has state fields + assert!(json.contains("Fix the login bug")); + assert!(json.contains("working")); + assert!(json.contains("/tmp/project")); + assert!(json.contains(r#""hostname","my-laptop"#)); + } + + #[test] + fn test_wrap_pns() { + let sk = test_secret_key(); + let pns_keys = enostr::pns::derive_pns_keys(&sk); + + let inner = r#"{"kind":1988,"content":"hello","tags":[],"created_at":0,"pubkey":"abc","id":"def","sig":"ghi"}"#; + let wrapped = wrap_pns(inner, &pns_keys).unwrap(); + + // Outer event should be kind 1080 + assert!(wrapped.contains("1080")); + // Should NOT contain the plaintext inner content + assert!(!wrapped.contains("hello")); + // Should be valid JSON + assert!(serde_json::from_str::(&wrapped).is_ok()); + } +} diff --git a/crates/notedeck_dave/src/session_jsonl.rs b/crates/notedeck_dave/src/session_jsonl.rs new file mode 100644 index 000000000..8d9e5564a --- /dev/null +++ b/crates/notedeck_dave/src/session_jsonl.rs @@ -0,0 +1,476 @@ +//! Parse claude-code session JSONL lines with lossless round-trip support. +//! +//! Follows the `ProfileState` pattern from `enostr::profile` — wraps a +//! `serde_json::Value` with typed accessors so we can read the fields we +//! need for nostr event construction while preserving the raw JSON for +//! the `source-data` tag. + +use serde_json::Value; + +/// A single line from a claude-code session JSONL file. +/// +/// Wraps the raw JSON value to preserve all fields for lossless round-trip +/// via the `source-data` nostr tag. +#[derive(Debug, Clone)] +pub struct JsonlLine(Value); + +impl JsonlLine { + /// Parse a JSONL line from a string. + pub fn parse(line: &str) -> Result { + let value: Value = serde_json::from_str(line)?; + Ok(Self(value)) + } + + /// The raw JSON value. + pub fn value(&self) -> &Value { + &self.0 + } + + /// Serialize back to a JSON string (lossless). + pub fn to_json(&self) -> String { + // serde_json::to_string on a Value is infallible + serde_json::to_string(&self.0).unwrap() + } + + // -- Top-level field accessors -- + + fn get_str(&self, key: &str) -> Option<&str> { + self.0.get(key).and_then(|v| v.as_str()) + } + + /// The JSONL line type: "user", "assistant", "progress", "queue-operation", + /// "file-history-snapshot" + pub fn line_type(&self) -> Option<&str> { + self.get_str("type") + } + + pub fn uuid(&self) -> Option<&str> { + self.get_str("uuid") + } + + pub fn parent_uuid(&self) -> Option<&str> { + self.get_str("parentUuid") + } + + pub fn session_id(&self) -> Option<&str> { + self.get_str("sessionId") + } + + pub fn timestamp(&self) -> Option<&str> { + // Top-level timestamp, falling back to snapshot.timestamp + // (file-history-snapshot lines nest it there) + self.get_str("timestamp").or_else(|| { + self.0 + .get("snapshot") + .and_then(|s| s.get("timestamp")) + .and_then(|v| v.as_str()) + }) + } + + /// Parse the timestamp as a unix timestamp (seconds). + pub fn timestamp_secs(&self) -> Option { + let ts_str = self.timestamp()?; + let dt = chrono::DateTime::parse_from_rfc3339(ts_str).ok()?; + Some(dt.timestamp() as u64) + } + + pub fn cwd(&self) -> Option<&str> { + self.get_str("cwd") + } + + pub fn git_branch(&self) -> Option<&str> { + self.get_str("gitBranch") + } + + pub fn version(&self) -> Option<&str> { + self.get_str("version") + } + + pub fn slug(&self) -> Option<&str> { + self.get_str("slug") + } + + /// The `message` object, if present. + pub fn message(&self) -> Option> { + self.0.get("message").map(JsonlMessage) + } + + /// For queue-operation lines: the operation type ("dequeue", etc.) + pub fn operation(&self) -> Option<&str> { + self.get_str("operation") + } + + /// Determine the role string for nostr event tagging. + /// + /// This maps the JSONL structure to the design spec's role values: + /// user (text) → "user", user (tool_result) → "tool_result", + /// assistant (text) → "assistant", assistant (tool_use) → "tool_call", + /// progress → "progress", etc. + pub fn role(&self) -> Option<&str> { + match self.line_type()? { + "user" => { + // Check if the content is a tool_result array + if let Some(msg) = self.message() { + if msg.has_tool_result_content() { + return Some("tool_result"); + } + } + Some("user") + } + "assistant" => Some("assistant"), + "progress" => Some("progress"), + "queue-operation" => Some("queue-operation"), + "file-history-snapshot" => Some("file-history-snapshot"), + _ => None, + } + } +} + +/// A borrowed view into the `message` object of a JSONL line. +#[derive(Debug, Clone, Copy)] +pub struct JsonlMessage<'a>(&'a Value); + +impl<'a> JsonlMessage<'a> { + fn get_str(&self, key: &str) -> Option<&'a str> { + self.0.get(key).and_then(|v| v.as_str()) + } + + pub fn role(&self) -> Option<&'a str> { + self.get_str("role") + } + + pub fn model(&self) -> Option<&'a str> { + self.get_str("model") + } + + /// The raw content value — can be a string or an array of content blocks. + pub fn content(&self) -> Option<&'a Value> { + self.0.get("content") + } + + /// Check if content contains tool_result blocks (user messages with tool results). + pub fn has_tool_result_content(&self) -> bool { + match self.content() { + Some(Value::Array(arr)) => arr + .iter() + .any(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result")), + _ => false, + } + } + + /// Extract the content blocks as an iterator. + pub fn content_blocks(&self) -> Vec> { + match self.content() { + Some(Value::String(s)) => vec![ContentBlock::Text(s.as_str())], + Some(Value::Array(arr)) => arr.iter().filter_map(ContentBlock::from_value).collect(), + _ => vec![], + } + } + + /// Extract just the text from text blocks, concatenated. + pub fn text_content(&self) -> Option { + let blocks = self.content_blocks(); + let texts: Vec<&str> = blocks + .iter() + .filter_map(|b| match b { + ContentBlock::Text(t) => Some(*t), + _ => None, + }) + .collect(); + + if texts.is_empty() { + None + } else { + Some(texts.join("")) + } + } +} + +/// A content block from an assistant or user message. +#[derive(Debug, Clone)] +pub enum ContentBlock<'a> { + /// Plain text content. + Text(&'a str), + /// A tool use request (assistant → tool). + ToolUse { + id: &'a str, + name: &'a str, + input: &'a Value, + }, + /// A tool result (tool → user message). + ToolResult { + tool_use_id: &'a str, + content: &'a Value, + }, +} + +impl<'a> ContentBlock<'a> { + fn from_value(value: &'a Value) -> Option { + let block_type = value.get("type")?.as_str()?; + match block_type { + "text" => { + let text = value.get("text")?.as_str()?; + Some(ContentBlock::Text(text)) + } + "tool_use" => { + let id = value.get("id")?.as_str()?; + let name = value.get("name")?.as_str()?; + let input = value.get("input")?; + Some(ContentBlock::ToolUse { id, name, input }) + } + "tool_result" => { + let tool_use_id = value.get("tool_use_id")?.as_str()?; + let content = value.get("content")?; + Some(ContentBlock::ToolResult { + tool_use_id, + content, + }) + } + _ => None, + } + } +} + +/// Human-readable content extraction for the nostr event `content` field. +/// +/// This produces the text that goes into the nostr event content, +/// suitable for rendering in any nostr client. +pub fn extract_display_content(line: &JsonlLine) -> String { + match line.line_type() { + Some("user") => { + if let Some(msg) = line.message() { + if msg.has_tool_result_content() { + // Tool result content — summarize + let blocks = msg.content_blocks(); + let summaries: Vec = blocks + .iter() + .filter_map(|b| match b { + ContentBlock::ToolResult { content, .. } => match content { + Value::String(s) => Some(truncate_str(s, 500)), + _ => Some("[tool result]".to_string()), + }, + _ => None, + }) + .collect(); + summaries.join("\n") + } else if let Some(text) = msg.text_content() { + // Strip "Human: " prefix if present (claude-code adds it) + text.strip_prefix("Human: ").unwrap_or(&text).to_string() + } else { + String::new() + } + } else { + String::new() + } + } + Some("assistant") => { + if let Some(msg) = line.message() { + // For assistant messages, we'll produce content for each block. + // The caller handles splitting into multiple events for mixed content. + msg.text_content().unwrap_or_default() + } else { + String::new() + } + } + Some("progress") => line + .message() + .and_then(|m| m.text_content()) + .unwrap_or_else(|| "[progress]".to_string()), + Some("queue-operation") => { + let op = line.operation().unwrap_or("unknown"); + format!("[queue: {}]", op) + } + Some("file-history-snapshot") => "[file history snapshot]".to_string(), + _ => String::new(), + } +} + +/// Extract display content for a single content block (for assistant messages +/// that need to be split into multiple events). +pub fn display_content_for_block(block: &ContentBlock<'_>) -> String { + match block { + ContentBlock::Text(text) => text.to_string(), + ContentBlock::ToolUse { name, input, .. } => { + // Compact summary: tool name + truncated input + let input_str = serde_json::to_string(input).unwrap_or_default(); + let input_preview = truncate_str(&input_str, 200); + format!("Tool: {} {}", name, input_preview) + } + ContentBlock::ToolResult { content, .. } => match content { + Value::String(s) => truncate_str(s, 500), + _ => "[tool result]".to_string(), + }, + } +} + +fn truncate_str(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let truncated: String = s.chars().take(max_chars).collect(); + format!("{}...", truncated) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_user_text_message() { + let line = r#"{"parentUuid":null,"cwd":"/Users/jb55/dev/notedeck","sessionId":"abc-123","version":"2.0.64","gitBranch":"main","type":"user","message":{"role":"user","content":"Human: Hello world\n\n"},"uuid":"uuid-1","timestamp":"2026-02-09T20:43:35.675Z"}"#; + + let parsed = JsonlLine::parse(line).unwrap(); + assert_eq!(parsed.line_type(), Some("user")); + assert_eq!(parsed.uuid(), Some("uuid-1")); + assert_eq!(parsed.parent_uuid(), None); + assert_eq!(parsed.session_id(), Some("abc-123")); + assert_eq!(parsed.cwd(), Some("/Users/jb55/dev/notedeck")); + assert_eq!(parsed.version(), Some("2.0.64")); + assert_eq!(parsed.role(), Some("user")); + + let msg = parsed.message().unwrap(); + assert_eq!(msg.role(), Some("user")); + assert_eq!( + msg.text_content(), + Some("Human: Hello world\n\n".to_string()) + ); + + let content = extract_display_content(&parsed); + assert_eq!(content, "Hello world\n\n"); + } + + #[test] + fn test_parse_assistant_text_message() { + let line = r#"{"parentUuid":"uuid-1","cwd":"/Users/jb55/dev/notedeck","sessionId":"abc-123","version":"2.0.64","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"text","text":"I can help with that."}]},"type":"assistant","uuid":"uuid-2","timestamp":"2026-02-09T20:43:38.421Z"}"#; + + let parsed = JsonlLine::parse(line).unwrap(); + assert_eq!(parsed.line_type(), Some("assistant")); + assert_eq!(parsed.role(), Some("assistant")); + + let msg = parsed.message().unwrap(); + assert_eq!(msg.model(), Some("claude-opus-4-5-20251101")); + assert_eq!( + msg.text_content(), + Some("I can help with that.".to_string()) + ); + } + + #[test] + fn test_parse_assistant_tool_use() { + let line = r#"{"parentUuid":"uuid-1","cwd":"/tmp","sessionId":"abc","version":"2.0.64","message":{"model":"claude-opus-4-5-20251101","role":"assistant","content":[{"type":"tool_use","id":"toolu_123","name":"Read","input":{"file_path":"/tmp/test.rs"}}]},"type":"assistant","uuid":"uuid-3","timestamp":"2026-02-09T20:43:38.421Z"}"#; + + let parsed = JsonlLine::parse(line).unwrap(); + let msg = parsed.message().unwrap(); + let blocks = msg.content_blocks(); + assert_eq!(blocks.len(), 1); + + match &blocks[0] { + ContentBlock::ToolUse { id, name, input } => { + assert_eq!(*id, "toolu_123"); + assert_eq!(*name, "Read"); + assert_eq!( + input.get("file_path").unwrap().as_str(), + Some("/tmp/test.rs") + ); + } + _ => panic!("expected ToolUse block"), + } + } + + #[test] + fn test_parse_user_tool_result() { + let line = r#"{"parentUuid":"uuid-3","cwd":"/tmp","sessionId":"abc","version":"2.0.64","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_123","type":"tool_result","content":"file contents here"}]},"uuid":"uuid-4","timestamp":"2026-02-09T20:43:38.476Z"}"#; + + let parsed = JsonlLine::parse(line).unwrap(); + assert_eq!(parsed.role(), Some("tool_result")); + + let msg = parsed.message().unwrap(); + assert!(msg.has_tool_result_content()); + + let blocks = msg.content_blocks(); + assert_eq!(blocks.len(), 1); + match &blocks[0] { + ContentBlock::ToolResult { + tool_use_id, + content, + } => { + assert_eq!(*tool_use_id, "toolu_123"); + assert_eq!(content.as_str(), Some("file contents here")); + } + _ => panic!("expected ToolResult block"), + } + } + + #[test] + fn test_parse_queue_operation() { + let line = r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-02-09T20:43:35.669Z","sessionId":"abc-123"}"#; + + let parsed = JsonlLine::parse(line).unwrap(); + assert_eq!(parsed.line_type(), Some("queue-operation")); + assert_eq!(parsed.operation(), Some("dequeue")); + assert_eq!(parsed.role(), Some("queue-operation")); + + let content = extract_display_content(&parsed); + assert_eq!(content, "[queue: dequeue]"); + } + + #[test] + fn test_lossless_roundtrip() { + // The key property: parse → to_json should preserve all fields + let original = r#"{"type":"user","uuid":"abc","parentUuid":null,"sessionId":"sess","timestamp":"2026-02-09T20:43:35.675Z","cwd":"/tmp","gitBranch":"main","version":"2.0.64","isSidechain":false,"userType":"external","message":{"role":"user","content":"hello"},"unknownField":"preserved"}"#; + + let parsed = JsonlLine::parse(original).unwrap(); + let roundtripped = parsed.to_json(); + + // Parse both as Value to compare (field order may differ) + let orig_val: Value = serde_json::from_str(original).unwrap(); + let rt_val: Value = serde_json::from_str(&roundtripped).unwrap(); + assert_eq!(orig_val, rt_val); + } + + #[test] + fn test_timestamp_secs() { + let line = r#"{"type":"user","timestamp":"2026-02-09T20:43:35.675Z","sessionId":"abc"}"#; + let parsed = JsonlLine::parse(line).unwrap(); + assert!(parsed.timestamp_secs().is_some()); + } + + #[test] + fn test_timestamp_fallback_snapshot() { + // file-history-snapshot lines have timestamp nested in snapshot + let line = r#"{"type":"file-history-snapshot","messageId":"abc","snapshot":{"messageId":"abc","trackedFileBackups":{},"timestamp":"2026-02-11T01:29:31.555Z"},"isSnapshotUpdate":false}"#; + let parsed = JsonlLine::parse(line).unwrap(); + assert_eq!(parsed.timestamp(), Some("2026-02-11T01:29:31.555Z")); + assert!(parsed.timestamp_secs().is_some()); + } + + #[test] + fn test_mixed_assistant_content() { + let line = r#"{"type":"assistant","uuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:00Z","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Here is what I found:"},{"type":"tool_use","id":"t1","name":"Glob","input":{"pattern":"**/*.rs"}}]}}"#; + + let parsed = JsonlLine::parse(line).unwrap(); + let msg = parsed.message().unwrap(); + let blocks = msg.content_blocks(); + assert_eq!(blocks.len(), 2); + + // First block is text + assert!(matches!( + blocks[0], + ContentBlock::Text("Here is what I found:") + )); + + // Second block is tool use + match &blocks[1] { + ContentBlock::ToolUse { name, .. } => assert_eq!(*name, "Glob"), + _ => panic!("expected ToolUse"), + } + + // display_content_for_block should work on each + assert_eq!( + display_content_for_block(&blocks[0]), + "Here is what I found:" + ); + assert!(display_content_for_block(&blocks[1]).starts_with("Tool: Glob")); + } +} diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs new file mode 100644 index 000000000..7d4a57029 --- /dev/null +++ b/crates/notedeck_dave/src/session_loader.rs @@ -0,0 +1,293 @@ +//! Load a previous session's conversation from nostr events in ndb. +//! +//! Queries for kind-1988 events with a matching `d` tag (session ID), +//! orders them by created_at, and converts them into `Message` variants +//! for populating the chat UI. + +use crate::messages::{AssistantMessage, PermissionRequest, PermissionResponseType, ToolResult}; +use crate::session::PermissionTracker; +use crate::session_events::{get_tag_value, is_conversation_role, AI_CONVERSATION_KIND}; +use crate::Message; +use nostrdb::{Filter, Ndb, NoteKey, Transaction}; +use std::collections::HashSet; + +/// Query replaceable events via `ndb.fold`, deduplicating by `d` tag. +/// +/// nostrdb doesn't deduplicate replaceable events internally, so multiple +/// revisions of the same (kind, pubkey, d-tag) tuple may exist. This +/// folds over all matching notes and keeps only the one with the highest +/// `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 { + query_replaceable_filtered(ndb, txn, filters, |_| true) +} + +/// Like `query_replaceable`, but with a predicate to filter notes. +/// +/// The predicate is called on the latest revision of each d-tag group. +/// If it returns false, that d-tag is removed from results (even if an +/// older revision would have passed). +pub fn query_replaceable_filtered( + ndb: &Ndb, + txn: &Transaction, + filters: &[Filter], + predicate: impl Fn(&nostrdb::Note) -> bool, +) -> Vec { + // Fold: for each d-tag value, track the latest created_at and optionally + // a NoteKey (only if the latest revision passes the predicate). + // Notes may arrive in any order from ndb.fold, so we always track the + // highest timestamp and only keep a key when that revision is valid. + let best = ndb.fold( + txn, + filters, + std::collections::HashMap::)>::new(), + |mut acc, note| { + let Some(d_tag) = get_tag_value(¬e, "d") else { + return acc; + }; + + let created_at = note.created_at(); + + if let Some((existing_ts, _)) = acc.get(d_tag) { + if created_at <= *existing_ts { + return acc; + } + } + + let key = if predicate(¬e) { + Some(note.key().expect("note key")) + } else { + None + }; + + acc.insert(d_tag.to_string(), (created_at, key)); + acc + }, + ); + + match best { + Ok(map) => map.into_values().filter_map(|(_, key)| key).collect(), + Err(_) => vec![], + } +} + +/// Result of loading session messages, including threading info for live events. +pub struct LoadedSession { + pub messages: Vec, + pub root_note_id: Option<[u8; 32]>, + pub last_note_id: Option<[u8; 32]>, + pub event_count: u32, + /// Permission state loaded from events (responded set + request note IDs). + pub permissions: PermissionTracker, + /// All note IDs found, for seeding dedup in live polling. + pub note_ids: HashSet<[u8; 32]>, +} + +/// Load conversation messages from ndb for a given session ID. +/// +/// This queries for kind-1988 events with a `d` tag matching the session ID, +/// sorts them chronologically, and converts relevant roles into Messages. +pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> LoadedSession { + let filter = Filter::new() + .kinds([AI_CONVERSATION_KIND as u64]) + .tags([session_id], 'd') + .build(); + + let results = match ndb.query(txn, &[filter], 10000) { + Ok(r) => r, + Err(_) => { + return LoadedSession { + messages: vec![], + root_note_id: None, + last_note_id: None, + event_count: 0, + permissions: PermissionTracker::new(), + note_ids: HashSet::new(), + } + } + }; + + // Collect notes with their created_at for sorting + let mut notes: Vec<_> = results + .iter() + .filter_map(|qr| ndb.get_note_by_key(txn, qr.note_key).ok()) + .collect(); + + // Sort by created_at (chronological order) + notes.sort_by_key(|note| note.created_at()); + + let event_count = notes.len() as u32; + let note_ids: HashSet<[u8; 32]> = notes.iter().map(|n| *n.id()).collect(); + + // Find the first conversation note (skip metadata like queue-operation) + // so the threading root is a real message. + let root_note_id = notes + .iter() + .find(|n| { + get_tag_value(n, "role") + .map(is_conversation_role) + .unwrap_or(false) + }) + .map(|n| *n.id()); + let last_note_id = notes.last().map(|n| *n.id()); + + // First pass: collect responded permission IDs and perm request note IDs + let mut permissions = PermissionTracker::new(); + for note in ¬es { + let role = get_tag_value(note, "role"); + if role == Some("permission_response") { + if let Some(perm_id_str) = get_tag_value(note, "perm-id") { + if let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) { + permissions.responded.insert(perm_id); + } + } + } else if role == Some("permission_request") { + if let Some(perm_id_str) = get_tag_value(note, "perm-id") { + if let Ok(perm_id) = uuid::Uuid::parse_str(perm_id_str) { + permissions.request_note_ids.insert(perm_id, *note.id()); + } + } + } + } + + // Second pass: convert to messages + let mut messages = Vec::new(); + for note in ¬es { + let content = note.content(); + let role = get_tag_value(note, "role"); + + let msg = match role { + Some("user") => Some(Message::User(content.to_string())), + Some("assistant") | Some("tool_call") => Some(Message::Assistant( + AssistantMessage::from_text(content.to_string()), + )), + Some("tool_result") => { + let summary = truncate(content, 200); + Some(Message::ToolResult(ToolResult { + tool_name: get_tag_value(note, "tool-name") + .unwrap_or("tool") + .to_string(), + summary, + })) + } + Some("permission_request") => { + if let Ok(content_json) = serde_json::from_str::(content) { + let tool_name = content_json["tool_name"] + .as_str() + .unwrap_or("unknown") + .to_string(); + let tool_input = content_json + .get("tool_input") + .cloned() + .unwrap_or(serde_json::Value::Null); + let perm_id = get_tag_value(note, "perm-id") + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + .unwrap_or_else(uuid::Uuid::new_v4); + + let response = if permissions.responded.contains(&perm_id) { + Some(PermissionResponseType::Allowed) + } else { + None + }; + + Some(Message::PermissionRequest(PermissionRequest { + id: perm_id, + tool_name, + tool_input, + response, + answer_summary: None, + cached_plan: None, + })) + } else { + None + } + } + // Skip permission_response, progress, queue-operation, etc. + _ => None, + }; + + if let Some(msg) = msg { + messages.push(msg); + } + } + + LoadedSession { + messages, + root_note_id, + last_note_id, + event_count, + permissions, + note_ids, + } +} + +/// A persisted session state from a kind-31988 event. +pub struct SessionState { + pub claude_session_id: String, + pub title: String, + pub cwd: String, + pub status: String, + pub hostname: String, + pub created_at: u64, +} + +/// Load all session states from kind-31988 events in ndb. +/// +/// Uses `query_replaceable_filtered` to deduplicate by d-tag, keeping +/// only the most recent non-deleted revision of each session state. +pub fn load_session_states(ndb: &Ndb, txn: &Transaction) -> Vec { + use crate::session_events::AI_SESSION_STATE_KIND; + + let filter = Filter::new() + .kinds([AI_SESSION_STATE_KIND as u64]) + .tags(["ai-session-state"], 't') + .build(); + + let is_valid = |note: &nostrdb::Note| { + // Skip deleted sessions + if get_tag_value(note, "status") == Some("deleted") { + return false; + } + // Skip old JSON-content format events + if note.content().starts_with('{') { + return false; + } + true + }; + + let note_keys = query_replaceable_filtered(ndb, txn, &[filter], is_valid); + + let mut states = Vec::new(); + for key in note_keys { + let Ok(note) = ndb.get_note_by_key(txn, key) else { + continue; + }; + + let Some(claude_session_id) = get_tag_value(¬e, "d") else { + continue; + }; + + states.push(SessionState { + claude_session_id: claude_session_id.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(), + hostname: get_tag_value(¬e, "hostname").unwrap_or("").to_string(), + created_at: note.created_at(), + }); + } + + states +} + +fn truncate(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let truncated: String = s.chars().take(max_chars).collect(); + format!("{}...", truncated) + } +} diff --git a/crates/notedeck_dave/src/session_reconstructor.rs b/crates/notedeck_dave/src/session_reconstructor.rs new file mode 100644 index 000000000..84261d34d --- /dev/null +++ b/crates/notedeck_dave/src/session_reconstructor.rs @@ -0,0 +1,86 @@ +//! Reconstruct JSONL from kind-1989 source-data nostr events stored in ndb. +//! +//! Queries events by session ID (`d` tag), sorts by `seq` tag, +//! extracts `source-data` tags, and returns the original JSONL lines. + +use crate::session_events::{get_tag_value, AI_SOURCE_DATA_KIND}; +use nostrdb::{Filter, Ndb, Transaction}; + +#[derive(Debug)] +pub enum ReconstructError { + Query(String), + Io(String), +} + +impl std::fmt::Display for ReconstructError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReconstructError::Query(e) => write!(f, "ndb query failed: {}", e), + ReconstructError::Io(e) => write!(f, "io error: {}", e), + } + } +} + +/// Reconstruct JSONL lines from ndb events for a given session ID. +/// +/// Returns lines in original order (sorted by `seq` tag), suitable for +/// writing to a JSONL file or feeding to `claude --resume`. +pub fn reconstruct_jsonl_lines( + ndb: &Ndb, + txn: &Transaction, + session_id: &str, +) -> Result, ReconstructError> { + let filters = [Filter::new() + .kinds([AI_SOURCE_DATA_KIND as u64]) + .tags([session_id], 'd') + .limit(10000) + .build()]; + + // Use ndb.fold to iterate events without collecting QueryResults + let mut entries: Vec<(u32, String)> = Vec::new(); + + let _ = ndb.fold(txn, &filters, &mut entries, |entries, note| { + let seq = get_tag_value(¬e, "seq").and_then(|s| s.parse::().ok()); + let source_data = get_tag_value(¬e, "source-data"); + + // Only events with source-data contribute JSONL lines. + // Split events only have source-data on the first event (i=0), + // so we naturally get one JSONL line per original JSONL line. + if let (Some(seq), Some(data)) = (seq, source_data) { + entries.push((seq, data.to_string())); + } + + entries + }); + + // Sort by seq for original ordering + entries.sort_by_key(|(seq, _)| *seq); + + // Deduplicate by source-data content (safety net for re-ingestion) + entries.dedup_by(|a, b| a.1 == b.1); + + Ok(entries.into_iter().map(|(_, data)| data).collect()) +} + +/// Reconstruct JSONL and write to a file. +/// +/// Returns the number of lines written. +pub fn reconstruct_jsonl_file( + ndb: &Ndb, + txn: &Transaction, + session_id: &str, + output_path: &std::path::Path, +) -> Result { + let lines = reconstruct_jsonl_lines(ndb, txn, session_id)?; + let count = lines.len(); + + use std::io::Write; + let mut file = + std::fs::File::create(output_path).map_err(|e| ReconstructError::Io(e.to_string()))?; + + for line in &lines { + writeln!(file, "{}", line).map_err(|e| ReconstructError::Io(e.to_string()))?; + } + + Ok(count) +} diff --git a/crates/notedeck_dave/src/ui/badge.rs b/crates/notedeck_dave/src/ui/badge.rs index 812e51d31..560623753 100644 --- a/crates/notedeck_dave/src/ui/badge.rs +++ b/crates/notedeck_dave/src/ui/badge.rs @@ -145,7 +145,7 @@ impl<'a> StatusBadge<'a> { let desired_size = Vec2::new(galley.size().x + keybind_extra, galley.size().y) + padding * 2.0; - let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover()); + let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); if ui.is_rect_visible(rect) { let painter = ui.painter(); @@ -153,6 +153,15 @@ impl<'a> StatusBadge<'a> { // Full pill rounding (half of height) let rounding = rect.height() / 2.0; + // Adjust background color based on hover/click state + let bg_color = if response.is_pointer_button_down_on() { + bg_color.gamma_multiply(1.8) + } else if response.hovered() { + bg_color.gamma_multiply(1.4) + } else { + bg_color + }; + // Background painter.rect_filled(rect, rounding, bg_color); diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs index 60b086a6d..e3611e32b 100644 --- a/crates/notedeck_dave/src/ui/dave.rs +++ b/crates/notedeck_dave/src/ui/dave.rs @@ -48,6 +48,8 @@ pub struct DaveUi<'a> { ai_mode: AiMode, /// Git status cache for current session (agentic only) git_status: Option<&'a mut GitStatusCache>, + /// Whether this is a remote session (no local Claude process) + is_remote: bool, } /// The response the app generates. The response contains an optional @@ -121,6 +123,10 @@ pub enum DaveAction { request_id: Uuid, approved: bool, }, + /// Toggle plan mode (clicked PLAN badge) + TogglePlanMode, + /// Toggle auto-steal focus mode (clicked AUTO badge) + ToggleAutoSteal, } impl<'a> DaveUi<'a> { @@ -148,6 +154,7 @@ impl<'a> DaveUi<'a> { auto_steal_focus: false, ai_mode, git_status: None, + is_remote: false, } } @@ -208,11 +215,16 @@ impl<'a> DaveUi<'a> { self } + pub fn is_remote(mut self, is_remote: bool) -> Self { + self.is_remote = is_remote; + self + } + fn chat_margin(&self, ctx: &egui::Context) -> i8 { if self.compact || notedeck::ui::is_narrow(ctx) { - 20 + 8 } else { - 100 + 20 } } @@ -228,6 +240,9 @@ impl<'a> DaveUi<'a> { /// The main render function. Call this to render Dave pub fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { + // Override Truncate wrap mode that StripBuilder sets when clip=true + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); + // Skip top buttons in compact mode (scene panel has its own controls) let action = if self.compact { None @@ -241,7 +256,7 @@ impl<'a> DaveUi<'a> { let margin = self.chat_margin(ui.ctx()); let bottom_margin = 100; - let r = egui::Frame::new() + let mut r = egui::Frame::new() .outer_margin(egui::Margin { left: margin, right: margin, @@ -254,23 +269,48 @@ impl<'a> DaveUi<'a> { .show(ui, |ui| self.inputbox(app_ctx.i18n, ui)) .inner; - if let Some(git_status) = &mut self.git_status { - // Explicitly reserve height so bottom_up layout - // keeps the chat ScrollArea from overlapping. - let h = if git_status.expanded { 200.0 } else { 24.0 }; - let w = ui.available_width(); - ui.allocate_ui(egui::vec2(w, h), |ui| { - egui::Frame::new() - .outer_margin(egui::Margin { - left: margin, - right: margin, - top: 4, - bottom: 0, + { + let plan_mode_active = self.plan_mode_active; + let auto_steal_focus = self.auto_steal_focus; + let is_agentic = self.ai_mode == AiMode::Agentic; + let has_git = self.git_status.is_some(); + + // Show status bar when there's git status or badges to display + if has_git || is_agentic { + // Explicitly reserve height so bottom_up layout + // keeps the chat ScrollArea from overlapping. + let h = if self.git_status.as_ref().is_some_and(|gs| gs.expanded) { + 200.0 + } else { + 24.0 + }; + let w = ui.available_width(); + let badge_action = ui + .allocate_ui(egui::vec2(w, h), |ui| { + egui::Frame::new() + .outer_margin(egui::Margin { + left: margin, + right: margin, + top: 4, + bottom: 0, + }) + .show(ui, |ui| { + status_bar_ui( + self.git_status.as_deref_mut(), + is_agentic, + plan_mode_active, + auto_steal_focus, + ui, + ) + }) + .inner }) - .show(ui, |ui| { - git_status_ui::git_status_bar_ui(git_status, ui); - }); - }); + .inner; + + if let Some(action) = badge_action { + r = DaveResponse::new(action).or(r); + } + } } let chat_response = egui::ScrollArea::vertical() @@ -383,11 +423,14 @@ impl<'a> DaveUi<'a> { .color(ui.visuals().weak_text_color()) .italics(), ); - ui.label( - egui::RichText::new("(press esc to interrupt)") - .color(ui.visuals().weak_text_color()) - .small(), - ); + // Don't show interrupt hint for remote sessions + if !self.is_remote { + ui.label( + egui::RichText::new("(press esc to interrupt)") + .color(ui.visuals().weak_text_color()) + .small(), + ); + } }); } @@ -501,13 +544,10 @@ impl<'a> DaveUi<'a> { // Diff view diff::file_update_ui(&file_update, ui); - // Approve/deny buttons at the bottom right - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - self.permission_buttons(request, ui, &mut action); - }, - ); + // Approve/deny buttons at the bottom left + ui.horizontal(|ui| { + self.permission_buttons(request, ui, &mut action); + }); }); } else { // Parse tool input for display (existing logic) @@ -534,8 +574,6 @@ impl<'a> DaveUi<'a> { ui.horizontal(|ui| { ui.label(egui::RichText::new(&request.tool_name).strong()); ui.label(desc); - - self.permission_buttons(request, ui, &mut action); }); // Command on next line if present if let Some(cmd) = command { @@ -549,16 +587,10 @@ impl<'a> DaveUi<'a> { ui.horizontal(|ui| { ui.label(egui::RichText::new(&request.tool_name).strong()); ui.label(egui::RichText::new(value).monospace()); - - self.permission_buttons(request, ui, &mut action); }); } else { // Fallback: show JSON - ui.horizontal(|ui| { - ui.label(egui::RichText::new(&request.tool_name).strong()); - - self.permission_buttons(request, ui, &mut action); - }); + ui.label(egui::RichText::new(&request.tool_name).strong()); let formatted = serde_json::to_string_pretty(&request.tool_input) .unwrap_or_else(|_| request.tool_input.to_string()); ui.add( @@ -568,6 +600,11 @@ impl<'a> DaveUi<'a> { .wrap_mode(egui::TextWrapMode::Wrap), ); } + + // Buttons on their own line + ui.horizontal(|ui| { + self.permission_buttons(request, ui, &mut action); + }); }); } } @@ -584,87 +621,59 @@ impl<'a> DaveUi<'a> { action: &mut Option, ) { let shift_held = ui.input(|i| i.modifiers.shift); + let in_tentative = self.permission_message_state != PermissionMessageState::None; + + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + if in_tentative { + tentative_send_ui(self.permission_message_state, "Allow", "Deny", ui, action); + } else { + let button_text_color = ui.visuals().widgets.active.fg_stroke.color; + + // Allow button (green) with integrated keybind hint + let allow_response = super::badge::ActionButton::new( + "Allow", + egui::Color32::from_rgb(34, 139, 34), + button_text_color, + ) + .keybind("1") + .show(ui) + .on_hover_text("Press 1 to allow, Shift+1 to allow with message"); + + // Deny button (red) with integrated keybind hint + let deny_response = super::badge::ActionButton::new( + "Deny", + egui::Color32::from_rgb(178, 34, 34), + button_text_color, + ) + .keybind("2") + .show(ui) + .on_hover_text("Press 2 to deny, Shift+2 to deny with message"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let button_text_color = ui.visuals().widgets.active.fg_stroke.color; - - // Deny button (red) with integrated keybind hint - let deny_response = super::badge::ActionButton::new( - "Deny", - egui::Color32::from_rgb(178, 34, 34), - button_text_color, - ) - .keybind("2") - .show(ui) - .on_hover_text("Press 2 to deny, Shift+2 to deny with message"); - - if deny_response.clicked() { - if shift_held { - // Shift+click: enter tentative deny mode - *action = Some(DaveAction::TentativeDeny); - } else { - // Normal click: immediate deny - *action = Some(DaveAction::PermissionResponse { - request_id: request.id, - response: PermissionResponse::Deny { - reason: "User denied".into(), - }, - }); - } - } - - // Allow button (green) with integrated keybind hint - let allow_response = super::badge::ActionButton::new( - "Allow", - egui::Color32::from_rgb(34, 139, 34), - button_text_color, - ) - .keybind("1") - .show(ui) - .on_hover_text("Press 1 to allow, Shift+1 to allow with message"); - - if allow_response.clicked() { - if shift_held { - // Shift+click: enter tentative accept mode - *action = Some(DaveAction::TentativeAccept); - } else { - // Normal click: immediate allow - *action = Some(DaveAction::PermissionResponse { - request_id: request.id, - response: PermissionResponse::Allow { message: None }, - }); + if deny_response.clicked() { + if shift_held { + *action = Some(DaveAction::TentativeDeny); + } else { + *action = Some(DaveAction::PermissionResponse { + request_id: request.id, + response: PermissionResponse::Deny { + reason: "User denied".into(), + }, + }); + } } - } - // Show tentative state indicator OR shift hint - match self.permission_message_state { - PermissionMessageState::TentativeAccept => { - ui.label( - egui::RichText::new("✓ Will Allow") - .color(egui::Color32::from_rgb(100, 180, 100)) - .strong(), - ); - } - PermissionMessageState::TentativeDeny => { - ui.label( - egui::RichText::new("✗ Will Deny") - .color(egui::Color32::from_rgb(200, 100, 100)) - .strong(), - ); - } - PermissionMessageState::None => { - // Always show hint for adding message - let hint_color = if shift_held { - ui.visuals().warn_fg_color + if allow_response.clicked() { + if shift_held { + *action = Some(DaveAction::TentativeAccept); } else { - ui.visuals().weak_text_color() - }; - ui.label( - egui::RichText::new("(⇧ for message)") - .color(hint_color) - .small(), - ); + *action = Some(DaveAction::PermissionResponse { + request_id: request.id, + response: PermissionResponse::Allow { message: None }, + }); + } } + + add_msg_link(ui, action); } }); } @@ -716,80 +725,64 @@ impl<'a> DaveUi<'a> { // Approve/Reject buttons with shift support for adding message let shift_held = ui.input(|i| i.modifiers.shift); + let in_tentative = + self.permission_message_state != PermissionMessageState::None; + + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + if in_tentative { + tentative_send_ui( + self.permission_message_state, + "Approve", + "Reject", + ui, + &mut action, + ); + } else { + let button_text_color = ui.visuals().widgets.active.fg_stroke.color; - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let button_text_color = ui.visuals().widgets.active.fg_stroke.color; - - // Reject button (red) - let reject_response = super::badge::ActionButton::new( - "Reject", - egui::Color32::from_rgb(178, 34, 34), - button_text_color, - ) - .keybind("2") - .show(ui) - .on_hover_text("Press 2 to reject, Shift+2 to reject with message"); + // Approve button (green) + let approve_response = super::badge::ActionButton::new( + "Approve", + egui::Color32::from_rgb(34, 139, 34), + button_text_color, + ) + .keybind("1") + .show(ui) + .on_hover_text("Press 1 to approve, Shift+1 to approve with message"); - if reject_response.clicked() { - if shift_held { - action = Some(DaveAction::TentativeDeny); - } else { - action = Some(DaveAction::ExitPlanMode { - request_id: request.id, - approved: false, - }); + if approve_response.clicked() { + if shift_held { + action = Some(DaveAction::TentativeAccept); + } else { + action = Some(DaveAction::ExitPlanMode { + request_id: request.id, + approved: true, + }); + } } - } - - // Approve button (green) - let approve_response = super::badge::ActionButton::new( - "Approve", - egui::Color32::from_rgb(34, 139, 34), - button_text_color, - ) - .keybind("1") - .show(ui) - .on_hover_text("Press 1 to approve, Shift+1 to approve with message"); - if approve_response.clicked() { - if shift_held { - action = Some(DaveAction::TentativeAccept); - } else { - action = Some(DaveAction::ExitPlanMode { - request_id: request.id, - approved: true, - }); - } - } + // Reject button (red) + let reject_response = super::badge::ActionButton::new( + "Reject", + egui::Color32::from_rgb(178, 34, 34), + button_text_color, + ) + .keybind("2") + .show(ui) + .on_hover_text("Press 2 to reject, Shift+2 to reject with message"); - // Show tentative state indicator OR shift hint - match self.permission_message_state { - PermissionMessageState::TentativeAccept => { - ui.label( - egui::RichText::new("✓ Will Approve") - .color(egui::Color32::from_rgb(100, 180, 100)) - .strong(), - ); - } - PermissionMessageState::TentativeDeny => { - ui.label( - egui::RichText::new("✗ Will Reject") - .color(egui::Color32::from_rgb(200, 100, 100)) - .strong(), - ); - } - PermissionMessageState::None => { - let hint_color = if shift_held { - ui.visuals().warn_fg_color + if reject_response.clicked() { + if shift_held { + action = Some(DaveAction::TentativeDeny); } else { - ui.visuals().weak_text_color() - }; - ui.label( - egui::RichText::new("(⇧ for message)") - .color(hint_color) - .small(), - ); + action = Some(DaveAction::ExitPlanMode { + request_id: request.id, + approved: false, + }); + } } + + add_msg_link(ui, &mut action); } }); }); @@ -1019,39 +1012,6 @@ impl<'a> DaveUi<'a> { dave_response = DaveResponse::send(); } - // Show plan mode and auto-steal indicators only in Agentic mode - if self.ai_mode == AiMode::Agentic { - let ctrl_held = ui.input(|i| i.modifiers.ctrl); - - // Plan mode indicator with optional keybind hint when Ctrl is held - let mut plan_badge = - super::badge::StatusBadge::new("PLAN").variant(if self.plan_mode_active { - super::badge::BadgeVariant::Info - } else { - super::badge::BadgeVariant::Default - }); - if ctrl_held { - plan_badge = plan_badge.keybind("M"); - } - plan_badge - .show(ui) - .on_hover_text("Ctrl+M to toggle plan mode"); - - // Auto-steal focus indicator - let mut auto_badge = - super::badge::StatusBadge::new("AUTO").variant(if self.auto_steal_focus { - super::badge::BadgeVariant::Info - } else { - super::badge::BadgeVariant::Default - }); - if ctrl_held { - auto_badge = auto_badge.keybind("\\"); - } - auto_badge - .show(ui) - .on_hover_text("Ctrl+\\ to toggle auto-focus mode"); - } - let r = ui.add( egui::TextEdit::multiline(self.input) .desired_width(f32::INFINITY) @@ -1122,3 +1082,161 @@ impl<'a> DaveUi<'a> { markdown_ui::render_assistant_message(elements, partial, buffer, ui); } } + +/// Send button + clickable accept/deny toggle shown when in tentative state. +fn tentative_send_ui( + state: PermissionMessageState, + accept_label: &str, + deny_label: &str, + ui: &mut egui::Ui, + action: &mut Option, +) { + if ui + .add(egui::Button::new(egui::RichText::new("Send").strong())) + .clicked() + { + *action = Some(DaveAction::Send); + } + + match state { + PermissionMessageState::TentativeAccept => { + if ui + .link( + egui::RichText::new(format!("✓ Will {accept_label}")) + .color(egui::Color32::from_rgb(100, 180, 100)) + .strong(), + ) + .clicked() + { + *action = Some(DaveAction::TentativeDeny); + } + } + PermissionMessageState::TentativeDeny => { + if ui + .link( + egui::RichText::new(format!("✗ Will {deny_label}")) + .color(egui::Color32::from_rgb(200, 100, 100)) + .strong(), + ) + .clicked() + { + *action = Some(DaveAction::TentativeAccept); + } + } + PermissionMessageState::None => {} + } +} + +/// Clickable "+ msg" link that enters tentative accept mode. +fn add_msg_link(ui: &mut egui::Ui, action: &mut Option) { + if ui + .link( + egui::RichText::new("+ msg") + .color(ui.visuals().weak_text_color()) + .small(), + ) + .clicked() + { + *action = Some(DaveAction::TentativeAccept); + } +} + +/// Renders the status bar containing git status and toggle badges. +fn status_bar_ui( + mut git_status: Option<&mut GitStatusCache>, + is_agentic: bool, + plan_mode_active: bool, + auto_steal_focus: bool, + ui: &mut egui::Ui, +) -> Option { + let snapshot = git_status + .as_deref() + .and_then(git_status_ui::StatusSnapshot::from_cache); + + ui.vertical(|ui| { + let action = ui + .horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + + if let Some(git_status) = git_status.as_deref_mut() { + git_status_ui::git_status_content_ui(git_status, &snapshot, ui); + + // Right-aligned section: badges then refresh + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let action = if is_agentic { + toggle_badges_ui(ui, plan_mode_active, auto_steal_focus) + } else { + None + }; + + git_status_ui::git_refresh_button_ui(git_status, ui); + + action + }) + .inner + } else if is_agentic { + // No git status (remote session) - just show badges + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + toggle_badges_ui(ui, plan_mode_active, auto_steal_focus) + }) + .inner + } else { + None + } + }) + .inner; + + if let Some(git_status) = git_status.as_deref() { + git_status_ui::git_expanded_files_ui(git_status, &snapshot, ui); + } + + action + }) + .inner +} + +/// Render clickable PLAN and AUTO toggle badges. Returns an action if clicked. +fn toggle_badges_ui( + ui: &mut egui::Ui, + plan_mode_active: bool, + auto_steal_focus: bool, +) -> Option { + let ctrl_held = ui.input(|i| i.modifiers.ctrl); + let mut action = None; + + // AUTO badge (rendered first in right-to-left, so it appears rightmost) + let mut auto_badge = super::badge::StatusBadge::new("AUTO").variant(if auto_steal_focus { + super::badge::BadgeVariant::Info + } else { + super::badge::BadgeVariant::Default + }); + if ctrl_held { + auto_badge = auto_badge.keybind("\\"); + } + if auto_badge + .show(ui) + .on_hover_text("Click or Ctrl+\\ to toggle auto-focus mode") + .clicked() + { + action = Some(DaveAction::ToggleAutoSteal); + } + + // PLAN badge + let mut plan_badge = super::badge::StatusBadge::new("PLAN").variant(if plan_mode_active { + super::badge::BadgeVariant::Info + } else { + super::badge::BadgeVariant::Default + }); + if ctrl_held { + plan_badge = plan_badge.keybind("M"); + } + if plan_badge + .show(ui) + .on_hover_text("Click or Ctrl+M to toggle plan mode") + .clicked() + { + action = Some(DaveAction::TogglePlanMode); + } + + action +} diff --git a/crates/notedeck_dave/src/ui/diff.rs b/crates/notedeck_dave/src/ui/diff.rs index 8ea5edb04..bd769dfbf 100644 --- a/crates/notedeck_dave/src/ui/diff.rs +++ b/crates/notedeck_dave/src/ui/diff.rs @@ -8,13 +8,14 @@ const LINE_NUMBER_COLOR: Color32 = Color32::from_rgb(128, 128, 128); /// Render a file update diff view pub fn file_update_ui(update: &FileUpdate, ui: &mut Ui) { - // Code block frame - no scroll, just show full diff height egui::Frame::new() .fill(ui.visuals().extreme_bg_color) .inner_margin(8.0) .corner_radius(4.0) .show(ui, |ui| { - render_diff_lines(update.diff_lines(), &update.update_type, ui); + egui::ScrollArea::horizontal().show(ui, |ui| { + render_diff_lines(update.diff_lines(), &update.update_type, ui); + }); }); } diff --git a/crates/notedeck_dave/src/ui/git_status_ui.rs b/crates/notedeck_dave/src/ui/git_status_ui.rs index 1743ecee1..516cf26ed 100644 --- a/crates/notedeck_dave/src/ui/git_status_ui.rs +++ b/crates/notedeck_dave/src/ui/git_status_ui.rs @@ -8,7 +8,7 @@ const UNTRACKED_COLOR: Color32 = Color32::from_rgb(128, 128, 128); /// Snapshot of git status data extracted from the cache to avoid /// borrow conflicts when mutating `cache.expanded`. -struct StatusSnapshot { +pub struct StatusSnapshot { branch: Option, modified: usize, added: usize, @@ -19,7 +19,7 @@ struct StatusSnapshot { } impl StatusSnapshot { - fn from_cache(cache: &GitStatusCache) -> Option> { + pub fn from_cache(cache: &GitStatusCache) -> Option> { match cache.current() { Some(Ok(data)) => Some(Ok(StatusSnapshot { branch: data.branch.clone(), @@ -40,109 +40,6 @@ impl StatusSnapshot { } } -/// Render the git status bar. -pub fn git_status_bar_ui(cache: &mut GitStatusCache, ui: &mut Ui) { - // Snapshot data so we can freely mutate cache.expanded below - let snapshot = StatusSnapshot::from_cache(cache); - - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - - match &snapshot { - Some(Ok(snap)) => { - // Show expand arrow only when dirty - if !snap.is_clean { - let arrow = if cache.expanded { - "\u{25BC}" - } else { - "\u{25B6}" - }; - if ui - .add( - egui::Label::new(RichText::new(arrow).weak().monospace().size(9.0)) - .sense(egui::Sense::click()), - ) - .clicked() - { - cache.expanded = !cache.expanded; - } - } - - // Branch name - let branch_text = snap.branch.as_deref().unwrap_or("detached"); - ui.label(RichText::new(branch_text).weak().monospace().size(11.0)); - - if snap.is_clean { - ui.label(RichText::new("clean").weak().size(11.0)); - } else { - count_label(ui, "~", snap.modified, MODIFIED_COLOR); - count_label(ui, "+", snap.added, ADDED_COLOR); - count_label(ui, "-", snap.deleted, DELETED_COLOR); - count_label(ui, "?", snap.untracked, UNTRACKED_COLOR); - } - } - Some(Err(_)) => { - ui.label(RichText::new("git: not available").weak().size(11.0)); - } - None => { - ui.spinner(); - ui.label(RichText::new("checking git...").weak().size(11.0)); - } - } - - // Refresh button (right-aligned) - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .add( - egui::Label::new(RichText::new("\u{21BB}").weak().size(12.0)) - .sense(egui::Sense::click()), - ) - .on_hover_text("Refresh git status") - .clicked() - { - cache.request_refresh(); - } - }); - }); - - // Expanded file list - if cache.expanded { - if let Some(Ok(snap)) = &snapshot { - if !snap.files.is_empty() { - ui.add_space(4.0); - - egui::Frame::new() - .fill(ui.visuals().extreme_bg_color) - .inner_margin(egui::Margin::symmetric(8, 4)) - .corner_radius(4.0) - .show(ui, |ui| { - egui::ScrollArea::vertical() - .max_height(150.0) - .show(ui, |ui| { - for (status, path) in &snap.files { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 8.0; - let color = status_color(status); - ui.label( - RichText::new(status) - .monospace() - .size(11.0) - .color(color), - ); - ui.label( - RichText::new(path).monospace().size(11.0).weak(), - ); - }); - } - }); - }); - } - } - } - }); -} - fn count_label(ui: &mut Ui, prefix: &str, count: usize, color: Color32) { if count > 0 { ui.label( @@ -165,3 +62,105 @@ fn status_color(status: &str) -> Color32 { MODIFIED_COLOR } } + +/// Render the left-side git status content (expand arrow, branch, counts). +pub fn git_status_content_ui( + cache: &mut GitStatusCache, + snapshot: &Option>, + ui: &mut Ui, +) { + match snapshot { + Some(Ok(snap)) => { + // Show expand arrow only when dirty + if !snap.is_clean { + let arrow = if cache.expanded { + "\u{25BC}" + } else { + "\u{25B6}" + }; + if ui + .add( + egui::Label::new(RichText::new(arrow).weak().monospace().size(9.0)) + .sense(egui::Sense::click()), + ) + .clicked() + { + cache.expanded = !cache.expanded; + } + } + + // Branch name + let branch_text = snap.branch.as_deref().unwrap_or("detached"); + ui.label(RichText::new(branch_text).weak().monospace().size(11.0)); + + if snap.is_clean { + ui.label(RichText::new("clean").weak().size(11.0)); + } else { + count_label(ui, "~", snap.modified, MODIFIED_COLOR); + count_label(ui, "+", snap.added, ADDED_COLOR); + count_label(ui, "-", snap.deleted, DELETED_COLOR); + count_label(ui, "?", snap.untracked, UNTRACKED_COLOR); + } + } + Some(Err(_)) => { + ui.label(RichText::new("git: not available").weak().size(11.0)); + } + None => { + ui.spinner(); + ui.label(RichText::new("checking git...").weak().size(11.0)); + } + } +} + +/// Render the git refresh button. +pub fn git_refresh_button_ui(cache: &mut GitStatusCache, ui: &mut Ui) { + if ui + .add( + egui::Label::new(RichText::new("\u{21BB}").weak().size(12.0)) + .sense(egui::Sense::click()), + ) + .on_hover_text("Refresh git status") + .clicked() + { + cache.request_refresh(); + } +} + +/// Render the expanded file list portion of git status. +pub fn git_expanded_files_ui( + cache: &GitStatusCache, + snapshot: &Option>, + ui: &mut Ui, +) { + if cache.expanded { + if let Some(Ok(snap)) = snapshot { + if !snap.files.is_empty() { + ui.add_space(4.0); + + egui::Frame::new() + .fill(ui.visuals().extreme_bg_color) + .inner_margin(egui::Margin::symmetric(8, 4)) + .corner_radius(4.0) + .show(ui, |ui| { + egui::ScrollArea::vertical() + .max_height(150.0) + .show(ui, |ui| { + for (status, path) in &snap.files { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + let color = status_color(status); + ui.label( + RichText::new(status) + .monospace() + .size(11.0) + .color(color), + ); + ui.label(RichText::new(path).monospace().size(11.0).weak()); + }); + } + }); + }); + } + } + } +} diff --git a/crates/notedeck_dave/src/ui/markdown_ui.rs b/crates/notedeck_dave/src/ui/markdown_ui.rs index 0ce070877..c6ee2179a 100644 --- a/crates/notedeck_dave/src/ui/markdown_ui.rs +++ b/crates/notedeck_dave/src/ui/markdown_ui.rs @@ -226,20 +226,22 @@ fn render_inlines(inlines: &[InlineElement], theme: &MdTheme, buffer: &str, ui: } fn render_code_block(language: Option<&str>, content: &str, theme: &MdTheme, ui: &mut Ui) { + use egui_extras::syntax_highlighting::{self, CodeTheme}; + egui::Frame::default() .fill(theme.code_bg) .inner_margin(8.0) .corner_radius(4.0) .show(ui, |ui| { - // Language label if present if let Some(lang) = language { ui.label(RichText::new(lang).small().weak()); } - // Code content - ui.add( - egui::Label::new(RichText::new(content).monospace().color(theme.code_text)).wrap(), - ); + let lang = language.unwrap_or("text"); + let code_theme = CodeTheme::from_style(ui.style()); + let layout_job = + syntax_highlighting::highlight(ui.ctx(), ui.style(), &code_theme, content, lang); + ui.add(egui::Label::new(layout_job).wrap()); }); ui.add_space(8.0); } @@ -271,7 +273,9 @@ fn render_table(headers: &[Span], rows: &[Vec], theme: &MdTheme, buffer: & let cell_padding = egui::Margin::symmetric(8, 4); - let mut builder = TableBuilder::new(ui).vscroll(false); + // 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); for _ in 0..num_cols { builder = builder.column(Column::auto().resizable(true)); } @@ -315,20 +319,28 @@ fn render_partial(partial: &Partial, theme: &MdTheme, buffer: &str, ui: &mut Ui) match &partial.kind { PartialKind::CodeFence { language, .. } => { - // Show incomplete code block + use egui_extras::syntax_highlighting::{self, CodeTheme}; + egui::Frame::default() .fill(theme.code_bg) .inner_margin(8.0) .corner_radius(4.0) .show(ui, |ui| { - if let Some(lang) = language { - ui.label(RichText::new(lang.resolve(buffer)).small().weak()); + let lang_str = language.map(|s| s.resolve(buffer)); + if let Some(lang) = lang_str { + ui.label(RichText::new(lang).small().weak()); } - ui.add( - egui::Label::new(RichText::new(content).monospace().color(theme.code_text)) - .wrap(), + + let lang = lang_str.unwrap_or("text"); + let code_theme = CodeTheme::from_style(ui.style()); + let layout_job = syntax_highlighting::highlight( + ui.ctx(), + ui.style(), + &code_theme, + content, + lang, ); - // Blinking cursor indicator would require animation; just show underscore + ui.add(egui::Label::new(layout_job).wrap()); ui.label(RichText::new("_").weak()); }); } diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs index 1b17abe51..ed2c3f98d 100644 --- a/crates/notedeck_dave/src/ui/mod.rs +++ b/crates/notedeck_dave/src/ui/mod.rs @@ -34,11 +34,63 @@ use crate::agent_status::AgentStatus; use crate::config::{AiMode, DaveSettings, ModelConfig}; use crate::focus_queue::FocusQueue; use crate::messages::PermissionResponse; -use crate::session::{PermissionMessageState, SessionId, SessionManager}; +use crate::session::{ChatSession, PermissionMessageState, SessionId, SessionManager}; use crate::session_discovery::discover_sessions; use crate::update; use crate::DaveOverlay; +/// Build a DaveUi from a session, wiring up all the common builder fields. +fn build_dave_ui<'a>( + session: &'a mut ChatSession, + model_config: &ModelConfig, + is_interrupt_pending: bool, + auto_steal_focus: bool, +) -> DaveUi<'a> { + let is_working = session.status() == AgentStatus::Working; + let has_pending_permission = session.has_pending_permissions(); + let plan_mode_active = session.is_plan_mode(); + let is_remote = session.is_remote(); + + let mut ui_builder = DaveUi::new( + model_config.trial, + &session.chat, + &mut session.input, + &mut session.focus_requested, + session.ai_mode, + ) + .is_working(is_working) + .interrupt_pending(is_interrupt_pending) + .has_pending_permission(has_pending_permission) + .plan_mode_active(plan_mode_active) + .auto_steal_focus(auto_steal_focus) + .is_remote(is_remote); + + if let Some(agentic) = &mut session.agentic { + ui_builder = ui_builder + .permission_message_state(agentic.permission_message_state) + .question_answers(&mut agentic.question_answers) + .question_index(&mut agentic.question_index) + .is_compacting(agentic.is_compacting); + + // Only show git status for local sessions + if !is_remote { + ui_builder = ui_builder.git_status(&mut agentic.git_status); + } + } + + ui_builder +} + +/// Set tentative permission state on the active session's agentic data. +fn set_tentative_state(session_manager: &mut SessionManager, state: PermissionMessageState) { + if let Some(session) = session_manager.get_active_mut() { + if let Some(agentic) = &mut session.agentic { + agentic.permission_message_state = state; + } + session.focus_requested = true; + } +} + /// UI result from overlay rendering pub enum OverlayResult { /// No action taken @@ -54,6 +106,8 @@ pub enum OverlayResult { cwd: std::path::PathBuf, session_id: String, title: String, + /// Path to the JSONL file for archive conversion + file_path: std::path::PathBuf, }, /// Create a new session in the given directory NewSession { cwd: std::path::PathBuf }, @@ -120,11 +174,13 @@ pub fn session_picker_overlay_ui( cwd, session_id, title, + file_path, } => { return OverlayResult::ResumeSession { cwd, session_id, title, + file_path, }; } SessionPickerAction::NewSession { cwd } => { @@ -209,34 +265,14 @@ pub fn scene_ui( ui.heading(&session.title); ui.separator(); - let is_working = session.status() == AgentStatus::Working; - let has_pending_permission = session.has_pending_permissions(); - let plan_mode_active = session.is_plan_mode(); - - let mut ui_builder = DaveUi::new( - model_config.trial, - &session.chat, - &mut session.input, - &mut session.focus_requested, - session.ai_mode, + let response = build_dave_ui( + session, + model_config, + is_interrupt_pending, + auto_steal_focus, ) .compact(true) - .is_working(is_working) - .interrupt_pending(is_interrupt_pending) - .has_pending_permission(has_pending_permission) - .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus); - - if let Some(agentic) = &mut session.agentic { - ui_builder = ui_builder - .permission_message_state(agentic.permission_message_state) - .question_answers(&mut agentic.question_answers) - .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting) - .git_status(&mut agentic.git_status); - } - - let response = ui_builder.ui(app_ctx, ui); + .ui(app_ctx, ui); if response.action.is_some() { dave_response = response; } @@ -287,12 +323,15 @@ 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, bool) { let available = ui.available_rect_before_wrap(); - let sidebar_width = 280.0; + let sidebar_width = if available.width() < 830.0 { + 200.0 + } else { + 280.0 + }; let ctrl_held = ui.input(|i| i.modifiers.ctrl); let mut toggle_scene = false; @@ -309,7 +348,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") @@ -324,7 +367,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 }) @@ -333,33 +376,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() { - let is_working = session.status() == AgentStatus::Working; - let has_pending_permission = session.has_pending_permissions(); - let plan_mode_active = session.is_plan_mode(); - - let mut ui_builder = DaveUi::new( - model_config.trial, - &session.chat, - &mut session.input, - &mut session.focus_requested, - session.ai_mode, + build_dave_ui( + session, + model_config, + is_interrupt_pending, + auto_steal_focus, ) - .is_working(is_working) - .interrupt_pending(is_interrupt_pending) - .has_pending_permission(has_pending_permission) - .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus); - - if let Some(agentic) = &mut session.agentic { - ui_builder = ui_builder - .permission_message_state(agentic.permission_message_state) - .question_answers(&mut agentic.question_answers) - .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting) - .git_status(&mut agentic.git_status); - } - - ui_builder.ui(app_ctx, ui) + .ui(app_ctx, ui) } else { DaveResponse::default() } @@ -377,7 +400,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, @@ -388,38 +410,19 @@ 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 is_working = session.status() == AgentStatus::Working; - let has_pending_permission = session.has_pending_permissions(); - let plan_mode_active = session.is_plan_mode(); - - let mut ui_builder = DaveUi::new( - model_config.trial, - &session.chat, - &mut session.input, - &mut session.focus_requested, - session.ai_mode, + let response = build_dave_ui( + session, + model_config, + is_interrupt_pending, + auto_steal_focus, ) - .is_working(is_working) - .interrupt_pending(is_interrupt_pending) - .has_pending_permission(has_pending_permission) - .plan_mode_active(plan_mode_active) - .auto_steal_focus(auto_steal_focus); - - if let Some(agentic) = &mut session.agentic { - ui_builder = ui_builder - .permission_message_state(agentic.permission_message_state) - .question_answers(&mut agentic.question_answers) - .question_index(&mut agentic.question_index) - .is_compacting(agentic.is_compacting) - .git_status(&mut agentic.git_status); - } - - (ui_builder.ui(app_ctx, ui), None) + .ui(app_ctx, ui); + (response, None) } else { (DaveResponse::default(), None) } @@ -433,6 +436,8 @@ pub enum KeyActionResult { CloneAgent, DeleteSession(SessionId), SetAutoSteal(bool), + /// Permission response needs relay publishing. + PublishPermissionResponse(update::PermissionPublish), } /// Handle a keybinding action. @@ -452,7 +457,7 @@ pub fn handle_key_action( match key_action { KeyAction::AcceptPermission => { if let Some(request_id) = update::first_pending_permission(session_manager) { - update::handle_permission_response( + let result = update::handle_permission_response( session_manager, request_id, PermissionResponse::Allow { message: None }, @@ -460,12 +465,15 @@ pub fn handle_key_action( if let Some(session) = session_manager.get_active_mut() { session.focus_requested = true; } + if let Some(publish) = result { + return KeyActionResult::PublishPermissionResponse(publish); + } } KeyActionResult::None } KeyAction::DenyPermission => { if let Some(request_id) = update::first_pending_permission(session_manager) { - update::handle_permission_response( + let result = update::handle_permission_response( session_manager, request_id, PermissionResponse::Deny { @@ -475,25 +483,18 @@ pub fn handle_key_action( if let Some(session) = session_manager.get_active_mut() { session.focus_requested = true; } + if let Some(publish) = result { + return KeyActionResult::PublishPermissionResponse(publish); + } } KeyActionResult::None } KeyAction::TentativeAccept => { - if let Some(session) = session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = PermissionMessageState::TentativeAccept; - } - session.focus_requested = true; - } + set_tentative_state(session_manager, PermissionMessageState::TentativeAccept); KeyActionResult::None } KeyAction::TentativeDeny => { - if let Some(session) = session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = PermissionMessageState::TentativeDeny; - } - session.focus_requested = true; - } + set_tentative_state(session_manager, PermissionMessageState::TentativeDeny); KeyActionResult::None } KeyAction::CancelTentative => { @@ -572,6 +573,8 @@ pub enum SendActionResult { Handled, /// Normal send - caller should send the user message SendMessage, + /// Permission response needs relay publishing. + NeedsRelayPublish(update::PermissionPublish), } /// Handle the Send action, including tentative permission states. @@ -600,11 +603,14 @@ pub fn handle_send_action( if is_exit_plan_mode { update::exit_plan_mode(session_manager, backend, ctx); } - update::handle_permission_response( + let result = update::handle_permission_response( session_manager, request_id, PermissionResponse::Allow { message }, ); + if let Some(publish) = result { + return SendActionResult::NeedsRelayPublish(publish); + } } SendActionResult::Handled } @@ -618,11 +624,14 @@ pub fn handle_send_action( if let Some(session) = session_manager.get_active_mut() { session.input.clear(); } - update::handle_permission_response( + let result = update::handle_permission_response( session_manager, request_id, PermissionResponse::Deny { reason }, ); + if let Some(publish) = result { + return SendActionResult::NeedsRelayPublish(publish); + } } SendActionResult::Handled } @@ -638,6 +647,10 @@ pub enum UiActionResult { SendAction, /// Return an AppAction AppAction(notedeck::AppAction), + /// Permission response needs relay publishing. + PublishPermissionResponse(update::PermissionPublish), + /// Toggle auto-steal focus mode (needs state from DaveApp) + ToggleAutoSteal, } /// Handle a UI action from DaveUi. @@ -670,50 +683,48 @@ pub fn handle_ui_action( DaveAction::PermissionResponse { request_id, response, - } => { - update::handle_permission_response(session_manager, request_id, response); - UiActionResult::Handled - } + } => update::handle_permission_response(session_manager, request_id, response).map_or( + UiActionResult::Handled, + UiActionResult::PublishPermissionResponse, + ), DaveAction::Interrupt => { update::execute_interrupt(session_manager, backend, ctx); UiActionResult::Handled } DaveAction::TentativeAccept => { - if let Some(session) = session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = PermissionMessageState::TentativeAccept; - } - session.focus_requested = true; - } + set_tentative_state(session_manager, PermissionMessageState::TentativeAccept); UiActionResult::Handled } DaveAction::TentativeDeny => { - if let Some(session) = session_manager.get_active_mut() { - if let Some(agentic) = &mut session.agentic { - agentic.permission_message_state = PermissionMessageState::TentativeDeny; - } - session.focus_requested = true; - } + set_tentative_state(session_manager, PermissionMessageState::TentativeDeny); UiActionResult::Handled } DaveAction::QuestionResponse { request_id, answers, - } => { - update::handle_question_response(session_manager, request_id, answers); + } => update::handle_question_response(session_manager, request_id, answers).map_or( + UiActionResult::Handled, + UiActionResult::PublishPermissionResponse, + ), + DaveAction::TogglePlanMode => { + update::toggle_plan_mode(session_manager, backend, ctx); + if let Some(session) = session_manager.get_active_mut() { + session.focus_requested = true; + } UiActionResult::Handled } + DaveAction::ToggleAutoSteal => UiActionResult::ToggleAutoSteal, DaveAction::ExitPlanMode { request_id, approved, } => { - if approved { + let result = if approved { update::exit_plan_mode(session_manager, backend, ctx); update::handle_permission_response( session_manager, request_id, PermissionResponse::Allow { message: None }, - ); + ) } else { update::handle_permission_response( session_manager, @@ -721,9 +732,12 @@ pub fn handle_ui_action( PermissionResponse::Deny { reason: "User rejected plan".into(), }, - ); - } - UiActionResult::Handled + ) + }; + result.map_or( + UiActionResult::Handled, + UiActionResult::PublishPermissionResponse, + ) } } } diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs index 074c0997f..c81032e2d 100644 --- 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 { 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,64 +89,113 @@ impl<'a> SessionListUi<'a> { fn sessions_list_ui(&self, ui: &mut egui::Ui) -> Option { 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, + ) -> Option { + 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, + &session.hostname, + 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, ui: &mut egui::Ui, title: &str, cwd: &Path, + hostname: &str, is_active: bool, shortcut_hint: Option, status: AgentStatus, queue_priority: Option, + 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); @@ -248,22 +288,28 @@ impl<'a> SessionListUi<'a> { // Draw cwd below title - only in Agentic mode if show_cwd { let cwd_pos = rect.left_center() + egui::vec2(text_start_x, 7.0); - cwd_ui(ui, cwd, cwd_pos, max_text_width); + cwd_ui(ui, cwd, hostname, cwd_pos, max_text_width); } response } } -/// Draw cwd text (monospace, weak+small) with clipping -fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, pos: egui::Pos2, max_width: f32) { - let cwd_text = cwd_path.to_string_lossy(); +/// 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 display_text = if hostname.is_empty() { + cwd_str.to_string() + } else { + format!("{}:{}", hostname, cwd_str) + }; let cwd_font = egui::FontId::monospace(10.0); let cwd_color = ui.visuals().weak_text_color(); let cwd_galley = ui .painter() - .layout_no_wrap(cwd_text.to_string(), cwd_font.clone(), cwd_color); + .layout_no_wrap(display_text.clone(), cwd_font.clone(), cwd_color); if cwd_galley.size().x > max_width { let clip_rect = egui::Rect::from_min_size( @@ -279,7 +325,7 @@ fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, pos: egui::Pos2, max_width: f32) { ui.painter().text( pos, egui::Align2::LEFT_CENTER, - &cwd_text, + &display_text, cwd_font, cwd_color, ); diff --git a/crates/notedeck_dave/src/ui/session_picker.rs b/crates/notedeck_dave/src/ui/session_picker.rs index 10b288ce6..fc21343f4 100644 --- a/crates/notedeck_dave/src/ui/session_picker.rs +++ b/crates/notedeck_dave/src/ui/session_picker.rs @@ -17,6 +17,8 @@ pub enum SessionPickerAction { cwd: PathBuf, session_id: String, title: String, + /// Path to the JSONL file for archive conversion + file_path: PathBuf, }, /// User wants to start a new session (no resume) NewSession { cwd: PathBuf }, @@ -102,6 +104,7 @@ impl SessionPicker { cwd, session_id: session.session_id.clone(), title: session.summary.clone(), + file_path: session.file_path.clone(), }); } } @@ -283,6 +286,7 @@ impl SessionPicker { cwd: cwd.clone(), session_id: session.session_id.clone(), title: session.summary.clone(), + file_path: session.file_path.clone(), }); } }); diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs index 042f705c6..9fd102bdd 100644 --- a/crates/notedeck_dave/src/update.rs +++ b/crates/notedeck_dave/src/update.rs @@ -72,7 +72,7 @@ pub fn execute_interrupt( backend.interrupt_session(session_id, ctx.clone()); session.incoming_tokens = None; if let Some(agentic) = &mut session.agentic { - agentic.pending_permissions.clear(); + agentic.permissions.pending.clear(); } tracing::debug!("Interrupted session {}", session.id); } @@ -138,21 +138,35 @@ pub fn exit_plan_mode( /// Get the first pending permission request ID for the active session. pub fn first_pending_permission(session_manager: &SessionManager) -> Option { - session_manager - .get_active() - .and_then(|session| session.agentic.as_ref()) - .and_then(|agentic| agentic.pending_permissions.keys().next().copied()) + 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.permissions.responded); + for msg in &session.chat { + if let Message::PermissionRequest(req) = msg { + if req.response.is_none() && responded.is_none_or(|ids| !ids.contains(&req.id)) { + return Some(req.id); + } + } + } + None + } else { + // Local: check oneshot senders + session + .agentic + .as_ref() + .and_then(|a| a.permissions.pending.keys().next().copied()) + } } /// Get the tool name of the first pending permission request. pub fn pending_permission_tool_name(session_manager: &SessionManager) -> Option<&str> { + let request_id = first_pending_permission(session_manager)?; let session = session_manager.get_active()?; - let agentic = session.agentic.as_ref()?; - let request_id = agentic.pending_permissions.keys().next()?; for msg in &session.chat { if let Message::PermissionRequest(req) = msg { - if &req.id == request_id { + if req.id == request_id { return Some(&req.tool_name); } } @@ -171,22 +185,35 @@ pub fn has_pending_exit_plan_mode(session_manager: &SessionManager) -> bool { pending_permission_tool_name(session_manager) == Some("ExitPlanMode") } +/// Data needed to publish a permission response to relays. +pub struct PermissionPublish { + pub perm_id: uuid::Uuid, + pub allowed: bool, + pub message: Option, +} + /// Handle a permission response (from UI button or keybinding). pub fn handle_permission_response( session_manager: &mut SessionManager, request_id: uuid::Uuid, response: PermissionResponse, -) { - let Some(session) = session_manager.get_active_mut() else { - return; - }; +) -> Option { + let session = session_manager.get_active_mut()?; + + let is_remote = session.is_remote(); - // Record the response type in the message for UI display let response_type = match &response { PermissionResponse::Allow { .. } => crate::messages::PermissionResponseType::Allowed, PermissionResponse::Deny { .. } => crate::messages::PermissionResponseType::Denied, }; + // Extract relay-publish info before we move `response`. + let allowed = matches!(&response, PermissionResponse::Allow { .. }); + let message = match &response { + PermissionResponse::Allow { message } => message.clone(), + PermissionResponse::Deny { reason } => Some(reason.clone()), + }; + // If Allow has a message, add it as a User message to the chat if let PermissionResponse::Allow { message: Some(msg) } = &response { if !msg.is_empty() { @@ -199,27 +226,23 @@ pub fn handle_permission_response( agentic.permission_message_state = PermissionMessageState::None; } - for msg in &mut session.chat { - if let Message::PermissionRequest(req) = msg { - if req.id == request_id { - req.response = Some(response_type); - break; - } - } - } - + // Resolve through the single unified path if let Some(agentic) = &mut session.agentic { - if let Some(sender) = agentic.pending_permissions.remove(&request_id) { - if sender.send(response).is_err() { - tracing::error!( - "Failed to send permission response for request {}", - request_id - ); - } - } else { - tracing::warn!("No pending permission found for request {}", request_id); - } + agentic.permissions.resolve( + &mut session.chat, + request_id, + response_type, + None, + is_remote, + Some(response), + ); } + + Some(PermissionPublish { + perm_id: request_id, + allowed, + message, + }) } /// Handle a user's response to an AskUserQuestion tool call. @@ -227,10 +250,10 @@ pub fn handle_question_response( session_manager: &mut SessionManager, request_id: uuid::Uuid, answers: Vec, -) { - let Some(session) = session_manager.get_active_mut() else { - return; - }; +) -> Option { + let session = session_manager.get_active_mut()?; + + let is_remote = session.is_remote(); // Find the original AskUserQuestion request to get the question labels let questions_input = session.chat.iter().find_map(|msg| { @@ -313,43 +336,62 @@ pub fn handle_question_response( ) }; - // Mark the request as allowed in the UI and store the summary for display - for msg in &mut session.chat { - if let Message::PermissionRequest(req) = msg { - if req.id == request_id { - req.response = Some(crate::messages::PermissionResponseType::Allowed); - req.answer_summary = answer_summary.clone(); - break; - } - } - } - - // Clean up transient answer state and send response (agentic only) + // Clean up transient answer state if let Some(agentic) = &mut session.agentic { agentic.question_answers.remove(&request_id); agentic.question_index.remove(&request_id); - // Send the response through the permission channel - if let Some(sender) = agentic.pending_permissions.remove(&request_id) { - let response = PermissionResponse::Allow { - message: Some(formatted_response), - }; - if sender.send(response).is_err() { - tracing::error!( - "Failed to send question response for request {}", - request_id - ); - } - } else { - tracing::warn!("No pending permission found for request {}", request_id); - } - } + // Resolve through the single unified path + let oneshot_response = PermissionResponse::Allow { + message: Some(formatted_response.clone()), + }; + agentic.permissions.resolve( + &mut session.chat, + request_id, + crate::messages::PermissionResponseType::Allowed, + answer_summary, + is_remote, + Some(oneshot_response), + ); + } + + Some(PermissionPublish { + perm_id: request_id, + allowed: true, + message: Some(formatted_response), + }) } // ============================================================================= // Agent Navigation // ============================================================================= +/// Switch to a session and optionally focus it in the scene. +/// +/// Handles the common pattern of: switch_to → scene.select → scene.focus_on → focus_requested. +/// Used by navigation, focus queue, and auto-steal-focus operations. +pub fn switch_and_focus_session( + session_manager: &mut SessionManager, + scene: &mut AgentScene, + show_scene: bool, + id: SessionId, +) { + session_manager.switch_to(id); + if show_scene { + scene.select(id); + if let Some(session) = session_manager.get(id) { + if let Some(agentic) = &session.agentic { + scene.focus_on(agentic.scene_position); + } + } + } + if let Some(session) = session_manager.get_mut(id) { + if !session.has_pending_permissions() { + session.focus_requested = true; + } + } +} + /// Switch to agent by index in the ordered list (0-indexed). pub fn switch_to_agent_by_index( session_manager: &mut SessionManager, @@ -359,23 +401,16 @@ pub fn switch_to_agent_by_index( ) { let ids = session_manager.session_ids(); if let Some(&id) = ids.get(index) { - session_manager.switch_to(id); - if show_scene { - scene.select(id); - } - if let Some(session) = session_manager.get_mut(id) { - if !session.has_pending_permissions() { - session.focus_requested = true; - } - } + switch_and_focus_session(session_manager, scene, show_scene, id); } } -/// Cycle to the next agent. -pub fn cycle_next_agent( +/// Cycle agents using a direction function that computes the next index. +fn cycle_agent( session_manager: &mut SessionManager, scene: &mut AgentScene, show_scene: bool, + index_fn: impl FnOnce(usize, usize) -> usize, ) { let ids = session_manager.session_ids(); if ids.is_empty() { @@ -385,50 +420,36 @@ pub fn cycle_next_agent( .active_id() .and_then(|active| ids.iter().position(|&id| id == active)) .unwrap_or(0); - let next_idx = (current_idx + 1) % ids.len(); + let next_idx = index_fn(current_idx, ids.len()); if let Some(&id) = ids.get(next_idx) { - session_manager.switch_to(id); - if show_scene { - scene.select(id); - } - if let Some(session) = session_manager.get_mut(id) { - if !session.has_pending_permissions() { - session.focus_requested = true; - } - } + switch_and_focus_session(session_manager, scene, show_scene, id); } } +/// Cycle to the next agent. +pub fn cycle_next_agent( + session_manager: &mut SessionManager, + scene: &mut AgentScene, + show_scene: bool, +) { + cycle_agent(session_manager, scene, show_scene, |idx, len| { + (idx + 1) % len + }); +} + /// Cycle to the previous agent. pub fn cycle_prev_agent( session_manager: &mut SessionManager, scene: &mut AgentScene, show_scene: bool, ) { - let ids = session_manager.session_ids(); - if ids.is_empty() { - return; - } - let current_idx = session_manager - .active_id() - .and_then(|active| ids.iter().position(|&id| id == active)) - .unwrap_or(0); - let prev_idx = if current_idx == 0 { - ids.len() - 1 - } else { - current_idx - 1 - }; - if let Some(&id) = ids.get(prev_idx) { - session_manager.switch_to(id); - if show_scene { - scene.select(id); - } - if let Some(session) = session_manager.get_mut(id) { - if !session.has_pending_permissions() { - session.focus_requested = true; - } + cycle_agent(session_manager, scene, show_scene, |idx, len| { + if idx == 0 { + len - 1 + } else { + idx - 1 } - } + }); } // ============================================================================= @@ -443,20 +464,7 @@ pub fn focus_queue_next( show_scene: bool, ) { if let Some(session_id) = focus_queue.next() { - session_manager.switch_to(session_id); - if show_scene { - scene.select(session_id); - if let Some(session) = session_manager.get(session_id) { - if let Some(agentic) = &session.agentic { - scene.focus_on(agentic.scene_position); - } - } - } - if let Some(session) = session_manager.get_mut(session_id) { - if !session.has_pending_permissions() { - session.focus_requested = true; - } - } + switch_and_focus_session(session_manager, scene, show_scene, session_id); } } @@ -468,20 +476,7 @@ pub fn focus_queue_prev( show_scene: bool, ) { if let Some(session_id) = focus_queue.prev() { - session_manager.switch_to(session_id); - if show_scene { - scene.select(session_id); - if let Some(session) = session_manager.get(session_id) { - if let Some(agentic) = &session.agentic { - scene.focus_on(agentic.scene_position); - } - } - } - if let Some(session) = session_manager.get_mut(session_id) { - if !session.has_pending_permissions() { - session.focus_requested = true; - } - } + switch_and_focus_session(session_manager, scene, show_scene, session_id); } } @@ -512,15 +507,7 @@ pub fn toggle_auto_steal( } else { // Disabling: switch back to home session if set if let Some(home_id) = home_session.take() { - session_manager.switch_to(home_id); - if show_scene { - scene.select(home_id); - if let Some(session) = session_manager.get(home_id) { - if let Some(agentic) = &session.agentic { - scene.focus_on(agentic.scene_position); - } - } - } + switch_and_focus_session(session_manager, scene, show_scene, home_id); tracing::debug!("Auto-steal focus disabled, returned to home session"); } } @@ -534,7 +521,7 @@ pub fn toggle_auto_steal( } /// Process auto-steal focus logic: switch to focus queue items as needed. -/// Returns true if focus was stolen (switched to a NeedsInput session), +/// Returns true if focus was stolen (switched to a NeedsInput or Done session), /// which can be used to raise the OS window. pub fn process_auto_steal_focus( session_manager: &mut SessionManager, @@ -549,6 +536,7 @@ pub fn process_auto_steal_focus( } let has_needs_input = focus_queue.has_needs_input(); + let has_done = focus_queue.has_done(); if has_needs_input { // There are NeedsInput items - check if we need to steal focus @@ -567,31 +555,41 @@ pub fn process_auto_steal_focus( if let Some(idx) = focus_queue.first_needs_input_index() { focus_queue.set_cursor(idx); if let Some(entry) = focus_queue.current() { - session_manager.switch_to(entry.session_id); - if show_scene { - scene.select(entry.session_id); - if let Some(session) = session_manager.get(entry.session_id) { - if let Some(agentic) = &session.agentic { - scene.focus_on(agentic.scene_position); - } - } - } + switch_and_focus_session(session_manager, scene, show_scene, entry.session_id); tracing::debug!("Auto-steal: switched to session {:?}", entry.session_id); return true; } } } - } else if let Some(home_id) = home_session.take() { - // No more NeedsInput items - return to saved session - session_manager.switch_to(home_id); - if show_scene { - scene.select(home_id); - if let Some(session) = session_manager.get(home_id) { - if let Some(agentic) = &session.agentic { - scene.focus_on(agentic.scene_position); + } else if has_done { + // No NeedsInput but there are Done items - auto-focus those + let current_session = session_manager.active_id(); + let current_priority = current_session.and_then(|id| focus_queue.get_session_priority(id)); + let already_on_done = current_priority == Some(FocusPriority::Done); + + if !already_on_done { + // Save current session before stealing (only if we haven't saved yet) + if home_session.is_none() { + *home_session = current_session; + tracing::debug!("Auto-steal: saved home session {:?}", home_session); + } + + // Jump to first Done item + 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); + tracing::debug!( + "Auto-steal: switched to Done session {:?}", + entry.session_id + ); + return true; } } } + } else if let Some(home_id) = home_session.take() { + // No more NeedsInput or Done items - return to saved session + switch_and_focus_session(session_manager, scene, show_scene, home_id); tracing::debug!("Auto-steal: returned to home session {:?}", home_id); } @@ -864,11 +862,13 @@ pub fn create_session_with_cwd( show_scene: bool, ai_mode: AiMode, cwd: PathBuf, + hostname: &str, ) -> SessionId { directory_picker.add_recent(cwd.clone()); let id = session_manager.new_session(cwd, ai_mode); if let Some(session) = session_manager.get_mut(id) { + session.hostname = hostname.to_string(); session.focus_requested = true; if show_scene { scene.select(id); @@ -891,11 +891,13 @@ pub fn create_resumed_session_with_cwd( cwd: PathBuf, resume_session_id: String, title: String, + hostname: &str, ) -> SessionId { directory_picker.add_recent(cwd.clone()); 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.focus_requested = true; if show_scene { scene.select(id); @@ -914,6 +916,7 @@ pub fn clone_active_agent( scene: &mut AgentScene, show_scene: bool, ai_mode: AiMode, + hostname: &str, ) -> Option { let cwd = session_manager .get_active() @@ -925,6 +928,7 @@ pub fn clone_active_agent( show_scene, ai_mode, cwd, + hostname, )) } diff --git a/docs/ai-conversation-nostr-design.md b/docs/ai-conversation-nostr-design.md new file mode 100644 index 000000000..4df99463e --- /dev/null +++ b/docs/ai-conversation-nostr-design.md @@ -0,0 +1,138 @@ +# AI Conversation Nostr Notes — Design Spec + +## Overview + +Represent claude-code session JSONL lines as nostr events, enabling: +1. **Session presentation resume** — reload a previous session's UI from local nostr DB without re-parsing JSONL +2. **Round-trip fidelity** — reconstruct the original JSONL from nostr events for claude-code `--resume` +3. **Future sharing** — structure is ready for publishing sessions to relays (with privacy considerations deferred) + +## Architecture + +``` +claude-code JSONL ──→ nostr events ──→ ndb.process_event (local) + │ + ├──→ UI rendering (presentation from nostr query) + └──→ JSONL reconstruction (for --resume) +``` + +## Event Structure + +**Kind**: Regular event (1000-9999 range, specific number TBD). Immutable — no replaceable events. + +Each JSONL line becomes one nostr event. Every message type (user, assistant, tool_call, tool_result, progress, etc.) gets its own note for 1:1 JSONL line reconstruction. + +### Note Format + +```json +{ + "kind": "", + "content": "", + "tags": [ + // Session identity + ["d", ""], + ["session-slug", ""], + + // Threading (NIP-10) + ["e", "", "", "root"], + ["e", "", "", "reply"], + + // Message metadata + ["source", "claude-code"], + ["source-version", "2.1.42"], + ["role", ""], + ["model", "claude-opus-4-6"], + ["turn-type", ""], + + // Discoverability + ["t", "ai-conversation"], + + // Lossless reconstruction (Option 3) + ["source-data", ""] + ] +} +``` + +### Content Field + +Human-readable text suitable for rendering in any nostr client: +- **user**: The user's message text +- **assistant**: The assistant's rendered markdown text (text blocks only) +- **tool_call**: Summary like `Glob: {"pattern": "**/*.rs"}` or tool name + input preview +- **tool_result**: The tool output text (possibly truncated for presentation) +- **progress**: Description of the progress event +- **queue-operation / file-history-snapshot**: Minimal description + +### source-data Tag + +Contains the **full JSONL line** as a JSON string with these transformations applied: +- **Path normalization**: All absolute paths converted to relative (using `cwd` as base) +- **Sensitive data stripping** (TODO — deferred to later task): + - Token usage / cache statistics + - API request IDs + - Permission mode details + +On reconstruction, relative paths are re-expanded using the local machine's working directory. + +## JSONL Line Type → Nostr Event Mapping + +| JSONL `type` | `role` tag | `content` | Notes | +|---|---|---|---| +| `user` (text) | `user` | User's message text | Simple text content | +| `user` (tool_result) | `tool_result` | Tool output text | Separated from user text | +| `assistant` (text) | `assistant` | Rendered markdown | Text blocks from content array | +| `assistant` (tool_use) | `tool_call` | Tool name + input summary | Each tool_use block = separate note | +| `progress` | `progress` | Hook progress description | Mapped for round-trip fidelity | +| `queue-operation` | `queue-operation` | Operation type | Mapped for round-trip fidelity | +| `file-history-snapshot` | `file-history-snapshot` | Snapshot summary | Mapped for round-trip fidelity | + +**Important**: Assistant messages with mixed content (text + tool_use blocks) are split into multiple nostr events — one per content block. Each gets its own note, threaded in sequence via `e` tags. + +## Conversation Threading + +Uses **NIP-10** reply threading: +- First note in a session: no `e` tags (it is the root) +- All subsequent notes: `["e", "", "", "root"]` + `["e", "", "", "reply"]` +- The `e` tags always reference **nostr note IDs** (not JSONL UUIDs) +- UUID-to-note-ID mapping is maintained during conversion + +## Path Normalization + +When converting JSONL → nostr events: +1. Extract `cwd` from the JSONL line +2. All absolute paths that start with `cwd` are converted to relative paths +3. `cwd` itself is stored as a relative path (or stripped, with project root as implicit base) + +When reconstructing nostr events → JSONL: +1. Determine local working directory +2. Re-expand all relative paths to absolute using local `cwd` +3. Update `cwd`, `gitBranch`, and machine-specific fields + +## Data Flow (Phase 1 — Local Only) + +### Publishing (JSONL → nostr events) +1. On session activity, dave reads new JSONL lines +2. Each line is converted to a nostr event (normalize paths, extract presentation content) +3. Events are inserted via `ndb.process_event()` (local relay only) +4. UUID-to-note-ID mapping is cached for threading + +### Consuming (nostr events → UI) +1. Query ndb for events with the session's `d` tag +2. Order by `e` tag threading (NIP-10 reply chain) +3. Render `content` field directly in the conversation UI +4. `role` tag determines message styling (user bubble, assistant bubble, tool collapse, etc.) + +### Reconstruction (nostr events → JSONL, for future resume) +1. Query ndb for all events in a session (by `d` tag) +2. Order by reply chain +3. Extract `source-data` tag from each event +4. De-normalize paths (relative → absolute for local machine) +5. Write as JSONL file +6. Resume via `claude --resume ` + +## Non-Goals (Phase 1) + +- Publishing to external relays (privacy concerns) +- Resuming shared sessions from other users +- Sensitive data stripping (noted as TODO) +- NIP proposal (informal notedeck convention for now)