notedeck

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

commit 3fe434ab777c09c64f1505be068801441b34b2aa
parent 4459238e52b909375b443dfba6d60619353240f3
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 22 Feb 2026 12:09:34 -0800

dave: add Compact & Approve button for ExitPlanMode

Adds a "Compact & Approve" option to the plan approval UI that
compacts context before starting implementation, similar to Claude
Code's feature. Uses a three-phase state machine
(Idle→WaitingForCompaction→ReadyToProceed) so the proceed message
only fires after compaction actually completes, not on any stream-end.

A shared `take_compact_and_proceed()` method on ChatSession handles
the proceed logic for both local sessions (at stream-end) and remote
sessions (on compaction_complete relay event, published back for the
desktop backend to pick up).

Also fixes pre-existing test failure in session_events where
build_session_state_event was missing the home_dir argument.

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

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 57++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/notedeck_dave/src/session.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session_events.rs | 1+
Mcrates/notedeck_dave/src/ui/dave.rs | 19+++++++++++++++++++
Mcrates/notedeck_dave/src/ui/mod.rs | 20++++++++++++++++++++
5 files changed, 136 insertions(+), 3 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -831,6 +831,15 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if let Some(agentic) = &mut session.agentic { agentic.is_compacting = false; agentic.last_compaction = Some(info.clone()); + + // Advance compact-and-proceed: compaction done, + // proceed message will fire at stream-end. + if agentic.compact_and_proceed + == crate::session::CompactAndProceedState::WaitingForCompaction + { + agentic.compact_and_proceed = + crate::session::CompactAndProceedState::ReadyToProceed; + } } session.chat.push(Message::CompactionComplete(info)); } @@ -876,6 +885,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr if session.needs_redispatch_after_stream_end() { needs_send.insert(session_id); } + + // After compact & approve: compaction must have + // completed (ReadyToProceed) before we send "Proceed". + if session.take_compact_and_proceed() { + needs_send.insert(session_id); + } } } _ => { @@ -1725,8 +1740,13 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// /// For local sessions: only process `role=user` messages arriving from /// remote clients (phone), collecting them for backend dispatch. - fn poll_remote_conversation_events(&mut self, ndb: &nostrdb::Ndb) -> Vec<(SessionId, String)> { + fn poll_remote_conversation_events( + &mut self, + ndb: &nostrdb::Ndb, + secret_key: Option<&[u8; 32]>, + ) -> (Vec<(SessionId, String)>, Vec<session_events::BuiltEvent>) { let mut remote_user_messages: Vec<(SessionId, String)> = Vec::new(); + let mut events_to_publish: Vec<session_events::BuiltEvent> = Vec::new(); let session_ids = self.session_manager.session_ids(); for session_id in session_ids { let Some(session) = self.session_manager.get_mut(session_id) else { @@ -1902,14 +1922,42 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr let info = crate::messages::CompactionInfo { pre_tokens }; agentic.last_compaction = Some(info.clone()); session.chat.push(Message::CompactionComplete(info)); + + // Advance compact-and-proceed: for remote sessions, + // there's no stream-end to wait for, so go straight + // to ReadyToProceed and consume immediately. + if agentic.compact_and_proceed + == crate::session::CompactAndProceedState::WaitingForCompaction + { + agentic.compact_and_proceed = + crate::session::CompactAndProceedState::ReadyToProceed; + } } _ => { // Skip progress, queue-operation, etc. } } + + // Handle proceed after compaction for remote sessions. + // Published as a relay event so the desktop backend picks it up. + if session.take_compact_and_proceed() { + if let Some(sk) = secret_key { + if let Some(evt) = ingest_live_event( + session, + ndb, + sk, + "Proceed with implementing the plan.", + "user", + None, + None, + ) { + events_to_publish.push(evt); + } + } + } } } - remote_user_messages + (remote_user_messages, events_to_publish) } /// Delete a session and clean up backend resources @@ -2255,7 +2303,10 @@ impl notedeck::App for Dave { // Only dispatch if the session isn't already streaming a response — // the message is already in chat, so it will be included when the // current stream finishes and we re-dispatch. - let remote_user_msgs = self.poll_remote_conversation_events(ctx.ndb); + let sk_bytes = secret_key_bytes(ctx.accounts.get_selected_account().keypair()); + let (remote_user_msgs, conv_events) = + self.poll_remote_conversation_events(ctx.ndb, sk_bytes.as_ref()); + self.pending_relay_events.extend(conv_events); for (sid, _msg) in remote_user_msgs { let should_dispatch = self .session_manager diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -37,6 +37,23 @@ pub struct SessionDetails { pub home_dir: String, } +/// Tracks the "Compact & Approve" lifecycle. +/// +/// Button click → `WaitingForCompaction` (intent recorded). +/// CompactionComplete → `ReadyToProceed` (compaction finished, safe to send). +/// Stream-end (local) or compaction_complete event (remote) → consume and fire. +#[derive(Default, Clone, Copy, PartialEq)] +pub enum CompactAndProceedState { + /// No compact-and-proceed in progress. + #[default] + Idle, + /// User clicked "Compact & Approve"; waiting for compaction to finish. + WaitingForCompaction, + /// Compaction finished; send "Proceed" on the next safe opportunity + /// (stream-end for local, immediately for remote). + ReadyToProceed, +} + /// State for permission response with message #[derive(Default, Clone, Copy, PartialEq)] pub enum PermissionMessageState { @@ -182,6 +199,8 @@ pub struct AgenticSessionData { /// Prevents duplicate messages when events are loaded during restore /// and then appear again via the subscription. pub seen_note_ids: HashSet<[u8; 32]>, + /// Tracks the "Compact & Approve" lifecycle. + pub compact_and_proceed: CompactAndProceedState, } impl AgenticSessionData { @@ -214,6 +233,7 @@ impl AgenticSessionData { remote_status_ts: 0, live_conversation_sub: None, seen_note_ids: HashSet::new(), + compact_and_proceed: CompactAndProceedState::Idle, } } @@ -790,6 +810,28 @@ impl ChatSession { pub fn needs_redispatch_after_stream_end(&self) -> bool { !self.is_streaming() && self.has_pending_user_message() } + + /// If "Compact & Approve" has reached ReadyToProceed, consume the state, + /// push a "Proceed" user message, and return true. + /// + /// Called from: + /// - Local sessions: at stream-end in process_events() + /// - Remote sessions: on compaction_complete in poll_remote_conversation_events() + pub fn take_compact_and_proceed(&mut self) -> bool { + let dominated = self + .agentic + .as_ref() + .is_none_or(|a| a.compact_and_proceed != CompactAndProceedState::ReadyToProceed); + + if dominated { + return false; + } + + self.agentic.as_mut().unwrap().compact_and_proceed = CompactAndProceedState::Idle; + self.chat + .push(Message::User("Proceed with implementing the plan.".into())); + true + } } #[cfg(test)] diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs @@ -1119,6 +1119,7 @@ mod tests { "/tmp/project", "working", "my-laptop", + "/home/testuser", &sk, ) .unwrap(); diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -136,6 +136,10 @@ pub enum DaveAction { request_id: Uuid, approved: bool, }, + /// User approved plan and wants to compact first + CompactAndApprove { + request_id: Uuid, + }, /// Toggle plan mode (clicked PLAN badge) TogglePlanMode, /// Toggle auto-steal focus mode (clicked AUTO badge) @@ -808,6 +812,21 @@ impl<'a> DaveUi<'a> { } } + // Compact & Approve button (blue, no keybind) + let compact_response = super::badge::ActionButton::new( + "Compact & Approve", + egui::Color32::from_rgb(59, 130, 246), + button_text_color, + ) + .show(ui) + .on_hover_text("Compact context then start implementing"); + + if compact_response.clicked() { + action = Some(DaveAction::CompactAndApprove { + request_id: request.id, + }); + } + // Reject button (red) let reject_response = super::badge::ActionButton::new( "Reject", diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -743,5 +743,25 @@ pub fn handle_ui_action( UiActionResult::PublishPermissionResponse, ) } + DaveAction::CompactAndApprove { request_id } => { + update::exit_plan_mode(session_manager, backend, ctx); + let result = update::handle_permission_response( + session_manager, + request_id, + PermissionResponse::Allow { + message: Some("/compact".into()), + }, + ); + if let Some(session) = session_manager.get_active_mut() { + if let Some(agentic) = &mut session.agentic { + agentic.compact_and_proceed = + crate::session::CompactAndProceedState::WaitingForCompaction; + } + } + result.map_or( + UiActionResult::Handled, + UiActionResult::PublishPermissionResponse, + ) + } } }