notedeck

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

commit 0314273c6e7fb2556f45e638d7ba6af82727e4ed
parent 0293fb9abea757d241cd95f6a161a93a4a26a1e4
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 27 Jan 2026 14:39:22 -0800

dave: show summary view for answered AskUserQuestion

After answering an AskUserQuestion, display a compact summary showing
the question header(s) and selected answer(s) instead of just "Allowed".

Pre-computes the summary text at submission time via AnswerSummary struct
to avoid per-frame JSON deserialization and allocations.

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

Diffstat:
Mcrates/notedeck_dave/src/backend/claude.rs | 1+
Mcrates/notedeck_dave/src/lib.rs | 46++++++++++++++++++++++++++++++++++++----------
Mcrates/notedeck_dave/src/messages.rs | 17+++++++++++++++++
Mcrates/notedeck_dave/src/ui/ask_question.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 8+++++++-
Mcrates/notedeck_dave/src/ui/mod.rs | 2+-
Mtodos.txt | 4+++-
7 files changed, 103 insertions(+), 13 deletions(-)

diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -280,6 +280,7 @@ async fn session_actor(session_id: String, mut command_rx: tokio_mpsc::Receiver< tool_name: perm_req.tool_name.clone(), tool_input: perm_req.tool_input.clone(), response: None, + answer_summary: None, }; let pending = PendingPermission { diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -777,6 +777,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr /// Handle a user's response to an AskUserQuestion tool call fn handle_question_response(&mut self, request_id: uuid::Uuid, answers: Vec<QuestionAnswer>) { + use messages::{AnswerSummary, AnswerSummaryEntry}; + if let Some(session) = self.session_manager.get_active_mut() { // Find the original AskUserQuestion request to get the question labels let questions_input = session.chat.iter().find_map(|msg| { @@ -791,9 +793,12 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } }); - // Format answers as JSON for the tool response - let formatted_response = if let Some(questions) = questions_input { + // Format answers as JSON for the tool response, and build summary for display + let (formatted_response, answer_summary) = if let Some(ref questions) = questions_input + { let mut answers_obj = serde_json::Map::new(); + let mut summary_entries = Vec::with_capacity(questions.questions.len()); + for (q_idx, (question, answer)) in questions.questions.iter().zip(answers.iter()).enumerate() { @@ -810,16 +815,22 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr "selected".to_string(), serde_json::Value::Array( selected_labels - .into_iter() + .iter() + .cloned() .map(serde_json::Value::String) .collect(), ), ); + // Build display text for summary + let mut display_parts = selected_labels; if let Some(ref other) = answer.other_text { if !other.is_empty() { - answer_obj - .insert("other".to_string(), serde_json::Value::String(other.clone())); + answer_obj.insert( + "other".to_string(), + serde_json::Value::String(other.clone()), + ); + display_parts.push(format!("Other: {}", other)); } } @@ -829,27 +840,42 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } else { format!("question_{}", q_idx) }; - answers_obj.insert(key, serde_json::Value::Object(answer_obj)); + answers_obj.insert(key.clone(), serde_json::Value::Object(answer_obj)); + + summary_entries.push(AnswerSummaryEntry { + header: key, + answer: display_parts.join(", "), + }); } - serde_json::json!({ "answers": answers_obj }).to_string() + ( + serde_json::json!({ "answers": answers_obj }).to_string(), + Some(AnswerSummary { + entries: summary_entries, + }), + ) } else { // Fallback: just serialize the answers directly - serde_json::to_string(&answers).unwrap_or_else(|_| "{}".to_string()) + ( + serde_json::to_string(&answers).unwrap_or_else(|_| "{}".to_string()), + None, + ) }; - // Mark the request as allowed in the UI + // Mark the request as allowed in the UI and store the summary for display for msg in &mut session.chat { if let Message::PermissionRequest(req) = msg { if req.id == request_id { req.response = Some(messages::PermissionResponseType::Allowed); + req.answer_summary = answer_summary.clone(); break; } } } - // Clean up answer state + // Clean up transient answer state session.question_answers.remove(&request_id); + session.question_index.remove(&request_id); // Send the response through the permission channel // AskUserQuestion responses are sent as Allow with the formatted answers as the message diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs @@ -49,6 +49,23 @@ pub struct PermissionRequest { pub tool_input: serde_json::Value, /// The user's response (None if still pending) pub response: Option<PermissionResponseType>, + /// For AskUserQuestion: pre-computed summary of answers for display + pub answer_summary: Option<AnswerSummary>, +} + +/// A single entry in an answer summary +#[derive(Debug, Clone)] +pub struct AnswerSummaryEntry { + /// The question header (e.g., "Library", "Approach") + pub header: String, + /// The selected answer text, comma-separated if multiple + pub answer: String, +} + +/// Pre-computed summary of an AskUserQuestion response for display +#[derive(Debug, Clone)] +pub struct AnswerSummary { + pub entries: Vec<AnswerSummaryEntry>, } /// A permission request with the response channel (for channel communication) diff --git a/crates/notedeck_dave/src/ui/ask_question.rs b/crates/notedeck_dave/src/ui/ask_question.rs @@ -250,3 +250,41 @@ pub fn ask_user_question_ui( action } + +/// Render a compact summary of an answered AskUserQuestion +/// +/// Shows the question header(s) and selected answer(s) in a single line. +/// Uses pre-computed AnswerSummary to avoid per-frame allocations. +pub fn ask_user_question_summary_ui( + summary: &crate::messages::AnswerSummary, + ui: &mut egui::Ui, +) { + let inner_margin = 8.0; + let corner_radius = 6.0; + + egui::Frame::new() + .fill(ui.visuals().widgets.noninteractive.bg_fill) + .inner_margin(inner_margin) + .corner_radius(corner_radius) + .show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + for (idx, entry) in summary.entries.iter().enumerate() { + // Add separator between questions + if idx > 0 { + ui.separator(); + } + + // Header badge + badge::StatusBadge::new(&entry.header) + .variant(badge::BadgeVariant::Info) + .show(ui); + + // Pre-computed answer text + ui.label( + egui::RichText::new(&entry.answer) + .color(egui::Color32::from_rgb(100, 180, 100)), + ); + } + }); + }); +} diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -313,7 +313,13 @@ impl<'a> DaveUi<'a> { match request.response { Some(PermissionResponseType::Allowed) => { - // Responded state: Allowed + // Check if this is an answered AskUserQuestion with stored summary + if let Some(summary) = &request.answer_summary { + super::ask_user_question_summary_ui(summary, ui); + return None; + } + + // Responded state: Allowed (generic fallback) egui::Frame::new() .fill(ui.visuals().widgets.noninteractive.bg_fill) .inner_margin(inner_margin) diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -8,7 +8,7 @@ pub mod scene; pub mod session_list; mod settings; -pub use ask_question::ask_user_question_ui; +pub use ask_question::{ask_user_question_summary_ui, ask_user_question_ui}; pub use dave::{DaveAction, DaveResponse, DaveUi}; pub use keybind_hint::{keybind_hint, paint_keybind_hint}; pub use keybindings::{check_keybindings, KeyAction}; diff --git a/todos.txt b/todos.txt @@ -28,4 +28,6 @@ - [ ] handle ExitPlanMode which simply exits plan mode. claude-code sends this when its done its planning phase -- [ ] handle claude-code questions/answers (AskUserQuestion tool - could be a list of questions) +- [x] handle claude-code questions/answers (AskUserQuestion tool - could be a list of questions) + +- [x] AskUserQuestion: show a small summary view of the question and selected option(s) after answering