notedeck

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

commit 93a07519d0c371d514492134bd89d44496aa3df2
parent 684241f62a02cf36480fadbd5674b88403506f16
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 24 Feb 2026 18:28:00 -0800

dave: extract shared backend logic into backend/shared.rs

messages_to_prompt, get_pending_user_messages, prepare_prompt,
SessionCommand, and SessionHandle were duplicated across claude.rs
and codex.rs. Move them to a shared module so both backends import
from one place.

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

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 121++++++-------------------------------------------------------------------------
Mcrates/notedeck_dave/src/backend/codex.rs | 102+++++++------------------------------------------------------------------------
Mcrates/notedeck_dave/src/backend/mod.rs | 1+
Acrates/notedeck_dave/src/backend/shared.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 4++--
5 files changed, 139 insertions(+), 207 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -1,5 +1,6 @@ use crate::auto_accept::AutoAcceptRules; use crate::backend::session_info::parse_session_info; +use crate::backend::shared::{self, SessionCommand, SessionHandle}; use crate::backend::tool_summary::{ extract_response_content, format_tool_summary, truncate_output, }; @@ -36,30 +37,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 +48,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 @@ -652,23 +571,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 +672,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 +685,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 +698,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 +707,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 +735,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 +747,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,6 +2,7 @@ //! via its JSON-RPC-over-stdio protocol. use super::codex_protocol::*; +use super::shared::{self, SessionCommand, SessionHandle}; use super::tool_summary::{format_tool_summary, truncate_output}; use crate::auto_accept::AutoAcceptRules; use crate::backend::traits::AiBackend; @@ -29,28 +30,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. @@ -1151,50 +1130,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 +1149,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 +1894,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 +1907,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 +1920,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 +1929,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 +1957,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 +1969,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,118 @@ +//! Shared utilities used by multiple AI backend implementations. + +use crate::messages::DaveApiResponse; +use crate::Message; +use claude_agent_sdk_rs::PermissionMode; +use std::sync::mpsc; +use tokio::sync::mpsc as tokio_mpsc; + +/// 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") +} + +/// 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/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)