mod.rs (35177B)
1 mod ask_question; 2 pub mod badge; 3 mod dave; 4 pub mod diff; 5 pub mod directory_picker; 6 mod git_status_ui; 7 pub mod host_picker; 8 pub mod keybind_hint; 9 pub mod keybindings; 10 pub mod markdown_ui; 11 mod pill; 12 mod query_ui; 13 pub mod scene; 14 pub mod session_list; 15 pub mod session_picker; 16 mod settings; 17 mod top_buttons; 18 19 pub use ask_question::{ask_user_question_summary_ui, ask_user_question_ui}; 20 pub use dave::{DaveAction, DaveResponse, DaveUi}; 21 pub use directory_picker::{DirectoryPicker, DirectoryPickerAction}; 22 pub use host_picker::HostPickerAction; 23 pub use keybind_hint::{keybind_hint, paint_keybind_hint}; 24 pub use keybindings::{check_keybindings, KeyAction}; 25 pub use scene::{AgentScene, SceneAction, SceneResponse}; 26 pub use session_list::{SessionListAction, SessionListUi}; 27 pub use session_picker::{SessionPicker, SessionPickerAction}; 28 pub use settings::{DaveSettingsPanel, SettingsPanelAction}; 29 30 // ============================================================================= 31 // Standalone UI Functions 32 // ============================================================================= 33 34 use crate::agent_status::AgentStatus; 35 use crate::backend::BackendType; 36 use crate::config::{AiMode, DaveSettings, ModelConfig}; 37 use crate::focus_queue::FocusQueue; 38 use crate::messages::PermissionResponse; 39 use crate::session::{ChatSession, PermissionMessageState, SessionId, SessionManager}; 40 use crate::update; 41 use crate::DaveOverlay; 42 use egui::include_image; 43 44 /// Build a DaveUi from a session, wiring up all the common builder fields. 45 fn build_dave_ui<'a>( 46 session: &'a mut ChatSession, 47 model_config: &ModelConfig, 48 is_interrupt_pending: bool, 49 auto_steal_focus: bool, 50 ) -> DaveUi<'a> { 51 let is_working = session.status() == AgentStatus::Working; 52 let has_pending_permission = session.has_pending_permissions(); 53 let permission_mode = session.permission_mode(); 54 let is_remote = session.is_remote(); 55 56 let mut ui_builder = DaveUi::new( 57 model_config.trial, 58 session.id, 59 &session.chat, 60 &mut session.input, 61 &mut session.focus_requested, 62 session.ai_mode, 63 ) 64 .is_working(is_working) 65 .interrupt_pending(is_interrupt_pending) 66 .has_pending_permission(has_pending_permission) 67 .permission_mode(permission_mode) 68 .auto_steal_focus(auto_steal_focus) 69 .is_remote(is_remote) 70 .dispatch_state(session.dispatch_state) 71 .details(&session.details) 72 .backend_type(session.backend_type) 73 .last_activity(session.last_activity); 74 75 if let Some(agentic) = &mut session.agentic { 76 let model = agentic 77 .session_info 78 .as_ref() 79 .and_then(|si| si.model.as_deref()); 80 ui_builder = ui_builder 81 .permission_message_state(agentic.permission_message_state) 82 .question_answers(&mut agentic.question_answers) 83 .question_index(&mut agentic.question_index) 84 .is_compacting(agentic.is_compacting) 85 .usage(&agentic.usage, model); 86 87 // Only show git status for local sessions 88 if !is_remote { 89 ui_builder = ui_builder.git_status(&mut agentic.git_status); 90 } 91 } 92 93 ui_builder 94 } 95 96 /// Set tentative permission state on the active session's agentic data. 97 fn set_tentative_state(session_manager: &mut SessionManager, state: PermissionMessageState) { 98 if let Some(session) = session_manager.get_active_mut() { 99 if let Some(agentic) = &mut session.agentic { 100 agentic.permission_message_state = state; 101 } 102 session.focus_requested = true; 103 } 104 } 105 106 /// UI result from overlay rendering 107 pub enum OverlayResult { 108 /// No action taken 109 None, 110 /// Close the overlay 111 Close, 112 /// Directory was selected (no resumable sessions) 113 DirectorySelected(std::path::PathBuf), 114 /// Resume a session 115 ResumeSession { 116 cwd: std::path::PathBuf, 117 session_id: String, 118 title: String, 119 /// Path to the JSONL file for archive conversion 120 file_path: std::path::PathBuf, 121 }, 122 /// Create a new session in the given directory 123 NewSession { cwd: std::path::PathBuf }, 124 /// Go back to directory picker 125 BackToDirectoryPicker, 126 /// Apply new settings 127 ApplySettings(DaveSettings), 128 /// Host was selected. `None` = local, `Some(hostname)` = remote. 129 HostSelected(Option<String>), 130 } 131 132 /// Render the settings overlay UI. 133 pub fn settings_overlay_ui( 134 settings_panel: &mut DaveSettingsPanel, 135 settings: &DaveSettings, 136 ui: &mut egui::Ui, 137 ) -> OverlayResult { 138 if let Some(action) = settings_panel.overlay_ui(ui, settings) { 139 match action { 140 SettingsPanelAction::Save(new_settings) => { 141 return OverlayResult::ApplySettings(new_settings); 142 } 143 SettingsPanelAction::Cancel => { 144 return OverlayResult::Close; 145 } 146 } 147 } 148 OverlayResult::None 149 } 150 151 /// Render the directory picker overlay UI. 152 pub fn directory_picker_overlay_ui( 153 directory_picker: &mut DirectoryPicker, 154 has_sessions: bool, 155 ui: &mut egui::Ui, 156 ) -> OverlayResult { 157 if let Some(action) = directory_picker.overlay_ui(ui, has_sessions) { 158 match action { 159 DirectoryPickerAction::DirectorySelected(path) => { 160 return OverlayResult::DirectorySelected(path); 161 } 162 DirectoryPickerAction::Cancelled => { 163 if has_sessions { 164 return OverlayResult::Close; 165 } 166 } 167 DirectoryPickerAction::BrowseRequested => {} 168 } 169 } 170 OverlayResult::None 171 } 172 173 /// Render the session picker overlay UI. 174 pub fn session_picker_overlay_ui( 175 session_picker: &mut SessionPicker, 176 ui: &mut egui::Ui, 177 ) -> OverlayResult { 178 if let Some(action) = session_picker.overlay_ui(ui) { 179 match action { 180 SessionPickerAction::ResumeSession { 181 cwd, 182 session_id, 183 title, 184 file_path, 185 } => { 186 return OverlayResult::ResumeSession { 187 cwd, 188 session_id, 189 title, 190 file_path, 191 }; 192 } 193 SessionPickerAction::NewSession { cwd } => { 194 return OverlayResult::NewSession { cwd }; 195 } 196 SessionPickerAction::BackToDirectoryPicker => { 197 return OverlayResult::BackToDirectoryPicker; 198 } 199 } 200 } 201 OverlayResult::None 202 } 203 204 /// Render the host picker overlay UI. 205 pub fn host_picker_overlay_ui( 206 local_hostname: &str, 207 known_hosts: &[String], 208 has_sessions: bool, 209 ui: &mut egui::Ui, 210 ) -> OverlayResult { 211 if let Some(action) = 212 host_picker::host_picker_overlay_ui(ui, local_hostname, known_hosts, has_sessions) 213 { 214 match action { 215 HostPickerAction::HostSelected(host) => { 216 return OverlayResult::HostSelected(host); 217 } 218 HostPickerAction::Cancelled => { 219 return OverlayResult::Close; 220 } 221 } 222 } 223 OverlayResult::None 224 } 225 226 /// Brand color for a backend type. 227 pub fn backend_color(bt: BackendType) -> egui::Color32 { 228 match bt { 229 BackendType::Claude => egui::Color32::from_rgb(0xD9, 0x77, 0x57), // Anthropic terracotta 230 BackendType::Codex => egui::Color32::from_rgb(0x10, 0xA3, 0x7F), // OpenAI green 231 _ => egui::Color32::WHITE, 232 } 233 } 234 235 /// Get an icon image for a backend type, tinted with its brand color. 236 pub fn backend_icon(bt: BackendType) -> egui::Image<'static> { 237 let img = match bt { 238 BackendType::Claude => { 239 egui::Image::new(include_image!("../../../../assets/icons/claude-code.svg")) 240 } 241 BackendType::Codex => { 242 egui::Image::new(include_image!("../../../../assets/icons/codex.svg")) 243 } 244 _ => egui::Image::new(include_image!("../../../../assets/icons/sparkle.svg")), 245 }; 246 img.tint(backend_color(bt)) 247 } 248 249 /// Render the backend picker overlay UI. 250 /// Returns Some(BackendType) when the user has selected a backend. 251 pub fn backend_picker_overlay_ui( 252 available_backends: &[BackendType], 253 ui: &mut egui::Ui, 254 ) -> Option<BackendType> { 255 let mut selected = None; 256 257 // Handle keyboard shortcuts: 1-9 for quick selection 258 for (idx, &bt) in available_backends.iter().enumerate().take(9) { 259 let key = match idx { 260 0 => egui::Key::Num1, 261 1 => egui::Key::Num2, 262 2 => egui::Key::Num3, 263 3 => egui::Key::Num4, 264 4 => egui::Key::Num5, 265 _ => continue, 266 }; 267 if ui.input(|i| i.key_pressed(key)) { 268 return Some(bt); 269 } 270 } 271 272 let is_narrow = notedeck::ui::is_narrow(ui.ctx()); 273 274 egui::Frame::new() 275 .fill(ui.visuals().panel_fill) 276 .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20)) 277 .show(ui, |ui| { 278 ui.heading("Select Backend"); 279 ui.add_space(8.0); 280 ui.label("Choose which AI backend to use for this session:"); 281 ui.add_space(16.0); 282 283 let max_width = if is_narrow { 284 ui.available_width() 285 } else { 286 400.0 287 }; 288 289 ui.allocate_ui_with_layout( 290 egui::vec2(max_width, ui.available_height()), 291 egui::Layout::top_down(egui::Align::LEFT), 292 |ui| { 293 for (idx, &bt) in available_backends.iter().enumerate() { 294 let desired = egui::vec2(max_width, 44.0); 295 let (rect, response) = 296 ui.allocate_exact_size(desired, egui::Sense::click()); 297 let response = response.on_hover_cursor(egui::CursorIcon::PointingHand); 298 299 // Background 300 let fill = if response.hovered() { 301 ui.visuals().widgets.hovered.weak_bg_fill 302 } else { 303 ui.visuals().widgets.inactive.weak_bg_fill 304 }; 305 ui.painter().rect_filled(rect, 8.0, fill); 306 307 // Icon 308 let icon_size = 20.0; 309 let icon_x = rect.left() + 12.0; 310 let icon_rect = egui::Rect::from_center_size( 311 egui::pos2(icon_x + icon_size / 2.0, rect.center().y), 312 egui::vec2(icon_size, icon_size), 313 ); 314 backend_icon(bt).paint_at(ui, icon_rect); 315 316 // Label 317 let label = format!("[{}] {}", idx + 1, bt.display_name()); 318 let text_pos = egui::pos2(icon_x + icon_size + 10.0, rect.center().y); 319 ui.painter().text( 320 text_pos, 321 egui::Align2::LEFT_CENTER, 322 &label, 323 egui::FontId::proportional(16.0), 324 ui.visuals().text_color(), 325 ); 326 327 if response.clicked() { 328 selected = Some(bt); 329 } 330 ui.add_space(4.0); 331 } 332 }, 333 ); 334 }); 335 336 selected 337 } 338 339 /// Scene view action returned after rendering 340 pub enum SceneViewAction { 341 None, 342 ToggleToListView, 343 SpawnAgent, 344 DeleteSelected(Vec<SessionId>), 345 } 346 347 /// Render the scene view with RTS-style agent visualization and chat side panel. 348 #[allow(clippy::too_many_arguments)] 349 pub fn scene_ui( 350 session_manager: &mut SessionManager, 351 scene: &mut AgentScene, 352 focus_queue: &mut FocusQueue, 353 model_config: &ModelConfig, 354 is_interrupt_pending: bool, 355 auto_steal_focus: bool, 356 app_ctx: &mut notedeck::AppContext, 357 ui: &mut egui::Ui, 358 ) -> (DaveResponse, SceneViewAction) { 359 use egui_extras::{Size, StripBuilder}; 360 361 let mut dave_response = DaveResponse::default(); 362 let mut scene_response_opt: Option<SceneResponse> = None; 363 let mut view_action = SceneViewAction::None; 364 365 let ctrl_held = ui.input(|i| i.modifiers.ctrl); 366 367 StripBuilder::new(ui) 368 .size(Size::relative(0.25)) 369 .size(Size::remainder()) 370 .clip(true) 371 .horizontal(|mut strip| { 372 strip.cell(|ui| { 373 ui.horizontal(|ui| { 374 if ui 375 .button("+ New Agent") 376 .on_hover_text("Hold Ctrl to see keybindings") 377 .clicked() 378 { 379 view_action = SceneViewAction::SpawnAgent; 380 } 381 if ctrl_held { 382 keybind_hint(ui, "N"); 383 } 384 ui.separator(); 385 if ui 386 .button("List View") 387 .on_hover_text("Ctrl+L to toggle views") 388 .clicked() 389 { 390 view_action = SceneViewAction::ToggleToListView; 391 } 392 if ctrl_held { 393 keybind_hint(ui, "L"); 394 } 395 }); 396 ui.separator(); 397 scene_response_opt = Some(scene.ui(session_manager, focus_queue, ui, ctrl_held)); 398 }); 399 400 strip.cell(|ui| { 401 egui::Frame::new() 402 .fill(ui.visuals().faint_bg_color) 403 .inner_margin(egui::Margin::symmetric(8, 12)) 404 .show(ui, |ui| { 405 if let Some(selected_id) = scene.primary_selection() { 406 if let Some(session) = session_manager.get_mut(selected_id) { 407 ui.heading(session.details.display_title()); 408 ui.separator(); 409 410 let response = build_dave_ui( 411 session, 412 model_config, 413 is_interrupt_pending, 414 auto_steal_focus, 415 ) 416 .compact(true) 417 .ui(app_ctx, ui); 418 if response.action.is_some() { 419 dave_response = response; 420 } 421 } 422 } else { 423 ui.centered_and_justified(|ui| { 424 ui.label("Select an agent to view chat"); 425 }); 426 } 427 }); 428 }); 429 }); 430 431 // Handle scene actions 432 if let Some(response) = scene_response_opt { 433 if let Some(action) = response.action { 434 match action { 435 SceneAction::SelectionChanged(ids) => { 436 if let Some(id) = ids.first() { 437 session_manager.switch_to(*id); 438 focus_queue.dequeue(*id); 439 } 440 } 441 SceneAction::SpawnAgent => { 442 view_action = SceneViewAction::SpawnAgent; 443 } 444 SceneAction::DeleteSelected => { 445 view_action = SceneViewAction::DeleteSelected(scene.selected.clone()); 446 } 447 SceneAction::AgentMoved { id, position } => { 448 if let Some(session) = session_manager.get_mut(id) { 449 if let Some(agentic) = &mut session.agentic { 450 agentic.scene_position = position; 451 } 452 } 453 } 454 } 455 } 456 } 457 458 (dave_response, view_action) 459 } 460 461 /// Desktop layout with sidebar for session list. 462 #[allow(clippy::too_many_arguments)] 463 pub fn desktop_ui( 464 session_manager: &mut SessionManager, 465 focus_queue: &FocusQueue, 466 model_config: &ModelConfig, 467 is_interrupt_pending: bool, 468 auto_steal_focus: bool, 469 app_ctx: &mut notedeck::AppContext, 470 ui: &mut egui::Ui, 471 ) -> (DaveResponse, Option<SessionListAction>, bool) { 472 let available = ui.available_rect_before_wrap(); 473 let sidebar_width = if available.width() < 830.0 { 474 200.0 475 } else { 476 280.0 477 }; 478 let ctrl_held = ui.input(|i| i.modifiers.ctrl); 479 let mut toggle_scene = false; 480 481 let sidebar_rect = 482 egui::Rect::from_min_size(available.min, egui::vec2(sidebar_width, available.height())); 483 let chat_rect = egui::Rect::from_min_size( 484 egui::pos2(available.min.x + sidebar_width, available.min.y), 485 egui::vec2(available.width() - sidebar_width, available.height()), 486 ); 487 488 let session_action = ui 489 .allocate_new_ui(egui::UiBuilder::new().max_rect(sidebar_rect), |ui| { 490 egui::Frame::new() 491 .fill(ui.visuals().faint_bg_color) 492 .inner_margin(egui::Margin::symmetric(8, 12)) 493 .show(ui, |ui| { 494 let has_agentic = session_manager 495 .sessions_ordered() 496 .iter() 497 .any(|s| s.ai_mode == AiMode::Agentic); 498 if has_agentic { 499 ui.horizontal(|ui| { 500 if ui 501 .button("Scene View") 502 .on_hover_text("Ctrl+L to toggle views") 503 .clicked() 504 { 505 toggle_scene = true; 506 } 507 if ctrl_held { 508 keybind_hint(ui, "L"); 509 } 510 }); 511 ui.separator(); 512 } 513 SessionListUi::new(session_manager, focus_queue, ctrl_held).ui(ui) 514 }) 515 .inner 516 }) 517 .inner; 518 519 let chat_response = ui 520 .allocate_new_ui(egui::UiBuilder::new().max_rect(chat_rect), |ui| { 521 if let Some(session) = session_manager.get_active_mut() { 522 build_dave_ui( 523 session, 524 model_config, 525 is_interrupt_pending, 526 auto_steal_focus, 527 ) 528 .ui(app_ctx, ui) 529 } else { 530 DaveResponse::default() 531 } 532 }) 533 .inner; 534 535 (chat_response, session_action, toggle_scene) 536 } 537 538 /// Narrow/mobile layout - shows either session list or chat. 539 #[allow(clippy::too_many_arguments)] 540 pub fn narrow_ui( 541 session_manager: &mut SessionManager, 542 focus_queue: &FocusQueue, 543 model_config: &ModelConfig, 544 is_interrupt_pending: bool, 545 auto_steal_focus: bool, 546 show_session_list: bool, 547 app_ctx: &mut notedeck::AppContext, 548 ui: &mut egui::Ui, 549 ) -> (DaveResponse, Option<SessionListAction>) { 550 if show_session_list { 551 let ctrl_held = ui.input(|i| i.modifiers.ctrl); 552 let session_action = egui::Frame::new() 553 .fill(ui.visuals().faint_bg_color) 554 .inner_margin(egui::Margin::symmetric(8, 12)) 555 .show(ui, |ui| { 556 SessionListUi::new(session_manager, focus_queue, ctrl_held).ui(ui) 557 }) 558 .inner; 559 (DaveResponse::default(), session_action) 560 } else if let Some(session) = session_manager.get_active_mut() { 561 let dot_color = focus_queue.current().map(|e| e.priority.color()); 562 let fq_info = focus_queue.ui_info(); 563 let response = build_dave_ui( 564 session, 565 model_config, 566 is_interrupt_pending, 567 auto_steal_focus, 568 ) 569 .status_dot_color(dot_color) 570 .focus_queue_info(fq_info) 571 .ui(app_ctx, ui); 572 (response, None) 573 } else { 574 (DaveResponse::default(), None) 575 } 576 } 577 578 /// Result from handling a key action 579 pub enum KeyActionResult { 580 None, 581 ToggleView, 582 HandleInterrupt, 583 CloneAgent, 584 NewAgent, 585 DeleteSession(SessionId), 586 SetAutoSteal(bool), 587 /// Permission response needs relay publishing. 588 PublishPermissionResponse(update::PermissionPublish), 589 /// Permission mode command needs relay publishing (observer → host). 590 PublishModeCommand(update::ModeCommandPublish), 591 } 592 593 /// Handle a keybinding action. 594 #[allow(clippy::too_many_arguments)] 595 pub fn handle_key_action( 596 key_action: KeyAction, 597 session_manager: &mut SessionManager, 598 scene: &mut AgentScene, 599 focus_queue: &mut FocusQueue, 600 backend: &dyn crate::backend::AiBackend, 601 show_scene: bool, 602 auto_steal_focus: bool, 603 home_session: &mut Option<SessionId>, 604 ctx: &egui::Context, 605 ) -> KeyActionResult { 606 match key_action { 607 KeyAction::AcceptPermission => { 608 if let Some(request_id) = update::first_pending_permission(session_manager) { 609 let result = update::handle_permission_response( 610 session_manager, 611 request_id, 612 PermissionResponse::Allow { message: None }, 613 ); 614 if let Some(session) = session_manager.get_active_mut() { 615 session.focus_requested = true; 616 } 617 if let Some(publish) = result { 618 return KeyActionResult::PublishPermissionResponse(publish); 619 } 620 } 621 KeyActionResult::None 622 } 623 KeyAction::DenyPermission => { 624 if let Some(request_id) = update::first_pending_permission(session_manager) { 625 let result = update::handle_permission_response( 626 session_manager, 627 request_id, 628 PermissionResponse::Deny { 629 reason: "User denied".into(), 630 }, 631 ); 632 if let Some(session) = session_manager.get_active_mut() { 633 session.focus_requested = true; 634 } 635 if let Some(publish) = result { 636 return KeyActionResult::PublishPermissionResponse(publish); 637 } 638 } 639 KeyActionResult::None 640 } 641 KeyAction::TentativeAccept => { 642 set_tentative_state(session_manager, PermissionMessageState::TentativeAccept); 643 KeyActionResult::None 644 } 645 KeyAction::TentativeDeny => { 646 set_tentative_state(session_manager, PermissionMessageState::TentativeDeny); 647 KeyActionResult::None 648 } 649 KeyAction::AllowAlways => { 650 update::allow_always(session_manager); 651 if let Some(request_id) = update::first_pending_permission(session_manager) { 652 let result = update::handle_permission_response( 653 session_manager, 654 request_id, 655 PermissionResponse::Allow { message: None }, 656 ); 657 if let Some(session) = session_manager.get_active_mut() { 658 session.focus_requested = true; 659 } 660 if let Some(publish) = result { 661 return KeyActionResult::PublishPermissionResponse(publish); 662 } 663 } 664 KeyActionResult::None 665 } 666 KeyAction::TentativeAllowAlways => { 667 update::allow_always(session_manager); 668 set_tentative_state(session_manager, PermissionMessageState::TentativeAccept); 669 KeyActionResult::None 670 } 671 KeyAction::CancelTentative => { 672 if let Some(session) = session_manager.get_active_mut() { 673 if let Some(agentic) = &mut session.agentic { 674 agentic.permission_message_state = PermissionMessageState::None; 675 } 676 } 677 KeyActionResult::None 678 } 679 KeyAction::SwitchToAgent(index) => { 680 update::switch_to_agent_by_index(session_manager, scene, show_scene, index); 681 KeyActionResult::None 682 } 683 KeyAction::NextAgent => { 684 update::cycle_next_agent(session_manager, scene, show_scene); 685 KeyActionResult::None 686 } 687 KeyAction::PreviousAgent => { 688 update::cycle_prev_agent(session_manager, scene, show_scene); 689 KeyActionResult::None 690 } 691 KeyAction::NewAgent => KeyActionResult::NewAgent, 692 KeyAction::CloneAgent => KeyActionResult::CloneAgent, 693 KeyAction::Interrupt => KeyActionResult::HandleInterrupt, 694 KeyAction::ToggleView => KeyActionResult::ToggleView, 695 KeyAction::CyclePermissionMode => { 696 let publish = update::cycle_permission_mode(session_manager, backend, ctx); 697 if let Some(session) = session_manager.get_active_mut() { 698 session.focus_requested = true; 699 } 700 match publish { 701 Some(cmd) => KeyActionResult::PublishModeCommand(cmd), 702 None => KeyActionResult::None, 703 } 704 } 705 KeyAction::DeleteActiveSession => { 706 if let Some(id) = session_manager.active_id() { 707 KeyActionResult::DeleteSession(id) 708 } else { 709 KeyActionResult::None 710 } 711 } 712 KeyAction::FocusQueueNext => { 713 update::focus_queue_next(session_manager, focus_queue, scene, show_scene); 714 KeyActionResult::None 715 } 716 KeyAction::FocusQueuePrev => { 717 update::focus_queue_prev(session_manager, focus_queue, scene, show_scene); 718 KeyActionResult::None 719 } 720 KeyAction::FocusQueueToggleDone => { 721 update::focus_queue_toggle_done(focus_queue); 722 KeyActionResult::None 723 } 724 KeyAction::ToggleAutoSteal => { 725 let new_state = update::toggle_auto_steal( 726 session_manager, 727 scene, 728 show_scene, 729 auto_steal_focus, 730 home_session, 731 ); 732 KeyActionResult::SetAutoSteal(new_state) 733 } 734 KeyAction::OpenExternalEditor => { 735 update::open_external_editor(session_manager); 736 KeyActionResult::None 737 } 738 } 739 } 740 741 /// Result from handling a send action 742 pub enum SendActionResult { 743 /// Permission response was sent, no further action needed 744 Handled, 745 /// Normal send - caller should send the user message 746 SendMessage, 747 /// Permission response needs relay publishing. 748 NeedsRelayPublish(update::PermissionPublish), 749 } 750 751 /// Handle the Send action, including tentative permission states. 752 pub fn handle_send_action( 753 session_manager: &mut SessionManager, 754 backend: &dyn crate::backend::AiBackend, 755 ctx: &egui::Context, 756 ) -> SendActionResult { 757 let tentative_state = session_manager 758 .get_active() 759 .and_then(|s| s.agentic.as_ref()) 760 .map(|a| a.permission_message_state) 761 .unwrap_or(PermissionMessageState::None); 762 763 match tentative_state { 764 PermissionMessageState::TentativeAccept => { 765 let is_exit_plan_mode = update::has_pending_exit_plan_mode(session_manager); 766 if let Some(request_id) = update::first_pending_permission(session_manager) { 767 let message = session_manager 768 .get_active() 769 .map(|s| s.input.clone()) 770 .filter(|m| !m.is_empty()); 771 if let Some(session) = session_manager.get_active_mut() { 772 session.input.clear(); 773 } 774 if is_exit_plan_mode { 775 update::exit_plan_mode(session_manager, backend, ctx); 776 } 777 let result = update::handle_permission_response( 778 session_manager, 779 request_id, 780 PermissionResponse::Allow { message }, 781 ); 782 if let Some(publish) = result { 783 return SendActionResult::NeedsRelayPublish(publish); 784 } 785 } 786 SendActionResult::Handled 787 } 788 PermissionMessageState::TentativeDeny => { 789 if let Some(request_id) = update::first_pending_permission(session_manager) { 790 let reason = session_manager 791 .get_active() 792 .map(|s| s.input.clone()) 793 .filter(|m| !m.is_empty()) 794 .unwrap_or_else(|| "User denied".into()); 795 if let Some(session) = session_manager.get_active_mut() { 796 session.input.clear(); 797 } 798 let result = update::handle_permission_response( 799 session_manager, 800 request_id, 801 PermissionResponse::Deny { reason }, 802 ); 803 if let Some(publish) = result { 804 return SendActionResult::NeedsRelayPublish(publish); 805 } 806 } 807 SendActionResult::Handled 808 } 809 PermissionMessageState::None => SendActionResult::SendMessage, 810 } 811 } 812 813 /// Result from handling a UI action 814 pub enum UiActionResult { 815 /// Action was fully handled 816 Handled, 817 /// Send action - caller should handle send 818 SendAction, 819 /// Return an AppAction 820 AppAction(notedeck::AppAction), 821 /// Permission response needs relay publishing. 822 PublishPermissionResponse(update::PermissionPublish), 823 /// Toggle auto-steal focus mode (needs state from DaveApp) 824 ToggleAutoSteal, 825 /// New chat requested — caller routes through handle_new_chat() 826 NewChat, 827 /// Trigger manual context compaction 828 Compact, 829 /// Permission mode command needs relay publishing (observer → host). 830 PublishModeCommand(update::ModeCommandPublish), 831 /// Navigate to next focus queue item (mobile) 832 FocusQueueNext, 833 } 834 835 /// Handle a UI action from DaveUi. 836 #[allow(clippy::too_many_arguments)] 837 pub fn handle_ui_action( 838 action: DaveAction, 839 session_manager: &mut SessionManager, 840 backend: &dyn crate::backend::AiBackend, 841 active_overlay: &mut DaveOverlay, 842 show_session_list: &mut bool, 843 ctx: &egui::Context, 844 ) -> UiActionResult { 845 match action { 846 DaveAction::ToggleChrome => UiActionResult::AppAction(notedeck::AppAction::ToggleChrome), 847 DaveAction::Note(n) => UiActionResult::AppAction(notedeck::AppAction::Note(n)), 848 DaveAction::NewChat => UiActionResult::NewChat, 849 DaveAction::Send => UiActionResult::SendAction, 850 DaveAction::ShowSessionList => { 851 *show_session_list = !*show_session_list; 852 UiActionResult::Handled 853 } 854 DaveAction::OpenSettings => { 855 *active_overlay = DaveOverlay::Settings; 856 UiActionResult::Handled 857 } 858 DaveAction::UpdateSettings(_settings) => UiActionResult::Handled, 859 DaveAction::PermissionResponse { 860 request_id, 861 response, 862 } => update::handle_permission_response(session_manager, request_id, response).map_or( 863 UiActionResult::Handled, 864 UiActionResult::PublishPermissionResponse, 865 ), 866 DaveAction::Interrupt => { 867 update::execute_interrupt(session_manager, backend, ctx); 868 UiActionResult::Handled 869 } 870 DaveAction::TentativeAccept => { 871 set_tentative_state(session_manager, PermissionMessageState::TentativeAccept); 872 UiActionResult::Handled 873 } 874 DaveAction::TentativeDeny => { 875 set_tentative_state(session_manager, PermissionMessageState::TentativeDeny); 876 UiActionResult::Handled 877 } 878 DaveAction::AllowAlways { request_id } => { 879 update::allow_always(session_manager); 880 update::handle_permission_response( 881 session_manager, 882 request_id, 883 PermissionResponse::Allow { message: None }, 884 ) 885 .map_or( 886 UiActionResult::Handled, 887 UiActionResult::PublishPermissionResponse, 888 ) 889 } 890 DaveAction::TentativeAllowAlways => { 891 update::allow_always(session_manager); 892 set_tentative_state(session_manager, PermissionMessageState::TentativeAccept); 893 UiActionResult::Handled 894 } 895 DaveAction::QuestionResponse { 896 request_id, 897 answers, 898 } => update::handle_question_response(session_manager, request_id, answers).map_or( 899 UiActionResult::Handled, 900 UiActionResult::PublishPermissionResponse, 901 ), 902 DaveAction::CyclePermissionMode => { 903 let publish = update::cycle_permission_mode(session_manager, backend, ctx); 904 if let Some(session) = session_manager.get_active_mut() { 905 session.focus_requested = true; 906 } 907 match publish { 908 Some(cmd) => UiActionResult::PublishModeCommand(cmd), 909 None => UiActionResult::Handled, 910 } 911 } 912 DaveAction::ToggleAutoSteal => UiActionResult::ToggleAutoSteal, 913 DaveAction::FocusQueueNext => UiActionResult::FocusQueueNext, 914 DaveAction::ExitPlanMode { 915 request_id, 916 approved, 917 } => { 918 let result = if approved { 919 update::exit_plan_mode(session_manager, backend, ctx); 920 update::handle_permission_response( 921 session_manager, 922 request_id, 923 PermissionResponse::Allow { message: None }, 924 ) 925 } else { 926 update::handle_permission_response( 927 session_manager, 928 request_id, 929 PermissionResponse::Deny { 930 reason: "User rejected plan".into(), 931 }, 932 ) 933 }; 934 result.map_or( 935 UiActionResult::Handled, 936 UiActionResult::PublishPermissionResponse, 937 ) 938 } 939 DaveAction::CompactAndApprove { request_id } => { 940 update::exit_plan_mode(session_manager, backend, ctx); 941 let result = update::handle_permission_response( 942 session_manager, 943 request_id, 944 PermissionResponse::Allow { 945 message: Some("/compact".into()), 946 }, 947 ); 948 if let Some(session) = session_manager.get_active_mut() { 949 if let Some(agentic) = &mut session.agentic { 950 agentic.compact_and_proceed = 951 crate::session::CompactAndProceedState::WaitingForCompaction; 952 } 953 } 954 result.map_or( 955 UiActionResult::Handled, 956 UiActionResult::PublishPermissionResponse, 957 ) 958 } 959 DaveAction::Compact => UiActionResult::Compact, 960 } 961 }