notedeck

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

commit c5930c56a242741373e19ef150acf30f2fc11b4c
parent 93468bb882e7b6f9dda730e45fd21b9eb2443f53
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 25 Feb 2026 12:24:39 -0800

Merge branch 'dave'

Resolve negentropy sync conflicts: use correct `result` variable
name from dave branch while preserving doc comments from HEAD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
M.beads/issues.jsonl | 4++++
Mcrates/notedeck_dave/src/backend/claude.rs | 200+++++++++++--------------------------------------------------------------------
Mcrates/notedeck_dave/src/backend/codex.rs | 205++++++++++++++++---------------------------------------------------------------
Mcrates/notedeck_dave/src/backend/mod.rs | 1+
Acrates/notedeck_dave/src/backend/shared.rs | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 18+++++++++++-------
Mcrates/notedeck_dave/src/session.rs | 4++--
7 files changed, 312 insertions(+), 345 deletions(-)

diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl @@ -3,8 +3,10 @@ {"id":"notedeck-2yj","title":"Profile search","description":"GitHub #1111: Extend search functionality to include profile lookups. See https://github.com/damus-io/notedeck/issues/1111","status":"open","priority":2,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:36.780164431-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:36.780164431-08:00","labels":["columns"]} {"id":"notedeck-3ns","title":"Approve/deny view text not wrapping - goes off screen","description":"The approve/deny view for tool calls doesn't wrap text properly. Long descriptions go all the way off the screen, making them unreadable.","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:52:24.878602874-08:00","created_by":"William Casarin","updated_at":"2026-02-19T08:32:39.395960931-08:00","closed_at":"2026-02-19T08:32:39.395960931-08:00","close_reason":"Fixed by making handle_new_chat() check ai_mode - in chat mode, sessions are created directly without the directory picker overlay","labels":["dave"]} {"id":"notedeck-4no","title":"Follow packs show blank profiles","description":"GitHub #1107: Some profiles in follow packs display blank. See https://github.com/damus-io/notedeck/issues/1107","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:46:44.374295002-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:08.222622866-08:00","closed_at":"2026-02-18T16:19:08.222622866-08:00","close_reason":"Closing - not a priority right now","labels":["columns"]} +{"id":"notedeck-63y","title":"Extract session-detail-application pattern in lib.rs","description":"The same session detail application logic is repeated 3 times in lib.rs:\n\n1. restore_sessions_from_ndb() lines 1613-1650\n2. poll_session_state_events() lines 1806-1867\n3. poll_remote_conversation_events() (inline)\n\nEach applies hostname, custom_title, home_dir, sets up subscriptions, and seeds live threading data.\n\n## Related duplications\n- Live conversation subscription setup: repeated 4x (1630-1650, 1846-1866, 828-849, permission subs)\n- Remote session detection (hostname mismatch): duplicated at 1589-1596 and 1820-1827\n- Note polling boilerplate (poll-\u003etxn-\u003eiterate-\u003eget_note): repeated 3x at 1318-1327, 1670-1693, 1910-1925\n- Session state timestamp comparison: duplicated at 1614-1616 and 1750-1751\n\n## Approach\nExtract standalone functions:\n- apply_loaded_session_config()\n- setup_conversation_subscription()\n- is_session_remote(state, hostname)\n- Note-polling iterator helper","status":"open","priority":2,"issue_type":"chore","owner":"jb55@jb55.com","created_at":"2026-02-24T18:13:12.475093101-08:00","created_by":"William Casarin","updated_at":"2026-02-24T18:13:12.475093101-08:00"} {"id":"notedeck-84e","title":"Link previews (OpenGraph)","description":"GitHub #992: Display link previews using OpenGraph metadata. See https://github.com/damus-io/notedeck/issues/992","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:51:46.117752018-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:51:46.117752018-08:00","labels":["columns"]} {"id":"notedeck-ajn","title":"Contact lists aren't updated periodically","description":"GitHub #575: Contact lists don't refresh periodically as they should. See https://github.com/damus-io/notedeck/issues/575","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:49:46.741346469-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:49:46.741346469-08:00","labels":["columns"]} +{"id":"notedeck-ax6","title":"Extract shared backend logic into backend/shared.rs","description":"messages_to_prompt() and get_pending_user_messages() are identical in claude.rs and codex.rs. Permission request handling (~70 lines) is nearly identical across both. Session command spawning, subagent creation/completion, and tool result summary generation are also duplicated.\n\n## Files\n- crates/notedeck_dave/src/backend/claude.rs\n- crates/notedeck_dave/src/backend/codex.rs\n\n## Specific duplications\n- messages_to_prompt(): claude.rs:76-114, codex.rs:1152-1178\n- get_pending_user_messages(): claude.rs:119-131, codex.rs:1181-1193\n- Permission request handling: claude.rs:335-418, codex.rs:715-761\n- Session command spawning: claude.rs:707-718, codex.rs:1260-1271\n- Subagent creation: claude.rs:430-455, codex.rs:499-521\n- Tool result summary: claude.rs:547-551, codex.rs:673-682,796-805,828-843\n\n## Approach\nCreate a new backend/shared.rs module with standalone functions. Update claude.rs and codex.rs to call into the shared module.","notes":"Extracted messages_to_prompt(), get_pending_user_messages(), prepare_prompt(), SessionCommand, and SessionHandle into backend/shared.rs. Both claude.rs and codex.rs now import from the shared module. All 219 tests pass. Remaining duplication (permission handling, subagent utils, tool result summary) can be done in a follow-up.\nSecond commit: extracted send_tool_result() and complete_subagent() to shared.rs. Eliminated 4 repeated tool result construction sites and 2 subagent completion sites. Remaining: permission request handling duplication.","status":"closed","priority":2,"issue_type":"chore","owner":"jb55@jb55.com","created_at":"2026-02-24T18:13:11.701612468-08:00","created_by":"William Casarin","updated_at":"2026-02-24T18:52:54.342308465-08:00","closed_at":"2026-02-24T18:52:54.342308465-08:00","close_reason":"Extracted 5 shared utilities (messages_to_prompt, get_pending_user_messages, prepare_prompt, SessionCommand/SessionHandle, send_tool_result, complete_subagent, should_auto_accept, forward_permission_to_ui) into backend/shared.rs across 3 commits. Net ~100 lines removed from claude.rs/codex.rs."} {"id":"notedeck-azp","title":"Column requires opening app twice to show new notes","description":"GitHub #780: Columns require opening notedeck twice to display new notes on initial launch. See https://github.com/damus-io/notedeck/issues/780","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:49:39.093911034-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:07:26.357794127-08:00","closed_at":"2026-02-18T16:07:26.357794127-08:00","close_reason":"Closing - not a priority right now","labels":["columns"]} {"id":"notedeck-bce","title":"Handle ExitPlanMode tool call","description":"Handle ExitPlanMode which simply exits plan mode. Claude-code sends this when it's done its planning phase.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:17.311242243-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:38:19.839039601-08:00","closed_at":"2026-01-30T12:38:19.839039601-08:00","close_reason":"Implemented ExitPlanMode UI with Approve/Reject buttons. When approved, exits plan mode and allows the tool call. UI shows PLAN badge with 'Plan ready for approval' message.","labels":["dave"]} {"id":"notedeck-c3p","title":"Implement multiline message composer (Signal-style)","description":"Replaced singleline TextEdit with multiline, using Signal-style keybindings: Enter to send, Shift+Enter for newline. Based on existing dave.rs implementation.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T12:32:45.563930191-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:33:07.061369966-08:00","closed_at":"2026-01-30T12:33:07.061369966-08:00","close_reason":"Implemented: Changed TextEdit from singleline to multiline with Signal-style keybindings (Enter=send, Shift+Enter=newline) in convo.rs"} @@ -17,8 +19,10 @@ {"id":"notedeck-i40","title":"Quoted note target changes depending on wide or selected mode","description":"GitHub #1117: Quoted note references inconsistent based on view mode. See https://github.com/damus-io/notedeck/issues/1117","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:45:42.264025111-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:08.112313115-08:00","closed_at":"2026-02-18T16:19:08.112313115-08:00","close_reason":"Closing - not a priority right now","labels":["columns"]} {"id":"notedeck-j8c","title":"Chat sidebar should show last message from user or AI","description":"Chat sidebar text should show the user's or AI's last message, not our last message.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:39:11.482262998-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:07.562465153-08:00","closed_at":"2026-02-18T16:19:07.562465153-08:00","close_reason":"Closing - not a priority right now","labels":["dave"]} {"id":"notedeck-kpa","title":"Zap notification","description":"GitHub #1037: Notify users when zapped, showing zap amount, sender, and zapped content details. See https://github.com/damus-io/notedeck/issues/1037","status":"in_progress","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:51:29.181749931-08:00","created_by":"William Casarin","updated_at":"2026-02-19T08:33:39.25553682-08:00","labels":["columns"]} +{"id":"notedeck-os4","title":"Consolidate session module duplications","description":"Several duplications exist across the session_*.rs files.\n\n## SessionState construction (verbatim copy)\n- session_loader.rs:292-304 (load_session_states)\n- session_loader.rs:338-350 (latest_valid_session)\nExtract: build_session_state_from_note(note, session_id) -\u003e SessionState\n\n## Truncation functions (identical implementations)\n- session_loader.rs:353-359 truncate()\n- session_jsonl.rs:306-313 truncate_str()\n- session_discovery.rs:62-68 and 74-79 (inline)\nConsolidate into a single shared utility.\n\n## Builder tag patterns in session_events.rs\n- build_permission_request_event() lines 655-671\n- build_permission_response_event() lines 702-721\n- build_session_state_event() lines 760-765\nAll share identical source/t-tag addition logic.\nExtract: add_common_event_tags(), add_permission_base_tags()\n\n## Large functions to break up\n- build_events() (130 lines, 221-350)\n- build_single_event() (94 lines, 419-512)\n- load_session_messages() (153 lines, 92-244)","status":"open","priority":3,"issue_type":"chore","owner":"jb55@jb55.com","created_at":"2026-02-24T18:13:12.82235796-08:00","created_by":"William Casarin","updated_at":"2026-02-24T18:13:12.82235796-08:00"} {"id":"notedeck-p1n","title":"NIP-05 validation not working as intended","description":"GitHub #1274: NIP-05 validation feature is malfunctioning. See https://github.com/damus-io/notedeck/issues/1274","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:38:30.276030933-08:00","created_by":"William Casarin","updated_at":"2026-02-18T14:45:59.805875423-08:00","closed_at":"2026-02-18T14:45:59.805875423-08:00","close_reason":"Already fixed","labels":["columns"]} {"id":"notedeck-pj7","title":"Like button not visible in light theme","description":"GitHub #1246: Like button rendering visibility issue when switching to light theme mode. See https://github.com/damus-io/notedeck/issues/1246","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:41:16.601075159-08:00","created_by":"William Casarin","updated_at":"2026-01-30T13:38:50.529278612-08:00","closed_at":"2026-01-30T13:38:50.529278612-08:00","close_reason":"Fixed by applying text color tint unconditionally in like_button()","labels":["columns"]} {"id":"notedeck-war","title":"MacOS crash on PFP → Side menu Accounts","description":"GitHub #1270: Application crashes when navigating to Accounts via profile picture menu due to 'layer_id change panic'. See https://github.com/damus-io/notedeck/issues/1270","status":"closed","priority":1,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:41:09.082890703-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:08.002872446-08:00","closed_at":"2026-02-18T16:19:08.002872446-08:00","close_reason":"Closing - not a priority right now","labels":["columns"]} {"id":"notedeck-xer","title":"Persist conversation across app restarts","description":"Save and restore conversation state so it survives app restarts.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:11.196068397-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:07.782704625-08:00","closed_at":"2026-02-18T16:19:07.782704625-08:00","close_reason":"Closing - not a priority right now","labels":["dave"]} +{"id":"notedeck-z5q","title":"Break up Dave::update() and Dave::process_events()","description":"Dave::update() (~412 lines, lib.rs:2456-2868) and Dave::process_events() (~387 lines, lib.rs:594-981) are the two largest methods in the codebase. Each contains multiple distinct phases that could be standalone functions.\n\n## update() extractions\n- process_negentropy_sync() (~75 lines, 2463-2529)\n- handle_archive_conversion() (~80 lines, 2631-2711)\n- poll_message_load() (~30 lines, 2713-2742)\n- process_session_states() (~45 lines, 2799-2820)\n- publish_relay_events() (~25 lines, 2777-2791)\n\n## process_events() extractions\n- process_session_tokens() (~65 lines, 609-681)\n- handle_tool_call_execution() (~80 lines, 687-716)\n- handle_permission_request_event() (~45 lines, 719-776)\n- handle_stream_completion() (~55 lines, 923-969)\n\n## Also large in lib.rs\n- poll_remote_conversation_events() (~219 lines, 1886-2105)\n- poll_session_state_events() (~212 lines, 1665-1877)\n- restore_sessions_from_ndb() (~113 lines, 1546-1659)\n- poll_remote_permission_responses() (~103 lines, 1300-1403)\n\n## Approach\nExtract into standalone functions that take explicit parameters rather than \u0026mut self where possible, improving testability and making data flow explicit.","status":"closed","priority":2,"issue_type":"chore","owner":"jb55@jb55.com","created_at":"2026-02-24T18:13:12.129104216-08:00","created_by":"William Casarin","updated_at":"2026-02-25T12:22:33.668093418-08:00","closed_at":"2026-02-25T12:22:33.668095118-08:00"} {"id":"notedeck-zg7","title":"Quote notification","description":"GitHub #1041: Implement notifications when users' posts are quoted by others. See https://github.com/damus-io/notedeck/issues/1041","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:58.370192754-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:58.370192754-08:00","labels":["columns"]} diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -1,13 +1,10 @@ -use crate::auto_accept::AutoAcceptRules; use crate::backend::session_info::parse_session_info; -use crate::backend::tool_summary::{ - extract_response_content, format_tool_summary, truncate_output, -}; +use crate::backend::shared::{self, SessionCommand, SessionHandle}; +use crate::backend::tool_summary::extract_response_content; use crate::backend::traits::AiBackend; use crate::file_update::FileUpdate; use crate::messages::{ - CompactionInfo, DaveApiResponse, ExecutedTool, ParsedMarkdown, PendingPermission, - PermissionRequest, PermissionResponse, SubagentInfo, SubagentStatus, + CompactionInfo, DaveApiResponse, PermissionResponse, SubagentInfo, SubagentStatus, }; use crate::tools::Tool; use crate::Message; @@ -25,7 +22,6 @@ use std::sync::mpsc; use std::sync::Arc; use tokio::sync::mpsc as tokio_mpsc; use tokio::sync::oneshot; -use uuid::Uuid; /// Convert a ToolResultContent to a serde_json::Value for use with tool summary formatting fn tool_result_content_to_value(content: &Option<ToolResultContent>) -> serde_json::Value { @@ -36,30 +32,6 @@ fn tool_result_content_to_value(content: &Option<ToolResultContent>) -> serde_js } } -/// Commands sent to a session's actor task -enum SessionCommand { - Query { - prompt: String, - response_tx: mpsc::Sender<DaveApiResponse>, - ctx: egui::Context, - }, - /// Interrupt the current query - stops the stream but preserves session - Interrupt { - ctx: egui::Context, - }, - /// Set the permission mode (Default or Plan) - SetPermissionMode { - mode: PermissionMode, - ctx: egui::Context, - }, - Shutdown, -} - -/// Handle to a session's actor -struct SessionHandle { - command_tx: tokio_mpsc::Sender<SessionCommand>, -} - pub struct ClaudeBackend { /// Registry of active sessions (using dashmap for lock-free access) sessions: DashMap<String, SessionHandle>, @@ -71,64 +43,6 @@ impl ClaudeBackend { sessions: DashMap::new(), } } - - /// Convert our messages to a prompt for Claude Code - fn messages_to_prompt(messages: &[Message]) -> String { - let mut prompt = String::new(); - - // Include system message if present - for msg in messages { - if let Message::System(content) = msg { - prompt.push_str(content); - prompt.push_str("\n\n"); - break; - } - } - - // Format conversation history - for msg in messages { - match msg { - Message::System(_) => {} // Already handled - Message::User(content) => { - prompt.push_str("Human: "); - prompt.push_str(content); - prompt.push_str("\n\n"); - } - Message::Assistant(content) => { - prompt.push_str("Assistant: "); - prompt.push_str(content.text()); - prompt.push_str("\n\n"); - } - Message::ToolCalls(_) - | Message::ToolResponse(_) - | Message::Error(_) - | Message::PermissionRequest(_) - | Message::CompactionComplete(_) - | Message::Subagent(_) => { - // Skip tool-related, error, permission, compaction, and subagent messages - } - } - } - - prompt - } - - /// Collect all trailing user messages and join them. - /// When multiple messages are queued, they're all sent as one prompt - /// so the AI sees everything at once instead of one at a time. - pub fn get_pending_user_messages(messages: &[Message]) -> String { - let mut trailing: Vec<&str> = messages - .iter() - .rev() - .take_while(|m| matches!(m, Message::User(_))) - .filter_map(|m| match m { - Message::User(content) => Some(content.as_str()), - _ => None, - }) - .collect(); - trailing.reverse(); - trailing.join("\n") - } } /// Permission request forwarded from the callback to the actor @@ -334,53 +248,27 @@ async fn session_actor( // Handle permission requests (they're blocking the SDK) Some(perm_req) = perm_rx.recv() => { - // Check auto-accept rules - let auto_accept_rules = AutoAcceptRules::default(); - if auto_accept_rules.should_auto_accept(&perm_req.tool_name, &perm_req.tool_input) { - tracing::debug!("Auto-accepting {}: matched auto-accept rule", perm_req.tool_name); + if shared::should_auto_accept(&perm_req.tool_name, &perm_req.tool_input) { let _ = perm_req.response_tx.send(PermissionResult::Allow(PermissionResultAllow::default())); continue; } - // Forward permission request to UI - let request_id = Uuid::new_v4(); - let (ui_resp_tx, ui_resp_rx) = oneshot::channel(); - - let cached_plan = if perm_req.tool_name == "ExitPlanMode" { - perm_req - .tool_input - .get("plan") - .and_then(|v| v.as_str()) - .map(ParsedMarkdown::parse) - } else { - None - }; - - let request = PermissionRequest { - id: request_id, - tool_name: perm_req.tool_name.clone(), - tool_input: perm_req.tool_input.clone(), - response: None, - answer_summary: None, - cached_plan, - }; - - let pending = PendingPermission { - request, - response_tx: ui_resp_tx, + let ui_resp_rx = match shared::forward_permission_to_ui( + &perm_req.tool_name, + perm_req.tool_input.clone(), + &response_tx, + &ctx, + ) { + Some(rx) => rx, + None => { + let _ = perm_req.response_tx.send(PermissionResult::Deny(PermissionResultDeny { + message: "UI channel closed".to_string(), + interrupt: true, + })); + continue; + } }; - if response_tx.send(DaveApiResponse::PermissionRequest(pending)).is_err() { - tracing::error!("Failed to send permission request to UI"); - let _ = perm_req.response_tx.send(PermissionResult::Deny(PermissionResultDeny { - message: "UI channel closed".to_string(), - interrupt: true, - })); - continue; - } - - ctx.request_repaint(); - // Wait for UI response inline - blocking is OK since stream is // waiting for permission result anyway let tool_name = perm_req.tool_name.clone(); @@ -532,23 +420,13 @@ async fn session_actor( // Check if this is a Task tool completion if tool_name == "Task" { - // Pop this subagent from the stack - subagent_stack.retain(|id| id != tool_use_id); let result_text = extract_response_content(&result_value) .unwrap_or_else(|| "completed".to_string()); - let _ = response_tx.send(DaveApiResponse::SubagentCompleted { - task_id: tool_use_id.to_string(), - result: truncate_output(&result_text, 2000), - }); + shared::complete_subagent(tool_use_id, &result_text, &mut subagent_stack, &response_tx, &ctx); } - // Attach parent subagent context (top of stack) - let parent_task_id = subagent_stack.last().cloned(); - let summary = format_tool_summary(&tool_name, &tool_input, &result_value); let file_update = FileUpdate::from_tool_call(&tool_name, &tool_input); - let tool_result = ExecutedTool { tool_name, summary, parent_task_id, file_update }; - let _ = response_tx.send(DaveApiResponse::ToolResult(tool_result)); - ctx.request_repaint(); + shared::send_tool_result(&tool_name, &tool_input, &result_value, file_update, &subagent_stack, &response_tx, &ctx); } } } @@ -652,23 +530,7 @@ impl AiBackend for ClaudeBackend { ) { let (response_tx, response_rx) = mpsc::channel(); - // 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_pending_user_messages(&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_pending_user_messages(&messages) - } - }; + let prompt = shared::prepare_prompt(&messages, &resume_session_id); tracing::debug!( "Sending request to Claude Code: session={}, resumed={}, prompt length: {}, preview: {:?}", @@ -769,7 +631,7 @@ mod tests { #[test] fn pending_messages_single_user() { let messages = vec![Message::User("hello".into())]; - assert_eq!(ClaudeBackend::get_pending_user_messages(&messages), "hello"); + assert_eq!(shared::get_pending_user_messages(&messages), "hello"); } #[test] @@ -782,7 +644,7 @@ mod tests { Message::User("fourth".into()), ]; assert_eq!( - ClaudeBackend::get_pending_user_messages(&messages), + shared::get_pending_user_messages(&messages), "second\nthird\nfourth" ); } @@ -795,10 +657,7 @@ mod tests { Message::Assistant(AssistantMessage::from_text("reply".into())), Message::User("pending".into()), ]; - assert_eq!( - ClaudeBackend::get_pending_user_messages(&messages), - "pending" - ); + assert_eq!(shared::get_pending_user_messages(&messages), "pending"); } #[test] @@ -807,13 +666,13 @@ mod tests { Message::User("hello".into()), Message::Assistant(AssistantMessage::from_text("reply".into())), ]; - assert_eq!(ClaudeBackend::get_pending_user_messages(&messages), ""); + assert_eq!(shared::get_pending_user_messages(&messages), ""); } #[test] fn pending_messages_empty_chat() { let messages: Vec<Message> = vec![]; - assert_eq!(ClaudeBackend::get_pending_user_messages(&messages), ""); + assert_eq!(shared::get_pending_user_messages(&messages), ""); } #[test] @@ -835,7 +694,7 @@ mod tests { Message::User("queued 2".into()), ]; assert_eq!( - ClaudeBackend::get_pending_user_messages(&messages), + shared::get_pending_user_messages(&messages), "queued 1\nqueued 2" ); } @@ -847,9 +706,6 @@ mod tests { Message::User("b".into()), Message::User("c".into()), ]; - assert_eq!( - ClaudeBackend::get_pending_user_messages(&messages), - "a\nb\nc" - ); + assert_eq!(shared::get_pending_user_messages(&messages), "a\nb\nc"); } } diff --git a/crates/notedeck_dave/src/backend/codex.rs b/crates/notedeck_dave/src/backend/codex.rs @@ -2,13 +2,11 @@ //! via its JSON-RPC-over-stdio protocol. use super::codex_protocol::*; -use super::tool_summary::{format_tool_summary, truncate_output}; -use crate::auto_accept::AutoAcceptRules; +use super::shared::{self, SessionCommand, SessionHandle}; use crate::backend::traits::AiBackend; use crate::file_update::{FileUpdate, FileUpdateType}; use crate::messages::{ - CompactionInfo, DaveApiResponse, ExecutedTool, PendingPermission, PermissionRequest, - PermissionResponse, SessionInfo, SubagentInfo, SubagentStatus, + CompactionInfo, DaveApiResponse, PermissionResponse, SessionInfo, SubagentInfo, SubagentStatus, }; use crate::tools::Tool; use crate::Message; @@ -29,28 +27,6 @@ use uuid::Uuid; // Session actor // --------------------------------------------------------------------------- -/// Commands sent to a Codex session actor. -enum SessionCommand { - Query { - prompt: String, - response_tx: mpsc::Sender<DaveApiResponse>, - ctx: egui::Context, - }, - Interrupt { - ctx: egui::Context, - }, - SetPermissionMode { - mode: PermissionMode, - ctx: egui::Context, - }, - Shutdown, -} - -/// Handle kept by the backend to communicate with the actor. -struct SessionHandle { - command_tx: tokio_mpsc::Sender<SessionCommand>, -} - /// Result of processing a single Codex JSON-RPC message. enum HandleResult { /// Normal notification processed, keep reading. @@ -674,16 +650,17 @@ fn handle_codex_message( "diff": diff_text, }); let result_value = serde_json::json!({ "status": "ok" }); - let summary = format_tool_summary(tool_name, &tool_input, &result_value); - let file_update = make_codex_file_update(path, tool_name, change_type, &diff_text); - let _ = response_tx.send(DaveApiResponse::ToolResult(ExecutedTool { - tool_name: tool_name.to_string(), - summary, - parent_task_id: subagent_stack.last().cloned(), + shared::send_tool_result( + tool_name, + &tool_input, + &result_value, file_update, - })); + subagent_stack, + response_tx, + ctx, + ); } ctx.request_repaint(); } @@ -723,44 +700,14 @@ fn check_approval_or_forward( response_tx: &mpsc::Sender<DaveApiResponse>, ctx: &egui::Context, ) -> HandleResult { - let rules = AutoAcceptRules::default(); - if rules.should_auto_accept(tool_name, &tool_input) { - tracing::debug!("Auto-accepting {} (rpc_id={})", tool_name, rpc_id); + if shared::should_auto_accept(tool_name, &tool_input) { return HandleResult::AutoAccepted(rpc_id); } - // Forward to UI - let request_id = Uuid::new_v4(); - let (ui_resp_tx, ui_resp_rx) = oneshot::channel(); - - let request = PermissionRequest { - id: request_id, - tool_name: tool_name.to_string(), - tool_input, - response: None, - answer_summary: None, - cached_plan: None, - }; - - let pending = PendingPermission { - request, - response_tx: ui_resp_tx, - }; - - if response_tx - .send(DaveApiResponse::PermissionRequest(pending)) - .is_err() - { - tracing::error!("Failed to send permission request to UI"); - // Return auto-decline — can't reach UI - return HandleResult::AutoAccepted(rpc_id); // Will send Accept; could add a Declined variant - } - - ctx.request_repaint(); - - HandleResult::NeedsApproval { - rpc_id, - rx: ui_resp_rx, + match shared::forward_permission_to_ui(tool_name, tool_input, response_tx, ctx) { + Some(rx) => HandleResult::NeedsApproval { rpc_id, rx }, + // Can't reach UI — auto-accept as fallback + None => HandleResult::AutoAccepted(rpc_id), } } @@ -797,16 +744,15 @@ fn handle_item_completed( let tool_input = serde_json::json!({ "command": command }); let result_value = serde_json::json!({ "output": output, "exit_code": exit_code }); - let summary = format_tool_summary("Bash", &tool_input, &result_value); - let parent_task_id = subagent_stack.last().cloned(); - - let _ = response_tx.send(DaveApiResponse::ToolResult(ExecutedTool { - tool_name: "Bash".to_string(), - summary, - parent_task_id, - file_update: None, - })); - ctx.request_repaint(); + shared::send_tool_result( + "Bash", + &tool_input, + &result_value, + None, + subagent_stack, + response_tx, + ctx, + ); } "fileChange" => { @@ -829,36 +775,30 @@ fn handle_item_completed( "diff": diff, }); let result_value = serde_json::json!({ "status": "ok" }); - let summary = format_tool_summary(tool_name, &tool_input, &result_value); - let parent_task_id = subagent_stack.last().cloned(); - let file_update = make_codex_file_update( &file_path, tool_name, kind_str, diff.as_deref().unwrap_or(""), ); - let _ = response_tx.send(DaveApiResponse::ToolResult(ExecutedTool { - tool_name: tool_name.to_string(), - summary, - parent_task_id, + shared::send_tool_result( + tool_name, + &tool_input, + &result_value, file_update, - })); - ctx.request_repaint(); + subagent_stack, + response_tx, + ctx, + ); } "collabAgentToolCall" => { if let Some(item_id) = &completed.item_id { - subagent_stack.retain(|id| id != item_id); let result_text = completed .result .clone() .unwrap_or_else(|| "completed".to_string()); - let _ = response_tx.send(DaveApiResponse::SubagentCompleted { - task_id: item_id.clone(), - result: truncate_output(&result_text, 2000), - }); - ctx.request_repaint(); + shared::complete_subagent(item_id, &result_text, subagent_stack, response_tx, ctx); } } @@ -1151,50 +1091,6 @@ impl CodexBackend { sessions: DashMap::new(), } } - - /// Convert messages to a prompt string, same logic as the Claude backend. - fn messages_to_prompt(messages: &[Message]) -> String { - let mut prompt = String::new(); - for msg in messages { - if let Message::System(content) = msg { - prompt.push_str(content); - prompt.push_str("\n\n"); - break; - } - } - for msg in messages { - match msg { - Message::System(_) => {} - Message::User(content) => { - prompt.push_str("Human: "); - prompt.push_str(content); - prompt.push_str("\n\n"); - } - Message::Assistant(content) => { - prompt.push_str("Assistant: "); - prompt.push_str(content.text()); - prompt.push_str("\n\n"); - } - _ => {} - } - } - prompt - } - - /// Collect all trailing user messages and join them. - fn get_pending_user_messages(messages: &[Message]) -> String { - let mut trailing: Vec<&str> = messages - .iter() - .rev() - .take_while(|m| matches!(m, Message::User(_))) - .filter_map(|m| match m { - Message::User(content) => Some(content.as_str()), - _ => None, - }) - .collect(); - trailing.reverse(); - trailing.join("\n") - } } impl AiBackend for CodexBackend { @@ -1214,20 +1110,7 @@ impl AiBackend for CodexBackend { ) { let (response_tx, response_rx) = mpsc::channel(); - let prompt = if resume_session_id.is_some() { - Self::get_pending_user_messages(&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_pending_user_messages(&messages) - } - }; + let prompt = shared::prepare_prompt(&messages, &resume_session_id); tracing::debug!( "Codex request: session={}, resumed={}, prompt_len={}", @@ -1972,7 +1855,7 @@ mod tests { #[test] fn pending_messages_single_user() { let messages = vec![Message::User("hello".into())]; - assert_eq!(CodexBackend::get_pending_user_messages(&messages), "hello"); + assert_eq!(shared::get_pending_user_messages(&messages), "hello"); } #[test] @@ -1985,7 +1868,7 @@ mod tests { Message::User("fourth".into()), ]; assert_eq!( - CodexBackend::get_pending_user_messages(&messages), + shared::get_pending_user_messages(&messages), "second\nthird\nfourth" ); } @@ -1998,10 +1881,7 @@ mod tests { Message::Assistant(AssistantMessage::from_text("reply".into())), Message::User("pending".into()), ]; - assert_eq!( - CodexBackend::get_pending_user_messages(&messages), - "pending" - ); + assert_eq!(shared::get_pending_user_messages(&messages), "pending"); } #[test] @@ -2010,13 +1890,13 @@ mod tests { Message::User("hello".into()), Message::Assistant(AssistantMessage::from_text("reply".into())), ]; - assert_eq!(CodexBackend::get_pending_user_messages(&messages), ""); + assert_eq!(shared::get_pending_user_messages(&messages), ""); } #[test] fn pending_messages_empty_chat() { let messages: Vec<Message> = vec![]; - assert_eq!(CodexBackend::get_pending_user_messages(&messages), ""); + assert_eq!(shared::get_pending_user_messages(&messages), ""); } #[test] @@ -2038,7 +1918,7 @@ mod tests { Message::User("queued 2".into()), ]; assert_eq!( - CodexBackend::get_pending_user_messages(&messages), + shared::get_pending_user_messages(&messages), "queued 1\nqueued 2" ); } @@ -2050,10 +1930,7 @@ mod tests { Message::User("b".into()), Message::User("c".into()), ]; - assert_eq!( - CodexBackend::get_pending_user_messages(&messages), - "a\nb\nc" - ); + assert_eq!(shared::get_pending_user_messages(&messages), "a\nb\nc"); } // ----------------------------------------------------------------------- diff --git a/crates/notedeck_dave/src/backend/mod.rs b/crates/notedeck_dave/src/backend/mod.rs @@ -4,6 +4,7 @@ mod codex_protocol; mod openai; mod remote; mod session_info; +pub(crate) mod shared; mod tool_summary; mod traits; diff --git a/crates/notedeck_dave/src/backend/shared.rs b/crates/notedeck_dave/src/backend/shared.rs @@ -0,0 +1,225 @@ +//! Shared utilities used by multiple AI backend implementations. + +use crate::auto_accept::AutoAcceptRules; +use crate::backend::tool_summary::{format_tool_summary, truncate_output}; +use crate::file_update::FileUpdate; +use crate::messages::{DaveApiResponse, ExecutedTool, PendingPermission, PermissionRequest}; +use crate::Message; +use claude_agent_sdk_rs::PermissionMode; +use std::sync::mpsc; +use tokio::sync::mpsc as tokio_mpsc; +use tokio::sync::oneshot; +use uuid::Uuid; + +/// Commands sent to a session's actor task. +/// +/// Used identically by the Claude and Codex backends. +pub(crate) enum SessionCommand { + Query { + prompt: String, + response_tx: mpsc::Sender<DaveApiResponse>, + ctx: egui::Context, + }, + /// Interrupt the current query - stops the stream but preserves session + Interrupt { + ctx: egui::Context, + }, + /// Set the permission mode (Default or Plan) + SetPermissionMode { + mode: PermissionMode, + ctx: egui::Context, + }, + Shutdown, +} + +/// Handle kept by a backend to communicate with its session actor. +pub(crate) struct SessionHandle { + pub command_tx: tokio_mpsc::Sender<SessionCommand>, +} + +/// Convert our messages to a prompt string for the AI backend. +/// +/// Includes the system message (if any) followed by the conversation +/// history formatted as `Human:` / `Assistant:` turns. Tool-related, +/// error, permission, compaction and subagent messages are skipped. +pub fn messages_to_prompt(messages: &[Message]) -> String { + let mut prompt = String::new(); + + // Include system message if present + for msg in messages { + if let Message::System(content) = msg { + prompt.push_str(content); + prompt.push_str("\n\n"); + break; + } + } + + // Format conversation history + for msg in messages { + match msg { + Message::System(_) => {} // Already handled + Message::User(content) => { + prompt.push_str("Human: "); + prompt.push_str(content); + prompt.push_str("\n\n"); + } + Message::Assistant(content) => { + prompt.push_str("Assistant: "); + prompt.push_str(content.text()); + prompt.push_str("\n\n"); + } + Message::ToolCalls(_) + | Message::ToolResponse(_) + | Message::Error(_) + | Message::PermissionRequest(_) + | Message::CompactionComplete(_) + | Message::Subagent(_) => {} + } + } + + prompt +} + +/// Collect all trailing user messages and join them. +/// +/// When multiple messages are queued, they're all sent as one prompt +/// so the AI sees everything at once instead of one at a time. +pub fn get_pending_user_messages(messages: &[Message]) -> String { + let mut trailing: Vec<&str> = messages + .iter() + .rev() + .take_while(|m| matches!(m, Message::User(_))) + .filter_map(|m| match m { + Message::User(content) => Some(content.as_str()), + _ => None, + }) + .collect(); + trailing.reverse(); + trailing.join("\n") +} + +/// Remove a completed subagent from the stack and notify the UI. +pub fn complete_subagent( + task_id: &str, + result_text: &str, + subagent_stack: &mut Vec<String>, + response_tx: &mpsc::Sender<DaveApiResponse>, + ctx: &egui::Context, +) { + subagent_stack.retain(|id| id != task_id); + let _ = response_tx.send(DaveApiResponse::SubagentCompleted { + task_id: task_id.to_string(), + result: truncate_output(result_text, 2000), + }); + ctx.request_repaint(); +} + +/// Build an [`ExecutedTool`] from a completed tool call and send it +/// to the UI along with a repaint request. +pub fn send_tool_result( + tool_name: &str, + tool_input: &serde_json::Value, + result_value: &serde_json::Value, + file_update: Option<FileUpdate>, + subagent_stack: &[String], + response_tx: &mpsc::Sender<DaveApiResponse>, + ctx: &egui::Context, +) { + let summary = format_tool_summary(tool_name, tool_input, result_value); + let parent_task_id = subagent_stack.last().cloned(); + let tool_result = ExecutedTool { + tool_name: tool_name.to_string(), + summary, + parent_task_id, + file_update, + }; + let _ = response_tx.send(DaveApiResponse::ToolResult(tool_result)); + ctx.request_repaint(); +} + +/// Check auto-accept rules for a tool invocation. +/// +/// Returns `true` (and logs) when the tool should be silently +/// accepted without asking the user. +pub fn should_auto_accept(tool_name: &str, tool_input: &serde_json::Value) -> bool { + let rules = AutoAcceptRules::default(); + let accepted = rules.should_auto_accept(tool_name, tool_input); + if accepted { + tracing::debug!("Auto-accepting {}: matched auto-accept rule", tool_name); + } + accepted +} + +/// Build a [`PermissionRequest`] + [`PendingPermission`], send it to +/// the UI via `response_tx`, and return the oneshot receiver the +/// caller can `await` to get the user's decision. +/// +/// Returns `None` if the UI channel is closed (the request could not +/// be delivered). +pub fn forward_permission_to_ui( + tool_name: &str, + tool_input: serde_json::Value, + response_tx: &mpsc::Sender<DaveApiResponse>, + ctx: &egui::Context, +) -> Option<oneshot::Receiver<crate::messages::PermissionResponse>> { + let request_id = Uuid::new_v4(); + let (ui_resp_tx, ui_resp_rx) = oneshot::channel(); + + let cached_plan = if tool_name == "ExitPlanMode" { + tool_input + .get("plan") + .and_then(|v| v.as_str()) + .map(crate::messages::ParsedMarkdown::parse) + } else { + None + }; + + let request = PermissionRequest { + id: request_id, + tool_name: tool_name.to_string(), + tool_input, + response: None, + answer_summary: None, + cached_plan, + }; + + let pending = PendingPermission { + request, + response_tx: ui_resp_tx, + }; + + if response_tx + .send(DaveApiResponse::PermissionRequest(pending)) + .is_err() + { + tracing::error!("Failed to send permission request to UI"); + return None; + } + + ctx.request_repaint(); + Some(ui_resp_rx) +} + +/// Decide which prompt to send based on whether we're resuming a +/// session and how many user messages exist. +/// +/// - Resumed sessions always send just the pending messages (the +/// backend already has the full conversation context). +/// - New sessions send the full prompt on the first message, then +/// only pending messages for subsequent turns. +pub fn prepare_prompt(messages: &[Message], resume_session_id: &Option<String>) -> String { + if resume_session_id.is_some() { + get_pending_user_messages(messages) + } else { + let is_first_message = messages + .iter() + .filter(|m| matches!(m, Message::User(_))) + .count() + == 1; + if is_first_message { + messages_to_prompt(messages) + } else { + get_pending_user_messages(messages) + } + } +} diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -70,8 +70,8 @@ const DEFAULT_PNS_RELAY: &str = "ws://relay.jb55.com/"; /// Maximum consecutive negentropy sync rounds before stopping. /// Each round pulls up to the relay's limit (typically 500 events), -/// so 5 rounds fetches up to ~2500 recent events. -const MAX_NEG_SYNC_ROUNDS: u8 = 5; +/// so 20 rounds fetches up to ~10000 recent events. +const MAX_NEG_SYNC_ROUNDS: u8 = 20; /// Normalize a relay URL to always have a trailing slash. fn normalize_relay_url(url: String) -> String { @@ -2521,23 +2521,22 @@ impl notedeck::App for Dave { let filter = nostrdb::Filter::new() .kinds([enostr::pns::PNS_KIND as u64]) .authors([pns_keys.keypair.pubkey.bytes()]) - .limit(500) .build(); - let fetched = + let result = self.neg_sync .process(neg_events, ctx.ndb, ctx.pool, &filter, &self.pns_relay_url); // If events were found and we haven't hit the round limit, // trigger another sync to pull more recent data. - if fetched.new_events > 0 { + if result.new_events > 0 { self.neg_sync_round += 1; if self.neg_sync_round < MAX_NEG_SYNC_ROUNDS { tracing::info!( "negentropy: scheduling round {}/{} (got {} new, {} skipped)", self.neg_sync_round + 1, MAX_NEG_SYNC_ROUNDS, - fetched.new_events, - fetched.skipped + result.new_events, + result.skipped ); self.neg_sync.trigger_now(); } else { @@ -2546,6 +2545,11 @@ impl notedeck::App for Dave { MAX_NEG_SYNC_ROUNDS ); } + } else if result.skipped > 0 { + tracing::info!( + "negentropy: relay has {} events we can't reconcile, stopping", + result.skipped + ); } } diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -1363,7 +1363,7 @@ mod tests { #[test] fn batch_redispatch_full_lifecycle() { let mut session = test_session(); - use crate::backend::claude::ClaudeBackend; + use crate::backend::shared; // Step 1: User sends first message, it gets dispatched (single) session.chat.push(Message::User("hello".into())); @@ -1411,7 +1411,7 @@ mod tests { // Step 4: At redispatch time, get_pending_user_messages should // collect ALL trailing user messages - let prompt = ClaudeBackend::get_pending_user_messages(&session.chat); + let prompt = shared::get_pending_user_messages(&session.chat); assert_eq!(prompt, "also\ndo this\nand this"); // Step 5: Backend dispatches with the batch prompt (3 messages)