notedeck

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

dave.rs (42075B)


      1 use super::badge::{BadgeVariant, StatusBadge};
      2 use super::diff;
      3 use super::query_ui::query_call_ui;
      4 use super::top_buttons::top_buttons_ui;
      5 use crate::{
      6     config::{AiMode, DaveSettings},
      7     file_update::FileUpdate,
      8     messages::{
      9         AskUserQuestionInput, CompactionInfo, Message, PermissionRequest, PermissionResponse,
     10         PermissionResponseType, QuestionAnswer, SubagentInfo, SubagentStatus, ToolResult,
     11     },
     12     session::PermissionMessageState,
     13     tools::{PresentNotesCall, ToolCall, ToolCalls, ToolResponse},
     14 };
     15 use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
     16 use nostrdb::Transaction;
     17 use notedeck::{tr, AppContext, Localization, NoteAction, NoteContext};
     18 use notedeck_ui::{icons::search_icon, NoteOptions};
     19 use std::collections::HashMap;
     20 use uuid::Uuid;
     21 
     22 /// DaveUi holds all of the data it needs to render itself
     23 pub struct DaveUi<'a> {
     24     chat: &'a [Message],
     25     trial: bool,
     26     input: &'a mut String,
     27     compact: bool,
     28     is_working: bool,
     29     interrupt_pending: bool,
     30     has_pending_permission: bool,
     31     focus_requested: &'a mut bool,
     32     plan_mode_active: bool,
     33     /// State for tentative permission response (waiting for message)
     34     permission_message_state: PermissionMessageState,
     35     /// State for AskUserQuestion responses (selected options per question)
     36     question_answers: Option<&'a mut HashMap<Uuid, Vec<QuestionAnswer>>>,
     37     /// Current question index for multi-question AskUserQuestion
     38     question_index: Option<&'a mut HashMap<Uuid, usize>>,
     39     /// Whether conversation compaction is in progress
     40     is_compacting: bool,
     41     /// Whether auto-steal focus mode is active
     42     auto_steal_focus: bool,
     43     /// AI interaction mode (Chat vs Agentic)
     44     ai_mode: AiMode,
     45 }
     46 
     47 /// The response the app generates. The response contains an optional
     48 /// action to take.
     49 #[derive(Default, Debug)]
     50 pub struct DaveResponse {
     51     pub action: Option<DaveAction>,
     52 }
     53 
     54 impl DaveResponse {
     55     pub fn new(action: DaveAction) -> Self {
     56         DaveResponse {
     57             action: Some(action),
     58         }
     59     }
     60 
     61     fn note(action: NoteAction) -> DaveResponse {
     62         Self::new(DaveAction::Note(action))
     63     }
     64 
     65     pub fn or(self, r: DaveResponse) -> DaveResponse {
     66         DaveResponse {
     67             action: self.action.or(r.action),
     68         }
     69     }
     70 
     71     /// Generate a send response to the controller
     72     fn send() -> Self {
     73         Self::new(DaveAction::Send)
     74     }
     75 
     76     fn none() -> Self {
     77         DaveResponse::default()
     78     }
     79 }
     80 
     81 /// The actions the app generates. No default action is specfied in the
     82 /// UI code. This is handled by the app logic, however it chooses to
     83 /// process this message.
     84 #[derive(Debug)]
     85 pub enum DaveAction {
     86     /// The action generated when the user sends a message to dave
     87     Send,
     88     NewChat,
     89     ToggleChrome,
     90     Note(NoteAction),
     91     /// Toggle showing the session list (for mobile navigation)
     92     ShowSessionList,
     93     /// Open the settings panel
     94     OpenSettings,
     95     /// Settings were updated and should be persisted
     96     UpdateSettings(DaveSettings),
     97     /// User responded to a permission request
     98     PermissionResponse {
     99         request_id: Uuid,
    100         response: PermissionResponse,
    101     },
    102     /// User wants to interrupt/stop the current AI operation
    103     Interrupt,
    104     /// Enter tentative accept mode (Shift+click on Yes)
    105     TentativeAccept,
    106     /// Enter tentative deny mode (Shift+click on No)
    107     TentativeDeny,
    108     /// User responded to an AskUserQuestion
    109     QuestionResponse {
    110         request_id: Uuid,
    111         answers: Vec<QuestionAnswer>,
    112     },
    113     /// User approved or rejected an ExitPlanMode request
    114     ExitPlanMode {
    115         request_id: Uuid,
    116         approved: bool,
    117     },
    118 }
    119 
    120 impl<'a> DaveUi<'a> {
    121     pub fn new(
    122         trial: bool,
    123         chat: &'a [Message],
    124         input: &'a mut String,
    125         focus_requested: &'a mut bool,
    126         ai_mode: AiMode,
    127     ) -> Self {
    128         DaveUi {
    129             trial,
    130             chat,
    131             input,
    132             compact: false,
    133             is_working: false,
    134             interrupt_pending: false,
    135             has_pending_permission: false,
    136             focus_requested,
    137             plan_mode_active: false,
    138             permission_message_state: PermissionMessageState::None,
    139             question_answers: None,
    140             question_index: None,
    141             is_compacting: false,
    142             auto_steal_focus: false,
    143             ai_mode,
    144         }
    145     }
    146 
    147     pub fn permission_message_state(mut self, state: PermissionMessageState) -> Self {
    148         self.permission_message_state = state;
    149         self
    150     }
    151 
    152     pub fn question_answers(mut self, answers: &'a mut HashMap<Uuid, Vec<QuestionAnswer>>) -> Self {
    153         self.question_answers = Some(answers);
    154         self
    155     }
    156 
    157     pub fn question_index(mut self, index: &'a mut HashMap<Uuid, usize>) -> Self {
    158         self.question_index = Some(index);
    159         self
    160     }
    161 
    162     pub fn compact(mut self, compact: bool) -> Self {
    163         self.compact = compact;
    164         self
    165     }
    166 
    167     pub fn is_working(mut self, is_working: bool) -> Self {
    168         self.is_working = is_working;
    169         self
    170     }
    171 
    172     pub fn interrupt_pending(mut self, interrupt_pending: bool) -> Self {
    173         self.interrupt_pending = interrupt_pending;
    174         self
    175     }
    176 
    177     pub fn has_pending_permission(mut self, has_pending_permission: bool) -> Self {
    178         self.has_pending_permission = has_pending_permission;
    179         self
    180     }
    181 
    182     pub fn plan_mode_active(mut self, plan_mode_active: bool) -> Self {
    183         self.plan_mode_active = plan_mode_active;
    184         self
    185     }
    186 
    187     pub fn is_compacting(mut self, is_compacting: bool) -> Self {
    188         self.is_compacting = is_compacting;
    189         self
    190     }
    191 
    192     pub fn auto_steal_focus(mut self, auto_steal_focus: bool) -> Self {
    193         self.auto_steal_focus = auto_steal_focus;
    194         self
    195     }
    196 
    197     fn chat_margin(&self, ctx: &egui::Context) -> i8 {
    198         if self.compact || notedeck::ui::is_narrow(ctx) {
    199             20
    200         } else {
    201             100
    202         }
    203     }
    204 
    205     fn chat_frame(&self, ctx: &egui::Context) -> egui::Frame {
    206         let margin = self.chat_margin(ctx);
    207         egui::Frame::new().inner_margin(egui::Margin {
    208             left: margin,
    209             right: margin,
    210             top: 50,
    211             bottom: 0,
    212         })
    213     }
    214 
    215     /// The main render function. Call this to render Dave
    216     pub fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
    217         // Skip top buttons in compact mode (scene panel has its own controls)
    218         let action = if self.compact {
    219             None
    220         } else {
    221             top_buttons_ui(app_ctx, ui)
    222         };
    223 
    224         egui::Frame::NONE
    225             .show(ui, |ui| {
    226                 ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
    227                     let margin = self.chat_margin(ui.ctx());
    228                     let bottom_margin = 100;
    229 
    230                     let r = egui::Frame::new()
    231                         .outer_margin(egui::Margin {
    232                             left: margin,
    233                             right: margin,
    234                             top: 0,
    235                             bottom: bottom_margin,
    236                         })
    237                         .inner_margin(egui::Margin::same(8))
    238                         .fill(ui.visuals().extreme_bg_color)
    239                         .corner_radius(12.0)
    240                         .show(ui, |ui| self.inputbox(app_ctx.i18n, ui))
    241                         .inner;
    242 
    243                     let chat_response = egui::ScrollArea::vertical()
    244                         .id_salt("dave_chat_scroll")
    245                         .stick_to_bottom(true)
    246                         .auto_shrink([false; 2])
    247                         .show(ui, |ui| {
    248                             self.chat_frame(ui.ctx())
    249                                 .show(ui, |ui| {
    250                                     ui.vertical(|ui| self.render_chat(app_ctx, ui)).inner
    251                                 })
    252                                 .inner
    253                         })
    254                         .inner;
    255 
    256                     chat_response.or(r)
    257                 })
    258                 .inner
    259             })
    260             .inner
    261             .or(DaveResponse { action })
    262     }
    263 
    264     fn error_chat(&self, i18n: &mut Localization, err: &str, ui: &mut egui::Ui) {
    265         if self.trial {
    266             ui.add(egui::Label::new(
    267                 egui::RichText::new(
    268                     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"),
    269                 )
    270                 .weak(),
    271             ));
    272         } else {
    273             ui.add(egui::Label::new(
    274                 egui::RichText::new(format!("An error occured: {err}")).weak(),
    275             ));
    276         }
    277     }
    278 
    279     /// Render a chat message (user, assistant, tool call/response, etc)
    280     fn render_chat(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
    281         let mut response = DaveResponse::default();
    282         let is_agentic = self.ai_mode == AiMode::Agentic;
    283 
    284         for message in self.chat {
    285             match message {
    286                 Message::Error(err) => {
    287                     self.error_chat(ctx.i18n, err, ui);
    288                 }
    289                 Message::User(msg) => {
    290                     self.user_chat(msg, ui);
    291                 }
    292                 Message::Assistant(msg) => {
    293                     self.assistant_chat(msg, ui);
    294                 }
    295                 Message::ToolResponse(msg) => {
    296                     Self::tool_response_ui(msg, ui);
    297                 }
    298                 Message::System(_msg) => {
    299                     // system prompt is not rendered. Maybe we could
    300                     // have a debug option to show this
    301                 }
    302                 Message::ToolCalls(toolcalls) => {
    303                     if let Some(note_action) = Self::tool_calls_ui(ctx, toolcalls, ui) {
    304                         response = DaveResponse::note(note_action);
    305                     }
    306                 }
    307                 Message::PermissionRequest(request) => {
    308                     // Permission requests only in Agentic mode
    309                     if is_agentic {
    310                         if let Some(action) = self.permission_request_ui(request, ui) {
    311                             response = DaveResponse::new(action);
    312                         }
    313                     }
    314                 }
    315                 Message::ToolResult(result) => {
    316                     // Tool results only in Agentic mode
    317                     if is_agentic {
    318                         Self::tool_result_ui(result, ui);
    319                     }
    320                 }
    321                 Message::CompactionComplete(info) => {
    322                     // Compaction only in Agentic mode
    323                     if is_agentic {
    324                         Self::compaction_complete_ui(info, ui);
    325                     }
    326                 }
    327                 Message::Subagent(info) => {
    328                     // Subagents only in Agentic mode
    329                     if is_agentic {
    330                         Self::subagent_ui(info, ui);
    331                     }
    332                 }
    333             };
    334         }
    335 
    336         // Show status line at the bottom of chat when working or compacting
    337         let status_text = if is_agentic && self.is_compacting {
    338             Some("compacting...")
    339         } else if self.is_working {
    340             Some("computing...")
    341         } else {
    342             None
    343         };
    344 
    345         if let Some(status) = status_text {
    346             ui.horizontal(|ui| {
    347                 ui.add(egui::Spinner::new().size(14.0));
    348                 ui.label(
    349                     egui::RichText::new(status)
    350                         .color(ui.visuals().weak_text_color())
    351                         .italics(),
    352                 );
    353                 ui.label(
    354                     egui::RichText::new("(press esc to interrupt)")
    355                         .color(ui.visuals().weak_text_color())
    356                         .small(),
    357                 );
    358             });
    359         }
    360 
    361         response
    362     }
    363 
    364     fn tool_response_ui(_tool_response: &ToolResponse, _ui: &mut egui::Ui) {
    365         //ui.label(format!("tool_response: {:?}", tool_response));
    366     }
    367 
    368     /// Render a permission request with Allow/Deny buttons or response state
    369     fn permission_request_ui(
    370         &mut self,
    371         request: &PermissionRequest,
    372         ui: &mut egui::Ui,
    373     ) -> Option<DaveAction> {
    374         let mut action = None;
    375 
    376         let inner_margin = 8.0;
    377         let corner_radius = 6.0;
    378         let spacing_x = 8.0;
    379 
    380         ui.spacing_mut().item_spacing.x = spacing_x;
    381 
    382         match request.response {
    383             Some(PermissionResponseType::Allowed) => {
    384                 // Check if this is an answered AskUserQuestion with stored summary
    385                 if let Some(summary) = &request.answer_summary {
    386                     super::ask_user_question_summary_ui(summary, ui);
    387                     return None;
    388                 }
    389 
    390                 // Responded state: Allowed (generic fallback)
    391                 egui::Frame::new()
    392                     .fill(ui.visuals().widgets.noninteractive.bg_fill)
    393                     .inner_margin(inner_margin)
    394                     .corner_radius(corner_radius)
    395                     .show(ui, |ui| {
    396                         ui.horizontal(|ui| {
    397                             ui.label(
    398                                 egui::RichText::new("Allowed")
    399                                     .color(egui::Color32::from_rgb(100, 180, 100))
    400                                     .strong(),
    401                             );
    402                             ui.label(
    403                                 egui::RichText::new(&request.tool_name)
    404                                     .color(ui.visuals().text_color()),
    405                             );
    406                         });
    407                     });
    408             }
    409             Some(PermissionResponseType::Denied) => {
    410                 // Responded state: Denied
    411                 egui::Frame::new()
    412                     .fill(ui.visuals().widgets.noninteractive.bg_fill)
    413                     .inner_margin(inner_margin)
    414                     .corner_radius(corner_radius)
    415                     .show(ui, |ui| {
    416                         ui.horizontal(|ui| {
    417                             ui.label(
    418                                 egui::RichText::new("Denied")
    419                                     .color(egui::Color32::from_rgb(200, 100, 100))
    420                                     .strong(),
    421                             );
    422                             ui.label(
    423                                 egui::RichText::new(&request.tool_name)
    424                                     .color(ui.visuals().text_color()),
    425                             );
    426                         });
    427                     });
    428             }
    429             None => {
    430                 // Check if this is an ExitPlanMode tool call
    431                 if request.tool_name == "ExitPlanMode" {
    432                     return self.exit_plan_mode_ui(request, ui);
    433                 }
    434 
    435                 // Check if this is an AskUserQuestion tool call
    436                 if request.tool_name == "AskUserQuestion" {
    437                     if let Ok(questions) =
    438                         serde_json::from_value::<AskUserQuestionInput>(request.tool_input.clone())
    439                     {
    440                         if let (Some(answers_map), Some(index_map)) =
    441                             (&mut self.question_answers, &mut self.question_index)
    442                         {
    443                             return super::ask_user_question_ui(
    444                                 request,
    445                                 &questions,
    446                                 answers_map,
    447                                 index_map,
    448                                 ui,
    449                             );
    450                         }
    451                     }
    452                 }
    453 
    454                 // Check if this is a file update (Edit or Write tool)
    455                 if let Some(file_update) =
    456                     FileUpdate::from_tool_call(&request.tool_name, &request.tool_input)
    457                 {
    458                     // Render file update with diff view
    459                     egui::Frame::new()
    460                         .fill(ui.visuals().widgets.noninteractive.bg_fill)
    461                         .inner_margin(inner_margin)
    462                         .corner_radius(corner_radius)
    463                         .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
    464                         .show(ui, |ui| {
    465                             // Header with file path
    466                             diff::file_path_header(&file_update, ui);
    467 
    468                             // Diff view
    469                             diff::file_update_ui(&file_update, ui);
    470 
    471                             // Approve/deny buttons at the bottom right
    472                             ui.with_layout(
    473                                 egui::Layout::right_to_left(egui::Align::Center),
    474                                 |ui| {
    475                                     self.permission_buttons(request, ui, &mut action);
    476                                 },
    477                             );
    478                         });
    479                 } else {
    480                     // Parse tool input for display (existing logic)
    481                     let obj = request.tool_input.as_object();
    482                     let description = obj
    483                         .and_then(|o| o.get("description"))
    484                         .and_then(|v| v.as_str());
    485                     let command = obj.and_then(|o| o.get("command")).and_then(|v| v.as_str());
    486                     let single_value = obj
    487                         .filter(|o| o.len() == 1)
    488                         .and_then(|o| o.values().next())
    489                         .and_then(|v| v.as_str());
    490 
    491                     // Pending state: Show Allow/Deny buttons
    492                     egui::Frame::new()
    493                         .fill(ui.visuals().widgets.noninteractive.bg_fill)
    494                         .inner_margin(inner_margin)
    495                         .corner_radius(corner_radius)
    496                         .stroke(egui::Stroke::new(1.0, ui.visuals().warn_fg_color))
    497                         .show(ui, |ui| {
    498                             // Tool info display
    499                             if let Some(desc) = description {
    500                                 // Format: ToolName: description
    501                                 ui.horizontal(|ui| {
    502                                     ui.label(egui::RichText::new(&request.tool_name).strong());
    503                                     ui.label(desc);
    504 
    505                                     self.permission_buttons(request, ui, &mut action);
    506                                 });
    507                                 // Command on next line if present
    508                                 if let Some(cmd) = command {
    509                                     ui.add(
    510                                         egui::Label::new(egui::RichText::new(cmd).monospace())
    511                                             .wrap_mode(egui::TextWrapMode::Wrap),
    512                                     );
    513                                 }
    514                             } else if let Some(value) = single_value {
    515                                 // Format: ToolName `value`
    516                                 ui.horizontal(|ui| {
    517                                     ui.label(egui::RichText::new(&request.tool_name).strong());
    518                                     ui.label(egui::RichText::new(value).monospace());
    519 
    520                                     self.permission_buttons(request, ui, &mut action);
    521                                 });
    522                             } else {
    523                                 // Fallback: show JSON
    524                                 ui.horizontal(|ui| {
    525                                     ui.label(egui::RichText::new(&request.tool_name).strong());
    526 
    527                                     self.permission_buttons(request, ui, &mut action);
    528                                 });
    529                                 let formatted = serde_json::to_string_pretty(&request.tool_input)
    530                                     .unwrap_or_else(|_| request.tool_input.to_string());
    531                                 ui.add(
    532                                     egui::Label::new(
    533                                         egui::RichText::new(formatted).monospace().size(11.0),
    534                                     )
    535                                     .wrap_mode(egui::TextWrapMode::Wrap),
    536                                 );
    537                             }
    538                         });
    539                 }
    540             }
    541         }
    542 
    543         action
    544     }
    545 
    546     /// Render Allow/Deny buttons aligned to the right with keybinding hints
    547     fn permission_buttons(
    548         &self,
    549         request: &PermissionRequest,
    550         ui: &mut egui::Ui,
    551         action: &mut Option<DaveAction>,
    552     ) {
    553         let shift_held = ui.input(|i| i.modifiers.shift);
    554 
    555         ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
    556             let button_text_color = ui.visuals().widgets.active.fg_stroke.color;
    557 
    558             // Deny button (red) with integrated keybind hint
    559             let deny_response = super::badge::ActionButton::new(
    560                 "Deny",
    561                 egui::Color32::from_rgb(178, 34, 34),
    562                 button_text_color,
    563             )
    564             .keybind("2")
    565             .show(ui)
    566             .on_hover_text("Press 2 to deny, Shift+2 to deny with message");
    567 
    568             if deny_response.clicked() {
    569                 if shift_held {
    570                     // Shift+click: enter tentative deny mode
    571                     *action = Some(DaveAction::TentativeDeny);
    572                 } else {
    573                     // Normal click: immediate deny
    574                     *action = Some(DaveAction::PermissionResponse {
    575                         request_id: request.id,
    576                         response: PermissionResponse::Deny {
    577                             reason: "User denied".into(),
    578                         },
    579                     });
    580                 }
    581             }
    582 
    583             // Allow button (green) with integrated keybind hint
    584             let allow_response = super::badge::ActionButton::new(
    585                 "Allow",
    586                 egui::Color32::from_rgb(34, 139, 34),
    587                 button_text_color,
    588             )
    589             .keybind("1")
    590             .show(ui)
    591             .on_hover_text("Press 1 to allow, Shift+1 to allow with message");
    592 
    593             if allow_response.clicked() {
    594                 if shift_held {
    595                     // Shift+click: enter tentative accept mode
    596                     *action = Some(DaveAction::TentativeAccept);
    597                 } else {
    598                     // Normal click: immediate allow
    599                     *action = Some(DaveAction::PermissionResponse {
    600                         request_id: request.id,
    601                         response: PermissionResponse::Allow { message: None },
    602                     });
    603                 }
    604             }
    605 
    606             // Show tentative state indicator OR shift hint
    607             match self.permission_message_state {
    608                 PermissionMessageState::TentativeAccept => {
    609                     ui.label(
    610                         egui::RichText::new("✓ Will Allow")
    611                             .color(egui::Color32::from_rgb(100, 180, 100))
    612                             .strong(),
    613                     );
    614                 }
    615                 PermissionMessageState::TentativeDeny => {
    616                     ui.label(
    617                         egui::RichText::new("✗ Will Deny")
    618                             .color(egui::Color32::from_rgb(200, 100, 100))
    619                             .strong(),
    620                     );
    621                 }
    622                 PermissionMessageState::None => {
    623                     // Always show hint for adding message
    624                     let hint_color = if shift_held {
    625                         ui.visuals().warn_fg_color
    626                     } else {
    627                         ui.visuals().weak_text_color()
    628                     };
    629                     ui.label(
    630                         egui::RichText::new("(⇧ for message)")
    631                             .color(hint_color)
    632                             .small(),
    633                     );
    634                 }
    635             }
    636         });
    637     }
    638 
    639     /// Render ExitPlanMode tool call with Approve/Reject buttons
    640     fn exit_plan_mode_ui(
    641         &self,
    642         request: &PermissionRequest,
    643         ui: &mut egui::Ui,
    644     ) -> Option<DaveAction> {
    645         let mut action = None;
    646         let inner_margin = 12.0;
    647         let corner_radius = 8.0;
    648 
    649         // The plan content is in tool_input.plan field
    650         let plan_content = request
    651             .tool_input
    652             .get("plan")
    653             .and_then(|v| v.as_str())
    654             .unwrap_or("")
    655             .to_string();
    656 
    657         egui::Frame::new()
    658             .fill(ui.visuals().widgets.noninteractive.bg_fill)
    659             .inner_margin(inner_margin)
    660             .corner_radius(corner_radius)
    661             .stroke(egui::Stroke::new(1.0, ui.visuals().selection.stroke.color))
    662             .show(ui, |ui| {
    663                 ui.vertical(|ui| {
    664                     // Header with badge
    665                     ui.horizontal(|ui| {
    666                         super::badge::StatusBadge::new("PLAN")
    667                             .variant(super::badge::BadgeVariant::Info)
    668                             .show(ui);
    669                         ui.add_space(8.0);
    670                         ui.label(egui::RichText::new("Plan ready for approval").strong());
    671                     });
    672 
    673                     ui.add_space(8.0);
    674 
    675                     // Display the plan content as plain text (TODO: markdown rendering)
    676                     ui.add(
    677                         egui::Label::new(
    678                             egui::RichText::new(&plan_content)
    679                                 .monospace()
    680                                 .size(11.0)
    681                                 .color(ui.visuals().text_color()),
    682                         )
    683                         .wrap_mode(egui::TextWrapMode::Wrap),
    684                     );
    685 
    686                     ui.add_space(8.0);
    687 
    688                     // Approve/Reject buttons with shift support for adding message
    689                     let shift_held = ui.input(|i| i.modifiers.shift);
    690 
    691                     ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
    692                         let button_text_color = ui.visuals().widgets.active.fg_stroke.color;
    693 
    694                         // Reject button (red)
    695                         let reject_response = super::badge::ActionButton::new(
    696                             "Reject",
    697                             egui::Color32::from_rgb(178, 34, 34),
    698                             button_text_color,
    699                         )
    700                         .keybind("2")
    701                         .show(ui)
    702                         .on_hover_text("Press 2 to reject, Shift+2 to reject with message");
    703 
    704                         if reject_response.clicked() {
    705                             if shift_held {
    706                                 action = Some(DaveAction::TentativeDeny);
    707                             } else {
    708                                 action = Some(DaveAction::ExitPlanMode {
    709                                     request_id: request.id,
    710                                     approved: false,
    711                                 });
    712                             }
    713                         }
    714 
    715                         // Approve button (green)
    716                         let approve_response = super::badge::ActionButton::new(
    717                             "Approve",
    718                             egui::Color32::from_rgb(34, 139, 34),
    719                             button_text_color,
    720                         )
    721                         .keybind("1")
    722                         .show(ui)
    723                         .on_hover_text("Press 1 to approve, Shift+1 to approve with message");
    724 
    725                         if approve_response.clicked() {
    726                             if shift_held {
    727                                 action = Some(DaveAction::TentativeAccept);
    728                             } else {
    729                                 action = Some(DaveAction::ExitPlanMode {
    730                                     request_id: request.id,
    731                                     approved: true,
    732                                 });
    733                             }
    734                         }
    735 
    736                         // Show tentative state indicator OR shift hint
    737                         match self.permission_message_state {
    738                             PermissionMessageState::TentativeAccept => {
    739                                 ui.label(
    740                                     egui::RichText::new("✓ Will Approve")
    741                                         .color(egui::Color32::from_rgb(100, 180, 100))
    742                                         .strong(),
    743                                 );
    744                             }
    745                             PermissionMessageState::TentativeDeny => {
    746                                 ui.label(
    747                                     egui::RichText::new("✗ Will Reject")
    748                                         .color(egui::Color32::from_rgb(200, 100, 100))
    749                                         .strong(),
    750                                 );
    751                             }
    752                             PermissionMessageState::None => {
    753                                 let hint_color = if shift_held {
    754                                     ui.visuals().warn_fg_color
    755                                 } else {
    756                                     ui.visuals().weak_text_color()
    757                                 };
    758                                 ui.label(
    759                                     egui::RichText::new("(⇧ for message)")
    760                                         .color(hint_color)
    761                                         .small(),
    762                                 );
    763                             }
    764                         }
    765                     });
    766                 });
    767             });
    768 
    769         action
    770     }
    771 
    772     /// Render tool result metadata as a compact line
    773     fn tool_result_ui(result: &ToolResult, ui: &mut egui::Ui) {
    774         // Compact single-line display with subdued styling
    775         ui.horizontal(|ui| {
    776             // Tool name in slightly brighter text
    777             ui.add(egui::Label::new(
    778                 egui::RichText::new(&result.tool_name)
    779                     .size(11.0)
    780                     .color(ui.visuals().text_color().gamma_multiply(0.6))
    781                     .monospace(),
    782             ));
    783             // Summary in more subdued text
    784             if !result.summary.is_empty() {
    785                 ui.add(egui::Label::new(
    786                     egui::RichText::new(&result.summary)
    787                         .size(11.0)
    788                         .color(ui.visuals().text_color().gamma_multiply(0.4))
    789                         .monospace(),
    790                 ));
    791             }
    792         });
    793     }
    794 
    795     /// Render compaction complete notification
    796     fn compaction_complete_ui(info: &CompactionInfo, ui: &mut egui::Ui) {
    797         ui.horizontal(|ui| {
    798             ui.add(egui::Label::new(
    799                 egui::RichText::new("✓")
    800                     .size(11.0)
    801                     .color(egui::Color32::from_rgb(100, 180, 100)),
    802             ));
    803             ui.add(egui::Label::new(
    804                 egui::RichText::new(format!("Compacted ({} tokens)", info.pre_tokens))
    805                     .size(11.0)
    806                     .color(ui.visuals().weak_text_color())
    807                     .italics(),
    808             ));
    809         });
    810     }
    811 
    812     /// Render a single subagent's status
    813     fn subagent_ui(info: &SubagentInfo, ui: &mut egui::Ui) {
    814         ui.horizontal(|ui| {
    815             // Status badge with color based on status
    816             let variant = match info.status {
    817                 SubagentStatus::Running => BadgeVariant::Warning,
    818                 SubagentStatus::Completed => BadgeVariant::Success,
    819                 SubagentStatus::Failed => BadgeVariant::Destructive,
    820             };
    821             StatusBadge::new(&info.subagent_type)
    822                 .variant(variant)
    823                 .show(ui);
    824 
    825             // Description
    826             ui.label(
    827                 egui::RichText::new(&info.description)
    828                     .size(11.0)
    829                     .color(ui.visuals().text_color().gamma_multiply(0.7)),
    830             );
    831 
    832             // Show spinner for running subagents
    833             if info.status == SubagentStatus::Running {
    834                 ui.add(egui::Spinner::new().size(11.0));
    835             }
    836         });
    837     }
    838 
    839     fn search_call_ui(
    840         ctx: &mut AppContext,
    841         query_call: &crate::tools::QueryCall,
    842         ui: &mut egui::Ui,
    843     ) {
    844         ui.add(search_icon(16.0, 16.0));
    845         ui.add_space(8.0);
    846 
    847         query_call_ui(
    848             ctx.img_cache,
    849             ctx.ndb,
    850             query_call,
    851             ctx.media_jobs.sender(),
    852             ui,
    853         );
    854     }
    855 
    856     /// The ai has asked us to render some notes, so we do that here
    857     fn present_notes_ui(
    858         ctx: &mut AppContext,
    859         call: &PresentNotesCall,
    860         ui: &mut egui::Ui,
    861     ) -> Option<NoteAction> {
    862         let mut note_context = NoteContext {
    863             ndb: ctx.ndb,
    864             accounts: ctx.accounts,
    865             img_cache: ctx.img_cache,
    866             note_cache: ctx.note_cache,
    867             zaps: ctx.zaps,
    868             pool: ctx.pool,
    869             jobs: ctx.media_jobs.sender(),
    870             unknown_ids: ctx.unknown_ids,
    871             clipboard: ctx.clipboard,
    872             i18n: ctx.i18n,
    873             global_wallet: ctx.global_wallet,
    874         };
    875 
    876         let txn = Transaction::new(note_context.ndb).unwrap();
    877 
    878         egui::ScrollArea::horizontal()
    879             .max_height(400.0)
    880             .show(ui, |ui| {
    881                 ui.with_layout(Layout::left_to_right(Align::Min), |ui| {
    882                     ui.spacing_mut().item_spacing.x = 10.0;
    883                     let mut action: Option<NoteAction> = None;
    884 
    885                     for note_id in &call.note_ids {
    886                         let Ok(note) = note_context.ndb.get_note_by_id(&txn, note_id.bytes())
    887                         else {
    888                             continue;
    889                         };
    890 
    891                         let r = ui
    892                             .allocate_ui_with_layout(
    893                                 [400.0, 400.0].into(),
    894                                 Layout::centered_and_justified(ui.layout().main_dir()),
    895                                 |ui| {
    896                                     notedeck_ui::NoteView::new(
    897                                         &mut note_context,
    898                                         &note,
    899                                         NoteOptions::default(),
    900                                     )
    901                                     .preview_style()
    902                                     .hide_media(true)
    903                                     .show(ui)
    904                                 },
    905                             )
    906                             .inner;
    907 
    908                         if r.action.is_some() {
    909                             action = r.action;
    910                         }
    911                     }
    912 
    913                     action
    914                 })
    915                 .inner
    916             })
    917             .inner
    918     }
    919 
    920     fn tool_calls_ui(
    921         ctx: &mut AppContext,
    922         toolcalls: &[ToolCall],
    923         ui: &mut egui::Ui,
    924     ) -> Option<NoteAction> {
    925         let mut note_action: Option<NoteAction> = None;
    926 
    927         ui.vertical(|ui| {
    928             for call in toolcalls {
    929                 match call.calls() {
    930                     ToolCalls::PresentNotes(call) => {
    931                         let r = Self::present_notes_ui(ctx, call, ui);
    932                         if r.is_some() {
    933                             note_action = r;
    934                         }
    935                     }
    936                     ToolCalls::Invalid(err) => {
    937                         ui.label(format!("invalid tool call: {err:?}"));
    938                     }
    939                     ToolCalls::Query(search_call) => {
    940                         ui.allocate_ui_with_layout(
    941                             egui::vec2(ui.available_size().x, 32.0),
    942                             Layout::left_to_right(Align::Center),
    943                             |ui| {
    944                                 Self::search_call_ui(ctx, search_call, ui);
    945                             },
    946                         );
    947                     }
    948                 }
    949             }
    950         });
    951 
    952         note_action
    953     }
    954 
    955     fn inputbox(&mut self, i18n: &mut Localization, ui: &mut egui::Ui) -> DaveResponse {
    956         //ui.add_space(Self::chat_margin(ui.ctx()) as f32);
    957         ui.horizontal(|ui| {
    958             ui.with_layout(Layout::right_to_left(Align::Max), |ui| {
    959                 let mut dave_response = DaveResponse::none();
    960 
    961                 // Show Stop button when working, Ask button otherwise
    962                 if self.is_working {
    963                     if ui
    964                         .add(egui::Button::new(tr!(
    965                             i18n,
    966                             "Stop",
    967                             "Button to interrupt/stop the AI operation"
    968                         )))
    969                         .clicked()
    970                     {
    971                         dave_response = DaveResponse::new(DaveAction::Interrupt);
    972                     }
    973 
    974                     // Show "Press Esc again" indicator when interrupt is pending
    975                     if self.interrupt_pending {
    976                         ui.label(
    977                             egui::RichText::new("Press Esc again to stop")
    978                                 .color(ui.visuals().warn_fg_color),
    979                         );
    980                     }
    981                 } else if ui
    982                     .add(egui::Button::new(tr!(
    983                         i18n,
    984                         "Ask",
    985                         "Button to send message to Dave AI assistant"
    986                     )))
    987                     .clicked()
    988                 {
    989                     dave_response = DaveResponse::send();
    990                 }
    991 
    992                 // Show plan mode and auto-steal indicators only in Agentic mode
    993                 if self.ai_mode == AiMode::Agentic {
    994                     let ctrl_held = ui.input(|i| i.modifiers.ctrl);
    995 
    996                     // Plan mode indicator with optional keybind hint when Ctrl is held
    997                     let mut plan_badge =
    998                         super::badge::StatusBadge::new("PLAN").variant(if self.plan_mode_active {
    999                             super::badge::BadgeVariant::Info
   1000                         } else {
   1001                             super::badge::BadgeVariant::Default
   1002                         });
   1003                     if ctrl_held {
   1004                         plan_badge = plan_badge.keybind("M");
   1005                     }
   1006                     plan_badge
   1007                         .show(ui)
   1008                         .on_hover_text("Ctrl+M to toggle plan mode");
   1009 
   1010                     // Auto-steal focus indicator
   1011                     let mut auto_badge =
   1012                         super::badge::StatusBadge::new("AUTO").variant(if self.auto_steal_focus {
   1013                             super::badge::BadgeVariant::Info
   1014                         } else {
   1015                             super::badge::BadgeVariant::Default
   1016                         });
   1017                     if ctrl_held {
   1018                         auto_badge = auto_badge.keybind("\\");
   1019                     }
   1020                     auto_badge
   1021                         .show(ui)
   1022                         .on_hover_text("Ctrl+\\ to toggle auto-focus mode");
   1023                 }
   1024 
   1025                 let r = ui.add(
   1026                     egui::TextEdit::multiline(self.input)
   1027                         .desired_width(f32::INFINITY)
   1028                         .return_key(KeyboardShortcut::new(
   1029                             Modifiers {
   1030                                 shift: true,
   1031                                 ..Default::default()
   1032                             },
   1033                             Key::Enter,
   1034                         ))
   1035                         .hint_text(
   1036                             egui::RichText::new(tr!(
   1037                                 i18n,
   1038                                 "Ask dave anything...",
   1039                                 "Placeholder text for Dave AI input field"
   1040                             ))
   1041                             .weak(),
   1042                         )
   1043                         .frame(false),
   1044                 );
   1045                 notedeck_ui::include_input(ui, &r);
   1046 
   1047                 // Request focus if flagged (e.g., after spawning a new agent or entering tentative state)
   1048                 if *self.focus_requested {
   1049                     r.request_focus();
   1050                     *self.focus_requested = false;
   1051                 }
   1052 
   1053                 // Unfocus text input when there's a pending permission request
   1054                 // UNLESS we're in tentative state (user needs to type message)
   1055                 let in_tentative_state =
   1056                     self.permission_message_state != PermissionMessageState::None;
   1057                 if self.has_pending_permission && !in_tentative_state {
   1058                     r.surrender_focus();
   1059                 }
   1060 
   1061                 if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
   1062                     DaveResponse::send()
   1063                 } else {
   1064                     dave_response
   1065                 }
   1066             })
   1067             .inner
   1068         })
   1069         .inner
   1070     }
   1071 
   1072     fn user_chat(&self, msg: &str, ui: &mut egui::Ui) {
   1073         ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
   1074             egui::Frame::new()
   1075                 .inner_margin(10.0)
   1076                 .corner_radius(10.0)
   1077                 .fill(ui.visuals().widgets.inactive.weak_bg_fill)
   1078                 .show(ui, |ui| {
   1079                     ui.add(
   1080                         egui::Label::new(msg)
   1081                             .wrap_mode(egui::TextWrapMode::Wrap)
   1082                             .selectable(true),
   1083                     );
   1084                 })
   1085         });
   1086     }
   1087 
   1088     fn assistant_chat(&self, msg: &str, ui: &mut egui::Ui) {
   1089         ui.horizontal_wrapped(|ui| {
   1090             ui.add(
   1091                 egui::Label::new(msg)
   1092                     .wrap_mode(egui::TextWrapMode::Wrap)
   1093                     .selectable(true),
   1094             );
   1095         });
   1096     }
   1097 }