commit bd455002d06724d096f5bc1202c91c5800c2f073
parent 557ea7225eb8b971fb1fd05277811e885ecec090
Author: William Casarin <jb55@jb55.com>
Date: Tue, 27 Jan 2026 08:43:28 -0800
dave: add plan mode toggle with Ctrl+P
Add per-session plan mode that can be toggled with Ctrl+P. When active,
Claude will plan actions without executing them. Shows a "PLAN MODE"
badge in the input area when enabled.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
7 files changed, 115 insertions(+), 0 deletions(-)
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -29,6 +29,11 @@ enum SessionCommand {
Interrupt {
ctx: egui::Context,
},
+ /// Set the permission mode (Default or Plan)
+ SetPermissionMode {
+ mode: PermissionMode,
+ ctx: egui::Context,
+ },
Shutdown,
}
@@ -243,6 +248,14 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver<
"Query already in progress".to_string()
));
}
+ SessionCommand::SetPermissionMode { mode, ctx: mode_ctx } => {
+ // Permission mode change during query - apply it
+ tracing::debug!("Session {} setting permission mode to {:?} during query", session_id, mode);
+ if let Err(err) = client.set_permission_mode(mode).await {
+ tracing::error!("Failed to set permission mode: {}", err);
+ }
+ mode_ctx.request_repaint();
+ }
SessionCommand::Shutdown => {
tracing::debug!("Session actor {} shutting down during query", session_id);
// Drop stream and disconnect - break to exit loop first
@@ -400,6 +413,13 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver<
);
ctx.request_repaint();
}
+ SessionCommand::SetPermissionMode { mode, ctx } => {
+ tracing::debug!("Session {} setting permission mode to {:?}", session_id, mode);
+ if let Err(err) = client.set_permission_mode(mode).await {
+ tracing::error!("Failed to set permission mode: {}", err);
+ }
+ ctx.request_repaint();
+ }
SessionCommand::Shutdown => {
tracing::debug!("Session actor {} shutting down", session_id);
break;
@@ -505,6 +525,25 @@ impl AiBackend for ClaudeBackend {
});
}
}
+
+ fn set_permission_mode(&self, session_id: String, mode: PermissionMode, ctx: egui::Context) {
+ if let Some(handle) = self.sessions.get(&session_id) {
+ let command_tx = handle.command_tx.clone();
+ tokio::spawn(async move {
+ if let Err(err) = command_tx
+ .send(SessionCommand::SetPermissionMode { mode, ctx })
+ .await
+ {
+ tracing::warn!("Failed to send set_permission_mode command: {}", err);
+ }
+ });
+ } else {
+ tracing::debug!(
+ "Session {} not active, permission mode will apply on next query",
+ session_id
+ );
+ }
+ }
}
/// Extract string content from a tool response, handling various JSON structures
diff --git a/crates/notedeck_dave/src/backend/openai.rs b/crates/notedeck_dave/src/backend/openai.rs
@@ -7,6 +7,7 @@ use async_openai::{
types::{ChatCompletionRequestMessage, CreateChatCompletionRequest},
Client,
};
+use claude_agent_sdk_rs::PermissionMode;
use futures::StreamExt;
use nostrdb::{Ndb, Transaction};
use std::collections::HashMap;
@@ -171,4 +172,9 @@ impl AiBackend for OpenAiBackend {
// OpenAI backend doesn't support interrupts - requests complete atomically
// The JoinHandle can be aborted from the session side if needed
}
+
+ fn set_permission_mode(&self, _session_id: String, _mode: PermissionMode, _ctx: egui::Context) {
+ // OpenAI backend doesn't support permission modes / plan mode
+ tracing::warn!("Plan mode is not supported with the OpenAI backend");
+ }
}
diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs
@@ -1,5 +1,6 @@
use crate::messages::DaveApiResponse;
use crate::tools::Tool;
+use claude_agent_sdk_rs::PermissionMode;
use std::collections::HashMap;
use std::sync::mpsc;
use std::sync::Arc;
@@ -37,4 +38,8 @@ pub trait AiBackend: Send + Sync {
/// Interrupt the current query for a session.
/// This stops any in-progress work but preserves the session history.
fn interrupt_session(&self, session_id: String, ctx: egui::Context);
+
+ /// Set the permission mode for a session.
+ /// Plan mode makes Claude plan actions without executing them.
+ fn set_permission_mode(&self, session_id: String, mode: PermissionMode, ctx: egui::Context);
}
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -12,6 +12,7 @@ mod ui;
mod vec3;
use backend::{AiBackend, BackendType, ClaudeBackend, OpenAiBackend};
+use claude_agent_sdk_rs::PermissionMode;
use chrono::{Duration, Local};
use egui_wgpu::RenderState;
use enostr::KeypairUnowned;
@@ -373,6 +374,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
// Render chat UI for selected session
let has_pending_permission =
!session.pending_permissions.is_empty();
+ let plan_mode_active =
+ session.permission_mode == PermissionMode::Plan;
let response = DaveUi::new(
self.model_config.trial,
&session.chat,
@@ -383,6 +386,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.is_working(is_working)
.interrupt_pending(interrupt_pending)
.has_pending_permission(has_pending_permission)
+ .plan_mode_active(plan_mode_active)
.ui(app_ctx, ui);
if response.action.is_some() {
@@ -482,6 +486,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
if let Some(session) = self.session_manager.get_active_mut() {
let is_working = session.status() == crate::agent_status::AgentStatus::Working;
let has_pending_permission = !session.pending_permissions.is_empty();
+ let plan_mode_active = session.permission_mode == PermissionMode::Plan;
DaveUi::new(
self.model_config.trial,
&session.chat,
@@ -491,6 +496,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.is_working(is_working)
.interrupt_pending(interrupt_pending)
.has_pending_permission(has_pending_permission)
+ .plan_mode_active(plan_mode_active)
.ui(app_ctx, ui)
} else {
DaveResponse::default()
@@ -548,6 +554,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
if let Some(session) = self.session_manager.get_active_mut() {
let is_working = session.status() == crate::agent_status::AgentStatus::Working;
let has_pending_permission = !session.pending_permissions.is_empty();
+ let plan_mode_active = session.permission_mode == PermissionMode::Plan;
DaveUi::new(
self.model_config.trial,
&session.chat,
@@ -557,6 +564,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
.is_working(is_working)
.interrupt_pending(interrupt_pending)
.has_pending_permission(has_pending_permission)
+ .plan_mode_active(plan_mode_active)
.ui(app_ctx, ui)
} else {
DaveResponse::default()
@@ -654,6 +662,29 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
}
+ /// Toggle plan mode for the active session
+ fn toggle_plan_mode(&mut self, ctx: &egui::Context) {
+ if let Some(session) = self.session_manager.get_active_mut() {
+ // Toggle between Plan and Default modes
+ let new_mode = match session.permission_mode {
+ PermissionMode::Plan => PermissionMode::Default,
+ _ => PermissionMode::Plan,
+ };
+ session.permission_mode = new_mode;
+
+ // Notify the backend
+ let session_id = format!("dave-session-{}", session.id);
+ self.backend
+ .set_permission_mode(session_id, new_mode, ctx.clone());
+
+ tracing::debug!(
+ "Toggled plan mode for session {} to {:?}",
+ session.id,
+ new_mode
+ );
+ }
+ }
+
/// Get the first pending permission request ID for the active session
fn first_pending_permission(&self) -> Option<uuid::Uuid> {
self.session_manager
@@ -867,6 +898,9 @@ impl notedeck::App for Dave {
KeyAction::ToggleView => {
self.show_scene = !self.show_scene;
}
+ KeyAction::TogglePlanMode => {
+ self.toggle_plan_mode(ui.ctx());
+ }
}
}
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -4,6 +4,7 @@ use std::sync::mpsc::Receiver;
use crate::agent_status::AgentStatus;
use crate::messages::PermissionResponse;
use crate::{DaveApiResponse, Message};
+use claude_agent_sdk_rs::PermissionMode;
use tokio::sync::oneshot;
use uuid::Uuid;
@@ -27,6 +28,8 @@ pub struct ChatSession {
cached_status: AgentStatus,
/// Whether this session's input should be focused on the next frame
pub focus_requested: bool,
+ /// Permission mode for Claude (Default or Plan)
+ pub permission_mode: PermissionMode,
}
impl Drop for ChatSession {
@@ -56,6 +59,7 @@ impl ChatSession {
scene_position: egui::Vec2::new(x, y),
cached_status: AgentStatus::Idle,
focus_requested: false,
+ permission_mode: PermissionMode::Default,
}
}
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -25,6 +25,7 @@ pub struct DaveUi<'a> {
interrupt_pending: bool,
has_pending_permission: bool,
focus_requested: &'a mut bool,
+ plan_mode_active: bool,
}
/// The response the app generates. The response contains an optional
@@ -102,6 +103,7 @@ impl<'a> DaveUi<'a> {
interrupt_pending: false,
has_pending_permission: false,
focus_requested,
+ plan_mode_active: false,
}
}
@@ -125,6 +127,11 @@ impl<'a> DaveUi<'a> {
self
}
+ pub fn plan_mode_active(mut self, plan_mode_active: bool) -> Self {
+ self.plan_mode_active = plan_mode_active;
+ self
+ }
+
fn chat_margin(&self, ctx: &egui::Context) -> i8 {
if self.compact || notedeck::ui::is_narrow(ctx) {
20
@@ -609,6 +616,19 @@ impl<'a> DaveUi<'a> {
dave_response = DaveResponse::send();
}
+ // Show plan mode indicator
+ if self.plan_mode_active {
+ ui.add(
+ egui::Label::new(
+ egui::RichText::new("PLAN MODE")
+ .color(egui::Color32::from_rgb(100, 149, 237)) // Cornflower blue
+ .strong(),
+ )
+ .selectable(false),
+ )
+ .on_hover_text("Ctrl+P to toggle plan mode");
+ }
+
let r = ui.add(
egui::TextEdit::multiline(self.input)
.desired_width(f32::INFINITY)
diff --git a/crates/notedeck_dave/src/ui/keybindings.rs b/crates/notedeck_dave/src/ui/keybindings.rs
@@ -19,6 +19,8 @@ pub enum KeyAction {
Interrupt,
/// Toggle between scene view and classic view
ToggleView,
+ /// Toggle plan mode for the active session
+ TogglePlanMode,
}
/// Check for keybinding actions.
@@ -57,6 +59,11 @@ pub fn check_keybindings(ctx: &egui::Context, has_pending_permission: bool) -> O
return Some(KeyAction::ToggleView);
}
+ // Ctrl+P to toggle plan mode
+ if ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::P)) {
+ return Some(KeyAction::TogglePlanMode);
+ }
+
// Ctrl+1-9 for switching agents (works even with text input focus)
// Check this BEFORE permission bindings so Ctrl+number always switches agents
if let Some(action) = ctx.input(|i| {