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