notedeck

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

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:
Mcrates/notedeck_dave/src/backend/claude.rs | 39+++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/backend/openai.rs | 6++++++
Mcrates/notedeck_dave/src/backend/traits.rs | 5+++++
Mcrates/notedeck_dave/src/lib.rs | 34++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 4++++
Mcrates/notedeck_dave/src/ui/dave.rs | 20++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/keybindings.rs | 7+++++++
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| {