notedeck

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

commit 8ad2c98a713e282974518bc090cdd37d6371616f
parent 94d502076146ac410aebc4030b8cad9767309aee
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 18 Feb 2026 13:44:11 -0800

Merge branch 'nostr-synced-sessions': nostr-backed AI agent sessions with remote sync

Major feature: represent dave (AI agent) sessions as nostr events,
enabling session sync across devices via NIP-PNS (Private Note Storage)
encrypted relay publishing, and a lite remote-only client mode.

Session Events (kind 1988/1989):
- Convert claude-code JSONL conversation lines to nostr events in
  real-time, stored in nostrdb for local persistence and relay sync
- NIP-10 threading for conversation structure
- Lossless round-trip: reconstruct original JSONL from nostr events
  for claude-code --resume
- Path normalization for cross-machine portability
- Split source-data into kind 1989 to keep presentation events lean

NIP-PNS Encrypted Relay Publishing:
- Add enostr::pns module implementing NIP-PNS (Private Note Storage)
  crypto: HKDF-SHA256 key derivation, XChaCha20-Poly1305 encryption
- Publish session events to relay wrapped in PNS encryption
- Dedicated PNS relay subscription for remote session discovery
- Auto re-subscribe on relay reconnect

Remote/Lite Client Mode:
- Add remote-only backend for lite client mode (no local claude-code)
- SessionSource (Local/Remote) tracking throughout
- Live conversation polling with deduplication for remote sessions
- Phone-to-desktop messaging support
- Permission responses published as nostr events for cross-device flow

Session State Management:
- Persist active sessions via kind-31988 replaceable nostr notes
- Publish deleted state events on session removal
- Handle out-of-order replaceable event batches correctly
- Source hostname tagging for multi-device session list UI

UI/UX Improvements:
- Syntax highlighting for code blocks in chat
- Clickable PLAN/AUTO mode badges in status bar
- Mobile-friendly permission toggle buttons
- Responsive sidebar/chat margins for tablets
- Horizontal-only scroll for diff views
- Auto-focus Done sessions, suppress auto-steal during typing

name             added  removed  commits
William Casarin  +6851  -2276    65

William Casarin (65):
      Merge branch 'nostr-synced-sessions': nostr-backed AI agent sessions with remote sync
      Revert "fix zombie deleted sessions from out-of-order replaceable event batches"
      Revert "hide CWD and status bar for remote sessions in session list"
      add SessionSource (Local/Remote) for lite client remote mode
      add clickable toggle for permission feedback on mobile
      add live conversation polling and dedup for remote sessions
      add remote-only backend for lite client mode
      add source hostname to session state events and session list UI
      add syntax highlighting to code blocks
      add tool-name tag to tool_call/tool_result nostr events
      auto-focus Done sessions in the focus queue
      auto_accept: add `beads list` to auto-approved commands
      deduplicate replaceable events using ndb.fold in session loading
      disable relay publishing until PNS wrapping is implemented
      enable PNS-wrapped relay publishing for AI conversation events
      enostr: add NIP-PNS (Private Note Storage) crypto module
      fix PLAN/AUTO badges not showing on remote sessions
      fix PNS ingest: wrap as ["EVENT", {...}] for process_client_event
      fix PNS key derivation: use HKDF-Extract only, not Extract+Expand
      fix PNS relay URL trailing slash mismatch
      fix Q&A answer submit not working for remote sessions
      fix clippy lints: too_many_arguments and question_mark
      fix diff view overflow and move permission buttons to bottom left
      fix duplicate messages on phone and enable phone-to-desktop messaging
      fix missing relay events and duplicate sessions in dave app
      fix query_replaceable_filtered not handling arbitrary fold order
      fix remote messages clobbering in-flight streams
      fix remote messages not received until local message sent
      fix remote session status using stale replaceable event revisions
      fix tentative permission buttons: show Send when composing message
      fix text clipping in chat by overriding StripBuilder truncate mode
      fix zombie deleted sessions from out-of-order replaceable event batches
      hide CWD and status bar for remote sessions in session list
      hide git status and interrupt hint for remote sessions
      ingest events via PNS wrapping so 1080 events exist in ndb
      make PLAN/AUTO badges clickable and move to status bar
      markdown_ui: fix multiple tables clashing by adding id_salt
      move all permission buttons to their own line below tool info
      move permission buttons to left-to-right layout
      persist active sessions via kind-31988 replaceable nostr notes
      persist permission responses as nostr events for all sessions
      publish deleted state event when session is removed
      re-subscribe to PNS relay on reconnect and fix duplicate sessions
      reduce excessive chat margins for tablet and smaller screens
      refactor: consolidate permission state into PermissionTracker struct
      refactor: extract queue_built_event to deduplicate event publish pattern
      refactor: extract shared helpers to reduce duplication in dave crate
      refactor: unify permission publish data into PermissionPublish struct
      refactor: unify permission resolution into PermissionTracker::resolve()
      session_events: fix timestamp inheritance, subscription-based archive loading
      session_events: generate kind-1988 events in real-time during live conversations
      session_events: lossless round-trip with seq, split, tool-id, cwd tags
      session_events: permission events + relay publishing + remote response polling
      session_events: seed live threading from archive events on resume
      session_events: skip redundant archive conversion, fix threading root selection
      session_events: split source-data to kind 1989, wire archive into resume flow
      shrink sidebar on medium screens for better chat area on tablets
      skip old JSON-content session state events during restore
      subscribe to PNS events on relays for remote session discovery
      suppress auto-steal focus while user is typing
      use dedicated PNS relay (ws://relay.jb55.com) instead of broadcasting
      use horizontal-only scroll for diff view
      use tags instead of JSON content for session state events
      wip: AI conversation nostr notes (shelved for redesign)
      wire PNS into dave startup, bump nostrdb to nip-pns branch

Diffstat:
MCargo.lock | 28++++++++++++++++++++++++++--
MCargo.toml | 3++-
Mcrates/enostr/Cargo.toml | 7+++++--
Mcrates/enostr/src/lib.rs | 1+
Acrates/enostr/src/pns.rs | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/app.rs | 8+++++++-
Mcrates/notedeck_dave/Cargo.toml | 2++
Mcrates/notedeck_dave/src/agent_status.rs | 23+++++++++++++++++++++++
Mcrates/notedeck_dave/src/auto_accept.rs | 1+
Mcrates/notedeck_dave/src/backend/claude.rs | 36++++++++++++++++++++----------------
Mcrates/notedeck_dave/src/backend/mod.rs | 2++
Acrates/notedeck_dave/src/backend/remote.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/backend/traits.rs | 2++
Mcrates/notedeck_dave/src/config.rs | 11+++++++----
Mcrates/notedeck_dave/src/focus_queue.rs | 14++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 1373+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acrates/notedeck_dave/src/path_normalize.rs | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 333++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Acrates/notedeck_dave/src/session_converter.rs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/session_events.rs | 1156++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/session_jsonl.rs | 476+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/session_loader.rs | 293+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/notedeck_dave/src/session_reconstructor.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/badge.rs | 11++++++++++-
Mcrates/notedeck_dave/src/ui/dave.rs | 550++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcrates/notedeck_dave/src/ui/diff.rs | 5+++--
Mcrates/notedeck_dave/src/ui/git_status_ui.rs | 209+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/notedeck_dave/src/ui/markdown_ui.rs | 38+++++++++++++++++++++++++-------------
Mcrates/notedeck_dave/src/ui/mod.rs | 262++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mcrates/notedeck_dave/src/ui/session_list.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mcrates/notedeck_dave/src/ui/session_picker.rs | 4++++
Mcrates/notedeck_dave/src/update.rs | 328++++++++++++++++++++++++++++++++++++++++---------------------------------------
Adocs/ai-conversation-nostr-design.md | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
33 files changed, 5308 insertions(+), 733 deletions(-)

diff --git 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", @@ -2414,6 +2417,16 @@ dependencies = [ ] [[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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2731,6 +2744,15 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -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 @@ -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 @@ -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 @@ -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 @@ -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<String, PnsError> { + 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<String, PnsError> { + 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::<Sha256>::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 @@ -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 @@ -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 @@ -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<Self> { + 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 @@ -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 @@ -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 @@ -1,9 +1,11 @@ mod claude; mod openai; +mod remote; mod session_info; mod tool_summary; mod traits; pub use claude::ClaudeBackend; pub use openai::OpenAiBackend; +pub use remote::RemoteOnlyBackend; pub use traits::{AiBackend, BackendType}; diff --git a/crates/notedeck_dave/src/backend/remote.rs b/crates/notedeck_dave/src/backend/remote.rs @@ -0,0 +1,43 @@ +use crate::messages::DaveApiResponse; +use crate::tools::Tool; +use claude_agent_sdk_rs::PermissionMode; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::mpsc; +use std::sync::Arc; + +use super::AiBackend; + +/// A no-op backend for devices without API keys. +/// +/// Allows creating local Chat sessions (input is ignored) while viewing +/// and controlling remote Agentic sessions discovered from ndb/relays. +pub struct RemoteOnlyBackend; + +impl AiBackend for RemoteOnlyBackend { + fn stream_request( + &self, + _messages: Vec<crate::Message>, + _tools: Arc<HashMap<String, Tool>>, + _model: String, + _user_id: String, + _session_id: String, + _cwd: Option<PathBuf>, + _resume_session_id: Option<String>, + _ctx: egui::Context, + ) -> ( + mpsc::Receiver<DaveApiResponse>, + Option<tokio::task::JoinHandle<()>>, + ) { + // Return a closed channel — no local AI processing + let (_tx, rx) = mpsc::channel(); + (rx, None) + } + + fn cleanup_session(&self, _session_id: String) {} + + fn interrupt_session(&self, _session_id: String, _ctx: egui::Context) {} + + fn set_permission_mode(&self, _session_id: String, _mode: PermissionMode, _ctx: egui::Context) { + } +} diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs @@ -11,6 +11,8 @@ use std::sync::Arc; pub enum BackendType { OpenAI, Claude, + /// No local AI — only view/control remote agentic sessions from ndb + Remote, } /// Trait for AI backend implementations diff --git a/crates/notedeck_dave/src/config.rs b/crates/notedeck_dave/src/config.rs @@ -110,7 +110,7 @@ impl DaveSettings { /// Create settings from an existing ModelConfig (preserves env var values) pub fn from_model_config(config: &ModelConfig) -> Self { let provider = match config.backend { - BackendType::OpenAI => AiProvider::OpenAI, + BackendType::OpenAI | BackendType::Remote => AiProvider::OpenAI, BackendType::Claude => AiProvider::Anthropic, }; @@ -182,11 +182,13 @@ impl Default for ModelConfig { } } } else { - // Auto-detect: prefer Claude if key is available, otherwise OpenAI + // Auto-detect: prefer Claude if key is available, then OpenAI, then Remote if anthropic_api_key.is_some() { BackendType::Claude - } else { + } else if api_key.is_some() { BackendType::OpenAI + } else { + BackendType::Remote } }; @@ -203,6 +205,7 @@ impl Default for ModelConfig { .unwrap_or_else(|| match backend { BackendType::OpenAI => "gpt-4o".to_string(), BackendType::Claude => "claude-sonnet-4.5".to_string(), + BackendType::Remote => String::new(), }); ModelConfig { @@ -220,7 +223,7 @@ impl ModelConfig { pub fn ai_mode(&self) -> AiMode { match self.backend { BackendType::Claude => AiMode::Agentic, - BackendType::OpenAI => AiMode::Chat, + BackendType::OpenAI | BackendType::Remote => AiMode::Chat, } } diff --git a/crates/notedeck_dave/src/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<usize> { + 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 @@ -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<ipc::IpcListener>, + /// 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<PendingMessageLoad>, + /// Events waiting to be published to relays (queued from non-pool contexts). + pending_relay_events: Vec<session_events::BuiltEvent>, + /// 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<String>, + /// Local ndb subscription for kind-31988 session state events. + /// Fires when new session states are unwrapped from PNS events. + session_state_sub: Option<nostrdb::Subscription>, + /// Permission responses queued for relay publishing (from remote sessions). + /// Built and published in the update loop where AppContext is available. + pending_perm_responses: Vec<PermissionPublish>, + /// Sessions pending deletion state event publication. + /// Populated in delete_session(), drained in the update loop where AppContext is available. + pending_deletions: Vec<DeletedSessionInfo>, + /// 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<session_events::BuiltEvent, session_events::EventBuildError>, + event_desc: &str, + ndb: &nostrdb::Ndb, + sk: &[u8; 32], + queue: &mut Vec<session_events::BuiltEvent>, +) -> 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<session_events::BuiltEvent> { + 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<SessionId> { + /// 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<SessionId>, Vec<session_events::BuiltEvent>) { // Track which sessions need to send tool responses let mut needs_send: HashSet<SessionId> = HashSet::new(); + let mut events_to_publish: Vec<session_events::BuiltEvent> = 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(&note, "role"); + if role != Some("permission_response") { + continue; + } + + // Extract perm-id + let Some(perm_id_str) = session_events::get_tag_value(&note, "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::<serde_json::Value>(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<String> = 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(&note, "d") else { + continue; + }; + + let status_str = session_events::get_tag_value(&note, "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<SessionId> = 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(&note, "title") + .unwrap_or("Untitled") + .to_string(); + let cwd_str = session_events::get_tag_value(&note, "cwd").unwrap_or(""); + let cwd = std::path::PathBuf::from(cwd_str); + let hostname = session_events::get_tag_value(&note, "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 &notes { + // 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::<serde_json::Value>(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<AppAction> = 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 @@ -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 @@ -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<Uuid, oneshot::Sender<PermissionResponse>>, + /// Maps permission-request UUID → nostr note ID of the published request. + pub request_note_ids: HashMap<Uuid, [u8; 32]>, + /// Permission UUIDs that have already been responded to. + pub responded: HashSet<Uuid>, +} + +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<AnswerSummary>, + is_remote: bool, + oneshot_response: Option<PermissionResponse>, + ) { + // 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<Uuid>, + request_note_ids: HashMap<Uuid, [u8; 32]>, + ) { + 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<Uuid, oneshot::Sender<PermissionResponse>>, + /// 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<String>, /// 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<nostrdb::Subscription>, + /// Status as reported by the remote desktop's kind-31988 event. + /// Only meaningful when session source is Remote. + pub remote_status: Option<AgentStatus>, + /// 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<nostrdb::Subscription>, + /// 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<tokio::task::JoinHandle<()>>, /// 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<AgenticSessionData>, + /// Whether this session is local (has a Claude process) or remote (relay-only). + pub source: SessionSource, + /// Hostname of the machine where this session originated. + pub hostname: String, } 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::<DaveApiResponse>(); + 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::<DaveApiResponse>(); + 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::<DaveApiResponse>(); + 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::<DaveApiResponse>(); + 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 @@ -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<Vec<[u8; 32]>, 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 @@ -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<String, EventBuildError> { + 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<String, [u8; 32]>, + /// 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<String>, + /// Last seen timestamp in seconds (carried forward for lines that lack it). + last_timestamp: Option<u64>, +} + +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<String> { + 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<u64> { + 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<u64>) -> 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<BuiltEvent, EventBuildError> { + 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<Vec<BuiltEvent>, 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<ContentBlock<'_>> = 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<u64>, + secret_key: &[u8; 32], +) -> Result<BuiltEvent, EventBuildError> { + 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<u64>, + threading: &ThreadingState, + secret_key: &[u8; 32], +) -> Result<BuiltEvent, EventBuildError> { + 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<BuiltEvent, EventBuildError> { + 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<BuiltEvent, EventBuildError> { + // 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<BuiltEvent, EventBuildError> { + 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<BuiltEvent, EventBuildError> { + 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::<serde_json::Value>(&wrapped).is_ok()); + } +} diff --git a/crates/notedeck_dave/src/session_jsonl.rs 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<Self, serde_json::Error> { + 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<u64> { + 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<JsonlMessage<'_>> { + 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<ContentBlock<'a>> { + 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<String> { + 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<Self> { + 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<String> = 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 @@ -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<NoteKey> { + 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<NoteKey> { + // 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::<String, (u64, Option<NoteKey>)>::new(), + |mut acc, note| { + let Some(d_tag) = get_tag_value(&note, "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(&note) { + 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<Message>, + 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 &notes { + 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 &notes { + 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::<serde_json::Value>(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<SessionState> { + 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(&note, "d") else { + continue; + }; + + states.push(SessionState { + claude_session_id: claude_session_id.to_string(), + title: get_tag_value(&note, "title") + .unwrap_or("Untitled") + .to_string(), + cwd: get_tag_value(&note, "cwd").unwrap_or("").to_string(), + status: get_tag_value(&note, "status").unwrap_or("idle").to_string(), + hostname: get_tag_value(&note, "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 @@ -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<Vec<String>, 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(&note, "seq").and_then(|s| s.parse::<u32>().ok()); + let source_data = get_tag_value(&note, "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<usize, ReconstructError> { + 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 @@ -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 @@ -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<DaveAction>, ) { 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<DaveAction>, +) { + 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<DaveAction>) { + 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<DaveAction> { + 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<DaveAction> { + 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 @@ -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 @@ -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<String>, modified: usize, added: usize, @@ -19,7 +19,7 @@ struct StatusSnapshot { } impl StatusSnapshot { - fn from_cache(cache: &GitStatusCache) -> Option<Result<Self, ()>> { + pub fn from_cache(cache: &GitStatusCache) -> Option<Result<Self, ()>> { 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<Result<StatusSnapshot, ()>>, + 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<Result<StatusSnapshot, ()>>, + 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 @@ -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<Span>], 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 @@ -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<SessionListAction>, 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 @@ -22,7 +22,6 @@ pub struct SessionListUi<'a> { session_manager: &'a SessionManager, focus_queue: &'a FocusQueue, ctrl_held: bool, - ai_mode: AiMode, } impl<'a> SessionListUi<'a> { @@ -30,13 +29,11 @@ impl<'a> SessionListUi<'a> { session_manager: &'a SessionManager, focus_queue: &'a FocusQueue, ctrl_held: bool, - ai_mode: AiMode, ) -> Self { SessionListUi { session_manager, focus_queue, ctrl_held, - ai_mode, } } @@ -66,15 +63,9 @@ impl<'a> SessionListUi<'a> { fn header_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> { let mut action = None; - // Header text and tooltip depend on mode - let (header_text, new_tooltip) = match self.ai_mode { - AiMode::Chat => ("Chats", "New Chat"), - AiMode::Agentic => ("Agents", "New Agent"), - }; - ui.horizontal(|ui| { ui.add_space(4.0); - ui.label(egui::RichText::new(header_text).size(18.0).strong()); + ui.label(egui::RichText::new("Sessions").size(18.0).strong()); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let icon = app_images::new_message_image() @@ -84,7 +75,7 @@ impl<'a> SessionListUi<'a> { if ui .add(icon) .on_hover_cursor(egui::CursorIcon::PointingHand) - .on_hover_text(new_tooltip) + .on_hover_text("New Chat") .clicked() { action = Some(SessionListAction::NewSession); @@ -98,64 +89,113 @@ impl<'a> SessionListUi<'a> { fn sessions_list_ui(&self, ui: &mut egui::Ui) -> Option<SessionListAction> { let mut action = None; let active_id = self.session_manager.active_id(); - - for (index, session) in self.session_manager.sessions_ordered().iter().enumerate() { - let is_active = Some(session.id) == active_id; - // Show keyboard shortcut hint for first 9 sessions (1-9 keys), only when Ctrl held - let shortcut_hint = if self.ctrl_held && index < 9 { - Some(index + 1) - } else { - None - }; - - // Check if this session is in the focus queue - let queue_priority = self.focus_queue.get_session_priority(session.id); - - // Get cwd from agentic data, fallback to empty path for Chat mode - let empty_path = PathBuf::new(); - let cwd = session.cwd().unwrap_or(&empty_path); - - let response = self.session_item_ui( - ui, - &session.title, - cwd, - is_active, - shortcut_hint, - session.status(), - queue_priority, + let sessions = self.session_manager.sessions_ordered(); + + // Split into agents and chats + let agents: Vec<_> = sessions + .iter() + .enumerate() + .filter(|(_, s)| s.ai_mode == AiMode::Agentic) + .collect(); + let chats: Vec<_> = sessions + .iter() + .enumerate() + .filter(|(_, s)| s.ai_mode == AiMode::Chat) + .collect(); + + // Agents section + if !agents.is_empty() { + ui.label( + egui::RichText::new("Agents") + .size(12.0) + .color(ui.visuals().weak_text_color()), ); - - if response.clicked() { - action = Some(SessionListAction::SwitchTo(session.id)); + ui.add_space(4.0); + for (index, session) in &agents { + if let Some(a) = self.render_session_item(ui, session, *index, active_id) { + action = Some(a); + } } + ui.add_space(8.0); + } - // Right-click context menu for delete - response.context_menu(|ui| { - if ui.button("Delete").clicked() { - action = Some(SessionListAction::Delete(session.id)); - ui.close_menu(); + // Chats section + if !chats.is_empty() { + ui.label( + egui::RichText::new("Chats") + .size(12.0) + .color(ui.visuals().weak_text_color()), + ); + ui.add_space(4.0); + for (index, session) in &chats { + if let Some(a) = self.render_session_item(ui, session, *index, active_id) { + action = Some(a); } - }); + } } action } + fn render_session_item( + &self, + ui: &mut egui::Ui, + session: &crate::session::ChatSession, + index: usize, + active_id: Option<SessionId>, + ) -> Option<SessionListAction> { + let is_active = Some(session.id) == active_id; + let shortcut_hint = if self.ctrl_held && index < 9 { + Some(index + 1) + } else { + None + }; + let queue_priority = self.focus_queue.get_session_priority(session.id); + let empty_path = PathBuf::new(); + let cwd = session.cwd().unwrap_or(&empty_path); + + let response = self.session_item_ui( + ui, + &session.title, + cwd, + &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<usize>, status: AgentStatus, queue_priority: Option<FocusPriority>, + session_ai_mode: AiMode, ) -> egui::Response { - // In Chat mode: shorter height (no CWD), no status bar - // In Agentic mode: taller height with CWD and status bar - let show_cwd = self.ai_mode == AiMode::Agentic; - let show_status_bar = self.ai_mode == AiMode::Agentic; + // Per-session: Chat sessions get shorter height (no CWD), no status bar + // Agentic sessions get taller height with CWD and status bar + let show_cwd = session_ai_mode == AiMode::Agentic; + let show_status_bar = session_ai_mode == AiMode::Agentic; let item_height = if show_cwd { 48.0 } else { 32.0 }; let desired_size = egui::vec2(ui.available_width(), item_height); @@ -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 @@ -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 @@ -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<uuid::Uuid> { - 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<String>, +} + /// 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<PermissionPublish> { + 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<QuestionAnswer>, -) { - let Some(session) = session_manager.get_active_mut() else { - return; - }; +) -> Option<PermissionPublish> { + 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<SessionId> { 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 @@ -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": "<TBD>", + "content": "<human-readable presentation text>", + "tags": [ + // Session identity + ["d", "<session-id>"], + ["session-slug", "<human-readable-name>"], + + // Threading (NIP-10) + ["e", "<root-note-id>", "", "root"], + ["e", "<parent-note-id>", "", "reply"], + + // Message metadata + ["source", "claude-code"], + ["source-version", "2.1.42"], + ["role", "<user|assistant|system|tool_call|tool_result>"], + ["model", "claude-opus-4-6"], + ["turn-type", "<JSONL type field: user|assistant|progress|queue-operation|file-history-snapshot>"], + + // Discoverability + ["t", "ai-conversation"], + + // Lossless reconstruction (Option 3) + ["source-data", "<JSON-escaped JSONL line with paths normalized>"] + ] +} +``` + +### 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-id>", "", "root"]` + `["e", "<prev-id>", "", "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 <session-id>` + +## 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)