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:
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,
+ )
+ }
}
}