notedeck

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

commit 9806974fac0e1325d335c68213a11f8b37703a32
parent bd1f9de4c564d2d681c673af5a63eb206b8df53c
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 13 Feb 2026 13:52:30 -0800

dave: render plan view with markdown instead of plain text

Pre-parse plan content into MdElement at PermissionRequest
construction time so we avoid parsing every frame. Falls back
to plain text label if cached elements are unavailable.

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

Diffstat:
Mcrates/md-stream/src/parser.rs | 5+++++
Mcrates/notedeck_dave/src/backend/claude.rs | 14++++++++++++++
Mcrates/notedeck_dave/src/messages.rs | 2++
Mcrates/notedeck_dave/src/ui/dave.rs | 27+++++++++------------------
4 files changed, 30 insertions(+), 18 deletions(-)

diff --git a/crates/md-stream/src/parser.rs b/crates/md-stream/src/parser.rs @@ -60,6 +60,11 @@ impl StreamParser { &self.parsed } + /// Consume the parser and return the completed elements. + pub fn into_parsed(self) -> Vec<MdElement> { + self.parsed + } + /// Get the current partial state (for speculative rendering). pub fn partial(&self) -> Option<&Partial> { self.partial.as_ref() diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -341,12 +341,26 @@ async fn session_actor( let request_id = Uuid::new_v4(); let (ui_resp_tx, ui_resp_rx) = oneshot::channel(); + let cached_plan_elements = if perm_req.tool_name == "ExitPlanMode" { + perm_req.tool_input.get("plan") + .and_then(|v| v.as_str()) + .map(|plan| { + let mut parser = md_stream::StreamParser::new(); + parser.push(plan); + parser.finalize(); + parser.into_parsed() + }) + } else { + None + }; + let request = PermissionRequest { id: request_id, tool_name: perm_req.tool_name.clone(), tool_input: perm_req.tool_input.clone(), response: None, answer_summary: None, + cached_plan_elements, }; let pending = PendingPermission { diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs @@ -52,6 +52,8 @@ pub struct PermissionRequest { pub response: Option<PermissionResponseType>, /// For AskUserQuestion: pre-computed summary of answers for display pub answer_summary: Option<AnswerSummary>, + /// For ExitPlanMode: pre-parsed markdown elements from the plan content + pub cached_plan_elements: Option<Vec<MdElement>>, } /// A single entry in an answer summary diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -679,14 +679,6 @@ impl<'a> DaveUi<'a> { let inner_margin = 12.0; let corner_radius = 8.0; - // The plan content is in tool_input.plan field - let plan_content = request - .tool_input - .get("plan") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - egui::Frame::new() .fill(ui.visuals().widgets.noninteractive.bg_fill) .inner_margin(inner_margin) @@ -705,16 +697,15 @@ impl<'a> DaveUi<'a> { ui.add_space(8.0); - // Display the plan content as plain text (TODO: markdown rendering) - ui.add( - egui::Label::new( - egui::RichText::new(&plan_content) - .monospace() - .size(11.0) - .color(ui.visuals().text_color()), - ) - .wrap_mode(egui::TextWrapMode::Wrap), - ); + // Render plan content as markdown (pre-parsed at construction) + if let Some(elements) = &request.cached_plan_elements { + markdown_ui::render_assistant_message(elements, None, ui); + } else if let Some(plan) = + request.tool_input.get("plan").and_then(|v| v.as_str()) + { + // Fallback: render as plain text + ui.label(plan); + } ui.add_space(8.0);