notedeck

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

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 }