notedeck

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

commit 16bd4d80ff8d23542a752f948223113306aa6ee3
parent 808629c04ec25093fa5f2eba4d7f17b102c0d472
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 18 Feb 2026 12:36:22 -0800

make PLAN/AUTO badges clickable and move to status bar

The PLAN and AUTO toggle badges were not clickable (using hover sense)
and were placed in the input box area. This moves them to the git
status bar (right of the refresh button) and makes them clickable to
toggle plan mode and auto-steal focus mode respectively.

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

Diffstat:
Mcrates/notedeck_dave/src/lib.rs | 11+++++++++++
Mcrates/notedeck_dave/src/ui/badge.rs | 11++++++++++-
Mcrates/notedeck_dave/src/ui/dave.rs | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mcrates/notedeck_dave/src/ui/git_status_ui.rs | 209+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/notedeck_dave/src/ui/mod.rs | 10++++++++++
5 files changed, 256 insertions(+), 152 deletions(-)

diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -1845,6 +1845,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.pending_perm_responses.push(publish); None } + UiActionResult::ToggleAutoSteal => { + let new_state = crate::update::toggle_auto_steal( + &mut self.session_manager, + &mut self.scene, + self.show_scene, + self.auto_steal_focus, + &mut self.home_session, + ); + self.auto_steal_focus = new_state; + None + } UiActionResult::Handled => None, } } diff --git a/crates/notedeck_dave/src/ui/badge.rs b/crates/notedeck_dave/src/ui/badge.rs @@ -145,7 +145,7 @@ impl<'a> StatusBadge<'a> { let desired_size = Vec2::new(galley.size().x + keybind_extra, galley.size().y) + padding * 2.0; - let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover()); + let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); if ui.is_rect_visible(rect) { let painter = ui.painter(); @@ -153,6 +153,15 @@ impl<'a> StatusBadge<'a> { // Full pill rounding (half of height) let rounding = rect.height() / 2.0; + // Adjust background color based on hover/click state + let bg_color = if response.is_pointer_button_down_on() { + bg_color.gamma_multiply(1.8) + } else if response.hovered() { + bg_color.gamma_multiply(1.4) + } else { + bg_color + }; + // Background painter.rect_filled(rect, rounding, bg_color); diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -123,6 +123,10 @@ pub enum DaveAction { request_id: Uuid, approved: bool, }, + /// Toggle plan mode (clicked PLAN badge) + TogglePlanMode, + /// Toggle auto-steal focus mode (clicked AUTO badge) + ToggleAutoSteal, } impl<'a> DaveUi<'a> { @@ -252,7 +256,7 @@ impl<'a> DaveUi<'a> { let margin = self.chat_margin(ui.ctx()); let bottom_margin = 100; - let r = egui::Frame::new() + let mut r = egui::Frame::new() .outer_margin(egui::Margin { left: margin, right: margin, @@ -266,22 +270,40 @@ impl<'a> DaveUi<'a> { .inner; if let Some(git_status) = &mut self.git_status { + // Capture badge state before borrowing git_status + let plan_mode_active = self.plan_mode_active; + let auto_steal_focus = self.auto_steal_focus; + let is_agentic = self.ai_mode == AiMode::Agentic; + // Explicitly reserve height so bottom_up layout // keeps the chat ScrollArea from overlapping. let h = if git_status.expanded { 200.0 } else { 24.0 }; let w = ui.available_width(); - ui.allocate_ui(egui::vec2(w, h), |ui| { - egui::Frame::new() - .outer_margin(egui::Margin { - left: margin, - right: margin, - top: 4, - bottom: 0, - }) - .show(ui, |ui| { - git_status_ui::git_status_bar_ui(git_status, ui); - }); - }); + let badge_action = ui + .allocate_ui(egui::vec2(w, h), |ui| { + egui::Frame::new() + .outer_margin(egui::Margin { + left: margin, + right: margin, + top: 4, + bottom: 0, + }) + .show(ui, |ui| { + status_bar_ui( + git_status, + is_agentic, + plan_mode_active, + auto_steal_focus, + ui, + ) + }) + .inner + }) + .inner; + + if let Some(action) = badge_action { + r = DaveResponse::new(action).or(r); + } } let chat_response = egui::ScrollArea::vertical() @@ -1046,39 +1068,6 @@ impl<'a> DaveUi<'a> { dave_response = DaveResponse::send(); } - // Show plan mode and auto-steal indicators only in Agentic mode - if self.ai_mode == AiMode::Agentic { - let ctrl_held = ui.input(|i| i.modifiers.ctrl); - - // Plan mode indicator with optional keybind hint when Ctrl is held - let mut plan_badge = - super::badge::StatusBadge::new("PLAN").variant(if self.plan_mode_active { - super::badge::BadgeVariant::Info - } else { - super::badge::BadgeVariant::Default - }); - if ctrl_held { - plan_badge = plan_badge.keybind("M"); - } - plan_badge - .show(ui) - .on_hover_text("Ctrl+M to toggle plan mode"); - - // Auto-steal focus indicator - let mut auto_badge = - super::badge::StatusBadge::new("AUTO").variant(if self.auto_steal_focus { - super::badge::BadgeVariant::Info - } else { - super::badge::BadgeVariant::Default - }); - if ctrl_held { - auto_badge = auto_badge.keybind("\\"); - } - auto_badge - .show(ui) - .on_hover_text("Ctrl+\\ to toggle auto-focus mode"); - } - let r = ui.add( egui::TextEdit::multiline(self.input) .desired_width(f32::INFINITY) @@ -1149,3 +1138,89 @@ impl<'a> DaveUi<'a> { markdown_ui::render_assistant_message(elements, partial, buffer, ui); } } + +/// Renders the status bar containing git status and toggle badges. +fn status_bar_ui( + git_status: &mut GitStatusCache, + is_agentic: bool, + plan_mode_active: bool, + auto_steal_focus: bool, + ui: &mut egui::Ui, +) -> Option<DaveAction> { + let snapshot = git_status_ui::StatusSnapshot::from_cache(git_status); + + ui.vertical(|ui| { + let action = ui + .horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + + git_status_ui::git_status_content_ui(git_status, &snapshot, ui); + + // Right-aligned section: badges then refresh + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let action = if is_agentic { + toggle_badges_ui(ui, plan_mode_active, auto_steal_focus) + } else { + None + }; + + git_status_ui::git_refresh_button_ui(git_status, ui); + + action + }) + .inner + }) + .inner; + + git_status_ui::git_expanded_files_ui(git_status, &snapshot, ui); + + action + }) + .inner +} + +/// Render clickable PLAN and AUTO toggle badges. Returns an action if clicked. +fn toggle_badges_ui( + ui: &mut egui::Ui, + plan_mode_active: bool, + auto_steal_focus: bool, +) -> Option<DaveAction> { + let ctrl_held = ui.input(|i| i.modifiers.ctrl); + let mut action = None; + + // AUTO badge (rendered first in right-to-left, so it appears rightmost) + let mut auto_badge = super::badge::StatusBadge::new("AUTO").variant(if auto_steal_focus { + super::badge::BadgeVariant::Info + } else { + super::badge::BadgeVariant::Default + }); + if ctrl_held { + auto_badge = auto_badge.keybind("\\"); + } + if auto_badge + .show(ui) + .on_hover_text("Click or Ctrl+\\ to toggle auto-focus mode") + .clicked() + { + action = Some(DaveAction::ToggleAutoSteal); + } + + // PLAN badge + let mut plan_badge = super::badge::StatusBadge::new("PLAN").variant(if plan_mode_active { + super::badge::BadgeVariant::Info + } else { + super::badge::BadgeVariant::Default + }); + if ctrl_held { + plan_badge = plan_badge.keybind("M"); + } + if plan_badge + .show(ui) + .on_hover_text("Click or Ctrl+M to toggle plan mode") + .clicked() + { + action = Some(DaveAction::TogglePlanMode); + } + + action +} diff --git a/crates/notedeck_dave/src/ui/git_status_ui.rs b/crates/notedeck_dave/src/ui/git_status_ui.rs @@ -8,7 +8,7 @@ const UNTRACKED_COLOR: Color32 = Color32::from_rgb(128, 128, 128); /// Snapshot of git status data extracted from the cache to avoid /// borrow conflicts when mutating `cache.expanded`. -struct StatusSnapshot { +pub struct StatusSnapshot { branch: Option<String>, modified: usize, added: usize, @@ -19,7 +19,7 @@ struct StatusSnapshot { } impl StatusSnapshot { - fn from_cache(cache: &GitStatusCache) -> Option<Result<Self, ()>> { + pub fn from_cache(cache: &GitStatusCache) -> Option<Result<Self, ()>> { match cache.current() { Some(Ok(data)) => Some(Ok(StatusSnapshot { branch: data.branch.clone(), @@ -40,109 +40,6 @@ impl StatusSnapshot { } } -/// Render the git status bar. -pub fn git_status_bar_ui(cache: &mut GitStatusCache, ui: &mut Ui) { - // Snapshot data so we can freely mutate cache.expanded below - let snapshot = StatusSnapshot::from_cache(cache); - - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - - match &snapshot { - Some(Ok(snap)) => { - // Show expand arrow only when dirty - if !snap.is_clean { - let arrow = if cache.expanded { - "\u{25BC}" - } else { - "\u{25B6}" - }; - if ui - .add( - egui::Label::new(RichText::new(arrow).weak().monospace().size(9.0)) - .sense(egui::Sense::click()), - ) - .clicked() - { - cache.expanded = !cache.expanded; - } - } - - // Branch name - let branch_text = snap.branch.as_deref().unwrap_or("detached"); - ui.label(RichText::new(branch_text).weak().monospace().size(11.0)); - - if snap.is_clean { - ui.label(RichText::new("clean").weak().size(11.0)); - } else { - count_label(ui, "~", snap.modified, MODIFIED_COLOR); - count_label(ui, "+", snap.added, ADDED_COLOR); - count_label(ui, "-", snap.deleted, DELETED_COLOR); - count_label(ui, "?", snap.untracked, UNTRACKED_COLOR); - } - } - Some(Err(_)) => { - ui.label(RichText::new("git: not available").weak().size(11.0)); - } - None => { - ui.spinner(); - ui.label(RichText::new("checking git...").weak().size(11.0)); - } - } - - // Refresh button (right-aligned) - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .add( - egui::Label::new(RichText::new("\u{21BB}").weak().size(12.0)) - .sense(egui::Sense::click()), - ) - .on_hover_text("Refresh git status") - .clicked() - { - cache.request_refresh(); - } - }); - }); - - // Expanded file list - if cache.expanded { - if let Some(Ok(snap)) = &snapshot { - if !snap.files.is_empty() { - ui.add_space(4.0); - - egui::Frame::new() - .fill(ui.visuals().extreme_bg_color) - .inner_margin(egui::Margin::symmetric(8, 4)) - .corner_radius(4.0) - .show(ui, |ui| { - egui::ScrollArea::vertical() - .max_height(150.0) - .show(ui, |ui| { - for (status, path) in &snap.files { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 8.0; - let color = status_color(status); - ui.label( - RichText::new(status) - .monospace() - .size(11.0) - .color(color), - ); - ui.label( - RichText::new(path).monospace().size(11.0).weak(), - ); - }); - } - }); - }); - } - } - } - }); -} - fn count_label(ui: &mut Ui, prefix: &str, count: usize, color: Color32) { if count > 0 { ui.label( @@ -165,3 +62,105 @@ fn status_color(status: &str) -> Color32 { MODIFIED_COLOR } } + +/// Render the left-side git status content (expand arrow, branch, counts). +pub fn git_status_content_ui( + cache: &mut GitStatusCache, + snapshot: &Option<Result<StatusSnapshot, ()>>, + ui: &mut Ui, +) { + match snapshot { + Some(Ok(snap)) => { + // Show expand arrow only when dirty + if !snap.is_clean { + let arrow = if cache.expanded { + "\u{25BC}" + } else { + "\u{25B6}" + }; + if ui + .add( + egui::Label::new(RichText::new(arrow).weak().monospace().size(9.0)) + .sense(egui::Sense::click()), + ) + .clicked() + { + cache.expanded = !cache.expanded; + } + } + + // Branch name + let branch_text = snap.branch.as_deref().unwrap_or("detached"); + ui.label(RichText::new(branch_text).weak().monospace().size(11.0)); + + if snap.is_clean { + ui.label(RichText::new("clean").weak().size(11.0)); + } else { + count_label(ui, "~", snap.modified, MODIFIED_COLOR); + count_label(ui, "+", snap.added, ADDED_COLOR); + count_label(ui, "-", snap.deleted, DELETED_COLOR); + count_label(ui, "?", snap.untracked, UNTRACKED_COLOR); + } + } + Some(Err(_)) => { + ui.label(RichText::new("git: not available").weak().size(11.0)); + } + None => { + ui.spinner(); + ui.label(RichText::new("checking git...").weak().size(11.0)); + } + } +} + +/// Render the git refresh button. +pub fn git_refresh_button_ui(cache: &mut GitStatusCache, ui: &mut Ui) { + if ui + .add( + egui::Label::new(RichText::new("\u{21BB}").weak().size(12.0)) + .sense(egui::Sense::click()), + ) + .on_hover_text("Refresh git status") + .clicked() + { + cache.request_refresh(); + } +} + +/// Render the expanded file list portion of git status. +pub fn git_expanded_files_ui( + cache: &GitStatusCache, + snapshot: &Option<Result<StatusSnapshot, ()>>, + ui: &mut Ui, +) { + if cache.expanded { + if let Some(Ok(snap)) = snapshot { + if !snap.files.is_empty() { + ui.add_space(4.0); + + egui::Frame::new() + .fill(ui.visuals().extreme_bg_color) + .inner_margin(egui::Margin::symmetric(8, 4)) + .corner_radius(4.0) + .show(ui, |ui| { + egui::ScrollArea::vertical() + .max_height(150.0) + .show(ui, |ui| { + for (status, path) in &snap.files { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + let color = status_color(status); + ui.label( + RichText::new(status) + .monospace() + .size(11.0) + .color(color), + ); + ui.label(RichText::new(path).monospace().size(11.0).weak()); + }); + } + }); + }); + } + } + } +} diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -649,6 +649,8 @@ pub enum UiActionResult { AppAction(notedeck::AppAction), /// Permission response needs relay publishing. PublishPermissionResponse(update::PermissionPublish), + /// Toggle auto-steal focus mode (needs state from DaveApp) + ToggleAutoSteal, } /// Handle a UI action from DaveUi. @@ -704,6 +706,14 @@ pub fn handle_ui_action( UiActionResult::Handled, UiActionResult::PublishPermissionResponse, ), + DaveAction::TogglePlanMode => { + update::toggle_plan_mode(session_manager, backend, ctx); + if let Some(session) = session_manager.get_active_mut() { + session.focus_requested = true; + } + UiActionResult::Handled + } + DaveAction::ToggleAutoSteal => UiActionResult::ToggleAutoSteal, DaveAction::ExitPlanMode { request_id, approved,