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 ¬e, 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 }