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:
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)