notedeck

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

dave.rs (67793B)


      1 use super::badge::{BadgeVariant, StatusBadge};
      2 use super::diff;
      3 use super::git_status_ui;
      4 use super::markdown_ui;
      5 use super::query_ui::query_call_ui;
      6 use super::top_buttons::top_buttons_ui;
      7 use crate::{
      8     backend::BackendType,
      9     config::{AiMode, DaveSettings},
     10     file_update::FileUpdate,
     11     focus_queue::FocusPriority,
     12     git_status::GitStatusCache,
     13     messages::{
     14         AskUserQuestionInput, AssistantMessage, CompactionInfo, ExecutedTool, Message,
     15         PermissionRequest, PermissionResponse, PermissionResponseType, QuestionAnswer,
     16         SubagentInfo, SubagentStatus,
     17     },
     18     session::{PermissionMessageState, SessionDetails, SessionId},
     19     tools::{PresentNotesCall, ToolCall, ToolCalls, ToolResponse, ToolResponses},
     20 };
     21 use bitflags::bitflags;
     22 use claude_agent_sdk_rs::PermissionMode;
     23 use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
     24 use nostrdb::Transaction;
     25 use notedeck::{tr, AppContext, Localization, NoteAction, NoteContext};
     26 use notedeck_ui::{icons::search_icon, NoteOptions};
     27 use std::collections::HashMap;
     28 use uuid::Uuid;
     29 
     30 bitflags! {
     31     #[repr(transparent)]
     32     #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
     33     pub struct DaveUiFlags: u16 {
     34         const Trial            = 1 << 0;
     35         const Compact          = 1 << 1;
     36         const IsWorking        = 1 << 2;
     37         const InterruptPending = 1 << 3;
     38         const HasPendingPerm   = 1 << 4;
     39         const IsCompacting     = 1 << 5;
     40         const AutoStealFocus   = 1 << 6;
     41         const IsRemote         = 1 << 7;
     42     }
     43 }
     44 
     45 /// DaveUi holds all of the data it needs to render itself
     46 pub struct DaveUi<'a> {
     47     chat: &'a [Message],
     48     flags: DaveUiFlags,
     49     input: &'a mut String,
     50     focus_requested: &'a mut bool,
     51     /// Session ID for per-session scroll state
     52     session_id: SessionId,
     53     /// State for tentative permission response (waiting for message)
     54     permission_message_state: PermissionMessageState,
     55     /// State for AskUserQuestion responses (selected options per question)
     56     question_answers: Option<&'a mut HashMap<Uuid, Vec<QuestionAnswer>>>,
     57     /// Current question index for multi-question AskUserQuestion
     58     question_index: Option<&'a mut HashMap<Uuid, usize>>,
     59     /// AI interaction mode (Chat vs Agentic)
     60     ai_mode: AiMode,
     61     /// Git status cache for current session (agentic only)
     62     git_status: Option<&'a mut GitStatusCache>,
     63     /// Session details for header display
     64     details: Option<&'a SessionDetails>,
     65     /// Color for the notification dot on the mobile hamburger icon,
     66     /// derived from FocusPriority of the next focus queue entry.
     67     status_dot_color: Option<egui::Color32>,
     68     /// Usage metrics for the current session (tokens, cost)
     69     usage: Option<&'a crate::messages::UsageInfo>,
     70     /// Context window size for the current model
     71     context_window: u64,
     72     /// Dispatch lifecycle state, used for queued indicator logic.
     73     dispatch_state: crate::session::DispatchState,
     74     /// Which backend this session uses
     75     backend_type: BackendType,
     76     /// Current permission mode (Default, Plan, AcceptEdits)
     77     permission_mode: PermissionMode,
     78     /// When the last AI response token was received
     79     last_activity: Option<std::time::Instant>,
     80     /// Focus queue info for mobile NEXT badge: (position, total, priority)
     81     focus_queue_info: Option<(usize, usize, FocusPriority)>,
     82 }
     83 
     84 /// The response the app generates. The response contains an optional
     85 /// action to take.
     86 #[derive(Default, Debug)]
     87 pub struct DaveResponse {
     88     pub action: Option<DaveAction>,
     89 }
     90 
     91 impl DaveResponse {
     92     pub fn new(action: DaveAction) -> Self {
     93         DaveResponse {
     94             action: Some(action),
     95         }
     96     }
     97 
     98     fn note(action: NoteAction) -> DaveResponse {
     99         Self::new(DaveAction::Note(action))
    100     }
    101 
    102     pub fn or(self, r: DaveResponse) -> DaveResponse {
    103         DaveResponse {
    104             action: self.action.or(r.action),
    105         }
    106     }
    107 
    108     /// Generate a send response to the controller
    109     fn send() -> Self {
    110         Self::new(DaveAction::Send)
    111     }
    112 
    113     fn none() -> Self {
    114         DaveResponse::default()
    115     }
    116 }
    117 
    118 /// The actions the app generates. No default action is specfied in the
    119 /// UI code. This is handled by the app logic, however it chooses to
    120 /// process this message.
    121 #[derive(Debug)]
    122 pub enum DaveAction {
    123     /// The action generated when the user sends a message to dave
    124     Send,
    125     NewChat,
    126     ToggleChrome,
    127     Note(NoteAction),
    128     /// Toggle showing the session list (for mobile navigation)
    129     ShowSessionList,
    130     /// Open the settings panel
    131     OpenSettings,
    132     /// Settings were updated and should be persisted
    133     UpdateSettings(DaveSettings),
    134     /// User responded to a permission request
    135     PermissionResponse {
    136         request_id: Uuid,
    137         response: PermissionResponse,
    138     },
    139     /// User wants to interrupt/stop the current AI operation
    140     Interrupt,
    141     /// Enter tentative accept mode (Shift+click on Yes)
    142     TentativeAccept,
    143     /// Enter tentative deny mode (Shift+click on No)
    144     TentativeDeny,
    145     /// Allow always — add to session allowlist and accept
    146     AllowAlways {
    147         request_id: Uuid,
    148     },
    149     /// Tentative allow always — add to session allowlist, enter message mode
    150     TentativeAllowAlways,
    151     /// User responded to an AskUserQuestion
    152     QuestionResponse {
    153         request_id: Uuid,
    154         answers: Vec<QuestionAnswer>,
    155     },
    156     /// User approved or rejected an ExitPlanMode request
    157     ExitPlanMode {
    158         request_id: Uuid,
    159         approved: bool,
    160     },
    161     /// User approved plan and wants to compact first
    162     CompactAndApprove {
    163         request_id: Uuid,
    164     },
    165     /// Cycle permission mode: Default → Plan → AcceptEdits (clicked mode badge)
    166     CyclePermissionMode,
    167     /// Toggle auto-steal focus mode (clicked AUTO badge)
    168     ToggleAutoSteal,
    169     /// Trigger manual context compaction
    170     Compact,
    171     /// Navigate to the next focus queue item (mobile)
    172     FocusQueueNext,
    173 }
    174 
    175 impl<'a> DaveUi<'a> {
    176     pub fn new(
    177         trial: bool,
    178         session_id: SessionId,
    179         chat: &'a [Message],
    180         input: &'a mut String,
    181         focus_requested: &'a mut bool,
    182         ai_mode: AiMode,
    183     ) -> Self {
    184         let flags = if trial {
    185             DaveUiFlags::Trial
    186         } else {
    187             DaveUiFlags::empty()
    188         };
    189         DaveUi {
    190             flags,
    191             session_id,
    192             chat,
    193             input,
    194             focus_requested,
    195             permission_message_state: PermissionMessageState::None,
    196             question_answers: None,
    197             question_index: None,
    198             ai_mode,
    199             git_status: None,
    200             details: None,
    201             status_dot_color: None,
    202             usage: None,
    203             context_window: crate::messages::context_window_for_model(None),
    204             dispatch_state: crate::session::DispatchState::default(),
    205             backend_type: BackendType::Remote,
    206             permission_mode: PermissionMode::Default,
    207             last_activity: None,
    208             focus_queue_info: None,
    209         }
    210     }
    211 
    212     pub fn last_activity(mut self, instant: Option<std::time::Instant>) -> Self {
    213         self.last_activity = instant;
    214         self
    215     }
    216 
    217     pub fn backend_type(mut self, bt: BackendType) -> Self {
    218         self.backend_type = bt;
    219         self
    220     }
    221 
    222     pub fn details(mut self, details: &'a SessionDetails) -> Self {
    223         self.details = Some(details);
    224         self
    225     }
    226 
    227     pub fn permission_message_state(mut self, state: PermissionMessageState) -> Self {
    228         self.permission_message_state = state;
    229         self
    230     }
    231 
    232     pub fn question_answers(mut self, answers: &'a mut HashMap<Uuid, Vec<QuestionAnswer>>) -> Self {
    233         self.question_answers = Some(answers);
    234         self
    235     }
    236 
    237     pub fn question_index(mut self, index: &'a mut HashMap<Uuid, usize>) -> Self {
    238         self.question_index = Some(index);
    239         self
    240     }
    241 
    242     pub fn compact(mut self, val: bool) -> Self {
    243         self.flags.set(DaveUiFlags::Compact, val);
    244         self
    245     }
    246 
    247     pub fn is_working(mut self, val: bool) -> Self {
    248         self.flags.set(DaveUiFlags::IsWorking, val);
    249         self
    250     }
    251 
    252     pub fn dispatch_state(mut self, state: crate::session::DispatchState) -> Self {
    253         self.dispatch_state = state;
    254         self
    255     }
    256 
    257     pub fn interrupt_pending(mut self, val: bool) -> Self {
    258         self.flags.set(DaveUiFlags::InterruptPending, val);
    259         self
    260     }
    261 
    262     pub fn has_pending_permission(mut self, val: bool) -> Self {
    263         self.flags.set(DaveUiFlags::HasPendingPerm, val);
    264         self
    265     }
    266 
    267     pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
    268         self.permission_mode = mode;
    269         self
    270     }
    271 
    272     pub fn is_compacting(mut self, val: bool) -> Self {
    273         self.flags.set(DaveUiFlags::IsCompacting, val);
    274         self
    275     }
    276 
    277     /// Set the git status cache. Mutable because the UI toggles
    278     /// expand/collapse and triggers refresh on button click.
    279     pub fn git_status(mut self, cache: &'a mut GitStatusCache) -> Self {
    280         self.git_status = Some(cache);
    281         self
    282     }
    283 
    284     pub fn auto_steal_focus(mut self, val: bool) -> Self {
    285         self.flags.set(DaveUiFlags::AutoStealFocus, val);
    286         self
    287     }
    288 
    289     pub fn is_remote(mut self, val: bool) -> Self {
    290         self.flags.set(DaveUiFlags::IsRemote, val);
    291         self
    292     }
    293 
    294     pub fn status_dot_color(mut self, color: Option<egui::Color32>) -> Self {
    295         self.status_dot_color = color;
    296         self
    297     }
    298 
    299     pub fn focus_queue_info(mut self, info: Option<(usize, usize, FocusPriority)>) -> Self {
    300         self.focus_queue_info = info;
    301         self
    302     }
    303 
    304     pub fn usage(mut self, usage: &'a crate::messages::UsageInfo, model: Option<&str>) -> Self {
    305         self.usage = Some(usage);
    306         self.context_window = crate::messages::context_window_for_model(model);
    307         self
    308     }
    309 
    310     fn chat_margin(&self, ctx: &egui::Context) -> i8 {
    311         if self.flags.contains(DaveUiFlags::Compact) || notedeck::ui::is_narrow(ctx) {
    312             8
    313         } else {
    314             20
    315         }
    316     }
    317 
    318     fn chat_frame(&self, ctx: &egui::Context) -> egui::Frame {
    319         let margin = self.chat_margin(ctx);
    320         egui::Frame::new().inner_margin(egui::Margin {
    321             left: margin,
    322             right: margin,
    323             top: 50,
    324             bottom: 0,
    325         })
    326     }
    327 
    328     /// The main render function. Call this to render Dave
    329     pub fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
    330         // Override Truncate wrap mode that StripBuilder sets when clip=true
    331         ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
    332 
    333         let is_compact = self.flags.contains(DaveUiFlags::Compact);
    334 
    335         // Skip top buttons in compact mode (scene panel has its own controls)
    336         let action = if is_compact {
    337             None
    338         } else {
    339             let result = top_buttons_ui(app_ctx, ui, self.status_dot_color);
    340 
    341             // Render session details inline, to the right of the buttons
    342             if let Some(details) = self.details {
    343                 let available_width = ui.available_width();
    344                 let max_width = available_width - result.right_edge_x;
    345                 if max_width > 50.0 {
    346                     let details_rect = egui::Rect::from_min_size(
    347                         egui::pos2(result.right_edge_x, result.y),
    348                         egui::vec2(max_width, 32.0),
    349                     );
    350                     ui.allocate_new_ui(egui::UiBuilder::new().max_rect(details_rect), |ui| {
    351                         ui.set_clip_rect(details_rect);
    352                         session_header_ui(ui, details, self.backend_type);
    353                     });
    354                 }
    355             }
    356 
    357             result.action
    358         };
    359 
    360         egui::Frame::NONE
    361             .show(ui, |ui| {
    362                 ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
    363                     let margin = self.chat_margin(ui.ctx());
    364                     let bottom_margin = 100;
    365 
    366                     let mut r = egui::Frame::new()
    367                         .outer_margin(egui::Margin {
    368                             left: margin,
    369                             right: margin,
    370                             top: 0,
    371                             bottom: bottom_margin,
    372                         })
    373                         .inner_margin(egui::Margin::same(8))
    374                         .fill(ui.visuals().extreme_bg_color)
    375                         .corner_radius(12.0)
    376                         .show(ui, |ui| self.inputbox(app_ctx, ui))
    377                         .inner;
    378 
    379                     {
    380                         let permission_mode = self.permission_mode;
    381                         let auto_steal_focus = self.flags.contains(DaveUiFlags::AutoStealFocus);
    382                         let is_agentic = self.ai_mode == AiMode::Agentic;
    383                         let has_git = self.git_status.is_some();
    384 
    385                         // Show status bar when there's git status or badges to display
    386                         if has_git || is_agentic {
    387                             // Explicitly reserve height so bottom_up layout
    388                             // keeps the chat ScrollArea from overlapping.
    389                             let h = if self.git_status.as_ref().is_some_and(|gs| gs.expanded) {
    390                                 200.0
    391                             } else {
    392                                 24.0
    393                             };
    394                             let w = ui.available_width();
    395                             let badge_action = ui
    396                                 .allocate_ui(egui::vec2(w, h), |ui| {
    397                                     egui::Frame::new()
    398                                         .outer_margin(egui::Margin {
    399                                             left: margin,
    400                                             right: margin,
    401                                             top: 4,
    402                                             bottom: 0,
    403                                         })
    404                                         .show(ui, |ui| {
    405                                             status_bar_ui(
    406                                                 self.git_status.as_deref_mut(),
    407                                                 is_agentic,
    408                                                 permission_mode,
    409                                                 auto_steal_focus,
    410                                                 self.focus_queue_info,
    411                                                 self.usage,
    412                                                 self.context_window,
    413                                                 self.last_activity,
    414                                                 ui,
    415                                             )
    416                                         })
    417                                         .inner
    418                                 })
    419                                 .inner;
    420 
    421                             if let Some(action) = badge_action {
    422                                 r = DaveResponse::new(action).or(r);
    423                             }
    424                         }
    425                     }
    426 
    427                     let chat_response = egui::ScrollArea::vertical()
    428                         .id_salt(("dave_chat_scroll", self.session_id))
    429                         .stick_to_bottom(true)
    430                         .auto_shrink([false; 2])
    431                         .show(ui, |ui| {
    432                             self.chat_frame(ui.ctx())
    433                                 .show(ui, |ui| {
    434                                     ui.vertical(|ui| self.render_chat(app_ctx, ui)).inner
    435                                 })
    436                                 .inner
    437                         })
    438                         .inner;
    439 
    440                     chat_response.or(r)
    441                 })
    442                 .inner
    443             })
    444             .inner
    445             .or(DaveResponse { action })
    446     }
    447 
    448     fn error_chat(&self, i18n: &mut Localization, err: &str, ui: &mut egui::Ui) {
    449         if self.flags.contains(DaveUiFlags::Trial) {
    450             ui.add(egui::Label::new(
    451                 egui::RichText::new(
    452                     tr!(i18n, "The Dave Nostr AI assistant trial has ended :(. Thanks for testing! Zap-enabled Dave coming soon!", "Message shown when Dave trial period has ended"),
    453                 )
    454                 .weak(),
    455             ));
    456         } else {
    457             ui.add(egui::Label::new(
    458                 egui::RichText::new(format!("An error occured: {err}")).weak(),
    459             ));
    460         }
    461     }
    462 
    463     /// Render a chat message (user, assistant, tool call/response, etc)
    464     fn render_chat(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
    465         let mut response = DaveResponse::default();
    466         let is_agentic = self.ai_mode == AiMode::Agentic;
    467 
    468         // Find where queued (not-yet-dispatched) user messages start.
    469         // When streaming, append_token inserts an Assistant between the
    470         // dispatched User and any queued Users, so all trailing Users
    471         // after that Assistant are queued. Before the first token arrives
    472         // there's no Assistant yet, so we skip the dispatched count
    473         // trailing Users (they were all sent in the prompt).
    474         let queued_from = if self.flags.contains(DaveUiFlags::IsWorking) {
    475             let last_non_user = self
    476                 .chat
    477                 .iter()
    478                 .rposition(|m| !matches!(m, Message::User(_)));
    479             match last_non_user {
    480                 Some(i) if matches!(self.chat[i], Message::Assistant(ref m) if m.is_streaming()) => {
    481                     // Streaming assistant separates dispatched from queued
    482                     let first_trailing = i + 1;
    483                     if first_trailing < self.chat.len() {
    484                         Some(first_trailing)
    485                     } else {
    486                         None
    487                     }
    488                 }
    489                 Some(i) => {
    490                     // No streaming assistant yet — skip past the dispatched
    491                     // user messages (1 for single dispatch, N for batch)
    492                     let first_trailing = i + 1;
    493                     let skip = self.dispatch_state.dispatched_count().max(1);
    494                     let queued_start = first_trailing + skip;
    495                     if queued_start < self.chat.len() {
    496                         Some(queued_start)
    497                     } else {
    498                         None
    499                     }
    500                 }
    501                 None => None,
    502             }
    503         } else {
    504             None
    505         };
    506 
    507         for (i, message) in self.chat.iter().enumerate() {
    508             match message {
    509                 Message::Error(err) => {
    510                     self.error_chat(ctx.i18n, err, ui);
    511                 }
    512                 Message::User(msg) => {
    513                     let is_queued = queued_from.is_some_and(|qi| i >= qi);
    514                     self.user_chat(msg, is_queued, ui);
    515                 }
    516                 Message::Assistant(msg) => {
    517                     self.assistant_chat(msg, ui);
    518                 }
    519                 Message::ToolResponse(msg) => {
    520                     Self::tool_response_ui(msg, is_agentic, ui);
    521                 }
    522                 Message::System(_msg) => {
    523                     // system prompt is not rendered. Maybe we could
    524                     // have a debug option to show this
    525                 }
    526                 Message::ToolCalls(toolcalls) => {
    527                     if let Some(note_action) = Self::tool_calls_ui(ctx, toolcalls, ui) {
    528                         response = DaveResponse::note(note_action);
    529                     }
    530                 }
    531                 Message::PermissionRequest(request) => {
    532                     // Permission requests only in Agentic mode
    533                     if is_agentic {
    534                         if let Some(action) = self.permission_request_ui(request, ui) {
    535                             response = DaveResponse::new(action);
    536                         }
    537                     }
    538                 }
    539                 Message::CompactionComplete(info) => {
    540                     // Compaction only in Agentic mode
    541                     if is_agentic {
    542                         Self::compaction_complete_ui(info, ui);
    543                     }
    544                 }
    545                 Message::Subagent(info) => {
    546                     // Subagents only in Agentic mode
    547                     if is_agentic {
    548                         Self::subagent_ui(info, ui);
    549                     }
    550                 }
    551             };
    552         }
    553 
    554         // Show status line at the bottom of chat when working or compacting
    555         let status_text = if is_agentic && self.flags.contains(DaveUiFlags::IsCompacting) {
    556             Some("compacting...")
    557         } else if self.flags.contains(DaveUiFlags::IsWorking) {
    558             Some("computing...")
    559         } else {
    560             None
    561         };
    562 
    563         if let Some(status) = status_text {
    564             ui.horizontal(|ui| {
    565                 ui.add(egui::Spinner::new().size(14.0));
    566                 ui.label(
    567                     egui::RichText::new(status)
    568                         .color(ui.visuals().weak_text_color())
    569                         .italics(),
    570                 );
    571                 // Don't show interrupt hint for remote sessions
    572                 if !self.flags.contains(DaveUiFlags::IsRemote) {
    573                     ui.label(
    574                         egui::RichText::new("(press esc to interrupt)")
    575                             .color(ui.visuals().weak_text_color())
    576                             .small(),
    577                     );
    578                 }
    579             });
    580         }
    581 
    582         response
    583     }
    584 
    585     fn tool_response_ui(tool_response: &ToolResponse, is_agentic: bool, ui: &mut egui::Ui) {
    586         match tool_response.responses() {
    587             ToolResponses::ExecutedTool(result) => {
    588                 if is_agentic {
    589                     Self::executed_tool_ui(result, ui);
    590                 }
    591             }
    592             _ => {
    593                 //ui.label(format!("tool_response: {:?}", tool_response));
    594             }
    595         }
    596     }
    597 
    598     /// Render a permission request with Allow/Deny buttons or response state
    599     fn permission_request_ui(
    600         &mut self,
    601         request: &PermissionRequest,
    602         ui: &mut egui::Ui,
    603     ) -> Option<DaveAction> {
    604         let mut action = None;
    605 
    606         let inner_margin = 8.0;
    607         let corner_radius = 6.0;
    608         let spacing_x = 8.0;
    609 
    610         ui.spacing_mut().item_spacing.x = spacing_x;
    611 
    612         match request.response {
    613             Some(PermissionResponseType::Allowed) => {
    614                 // Check if this is an answered AskUserQuestion with stored summary
    615                 if let Some(summary) = &request.answer_summary {
    616                     super::ask_user_question_summary_ui(summary, ui);
    617                     return None;
    618                 }
    619 
    620                 // Responded state: Allowed (generic fallback)
    621                 egui::Frame::new()
    622                     .fill(ui.visuals().widgets.noninteractive.bg_fill)
    623                     .inner_margin(inner_margin)
    624                     .corner_radius(corner_radius)
    625                     .show(ui, |ui| {
    626                         ui.horizontal(|ui| {
    627                             ui.label(
    628                                 egui::RichText::new("Allowed")
    629                                     .color(egui::Color32::from_rgb(100, 180, 100))
    630                                     .strong(),
    631                             );
    632                             ui.label(
    633                                 egui::RichText::new(&request.tool_name)
    634                                     .color(ui.visuals().text_color()),
    635                             );
    636                         });
    637                     });
    638             }
    639             Some(PermissionResponseType::Denied) => {
    640                 // Responded state: Denied
    641                 egui::Frame::new()
    642                     .fill(ui.visuals().widgets.noninteractive.bg_fill)
    643                     .inner_margin(inner_margin)
    644                     .corner_radius(corner_radius)
    645                     .show(ui, |ui| {
    646                         ui.horizontal(|ui| {
    647                             ui.label(
    648                                 egui::RichText::new("Denied")
    649                                     .color(egui::Color32::from_rgb(200, 100, 100))
    650                                     .strong(),
    651                             );
    652                             ui.label(
    653                                 egui::RichText::new(&request.tool_name)
    654                                     .color(ui.visuals().text_color()),
    655                             );
    656                         });
    657                     });
    658             }
    659             None => {
    660                 // Check if this is an ExitPlanMode tool call
    661                 if request.tool_name == "ExitPlanMode" {
    662                     return self.exit_plan_mode_ui(request, ui);
    663                 }
    664 
    665                 // Check if this is an AskUserQuestion tool call
    666                 if request.tool_name == "AskUserQuestion" {
    667                     if let Ok(questions) =
    668                         serde_json::from_value::<AskUserQuestionInput>(request.tool_input.clone())
    669                     {
    670                         if let (Some(answers_map), Some(index_map)) =
    671                             (&mut self.question_answers, &mut self.question_index)
    672                         {
    673                             return super::ask_user_question_ui(
    674                                 request,
    675                                 &questions,
    676                                 answers_map,
    677                                 index_map,
    678                                 ui,
    679                             );
    680                         }
    681                     }
    682                 }
    683 
    684                 // Check if this is a file update (Edit or Write tool)
    685                 if let Some(file_update) =
    686                     FileUpdate::from_tool_call(&request.tool_name, &request.tool_input)
    687                 {
    688                     // Render file update with diff view
    689                     egui::Frame::new()
    690                         .fill(ui.visuals().widgets.noninteractive.bg_fill)
    691                         .inner_margin(inner_margin)
    692                         .corner_radius(corner_radius)
    693                         .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
    694                         .show(ui, |ui| {
    695                             // Header with file path
    696                             diff::file_path_header(&file_update, ui);
    697 
    698                             // Diff view (expand context only for local sessions)
    699                             let is_local = !self.flags.contains(DaveUiFlags::IsRemote);
    700                             diff::file_update_ui(&file_update, is_local, ui);
    701 
    702                             // Approve/deny buttons at the bottom left
    703                             ui.horizontal(|ui| {
    704                                 self.permission_buttons(request, ui, &mut action);
    705                             });
    706                         });
    707                 } else {
    708                     // Parse tool input for display (existing logic)
    709                     let obj = request.tool_input.as_object();
    710                     let description = obj
    711                         .and_then(|o| o.get("description"))
    712                         .and_then(|v| v.as_str());
    713                     let command = obj.and_then(|o| o.get("command")).and_then(|v| v.as_str());
    714                     let single_value = obj
    715                         .filter(|o| o.len() == 1)
    716                         .and_then(|o| o.values().next())
    717                         .and_then(|v| v.as_str());
    718 
    719                     // Pending state: Show Allow/Deny buttons
    720                     egui::Frame::new()
    721                         .fill(ui.visuals().widgets.noninteractive.bg_fill)
    722                         .inner_margin(inner_margin)
    723                         .corner_radius(corner_radius)
    724                         .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
    725                         .show(ui, |ui| {
    726                             // Tool info display
    727                             if let Some(desc) = description {
    728                                 // Format: ToolName: description
    729                                 ui.horizontal(|ui| {
    730                                     ui.label(egui::RichText::new(&request.tool_name).strong());
    731                                     ui.label(desc);
    732                                 });
    733                                 // Command on next line if present
    734                                 if let Some(cmd) = command {
    735                                     ui.add(
    736                                         egui::Label::new(egui::RichText::new(cmd).monospace())
    737                                             .wrap_mode(egui::TextWrapMode::Wrap),
    738                                     );
    739                                 }
    740                             } else if let Some(value) = single_value {
    741                                 // Format: ToolName `value`
    742                                 ui.horizontal(|ui| {
    743                                     ui.label(egui::RichText::new(&request.tool_name).strong());
    744                                     ui.label(egui::RichText::new(value).monospace());
    745                                 });
    746                             } else {
    747                                 // Fallback: show JSON
    748                                 ui.label(egui::RichText::new(&request.tool_name).strong());
    749                                 let formatted = serde_json::to_string_pretty(&request.tool_input)
    750                                     .unwrap_or_else(|_| request.tool_input.to_string());
    751                                 ui.add(
    752                                     egui::Label::new(
    753                                         egui::RichText::new(formatted).monospace().size(11.0),
    754                                     )
    755                                     .wrap_mode(egui::TextWrapMode::Wrap),
    756                                 );
    757                             }
    758 
    759                             // Buttons on their own line
    760                             ui.horizontal(|ui| {
    761                                 self.permission_buttons(request, ui, &mut action);
    762                             });
    763                         });
    764                 }
    765             }
    766         }
    767 
    768         action
    769     }
    770 
    771     /// Render Allow/Deny buttons aligned to the right with keybinding hints
    772     fn permission_buttons(
    773         &self,
    774         request: &PermissionRequest,
    775         ui: &mut egui::Ui,
    776         action: &mut Option<DaveAction>,
    777     ) {
    778         let shift_held = ui.input(|i| i.modifiers.shift);
    779         let in_tentative = self.permission_message_state != PermissionMessageState::None;
    780 
    781         ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
    782             if in_tentative {
    783                 tentative_send_ui(self.permission_message_state, "Allow", "Deny", ui, action);
    784             } else {
    785                 let button_text_color = ui.visuals().widgets.active.fg_stroke.color;
    786 
    787                 // Allow button (green) with integrated keybind hint
    788                 let allow_response = super::badge::ActionButton::new(
    789                     "Allow",
    790                     egui::Color32::from_rgb(34, 139, 34),
    791                     button_text_color,
    792                 )
    793                 .keybind("1")
    794                 .show(ui)
    795                 .on_hover_text("Press 1 to allow, Shift+1 to allow with message");
    796 
    797                 // Deny button (red) with integrated keybind hint
    798                 let deny_response = super::badge::ActionButton::new(
    799                     "Deny",
    800                     egui::Color32::from_rgb(178, 34, 34),
    801                     button_text_color,
    802                 )
    803                 .keybind("2")
    804                 .show(ui)
    805                 .on_hover_text("Press 2 to deny, Shift+2 to deny with message");
    806 
    807                 // Always button (blue) — allow and don't ask again this session
    808                 let always_response = super::badge::ActionButton::new(
    809                     "Always",
    810                     egui::Color32::from_rgb(30, 100, 180),
    811                     button_text_color,
    812                 )
    813                 .keybind("3")
    814                 .show(ui)
    815                 .on_hover_text("Press 3 to allow always for this session, Shift+3 with message");
    816 
    817                 if deny_response.clicked() {
    818                     if shift_held {
    819                         *action = Some(DaveAction::TentativeDeny);
    820                     } else {
    821                         *action = Some(DaveAction::PermissionResponse {
    822                             request_id: request.id,
    823                             response: PermissionResponse::Deny {
    824                                 reason: "User denied".into(),
    825                             },
    826                         });
    827                     }
    828                 }
    829 
    830                 if allow_response.clicked() {
    831                     if shift_held {
    832                         *action = Some(DaveAction::TentativeAccept);
    833                     } else {
    834                         *action = Some(DaveAction::PermissionResponse {
    835                             request_id: request.id,
    836                             response: PermissionResponse::Allow { message: None },
    837                         });
    838                     }
    839                 }
    840 
    841                 if always_response.clicked() {
    842                     if shift_held {
    843                         *action = Some(DaveAction::TentativeAllowAlways);
    844                     } else {
    845                         *action = Some(DaveAction::AllowAlways {
    846                             request_id: request.id,
    847                         });
    848                     }
    849                 }
    850 
    851                 add_msg_link(ui, shift_held, action);
    852             }
    853         });
    854     }
    855 
    856     /// Render ExitPlanMode tool call with Approve/Reject buttons
    857     fn exit_plan_mode_ui(
    858         &self,
    859         request: &PermissionRequest,
    860         ui: &mut egui::Ui,
    861     ) -> Option<DaveAction> {
    862         let mut action = None;
    863         let inner_margin = 12.0;
    864         let corner_radius = 8.0;
    865 
    866         egui::Frame::new()
    867             .fill(ui.visuals().widgets.noninteractive.bg_fill)
    868             .inner_margin(inner_margin)
    869             .corner_radius(corner_radius)
    870             .stroke(egui::Stroke::new(1.0, ui.visuals().selection.stroke.color))
    871             .show(ui, |ui| {
    872                 ui.vertical(|ui| {
    873                     // Header with badge
    874                     ui.horizontal(|ui| {
    875                         super::badge::StatusBadge::new("PLAN")
    876                             .variant(super::badge::BadgeVariant::Info)
    877                             .show(ui);
    878                         ui.add_space(8.0);
    879                         ui.label(egui::RichText::new("Plan ready for approval").strong());
    880                     });
    881 
    882                     ui.add_space(8.0);
    883 
    884                     // Render plan content as markdown (pre-parsed at construction)
    885                     if let Some(plan) = &request.cached_plan {
    886                         markdown_ui::render_assistant_message(
    887                             &plan.elements,
    888                             None,
    889                             &plan.source,
    890                             ui,
    891                         );
    892                     } else if let Some(plan_text) =
    893                         request.tool_input.get("plan").and_then(|v| v.as_str())
    894                     {
    895                         // Fallback: render as plain text
    896                         ui.label(plan_text);
    897                     }
    898 
    899                     ui.add_space(8.0);
    900 
    901                     // Approve/Reject buttons with shift support for adding message
    902                     let shift_held = ui.input(|i| i.modifiers.shift);
    903                     let in_tentative =
    904                         self.permission_message_state != PermissionMessageState::None;
    905 
    906                     ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
    907                         if in_tentative {
    908                             tentative_send_ui(
    909                                 self.permission_message_state,
    910                                 "Approve",
    911                                 "Reject",
    912                                 ui,
    913                                 &mut action,
    914                             );
    915                         } else {
    916                             let button_text_color = ui.visuals().widgets.active.fg_stroke.color;
    917 
    918                             // Approve button (green)
    919                             let approve_response = super::badge::ActionButton::new(
    920                                 "Approve",
    921                                 egui::Color32::from_rgb(34, 139, 34),
    922                                 button_text_color,
    923                             )
    924                             .keybind("1")
    925                             .show(ui)
    926                             .on_hover_text("Press 1 to approve, Shift+1 to approve with message");
    927 
    928                             if approve_response.clicked() {
    929                                 if shift_held {
    930                                     action = Some(DaveAction::TentativeAccept);
    931                                 } else {
    932                                     action = Some(DaveAction::ExitPlanMode {
    933                                         request_id: request.id,
    934                                         approved: true,
    935                                     });
    936                                 }
    937                             }
    938 
    939                             // Compact & Approve button (blue, no keybind)
    940                             let compact_response = super::badge::ActionButton::new(
    941                                 "Compact & Approve",
    942                                 egui::Color32::from_rgb(59, 130, 246),
    943                                 button_text_color,
    944                             )
    945                             .show(ui)
    946                             .on_hover_text("Compact context then start implementing");
    947 
    948                             if compact_response.clicked() {
    949                                 action = Some(DaveAction::CompactAndApprove {
    950                                     request_id: request.id,
    951                                 });
    952                             }
    953 
    954                             // Reject button (red)
    955                             let reject_response = super::badge::ActionButton::new(
    956                                 "Reject",
    957                                 egui::Color32::from_rgb(178, 34, 34),
    958                                 button_text_color,
    959                             )
    960                             .keybind("2")
    961                             .show(ui)
    962                             .on_hover_text("Press 2 to reject, Shift+2 to reject with message");
    963 
    964                             if reject_response.clicked() {
    965                                 if shift_held {
    966                                     action = Some(DaveAction::TentativeDeny);
    967                                 } else {
    968                                     action = Some(DaveAction::ExitPlanMode {
    969                                         request_id: request.id,
    970                                         approved: false,
    971                                     });
    972                                 }
    973                             }
    974 
    975                             add_msg_link(ui, shift_held, &mut action);
    976                         }
    977                     });
    978                 });
    979             });
    980 
    981         action
    982     }
    983 
    984     /// Render tool result metadata as a compact line
    985     fn executed_tool_ui(result: &ExecutedTool, ui: &mut egui::Ui) {
    986         if let Some(file_update) = &result.file_update {
    987             // File edit with diff — show collapsible header with inline diff
    988             let expand_id = ui.id().with("exec_diff").with(&result.summary);
    989             let is_small = file_update.diff_lines().len() < 10;
    990             let expanded: bool = ui.data(|d| d.get_temp(expand_id).unwrap_or(is_small));
    991 
    992             let header_resp = ui
    993                 .horizontal(|ui| {
    994                     let arrow = if expanded { "▼" } else { "▶" };
    995                     ui.add(egui::Label::new(
    996                         egui::RichText::new(arrow)
    997                             .size(10.0)
    998                             .color(ui.visuals().text_color().gamma_multiply(0.5)),
    999                     ));
   1000                     ui.add(egui::Label::new(
   1001                         egui::RichText::new(&result.tool_name)
   1002                             .size(11.0)
   1003                             .color(ui.visuals().text_color().gamma_multiply(0.6))
   1004                             .monospace(),
   1005                     ));
   1006                     if !result.summary.is_empty() {
   1007                         ui.add(egui::Label::new(
   1008                             egui::RichText::new(&result.summary)
   1009                                 .size(11.0)
   1010                                 .color(ui.visuals().text_color().gamma_multiply(0.4))
   1011                                 .monospace(),
   1012                         ));
   1013                     }
   1014                 })
   1015                 .response
   1016                 .interact(egui::Sense::click());
   1017 
   1018             if header_resp.clicked() {
   1019                 ui.data_mut(|d| d.insert_temp(expand_id, !expanded));
   1020             }
   1021 
   1022             if expanded {
   1023                 diff::file_path_header(file_update, ui);
   1024                 diff::file_update_ui(file_update, false, ui);
   1025             }
   1026         } else {
   1027             // Compact single-line display with subdued styling
   1028             ui.horizontal(|ui| {
   1029                 ui.add(egui::Label::new(
   1030                     egui::RichText::new(&result.tool_name)
   1031                         .size(11.0)
   1032                         .color(ui.visuals().text_color().gamma_multiply(0.6))
   1033                         .monospace(),
   1034                 ));
   1035                 if !result.summary.is_empty() {
   1036                     ui.add(egui::Label::new(
   1037                         egui::RichText::new(&result.summary)
   1038                             .size(11.0)
   1039                             .color(ui.visuals().text_color().gamma_multiply(0.4))
   1040                             .monospace(),
   1041                     ));
   1042                 }
   1043             });
   1044         }
   1045     }
   1046 
   1047     /// Render compaction complete notification
   1048     fn compaction_complete_ui(info: &CompactionInfo, ui: &mut egui::Ui) {
   1049         ui.horizontal(|ui| {
   1050             ui.add(egui::Label::new(
   1051                 egui::RichText::new("✓")
   1052                     .size(11.0)
   1053                     .color(egui::Color32::from_rgb(100, 180, 100)),
   1054             ));
   1055             ui.add(egui::Label::new(
   1056                 egui::RichText::new(format!("Compacted ({} tokens)", info.pre_tokens))
   1057                     .size(11.0)
   1058                     .color(ui.visuals().weak_text_color())
   1059                     .italics(),
   1060             ));
   1061         });
   1062     }
   1063 
   1064     /// Render a single subagent's status with expandable tool results
   1065     fn subagent_ui(info: &SubagentInfo, ui: &mut egui::Ui) {
   1066         let tool_count = info.tool_results.len();
   1067         let has_tools = tool_count > 0;
   1068         // Compute expand ID from outer ui, before horizontal changes the id scope
   1069         let expand_id = ui.id().with("subagent_expand").with(&info.task_id);
   1070 
   1071         ui.horizontal(|ui| {
   1072             // Status badge with color based on status
   1073             let variant = match info.status {
   1074                 SubagentStatus::Running => BadgeVariant::Warning,
   1075                 SubagentStatus::Completed => BadgeVariant::Success,
   1076                 SubagentStatus::Failed => BadgeVariant::Destructive,
   1077             };
   1078             StatusBadge::new(&info.subagent_type)
   1079                 .variant(variant)
   1080                 .show(ui);
   1081 
   1082             // Description
   1083             ui.label(
   1084                 egui::RichText::new(&info.description)
   1085                     .size(11.0)
   1086                     .color(ui.visuals().text_color().gamma_multiply(0.7)),
   1087             );
   1088 
   1089             // Show spinner for running subagents
   1090             if info.status == SubagentStatus::Running {
   1091                 ui.add(egui::Spinner::new().size(11.0));
   1092             }
   1093 
   1094             // Tool count indicator (clickable to expand)
   1095             if has_tools {
   1096                 let expanded = ui.data(|d| d.get_temp::<bool>(expand_id).unwrap_or(false));
   1097                 let arrow = if expanded { "▾" } else { "▸" };
   1098                 let label = format!("{} ({} tools)", arrow, tool_count);
   1099                 if ui
   1100                     .add(
   1101                         egui::Label::new(
   1102                             egui::RichText::new(label)
   1103                                 .size(10.0)
   1104                                 .color(ui.visuals().text_color().gamma_multiply(0.4)),
   1105                         )
   1106                         .sense(egui::Sense::click()),
   1107                     )
   1108                     .clicked()
   1109                 {
   1110                     ui.data_mut(|d| d.insert_temp(expand_id, !expanded));
   1111                 }
   1112             }
   1113         });
   1114 
   1115         // Expanded tool results
   1116         if has_tools {
   1117             let expanded = ui.data(|d| d.get_temp::<bool>(expand_id).unwrap_or(false));
   1118             if expanded {
   1119                 ui.indent(("subagent_tools", &info.task_id), |ui| {
   1120                     for result in &info.tool_results {
   1121                         Self::executed_tool_ui(result, ui);
   1122                     }
   1123                 });
   1124             }
   1125         }
   1126     }
   1127 
   1128     fn search_call_ui(
   1129         ctx: &mut AppContext,
   1130         query_call: &crate::tools::QueryCall,
   1131         ui: &mut egui::Ui,
   1132     ) {
   1133         ui.add(search_icon(16.0, 16.0));
   1134         ui.add_space(8.0);
   1135 
   1136         query_call_ui(
   1137             ctx.img_cache,
   1138             ctx.ndb,
   1139             query_call,
   1140             ctx.media_jobs.sender(),
   1141             ui,
   1142         );
   1143     }
   1144 
   1145     /// The ai has asked us to render some notes, so we do that here
   1146     fn present_notes_ui(
   1147         ctx: &mut AppContext,
   1148         call: &PresentNotesCall,
   1149         ui: &mut egui::Ui,
   1150     ) -> Option<NoteAction> {
   1151         let mut note_context = NoteContext {
   1152             ndb: ctx.ndb,
   1153             accounts: ctx.accounts,
   1154             img_cache: ctx.img_cache,
   1155             note_cache: ctx.note_cache,
   1156             zaps: ctx.zaps,
   1157             jobs: ctx.media_jobs.sender(),
   1158             unknown_ids: ctx.unknown_ids,
   1159             nip05_cache: ctx.nip05_cache,
   1160             clipboard: ctx.clipboard,
   1161             i18n: ctx.i18n,
   1162             global_wallet: ctx.global_wallet,
   1163         };
   1164 
   1165         let txn = Transaction::new(note_context.ndb).unwrap();
   1166 
   1167         egui::ScrollArea::horizontal()
   1168             .max_height(400.0)
   1169             .show(ui, |ui| {
   1170                 ui.with_layout(Layout::left_to_right(Align::Min), |ui| {
   1171                     ui.spacing_mut().item_spacing.x = 10.0;
   1172                     let mut action: Option<NoteAction> = None;
   1173 
   1174                     for note_id in &call.note_ids {
   1175                         let Ok(note) = note_context.ndb.get_note_by_id(&txn, note_id.bytes())
   1176                         else {
   1177                             continue;
   1178                         };
   1179 
   1180                         let r = ui
   1181                             .allocate_ui_with_layout(
   1182                                 [400.0, 400.0].into(),
   1183                                 Layout::centered_and_justified(ui.layout().main_dir()),
   1184                                 |ui| {
   1185                                     notedeck_ui::NoteView::new(
   1186                                         &mut note_context,
   1187                                         &note,
   1188                                         NoteOptions::default(),
   1189                                     )
   1190                                     .preview_style()
   1191                                     .hide_media(true)
   1192                                     .show(ui)
   1193                                 },
   1194                             )
   1195                             .inner;
   1196 
   1197                         if r.action.is_some() {
   1198                             action = r.action;
   1199                         }
   1200                     }
   1201 
   1202                     action
   1203                 })
   1204                 .inner
   1205             })
   1206             .inner
   1207     }
   1208 
   1209     fn tool_calls_ui(
   1210         ctx: &mut AppContext,
   1211         toolcalls: &[ToolCall],
   1212         ui: &mut egui::Ui,
   1213     ) -> Option<NoteAction> {
   1214         let mut note_action: Option<NoteAction> = None;
   1215 
   1216         ui.vertical(|ui| {
   1217             for call in toolcalls {
   1218                 match call.calls() {
   1219                     ToolCalls::PresentNotes(call) => {
   1220                         let r = Self::present_notes_ui(ctx, call, ui);
   1221                         if r.is_some() {
   1222                             note_action = r;
   1223                         }
   1224                     }
   1225                     ToolCalls::Invalid(err) => {
   1226                         ui.label(format!("invalid tool call: {err:?}"));
   1227                     }
   1228                     ToolCalls::Query(search_call) => {
   1229                         ui.allocate_ui_with_layout(
   1230                             egui::vec2(ui.available_size().x, 32.0),
   1231                             Layout::left_to_right(Align::Center),
   1232                             |ui| {
   1233                                 Self::search_call_ui(ctx, search_call, ui);
   1234                             },
   1235                         );
   1236                     }
   1237                 }
   1238             }
   1239         });
   1240 
   1241         note_action
   1242     }
   1243 
   1244     fn inputbox(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
   1245         let i18n = &mut *app_ctx.i18n;
   1246         // Constrain input height based on line count (min 1, max 8 lines)
   1247         let line_count = self.input.lines().count().max(1).clamp(1, 8);
   1248         let line_height = 20.0;
   1249         let base_height = 44.0;
   1250         let input_height = base_height + (line_count as f32 * line_height);
   1251         ui.allocate_ui(egui::vec2(ui.available_width(), input_height), |ui| {
   1252             ui.horizontal(|ui| {
   1253                 ui.with_layout(Layout::right_to_left(Align::Max), |ui| {
   1254                     let mut dave_response = DaveResponse::none();
   1255 
   1256                     // Always show Ask button (messages queue while working)
   1257                     if ui
   1258                         .add(
   1259                             egui::Button::new(tr!(
   1260                                 i18n,
   1261                                 "Ask",
   1262                                 "Button to send message to Dave AI assistant"
   1263                             ))
   1264                             .min_size(egui::vec2(60.0, 44.0)),
   1265                         )
   1266                         .clicked()
   1267                     {
   1268                         dave_response = DaveResponse::send();
   1269                     }
   1270 
   1271                     // Show Stop button alongside Ask for local working sessions
   1272                     if self.flags.contains(DaveUiFlags::IsWorking)
   1273                         && !self.flags.contains(DaveUiFlags::IsRemote)
   1274                     {
   1275                         if ui
   1276                             .add(
   1277                                 egui::Button::new(tr!(
   1278                                     i18n,
   1279                                     "Stop",
   1280                                     "Button to interrupt/stop the AI operation"
   1281                                 ))
   1282                                 .min_size(egui::vec2(60.0, 44.0)),
   1283                             )
   1284                             .clicked()
   1285                         {
   1286                             dave_response = DaveResponse::new(DaveAction::Interrupt);
   1287                         }
   1288 
   1289                         // Show "Press Esc again" indicator when interrupt is pending
   1290                         if self.flags.contains(DaveUiFlags::InterruptPending) {
   1291                             ui.label(
   1292                                 egui::RichText::new("Press Esc again to stop")
   1293                                     .color(ui.visuals().warn_fg_color),
   1294                             );
   1295                         }
   1296                     }
   1297 
   1298                     let r = ui.add(
   1299                         egui::TextEdit::multiline(self.input)
   1300                             .desired_width(f32::INFINITY)
   1301                             .return_key(KeyboardShortcut::new(
   1302                                 Modifiers {
   1303                                     shift: true,
   1304                                     ..Default::default()
   1305                                 },
   1306                                 Key::Enter,
   1307                             ))
   1308                             .hint_text(
   1309                                 egui::RichText::new(tr!(
   1310                                     i18n,
   1311                                     "Ask dave anything...",
   1312                                     "Placeholder text for Dave AI input field"
   1313                                 ))
   1314                                 .weak(),
   1315                             )
   1316                             .frame(false),
   1317                     );
   1318                     notedeck_ui::context_menu::input_context(
   1319                         ui,
   1320                         &r,
   1321                         app_ctx.clipboard,
   1322                         self.input,
   1323                         notedeck_ui::context_menu::PasteBehavior::Append,
   1324                     );
   1325 
   1326                     // Request focus if flagged (e.g., after spawning a new agent or entering tentative state).
   1327                     // Skip on mobile to avoid popping up the virtual keyboard on every session switch.
   1328                     if *self.focus_requested {
   1329                         if !notedeck::ui::is_compiled_as_mobile() {
   1330                             r.request_focus();
   1331                         }
   1332                         *self.focus_requested = false;
   1333                     }
   1334 
   1335                     // Unfocus text input when there's a pending permission request
   1336                     // UNLESS we're in tentative state (user needs to type message)
   1337                     let in_tentative_state =
   1338                         self.permission_message_state != PermissionMessageState::None;
   1339                     if self.flags.contains(DaveUiFlags::HasPendingPerm) && !in_tentative_state {
   1340                         r.surrender_focus();
   1341                     }
   1342 
   1343                     if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
   1344                         DaveResponse::send()
   1345                     } else {
   1346                         dave_response
   1347                     }
   1348                 })
   1349                 .inner
   1350             })
   1351             .inner
   1352         })
   1353         .inner
   1354     }
   1355 
   1356     fn user_chat(&self, msg: &str, is_queued: bool, ui: &mut egui::Ui) {
   1357         ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
   1358             let r = egui::Frame::new()
   1359                 .inner_margin(10.0)
   1360                 .corner_radius(10.0)
   1361                 .fill(ui.visuals().widgets.inactive.weak_bg_fill)
   1362                 .show(ui, |ui| {
   1363                     ui.add(
   1364                         egui::Label::new(msg)
   1365                             .wrap_mode(egui::TextWrapMode::Wrap)
   1366                             .selectable(true),
   1367                     );
   1368                     if is_queued {
   1369                         ui.label(
   1370                             egui::RichText::new("queued")
   1371                                 .small()
   1372                                 .color(ui.visuals().weak_text_color()),
   1373                         );
   1374                     }
   1375                 });
   1376             r.response.context_menu(|ui| {
   1377                 if ui.button("Copy").clicked() {
   1378                     ui.ctx().copy_text(msg.to_owned());
   1379                     ui.close_menu();
   1380                 }
   1381             });
   1382         });
   1383     }
   1384 
   1385     fn assistant_chat(&self, msg: &AssistantMessage, ui: &mut egui::Ui) {
   1386         let elements = msg.parsed_elements();
   1387         let partial = msg.partial();
   1388         let buffer = msg.buffer();
   1389         let text = msg.text().to_owned();
   1390         let r = ui.scope(|ui| {
   1391             markdown_ui::render_assistant_message(elements, partial, buffer, ui);
   1392         });
   1393         r.response.context_menu(|ui| {
   1394             if ui.button("Copy").clicked() {
   1395                 ui.ctx().copy_text(text.clone());
   1396                 ui.close_menu();
   1397             }
   1398         });
   1399     }
   1400 }
   1401 
   1402 /// Send button + clickable accept/deny toggle shown when in tentative state.
   1403 fn tentative_send_ui(
   1404     state: PermissionMessageState,
   1405     accept_label: &str,
   1406     deny_label: &str,
   1407     ui: &mut egui::Ui,
   1408     action: &mut Option<DaveAction>,
   1409 ) {
   1410     if ui
   1411         .add(egui::Button::new(egui::RichText::new("Send").strong()))
   1412         .clicked()
   1413     {
   1414         *action = Some(DaveAction::Send);
   1415     }
   1416 
   1417     match state {
   1418         PermissionMessageState::TentativeAccept => {
   1419             if ui
   1420                 .link(
   1421                     egui::RichText::new(format!("✓ Will {accept_label}"))
   1422                         .color(egui::Color32::from_rgb(100, 180, 100))
   1423                         .strong(),
   1424                 )
   1425                 .clicked()
   1426             {
   1427                 *action = Some(DaveAction::TentativeDeny);
   1428             }
   1429         }
   1430         PermissionMessageState::TentativeDeny => {
   1431             if ui
   1432                 .link(
   1433                     egui::RichText::new(format!("✗ Will {deny_label}"))
   1434                         .color(egui::Color32::from_rgb(200, 100, 100))
   1435                         .strong(),
   1436                 )
   1437                 .clicked()
   1438             {
   1439                 *action = Some(DaveAction::TentativeAccept);
   1440             }
   1441         }
   1442         PermissionMessageState::None => {}
   1443     }
   1444 }
   1445 
   1446 /// Clickable "+ msg [⇧]" link that enters tentative accept mode.
   1447 /// Highlights in warn color when Shift is held on desktop.
   1448 fn add_msg_link(ui: &mut egui::Ui, shift_held: bool, action: &mut Option<DaveAction>) {
   1449     let color = if shift_held {
   1450         ui.visuals().warn_fg_color
   1451     } else {
   1452         ui.visuals().weak_text_color()
   1453     };
   1454     if ui
   1455         .link(egui::RichText::new("+ msg [⇧]").color(color).small())
   1456         .clicked()
   1457     {
   1458         *action = Some(DaveAction::TentativeAccept);
   1459     }
   1460 }
   1461 
   1462 /// Format an Instant as a relative time string (e.g. "just now", "3m ago").
   1463 fn format_relative_time(instant: std::time::Instant) -> String {
   1464     let elapsed = instant.elapsed().as_secs();
   1465     if elapsed < 60 {
   1466         "just now".to_string()
   1467     } else if elapsed < 3600 {
   1468         format!("{}m ago", elapsed / 60)
   1469     } else if elapsed < 86400 {
   1470         format!("{}h ago", elapsed / 3600)
   1471     } else {
   1472         format!("{}d ago", elapsed / 86400)
   1473     }
   1474 }
   1475 
   1476 /// Renders the status bar containing git status and toggle badges.
   1477 #[allow(clippy::too_many_arguments)]
   1478 fn status_bar_ui(
   1479     mut git_status: Option<&mut GitStatusCache>,
   1480     is_agentic: bool,
   1481     permission_mode: PermissionMode,
   1482     auto_steal_focus: bool,
   1483     focus_queue_info: Option<(usize, usize, FocusPriority)>,
   1484     usage: Option<&crate::messages::UsageInfo>,
   1485     context_window: u64,
   1486     last_activity: Option<std::time::Instant>,
   1487     ui: &mut egui::Ui,
   1488 ) -> Option<DaveAction> {
   1489     let snapshot = git_status
   1490         .as_deref()
   1491         .and_then(git_status_ui::StatusSnapshot::from_cache);
   1492 
   1493     ui.vertical(|ui| {
   1494         let action = ui
   1495             .horizontal(|ui| {
   1496                 ui.spacing_mut().item_spacing.x = 6.0;
   1497 
   1498                 if let Some(git_status) = git_status.as_deref_mut() {
   1499                     git_status_ui::git_status_content_ui(git_status, &snapshot, ui);
   1500 
   1501                     // Right-aligned section: usage bar, badges, then refresh
   1502                     ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
   1503                         let badge_action = if is_agentic {
   1504                             toggle_badges_ui(
   1505                                 ui,
   1506                                 permission_mode,
   1507                                 auto_steal_focus,
   1508                                 focus_queue_info,
   1509                             )
   1510                         } else {
   1511                             None
   1512                         };
   1513                         if is_agentic {
   1514                             if let Some(instant) = last_activity {
   1515                                 ui.label(
   1516                                     egui::RichText::new(format_relative_time(instant))
   1517                                         .size(10.0)
   1518                                         .color(ui.visuals().weak_text_color()),
   1519                                 );
   1520                             }
   1521                             usage_bar_ui(usage, context_window, ui);
   1522                         }
   1523                         badge_action
   1524                     })
   1525                     .inner
   1526                 } else if is_agentic {
   1527                     // No git status (remote session) - just show badges and usage
   1528                     ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
   1529                         let badge_action = toggle_badges_ui(
   1530                             ui,
   1531                             permission_mode,
   1532                             auto_steal_focus,
   1533                             focus_queue_info,
   1534                         );
   1535                         if let Some(instant) = last_activity {
   1536                             ui.label(
   1537                                 egui::RichText::new(format_relative_time(instant))
   1538                                     .size(10.0)
   1539                                     .color(ui.visuals().weak_text_color()),
   1540                             );
   1541                         }
   1542                         usage_bar_ui(usage, context_window, ui);
   1543                         badge_action
   1544                     })
   1545                     .inner
   1546                 } else {
   1547                     None
   1548                 }
   1549             })
   1550             .inner;
   1551 
   1552         if let Some(git_status) = git_status.as_deref() {
   1553             git_status_ui::git_expanded_files_ui(git_status, &snapshot, ui);
   1554         }
   1555 
   1556         action
   1557     })
   1558     .inner
   1559 }
   1560 
   1561 /// Format a token count in a compact human-readable form (e.g. "45K", "1.2M")
   1562 fn format_tokens(tokens: u64) -> String {
   1563     if tokens >= 1_000_000 {
   1564         format!("{:.1}M", tokens as f64 / 1_000_000.0)
   1565     } else if tokens >= 1_000 {
   1566         format!("{}K", tokens / 1_000)
   1567     } else {
   1568         tokens.to_string()
   1569     }
   1570 }
   1571 
   1572 /// Renders the usage fill bar showing context window consumption.
   1573 fn usage_bar_ui(
   1574     usage: Option<&crate::messages::UsageInfo>,
   1575     context_window: u64,
   1576     ui: &mut egui::Ui,
   1577 ) {
   1578     let total = usage.map(|u| u.context_tokens()).unwrap_or(0);
   1579     if total == 0 {
   1580         return;
   1581     }
   1582     let usage = usage.unwrap();
   1583     let fraction = (total as f64 / context_window as f64).min(1.0) as f32;
   1584 
   1585     // Color based on fill level: green → yellow → red
   1586     let bar_color = if fraction < 0.5 {
   1587         egui::Color32::from_rgb(100, 180, 100)
   1588     } else if fraction < 0.8 {
   1589         egui::Color32::from_rgb(200, 180, 60)
   1590     } else {
   1591         egui::Color32::from_rgb(200, 80, 80)
   1592     };
   1593 
   1594     let weak = ui.visuals().weak_text_color();
   1595 
   1596     // Cost label
   1597     if let Some(cost) = usage.cost_usd {
   1598         if cost > 0.0 {
   1599             ui.add(egui::Label::new(
   1600                 egui::RichText::new(format!("${:.2}", cost))
   1601                     .size(10.0)
   1602                     .color(weak),
   1603             ));
   1604         }
   1605     }
   1606 
   1607     // Token count label
   1608     ui.add(egui::Label::new(
   1609         egui::RichText::new(format!(
   1610             "{} / {}",
   1611             format_tokens(total),
   1612             format_tokens(context_window)
   1613         ))
   1614         .size(10.0)
   1615         .color(weak),
   1616     ));
   1617 
   1618     // Fill bar
   1619     let bar_width = 60.0;
   1620     let bar_height = 8.0;
   1621     let (rect, _) = ui.allocate_exact_size(egui::vec2(bar_width, bar_height), egui::Sense::hover());
   1622     let painter = ui.painter_at(rect);
   1623 
   1624     // Background
   1625     painter.rect_filled(rect, 3.0, ui.visuals().faint_bg_color);
   1626 
   1627     // Fill
   1628     let fill_rect =
   1629         egui::Rect::from_min_size(rect.min, egui::vec2(bar_width * fraction, bar_height));
   1630     painter.rect_filled(fill_rect, 3.0, bar_color);
   1631 }
   1632 
   1633 /// Render clickable permission mode and AUTO toggle badges. Returns an action if clicked.
   1634 fn toggle_badges_ui(
   1635     ui: &mut egui::Ui,
   1636     permission_mode: PermissionMode,
   1637     auto_steal_focus: bool,
   1638     focus_queue_info: Option<(usize, usize, FocusPriority)>,
   1639 ) -> Option<DaveAction> {
   1640     let ctrl_held = ui.input(|i| i.modifiers.ctrl);
   1641     let is_narrow = notedeck::ui::is_narrow(ui.ctx());
   1642     let mut action = None;
   1643 
   1644     // NEXT badge for focus queue navigation (narrow/mobile only, rendered first = rightmost)
   1645     if is_narrow {
   1646         if let Some((_pos, _total, priority)) = focus_queue_info {
   1647             let variant = match priority {
   1648                 FocusPriority::NeedsInput => super::badge::BadgeVariant::Warning,
   1649                 FocusPriority::Error => super::badge::BadgeVariant::Destructive,
   1650                 FocusPriority::Done => super::badge::BadgeVariant::Info,
   1651             };
   1652             let mut next_badge = super::badge::StatusBadge::new("\u{25b6}").variant(variant);
   1653             if ctrl_held {
   1654                 next_badge = next_badge.keybind("N");
   1655             }
   1656             if next_badge
   1657                 .show(ui)
   1658                 .on_hover_text("Next in focus queue (Ctrl+N)")
   1659                 .clicked()
   1660             {
   1661                 action = Some(DaveAction::FocusQueueNext);
   1662             }
   1663         }
   1664     }
   1665 
   1666     // AUTO badge (rendered first in right-to-left, so it appears rightmost)
   1667     let mut auto_badge = super::badge::StatusBadge::new("AUTO").variant(if auto_steal_focus {
   1668         super::badge::BadgeVariant::Info
   1669     } else {
   1670         super::badge::BadgeVariant::Default
   1671     });
   1672     if ctrl_held {
   1673         auto_badge = auto_badge.keybind("\\");
   1674     }
   1675     if auto_badge
   1676         .show(ui)
   1677         .on_hover_text("Click or Ctrl+\\ to toggle auto-focus mode")
   1678         .clicked()
   1679     {
   1680         action = Some(DaveAction::ToggleAutoSteal);
   1681     }
   1682 
   1683     // Permission mode badge: cycles Default → Plan → AcceptEdits
   1684     let (label, variant) = match permission_mode {
   1685         PermissionMode::Plan => ("PLAN", BadgeVariant::Info),
   1686         PermissionMode::AcceptEdits => ("AUTO EDIT", BadgeVariant::Warning),
   1687         _ => ("PLAN", BadgeVariant::Default),
   1688     };
   1689     let mut mode_badge = StatusBadge::new(label).variant(variant);
   1690     if ctrl_held {
   1691         mode_badge = mode_badge.keybind("M");
   1692     }
   1693     if mode_badge
   1694         .show(ui)
   1695         .on_hover_text("Click or Ctrl+M to cycle: Default → Plan → Auto Edit")
   1696         .clicked()
   1697     {
   1698         action = Some(DaveAction::CyclePermissionMode);
   1699     }
   1700 
   1701     // COMPACT badge
   1702     let compact_badge =
   1703         super::badge::StatusBadge::new("COMPACT").variant(super::badge::BadgeVariant::Default);
   1704     if compact_badge
   1705         .show(ui)
   1706         .on_hover_text("Click to compact context")
   1707         .clicked()
   1708     {
   1709         action = Some(DaveAction::Compact);
   1710     }
   1711 
   1712     action
   1713 }
   1714 
   1715 fn session_header_ui(ui: &mut egui::Ui, details: &SessionDetails, backend_type: BackendType) {
   1716     ui.horizontal(|ui| {
   1717         // Backend icon
   1718         if backend_type.is_agentic() {
   1719             let icon = crate::ui::backend_icon(backend_type).max_height(16.0);
   1720             ui.add(icon);
   1721         }
   1722 
   1723         ui.vertical(|ui| {
   1724             ui.spacing_mut().item_spacing.y = 1.0;
   1725             ui.add(
   1726                 egui::Label::new(egui::RichText::new(details.display_title()).size(13.0))
   1727                     .wrap_mode(egui::TextWrapMode::Truncate),
   1728             );
   1729             if let Some(cwd) = &details.cwd {
   1730                 let cwd_display = if details.home_dir.is_empty() {
   1731                     crate::path_utils::abbreviate_path(cwd)
   1732                 } else {
   1733                     crate::path_utils::abbreviate_with_home(cwd, &details.home_dir)
   1734                 };
   1735                 let display_text = if details.hostname.is_empty() {
   1736                     cwd_display
   1737                 } else {
   1738                     format!("{}:{}", details.hostname, cwd_display)
   1739                 };
   1740                 ui.add(
   1741                     egui::Label::new(
   1742                         egui::RichText::new(display_text)
   1743                             .monospace()
   1744                             .size(10.0)
   1745                             .weak(),
   1746                     )
   1747                     .wrap_mode(egui::TextWrapMode::Truncate),
   1748                 );
   1749             }
   1750         });
   1751     });
   1752 }