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:
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