use super::badge::{BadgeVariant, StatusBadge};
use super::diff;
use super::git_status_ui;
use super::markdown_ui;
use super::query_ui::query_call_ui;
use super::run_ui;
use super::square_loading_spinner::SquareLoadingSpinner;
use super::top_buttons::top_buttons_ui;
use crate::{
    backend::BackendType,
    config::{AiMode, DaveSettings},
    file_update::FileUpdate,
    focus_queue::FocusPriority,
    git_status::GitStatusCache,
    messages::{
        ApprovalPromptInput, AssistantMessage, CompactionInfo, ExecutedTool, ImageAttachment,
        Message, PermissionRequest, PermissionResponse, PermissionResponseType, PermissionView,
        QuestionAnswer, SubagentInfo, SubagentStatus,
    },
    session::{PermissionMessageState, SessionDetails, SessionId},
    tools::{PresentNotesCall, ToolCall, ToolCalls, ToolResponse, ToolResponses},
};
use bitflags::bitflags;
use claude_agent_sdk_rs::PermissionMode;
use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
use nostrdb::Transaction;
use notedeck::{tr, AppContext, Localization, NoteAction, NoteContext};
use notedeck_ui::{icons::search_icon, NoteOptions};
use std::collections::HashMap;
use uuid::Uuid;

bitflags! {
    #[repr(transparent)]
    #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
    pub struct DaveUiFlags: u16 {
        const Trial            = 1 << 0;
        const Compact          = 1 << 1;
        const IsWorking        = 1 << 2;
        const InterruptPending = 1 << 3;
        const HasPendingPerm   = 1 << 4;
        const IsCompacting     = 1 << 5;
        const AutoStealFocus   = 1 << 6;
        const IsRemote         = 1 << 7;
        const AutoAcceptAll    = 1 << 8;
    }
}

/// DaveUi holds all of the data it needs to render itself
pub struct DaveUi<'a> {
    chat: &'a [Message],
    flags: DaveUiFlags,
    input: &'a mut String,
    focus_requested: &'a mut bool,
    /// Session ID for per-session scroll state
    session_id: SessionId,
    /// State for tentative permission response (waiting for message)
    permission_message_state: PermissionMessageState,
    /// State for shared question-set responses (selected options per question)
    question_answers: Option<&'a mut HashMap<Uuid, Vec<QuestionAnswer>>>,
    /// Current question index for multi-question prompts
    question_index: Option<&'a mut HashMap<Uuid, usize>>,
    /// AI interaction mode (Chat vs Agentic)
    ai_mode: AiMode,
    /// Git status cache for current session (agentic only)
    git_status: Option<&'a mut GitStatusCache>,
    /// Session details for header display
    details: Option<&'a SessionDetails>,
    /// Color for the notification dot on the mobile hamburger icon,
    /// derived from FocusPriority of the next focus queue entry.
    status_dot_color: Option<egui::Color32>,
    /// Usage metrics for the current session (tokens, cost)
    usage: Option<&'a crate::messages::UsageInfo>,
    /// Context window size for the current model
    context_window: u64,
    /// Dispatch lifecycle state, used for queued indicator logic.
    dispatch_state: crate::session::DispatchState,
    /// Which backend this session uses
    backend_type: BackendType,
    /// Current permission mode (Default, Plan, AcceptEdits)
    permission_mode: PermissionMode,
    /// When the last activity was seen, as wall-clock unix seconds
    last_activity: Option<u64>,
    /// Focus queue info for mobile NEXT badge: (position, total, priority)
    focus_queue_info: Option<(usize, usize, FocusPriority)>,
    /// Named run configs for this session's CWD
    run_configs: &'a [crate::config::RunConfig],
    /// IDs of configs currently running for this session
    running_config_ids: Option<&'a std::collections::HashSet<String>>,
    /// Pending image attachments staged for the next send
    pending_images: Option<&'a mut Vec<ImageAttachment>>,
}

/// The response the app generates. The response contains an optional
/// action to take.
#[derive(Default, Debug)]
pub struct DaveResponse {
    pub action: Option<DaveAction>,
}

impl DaveResponse {
    pub fn new(action: DaveAction) -> Self {
        DaveResponse {
            action: Some(action),
        }
    }

    fn note(action: NoteAction) -> DaveResponse {
        Self::new(DaveAction::Note(action))
    }

    pub fn or(self, r: DaveResponse) -> DaveResponse {
        DaveResponse {
            action: self.action.or(r.action),
        }
    }

    /// Generate a send response to the controller
    fn send() -> Self {
        Self::new(DaveAction::Send)
    }

    fn none() -> Self {
        DaveResponse::default()
    }
}

/// All actions related to run configurations and process control.
#[derive(Debug)]
pub enum RunAction {
    /// Launch the config with this stable UUID
    Launch { config_id: String },
    /// Stop the running process for the config with this stable UUID
    Stop { config_id: String },
    /// Open the editor to create a new config for the given CWD
    OpenNew { cwd: std::path::PathBuf },
    /// Open the editor to edit an existing config by its stable UUID
    OpenEdit {
        cwd: std::path::PathBuf,
        config_id: String,
    },
}

/// The actions the app generates. No default action is specfied in the
/// UI code. This is handled by the app logic, however it chooses to
/// process this message.
#[derive(Debug)]
pub enum DaveAction {
    /// The action generated when the user sends a message to dave
    Send,
    NewChat,
    ToggleChrome,
    Note(NoteAction),
    /// Toggle showing the session list (for mobile navigation)
    ShowSessionList,
    /// Open the settings panel
    OpenSettings,
    /// Settings were updated and should be persisted
    UpdateSettings(DaveSettings),
    /// User responded to a permission request
    PermissionResponse {
        request_id: Uuid,
        response: PermissionResponse,
    },
    /// User wants to interrupt/stop the current AI operation
    Interrupt,
    /// Exit the tool call: deny it and cancel the current turn
    ExitToolCall {
        request_id: Uuid,
    },
    /// Enter tentative accept mode (Shift+click on Yes)
    TentativeAccept,
    /// Enter tentative deny mode (Shift+click on No)
    TentativeDeny,
    /// Allow always — add to session allowlist and accept
    AllowAlways {
        request_id: Uuid,
    },
    /// Tentative allow always — add to session allowlist, enter message mode
    TentativeAllowAlways,
    /// User responded to a shared question-set prompt
    QuestionResponse {
        request_id: Uuid,
        answers: Vec<QuestionAnswer>,
    },
    /// User approved or rejected an ExitPlanMode request
    ExitPlanMode {
        request_id: Uuid,
        approved: bool,
    },
    /// User approved plan and wants to compact first
    CompactAndApprove {
        request_id: Uuid,
    },
    /// Cycle permission mode: Default → Plan → AcceptEdits (clicked mode badge)
    CyclePermissionMode,
    /// Set a specific permission mode (chosen from the mode badge's right-click
    /// menu): Default / Plan / AcceptEdits.
    SetPermissionMode(PermissionMode),
    /// Toggle dave-side "Auto Accept All" (right-click menu / Ctrl+Shift+M).
    ToggleAutoAcceptAll,
    /// Toggle auto-steal focus mode (clicked AUTO badge)
    ToggleAutoSteal,
    /// Trigger manual context compaction
    Compact,
    /// Navigate to the next focus queue item (mobile)
    FocusQueueNext,
    /// All run-config and process-control actions
    Run(RunAction),
}

impl<'a> DaveUi<'a> {
    pub fn new(
        trial: bool,
        session_id: SessionId,
        chat: &'a [Message],
        input: &'a mut String,
        focus_requested: &'a mut bool,
        ai_mode: AiMode,
    ) -> Self {
        let flags = if trial {
            DaveUiFlags::Trial
        } else {
            DaveUiFlags::empty()
        };
        DaveUi {
            flags,
            session_id,
            chat,
            input,
            focus_requested,
            permission_message_state: PermissionMessageState::None,
            question_answers: None,
            question_index: None,
            ai_mode,
            git_status: None,
            details: None,
            status_dot_color: None,
            usage: None,
            context_window: crate::messages::context_window_for_model(None),
            dispatch_state: crate::session::DispatchState::default(),
            backend_type: BackendType::Remote,
            permission_mode: PermissionMode::Default,
            last_activity: None,
            focus_queue_info: None,
            run_configs: &[],
            running_config_ids: None,
            pending_images: None,
        }
    }

    pub fn pending_images(mut self, images: &'a mut Vec<ImageAttachment>) -> Self {
        self.pending_images = Some(images);
        self
    }

    pub fn last_activity(mut self, ts: Option<u64>) -> Self {
        self.last_activity = ts;
        self
    }

    pub fn backend_type(mut self, bt: BackendType) -> Self {
        self.backend_type = bt;
        self
    }

    pub fn details(mut self, details: &'a SessionDetails) -> Self {
        self.details = Some(details);
        self
    }

    pub fn permission_message_state(mut self, state: PermissionMessageState) -> Self {
        self.permission_message_state = state;
        self
    }

    pub fn question_answers(mut self, answers: &'a mut HashMap<Uuid, Vec<QuestionAnswer>>) -> Self {
        self.question_answers = Some(answers);
        self
    }

    pub fn question_index(mut self, index: &'a mut HashMap<Uuid, usize>) -> Self {
        self.question_index = Some(index);
        self
    }

    pub fn compact(mut self, val: bool) -> Self {
        self.flags.set(DaveUiFlags::Compact, val);
        self
    }

    pub fn is_working(mut self, val: bool) -> Self {
        self.flags.set(DaveUiFlags::IsWorking, val);
        self
    }

    pub fn dispatch_state(mut self, state: crate::session::DispatchState) -> Self {
        self.dispatch_state = state;
        self
    }

    pub fn interrupt_pending(mut self, val: bool) -> Self {
        self.flags.set(DaveUiFlags::InterruptPending, val);
        self
    }

    pub fn has_pending_permission(mut self, val: bool) -> Self {
        self.flags.set(DaveUiFlags::HasPendingPerm, val);
        self
    }

    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
        self.permission_mode = mode;
        self
    }

    pub fn auto_accept_all(mut self, val: bool) -> Self {
        self.flags.set(DaveUiFlags::AutoAcceptAll, val);
        self
    }

    pub fn is_compacting(mut self, val: bool) -> Self {
        self.flags.set(DaveUiFlags::IsCompacting, val);
        self
    }

    /// Set the git status cache. Mutable because the UI toggles
    /// expand/collapse and triggers refresh on button click.
    pub fn git_status(mut self, cache: &'a mut GitStatusCache) -> Self {
        self.git_status = Some(cache);
        self
    }

    pub fn auto_steal_focus(mut self, val: bool) -> Self {
        self.flags.set(DaveUiFlags::AutoStealFocus, val);
        self
    }

    pub fn is_remote(mut self, val: bool) -> Self {
        self.flags.set(DaveUiFlags::IsRemote, val);
        self
    }

    pub fn status_dot_color(mut self, color: Option<egui::Color32>) -> Self {
        self.status_dot_color = color;
        self
    }

    pub fn focus_queue_info(mut self, info: Option<(usize, usize, FocusPriority)>) -> Self {
        self.focus_queue_info = info;
        self
    }

    pub fn run_configs(mut self, configs: &'a [crate::config::RunConfig]) -> Self {
        self.run_configs = configs;
        self
    }

    pub fn running_config_ids(
        mut self,
        ids: Option<&'a std::collections::HashSet<String>>,
    ) -> Self {
        self.running_config_ids = ids;
        self
    }

    pub fn usage(mut self, usage: &'a crate::messages::UsageInfo, model: Option<&str>) -> Self {
        self.usage = Some(usage);
        self.context_window = crate::messages::context_window_for_model(model);
        self
    }

    fn chat_margin(&self, ctx: &egui::Context) -> i8 {
        if self.flags.contains(DaveUiFlags::Compact) || notedeck::ui::is_narrow(ctx) {
            8
        } else {
            20
        }
    }

    fn chat_frame(&self, ctx: &egui::Context) -> egui::Frame {
        let margin = self.chat_margin(ctx);
        egui::Frame::new().inner_margin(egui::Margin {
            left: margin,
            right: margin,
            top: 50,
            bottom: 0,
        })
    }

    /// The main render function. Call this to render Dave
    pub fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
        // Clear the Truncate the enclosing `StripBuilder` cell sets when
        // clip=true (`egui_extras`: "better to truncate than hard clip"). `None`
        // rather than `Some(Wrap)`: what we want back is egui's default — each
        // `Ui` derives wrapping from its own layout — not a blanket rule that
        // outranks it. A style override reaches *inside* every descendant
        // widget, so forcing `Wrap` here made labels wrap in non-wrapping
        // horizontal rows, folding inline chips into tall blocks.
        ui.style_mut().wrap_mode = None;

        let is_compact = self.flags.contains(DaveUiFlags::Compact);

        // Skip top buttons in compact mode (scene panel has its own controls)
        let action = if is_compact {
            None
        } else {
            let result = top_buttons_ui(app_ctx, ui, self.status_dot_color);

            // Render session details inline, to the right of the buttons
            if let Some(details) = self.details {
                let max_width = ui.max_rect().right() - result.right_edge_x;
                if max_width > 50.0 {
                    let details_rect = egui::Rect::from_min_size(
                        egui::pos2(result.right_edge_x, result.y),
                        egui::vec2(max_width, 32.0),
                    );
                    let truncation = ui
                        .allocate_new_ui(egui::UiBuilder::new().max_rect(details_rect), |ui| {
                            ui.set_clip_rect(details_rect);
                            session_header_ui(ui, details, self.backend_type)
                        })
                        .inner;

                    if let Some(cwd) = &truncation.full {
                        let hover_resp = ui.interact(
                            details_rect,
                            egui::Id::new("session_header_hover"),
                            egui::Sense::hover(),
                        );
                        hover_resp.on_hover_text_at_pointer(cwd);
                    }
                }
            }

            result.action
        };

        egui::Frame::NONE
            .show(ui, |ui| {
                ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
                    let margin = self.chat_margin(ui.ctx());
                    let bottom_margin = 100;

                    let mut r = egui::Frame::new()
                        .outer_margin(egui::Margin {
                            left: margin,
                            right: margin,
                            top: 0,
                            bottom: bottom_margin,
                        })
                        .inner_margin(egui::Margin::same(notedeck::tokens::SPACING_SM as i8))
                        .fill(ui.visuals().extreme_bg_color)
                        .corner_radius(notedeck::tokens::RADIUS_LG)
                        .show(ui, |ui| self.inputbox(app_ctx, ui))
                        .inner;

                    {
                        let has_git = self.git_status.is_some();
                        let is_agentic = self.ai_mode == AiMode::Agentic;

                        // Show status bar when there's git status or badges to display
                        if has_git || is_agentic {
                            let h = if self.git_status.as_ref().is_some_and(|gs| gs.expanded) {
                                200.0
                            } else {
                                24.0
                            };
                            let w = ui.available_width();
                            let badge_action = ui
                                .allocate_ui(egui::vec2(w, h), |ui| {
                                    egui::Frame::new()
                                        .outer_margin(egui::Margin {
                                            left: margin,
                                            right: margin,
                                            top: 4,
                                            bottom: 0,
                                        })
                                        .show(ui, |ui| self.status_bar_ui(ui))
                                        .inner
                                })
                                .inner;

                            if let Some(action) = badge_action {
                                r = DaveResponse::new(action).or(r);
                            }
                        }
                    }

                    let chat_response = egui::ScrollArea::vertical()
                        .id_salt(("dave_chat_scroll", self.session_id))
                        .stick_to_bottom(true)
                        .auto_shrink([false; 2])
                        .show(ui, |ui| {
                            self.chat_frame(ui.ctx())
                                .show(ui, |ui| {
                                    ui.vertical(|ui| self.render_chat(app_ctx, ui)).inner
                                })
                                .inner
                        })
                        .inner;

                    chat_response.or(r)
                })
                .inner
            })
            .inner
            .or(DaveResponse { action })
    }

    fn error_chat(&self, i18n: &mut Localization, err: &str, ui: &mut egui::Ui) {
        if self.flags.contains(DaveUiFlags::Trial) {
            ui.add(egui::Label::new(
                egui::RichText::new(
                    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"),
                )
                .weak(),
            ));
        } else {
            ui.add(egui::Label::new(
                egui::RichText::new(format!("An error occured: {err}")).weak(),
            ));
        }
    }

    /// Render a chat message (user, assistant, tool call/response, etc)
    fn render_chat(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
        let mut response = DaveResponse::default();
        let is_agentic = self.ai_mode == AiMode::Agentic;

        // Find where queued (not-yet-dispatched) user messages start.
        // When streaming, append_token inserts an Assistant between the
        // dispatched User and any queued Users, so all trailing Users
        // after that Assistant are queued. Before the first token arrives
        // there's no Assistant yet, so we skip the dispatched count
        // trailing Users (they were all sent in the prompt).
        let queued_from = if self.flags.contains(DaveUiFlags::IsWorking) {
            let last_non_user = self
                .chat
                .iter()
                .rposition(|m| !matches!(m, Message::User(_)));
            match last_non_user {
                Some(i) if matches!(self.chat[i], Message::Assistant(ref m) if m.is_streaming()) => {
                    // Streaming assistant separates dispatched from queued
                    let first_trailing = i + 1;
                    if first_trailing < self.chat.len() {
                        Some(first_trailing)
                    } else {
                        None
                    }
                }
                Some(i) => {
                    // No streaming assistant yet — skip past the dispatched
                    // user messages (1 for single dispatch, N for batch)
                    let first_trailing = i + 1;
                    let skip = self.dispatch_state.dispatched_count().max(1);
                    let queued_start = first_trailing + skip;
                    if queued_start < self.chat.len() {
                        Some(queued_start)
                    } else {
                        None
                    }
                }
                None => None,
            }
        } else {
            None
        };

        for (i, message) in self.chat.iter().enumerate() {
            match message {
                Message::Error(err) => {
                    self.error_chat(ctx.i18n, err, ui);
                }
                Message::User(msg) => {
                    let is_queued = queued_from.is_some_and(|qi| i >= qi);
                    self.user_chat(msg, is_queued, ctx, ui);
                }
                Message::Assistant(msg) => {
                    self.assistant_chat(msg, ctx, ui);
                }
                Message::ToolResponse(msg) => {
                    Self::tool_response_ui(msg, is_agentic, ui);
                }
                Message::System(_msg) => {
                    // system prompt is not rendered. Maybe we could
                    // have a debug option to show this
                }
                Message::ToolCalls(toolcalls) => {
                    if let Some(note_action) = Self::tool_calls_ui(ctx, toolcalls, ui) {
                        response = DaveResponse::note(note_action);
                    }
                }
                Message::PermissionRequest(request) => {
                    // Permission requests only in Agentic mode
                    if is_agentic {
                        if let Some(action) =
                            self.permission_request_ui(request, Some(&mut *ctx), ui)
                        {
                            response = DaveResponse::new(action);
                        }
                    }
                }
                Message::CompactionComplete(info) => {
                    // Compaction only in Agentic mode
                    if is_agentic {
                        Self::compaction_complete_ui(info, ui);
                    }
                }
                Message::Subagent(info) => {
                    // Subagents only in Agentic mode
                    if is_agentic {
                        Self::subagent_ui(info, ui);
                    }
                }
                Message::TodoUpdate(_) => {
                    // TodoUpdate is displayed in the task list sidebar, not in chat
                }
            };
        }

        // Show status line at the bottom of chat when working or compacting
        let status_text = if is_agentic && self.flags.contains(DaveUiFlags::IsCompacting) {
            Some("compacting...")
        } else if self.flags.contains(DaveUiFlags::IsWorking) {
            Some("computing...")
        } else {
            None
        };

        if let Some(status) = status_text {
            ui.horizontal(|ui| {
                ui.add(SquareLoadingSpinner::new().size(14.0));
                ui.label(
                    egui::RichText::new(status)
                        .color(ui.visuals().weak_text_color())
                        .italics(),
                );
                // Don't show interrupt hint for remote sessions
                if !self.flags.contains(DaveUiFlags::IsRemote) {
                    ui.label(
                        egui::RichText::new("(press esc to interrupt)")
                            .color(ui.visuals().weak_text_color())
                            .small(),
                    );
                }
            });
        }

        response
    }

    fn tool_response_ui(tool_response: &ToolResponse, is_agentic: bool, ui: &mut egui::Ui) {
        match tool_response.responses() {
            ToolResponses::ExecutedTool(result) if is_agentic => {
                Self::executed_tool_ui(result, ui);
            }
            _ => {
                //ui.label(format!("tool_response: {:?}", tool_response));
            }
        }
    }

    /// Render a compact approval card for structured `requestUserInput` prompts.
    ///
    /// Returns `true` when the payload matched the expected shape and was rendered.
    fn approval_prompt_ui(
        &self,
        request: &PermissionRequest,
        prompt: &ApprovalPromptInput,
        inner_margin: f32,
        corner_radius: f32,
        ui: &mut egui::Ui,
        action: &mut Option<DaveAction>,
    ) -> bool {
        if prompt.questions.is_empty() {
            return false;
        }

        egui::Frame::new()
            .fill(ui.visuals().widgets.noninteractive.bg_fill)
            .inner_margin(inner_margin)
            .corner_radius(corner_radius)
            .stroke(egui::Stroke::new(1.0_f32, ui.visuals().warn_fg_color))
            .show(ui, |ui| {
                ui.label(egui::RichText::new(&request.tool_name).strong());
                ui.add_space(6.0);

                for (idx, question) in prompt.questions.iter().enumerate() {
                    if idx > 0 {
                        ui.add_space(4.0);
                        ui.separator();
                        ui.add_space(4.0);
                    }

                    if let Some(header) = question.header.as_deref().filter(|text| !text.is_empty())
                    {
                        ui.label(egui::RichText::new(header).small().weak());
                    }

                    let question_text = question
                        .question
                        .as_deref()
                        .filter(|text| !text.is_empty())
                        .unwrap_or("Allow this action?");
                    ui.add(
                        egui::Label::new(egui::RichText::new(question_text))
                            .wrap_mode(egui::TextWrapMode::Wrap),
                    );
                }

                ui.add_space(8.0);
                ui.horizontal(|ui| {
                    self.permission_buttons(request, ui, action);
                });
            });

        true
    }

    /// Render a permission request with Allow/Deny buttons or response state
    fn permission_request_ui(
        &mut self,
        request: &PermissionRequest,
        ctx: Option<&mut AppContext>,
        ui: &mut egui::Ui,
    ) -> Option<DaveAction> {
        let mut action = None;

        let inner_margin = notedeck::tokens::SPACING_SM;
        let corner_radius = notedeck::tokens::RADIUS_SM;
        let spacing_x = 8.0;

        ui.spacing_mut().item_spacing.x = spacing_x;

        match request.response {
            Some(PermissionResponseType::Allowed) => {
                // Check if this is an answered shared question set with stored summary
                if let Some(summary) = &request.answer_summary {
                    super::ask_user_question_summary_ui(summary, ui);
                    return None;
                }

                // Responded state: Allowed (generic fallback)
                responded_permission_ui(
                    request,
                    "Allowed",
                    egui::Color32::from_rgb(100, 180, 100),
                    inner_margin,
                    corner_radius,
                    ui,
                );
            }
            Some(PermissionResponseType::Denied) => {
                // Responded state: Denied
                responded_permission_ui(
                    request,
                    "Denied",
                    egui::Color32::from_rgb(200, 100, 100),
                    inner_margin,
                    corner_radius,
                    ui,
                );
            }
            None => {
                if request.view.is_plan_review() {
                    return self.exit_plan_mode_ui(request, ctx, ui);
                }

                if let PermissionView::QuestionSet(questions) = &request.view {
                    if let (Some(answers_map), Some(index_map)) =
                        (&mut self.question_answers, &mut self.question_index)
                    {
                        return super::ask_user_question_ui(
                            request,
                            questions,
                            answers_map,
                            index_map,
                            ui,
                        );
                    }
                }

                // Check if this is a file update (Edit or Write tool)
                if let Some(file_update) =
                    FileUpdate::from_tool_call(&request.tool_name, &request.tool_input)
                {
                    // Render file update with diff view
                    egui::Frame::new()
                        .fill(ui.visuals().widgets.noninteractive.bg_fill)
                        .inner_margin(inner_margin)
                        .corner_radius(corner_radius)
                        .stroke(egui::Stroke::new(
                            notedeck::tokens::STROKE_THIN,
                            ui.visuals().warn_fg_color,
                        ))
                        .show(ui, |ui| {
                            // Header with file path
                            diff::file_path_header(&file_update, ui);

                            // Diff view (expand context only for local sessions)
                            let is_local = !self.flags.contains(DaveUiFlags::IsRemote);
                            diff::file_update_ui(&file_update, is_local, ui);

                            // Approve/deny buttons at the bottom left
                            ui.horizontal(|ui| {
                                self.permission_buttons(request, ui, &mut action);
                            });
                        });
                } else if let Some(prompt) = request.view.approval_prompt() {
                    if !self.approval_prompt_ui(
                        request,
                        prompt,
                        inner_margin,
                        corner_radius,
                        ui,
                        &mut action,
                    ) {
                        return None;
                    }
                } else {
                    // Parse tool input for display (existing logic)
                    let obj = request.tool_input.as_object();
                    let description = obj
                        .and_then(|o| o.get("description"))
                        .and_then(|v| v.as_str());
                    let command = obj.and_then(|o| o.get("command")).and_then(|v| v.as_str());
                    let single_value = obj
                        .filter(|o| o.len() == 1)
                        .and_then(|o| o.values().next())
                        .and_then(|v| v.as_str());

                    // Pending state: Show Allow/Deny buttons
                    egui::Frame::new()
                        .fill(ui.visuals().widgets.noninteractive.bg_fill)
                        .inner_margin(inner_margin)
                        .corner_radius(corner_radius)
                        .stroke(egui::Stroke::new(
                            notedeck::tokens::STROKE_THIN,
                            ui.visuals().warn_fg_color,
                        ))
                        .show(ui, |ui| {
                            // Tool info display
                            if let Some(desc) = description {
                                // Format: ToolName: description
                                ui.horizontal(|ui| {
                                    ui.label(egui::RichText::new(&request.tool_name).strong());
                                    ui.label(desc);
                                });
                                // Command on next line if present
                                if let Some(cmd) = command {
                                    ui.add(
                                        egui::Label::new(egui::RichText::new(cmd).monospace())
                                            .wrap_mode(egui::TextWrapMode::Wrap),
                                    );
                                }
                            } else if let Some(value) = single_value {
                                // Format: ToolName `value`
                                ui.horizontal(|ui| {
                                    ui.label(egui::RichText::new(&request.tool_name).strong());
                                    ui.label(egui::RichText::new(value).monospace());
                                });
                            } else {
                                // Fallback: show JSON when we don't have a better renderer.
                                ui.label(egui::RichText::new(&request.tool_name).strong());
                                let formatted = serde_json::to_string_pretty(&request.tool_input)
                                    .unwrap_or_else(|_| request.tool_input.to_string());
                                ui.add(
                                    egui::Label::new(
                                        egui::RichText::new(formatted).monospace().size(11.0),
                                    )
                                    .wrap_mode(egui::TextWrapMode::Wrap),
                                );
                            }

                            // Buttons on their own line
                            ui.horizontal(|ui| {
                                self.permission_buttons(request, ui, &mut action);
                            });
                        });
                }
            }
        }

        action
    }

    /// Render Allow/Deny buttons aligned to the right with keybinding hints
    fn permission_buttons(
        &self,
        request: &PermissionRequest,
        ui: &mut egui::Ui,
        action: &mut Option<DaveAction>,
    ) {
        let shift_held = ui.input(|i| i.modifiers.shift);
        let in_tentative = self.permission_message_state != PermissionMessageState::None;

        ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
            if in_tentative {
                tentative_send_ui(self.permission_message_state, "Allow", "Deny", ui, action);
            } else {
                let button_text_color = ui.visuals().widgets.active.fg_stroke.color;

                // Allow button (green) with integrated keybind hint
                let allow_response = super::badge::ActionButton::new(
                    "Allow",
                    egui::Color32::from_rgb(34, 139, 34),
                    button_text_color,
                )
                .keybind("1")
                .show(ui)
                .on_hover_text("Press 1 to allow, Shift+1 to allow with message");

                // Deny button (red) with integrated keybind hint
                let deny_response = super::badge::ActionButton::new(
                    "Deny",
                    egui::Color32::from_rgb(178, 34, 34),
                    button_text_color,
                )
                .keybind("2")
                .show(ui)
                .on_hover_text("Press 2 to deny, Shift+2 to deny with message");

                // Always button (blue) — allow and don't ask again this session
                let always_response = super::badge::ActionButton::new(
                    "Always",
                    egui::Color32::from_rgb(30, 100, 180),
                    button_text_color,
                )
                .keybind("3")
                .show(ui)
                .on_hover_text("Press 3 to allow always for this session, Shift+3 with message");

                if deny_response.clicked() {
                    if shift_held {
                        *action = Some(DaveAction::TentativeDeny);
                    } else {
                        *action = Some(DaveAction::PermissionResponse {
                            request_id: request.id,
                            response: PermissionResponse::Deny {
                                reason: "User denied".into(),
                            },
                        });
                    }
                }

                if allow_response.clicked() {
                    if shift_held {
                        *action = Some(DaveAction::TentativeAccept);
                    } else {
                        *action = Some(DaveAction::PermissionResponse {
                            request_id: request.id,
                            response: PermissionResponse::Allow { message: None },
                        });
                    }
                }

                if always_response.clicked() {
                    if shift_held {
                        *action = Some(DaveAction::TentativeAllowAlways);
                    } else {
                        *action = Some(DaveAction::AllowAlways {
                            request_id: request.id,
                        });
                    }
                }

                // Exit button (orange) — deny permission and stop the turn
                let exit_response = super::badge::ActionButton::new(
                    "Exit",
                    egui::Color32::from_rgb(200, 140, 30),
                    button_text_color,
                )
                .show(ui)
                .on_hover_text("Deny this tool call and return to chat");

                if exit_response.clicked() {
                    *action = Some(DaveAction::ExitToolCall {
                        request_id: request.id,
                    });
                }

                add_msg_link(ui, shift_held, action);
            }
        });
    }

    /// Render ExitPlanMode tool call with Approve/Reject buttons
    fn exit_plan_mode_ui(
        &self,
        request: &PermissionRequest,
        ctx: Option<&mut AppContext>,
        ui: &mut egui::Ui,
    ) -> Option<DaveAction> {
        let mut action = None;
        let inner_margin = notedeck::tokens::SPACING_MD;
        let corner_radius = notedeck::tokens::RADIUS_MD;

        egui::Frame::new()
            .fill(ui.visuals().widgets.noninteractive.bg_fill)
            .inner_margin(inner_margin)
            .corner_radius(corner_radius)
            .stroke(egui::Stroke::new(
                notedeck::tokens::STROKE_THIN,
                ui.visuals().selection.stroke.color,
            ))
            .show(ui, |ui| {
                ui.vertical(|ui| {
                    // Header with badge
                    ui.horizontal(|ui| {
                        super::badge::StatusBadge::new("PLAN")
                            .variant(super::badge::BadgeVariant::Info)
                            .show(ui);
                        ui.add_space(8.0);
                        ui.label(egui::RichText::new("Plan ready for approval").strong());
                    });

                    ui.add_space(8.0);

                    // Render plan content as markdown (pre-parsed at construction).
                    // `ctx` is `Some` from the live render path and `None` from
                    // the ctx-less test harnesses; with it, refs in a plan body
                    // resolve to their inline chips like the other surfaces.
                    if let Some(plan) = request.view.plan_markdown() {
                        // Plan rendering holds no transaction; open one (when we
                        // have a context) so refs in the plan body resolve.
                        let mut note_ctx = ctx.map(|c| c.note_context());
                        let txn = note_ctx
                            .as_ref()
                            .and_then(|nc| Transaction::new(nc.ndb).ok());
                        markdown_ui::render_parsed_markdown(
                            &plan.elements,
                            None,
                            &plan.source,
                            note_ctx.as_mut(),
                            txn.as_ref(),
                            ui,
                        );
                    } else if let Some(plan_text) =
                        request.tool_input.get("plan").and_then(|v| v.as_str())
                    {
                        // Fallback: render as plain text
                        ui.label(plan_text);
                    }

                    ui.add_space(8.0);

                    // Approve/Reject buttons with shift support for adding message
                    let shift_held = ui.input(|i| i.modifiers.shift);
                    let in_tentative =
                        self.permission_message_state != PermissionMessageState::None;

                    ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
                        if in_tentative {
                            tentative_send_ui(
                                self.permission_message_state,
                                "Approve",
                                "Reject",
                                ui,
                                &mut action,
                            );
                        } else {
                            let button_text_color = ui.visuals().widgets.active.fg_stroke.color;

                            // Approve button (green)
                            let approve_response = super::badge::ActionButton::new(
                                "Approve",
                                egui::Color32::from_rgb(34, 139, 34),
                                button_text_color,
                            )
                            .keybind("1")
                            .show(ui)
                            .on_hover_text("Press 1 to approve, Shift+1 to approve with message");

                            if approve_response.clicked() {
                                if shift_held {
                                    action = Some(DaveAction::TentativeAccept);
                                } else {
                                    action = Some(DaveAction::ExitPlanMode {
                                        request_id: request.id,
                                        approved: true,
                                    });
                                }
                            }

                            // Compact & Approve button (blue, no keybind)
                            let compact_response = super::badge::ActionButton::new(
                                "Compact & Approve",
                                egui::Color32::from_rgb(59, 130, 246),
                                button_text_color,
                            )
                            .show(ui)
                            .on_hover_text("Compact context then start implementing");

                            if compact_response.clicked() {
                                action = Some(DaveAction::CompactAndApprove {
                                    request_id: request.id,
                                });
                            }

                            // Reject button (red)
                            let reject_response = super::badge::ActionButton::new(
                                "Reject",
                                egui::Color32::from_rgb(178, 34, 34),
                                button_text_color,
                            )
                            .keybind("2")
                            .show(ui)
                            .on_hover_text("Press 2 to reject, Shift+2 to reject with message");

                            if reject_response.clicked() {
                                if shift_held {
                                    action = Some(DaveAction::TentativeDeny);
                                } else {
                                    action = Some(DaveAction::ExitPlanMode {
                                        request_id: request.id,
                                        approved: false,
                                    });
                                }
                            }

                            add_msg_link(ui, shift_held, &mut action);
                        }
                    });
                });
            });

        action
    }

    /// Render tool result metadata as a compact line
    fn executed_tool_ui(result: &ExecutedTool, ui: &mut egui::Ui) {
        if let Some(file_update) = &result.file_update {
            // File edit with diff — show collapsible header with inline diff
            let expand_id = ui.id().with("exec_diff").with(&result.summary);
            let is_small = file_update.diff_lines().len() < 10;
            let expanded: bool = ui.data(|d| d.get_temp(expand_id).unwrap_or(is_small));

            let header_resp = ui
                .horizontal(|ui| {
                    let arrow = if expanded { "▼" } else { "▶" };
                    ui.add(egui::Label::new(
                        egui::RichText::new(arrow)
                            .size(10.0)
                            .color(ui.visuals().text_color().gamma_multiply(0.5)),
                    ));
                    ui.add(egui::Label::new(
                        egui::RichText::new(&result.tool_name)
                            .size(11.0)
                            .color(ui.visuals().text_color().gamma_multiply(0.6))
                            .monospace(),
                    ));
                    if !result.summary.is_empty() {
                        ui.add(egui::Label::new(
                            egui::RichText::new(&result.summary)
                                .size(11.0)
                                .color(ui.visuals().text_color().gamma_multiply(0.4))
                                .monospace(),
                        ));
                    }
                })
                .response
                .interact(egui::Sense::click());

            if header_resp.clicked() {
                ui.data_mut(|d| d.insert_temp(expand_id, !expanded));
            }

            if expanded {
                diff::file_path_header(file_update, ui);
                diff::file_update_ui(file_update, false, ui);
            }
        } else {
            // Compact single-line display with subdued styling
            ui.horizontal(|ui| {
                ui.add(egui::Label::new(
                    egui::RichText::new(&result.tool_name)
                        .size(11.0)
                        .color(ui.visuals().text_color().gamma_multiply(0.6))
                        .monospace(),
                ));
                if !result.summary.is_empty() {
                    ui.add(egui::Label::new(
                        egui::RichText::new(&result.summary)
                            .size(11.0)
                            .color(ui.visuals().text_color().gamma_multiply(0.4))
                            .monospace(),
                    ));
                }
            });
        }
    }

    /// Render compaction complete notification
    fn compaction_complete_ui(info: &CompactionInfo, ui: &mut egui::Ui) {
        ui.horizontal(|ui| {
            ui.add(egui::Label::new(
                egui::RichText::new("✓")
                    .size(11.0)
                    .color(egui::Color32::from_rgb(100, 180, 100)),
            ));
            ui.add(egui::Label::new(
                egui::RichText::new(format!("Compacted ({} tokens)", info.pre_tokens))
                    .size(11.0)
                    .color(ui.visuals().weak_text_color())
                    .italics(),
            ));
        });
    }

    /// Render a single subagent's status with expandable tool results
    fn subagent_ui(info: &SubagentInfo, ui: &mut egui::Ui) {
        let tool_count = info.tool_results.len();
        let has_tools = tool_count > 0;
        // Compute expand ID from outer ui, before horizontal changes the id scope
        let expand_id = ui.id().with("subagent_expand").with(&info.task_id);

        ui.horizontal(|ui| {
            // Status badge with color based on status
            let variant = match info.status {
                SubagentStatus::Running => BadgeVariant::Warning,
                SubagentStatus::Completed => BadgeVariant::Success,
                SubagentStatus::Failed => BadgeVariant::Destructive,
            };
            StatusBadge::new(&info.subagent_type)
                .variant(variant)
                .show(ui);

            // Description
            ui.label(
                egui::RichText::new(&info.description)
                    .size(11.0)
                    .color(ui.visuals().text_color().gamma_multiply(0.7)),
            );

            // A background subagent keeps running after the launching turn ends;
            // flag it as such until its wake-up completion lands.
            if info.background && info.status == SubagentStatus::Running {
                ui.label(
                    egui::RichText::new("· in background")
                        .size(10.0)
                        .italics()
                        .color(ui.visuals().text_color().gamma_multiply(0.5)),
                );
            }

            // Show spinner for running subagents
            if info.status == SubagentStatus::Running {
                ui.add(SquareLoadingSpinner::new().size(11.0));
            }

            // Tool count indicator (clickable to expand)
            if has_tools {
                let expanded = ui.data(|d| d.get_temp::<bool>(expand_id).unwrap_or(false));
                let arrow = if expanded { "▾" } else { "▸" };
                let label = format!("{} ({} tools)", arrow, tool_count);
                if ui
                    .add(
                        egui::Label::new(
                            egui::RichText::new(label)
                                .size(10.0)
                                .color(ui.visuals().text_color().gamma_multiply(0.4)),
                        )
                        .sense(egui::Sense::click()),
                    )
                    .clicked()
                {
                    ui.data_mut(|d| d.insert_temp(expand_id, !expanded));
                }
            }
        });

        // Expanded tool results
        if has_tools {
            let expanded = ui.data(|d| d.get_temp::<bool>(expand_id).unwrap_or(false));
            if expanded {
                ui.indent(("subagent_tools", &info.task_id), |ui| {
                    for result in &info.tool_results {
                        Self::executed_tool_ui(result, ui);
                    }
                });
            }
        }
    }

    fn search_call_ui(
        ctx: &mut AppContext,
        query_call: &crate::tools::QueryCall,
        ui: &mut egui::Ui,
    ) {
        ui.add(search_icon(16.0, 16.0));
        ui.add_space(8.0);

        query_call_ui(
            ctx.img_cache,
            ctx.ndb,
            query_call,
            ctx.media_jobs.sender(),
            ui,
        );
    }

    /// The ai has asked us to render some notes, so we do that here
    fn present_notes_ui(
        ctx: &mut AppContext,
        call: &PresentNotesCall,
        ui: &mut egui::Ui,
    ) -> Option<NoteAction> {
        let mut note_context = NoteContext {
            ndb: ctx.ndb,
            accounts: ctx.accounts,
            img_cache: ctx.img_cache,
            note_cache: ctx.note_cache,
            zaps: ctx.zaps,
            jobs: ctx.media_jobs.sender(),
            unknown_ids: ctx.unknown_ids,
            nip05_cache: ctx.nip05_cache,
            clipboard: ctx.clipboard,
            i18n: ctx.i18n,
            global_wallet: ctx.global_wallet,
            sound: ctx.sound,
            registries: ctx.registries,
            app_actions: ctx.app_actions,
        };

        let txn = Transaction::new(note_context.ndb).unwrap();

        egui::ScrollArea::horizontal()
            .max_height(400.0)
            .show(ui, |ui| {
                ui.with_layout(Layout::left_to_right(Align::Min), |ui| {
                    ui.spacing_mut().item_spacing.x = 10.0;
                    let mut action: Option<NoteAction> = None;

                    for note_id in &call.note_ids {
                        let Ok(note) = note_context.ndb.get_note_by_id(&txn, note_id.bytes())
                        else {
                            continue;
                        };

                        let r = ui
                            .allocate_ui_with_layout(
                                [400.0, 400.0].into(),
                                Layout::centered_and_justified(ui.layout().main_dir()),
                                |ui| {
                                    notedeck_ui::NoteView::new(
                                        &mut note_context,
                                        &note,
                                        NoteOptions::default(),
                                    )
                                    .preview_style()
                                    .hide_media(true)
                                    .show(ui)
                                },
                            )
                            .inner;

                        if r.action.is_some() {
                            action = r.action;
                        }
                    }

                    action
                })
                .inner
            })
            .inner
    }

    fn tool_calls_ui(
        ctx: &mut AppContext,
        toolcalls: &[ToolCall],
        ui: &mut egui::Ui,
    ) -> Option<NoteAction> {
        let mut note_action: Option<NoteAction> = None;

        ui.vertical(|ui| {
            for call in toolcalls {
                match call.calls() {
                    ToolCalls::PresentNotes(call) => {
                        let r = Self::present_notes_ui(ctx, call, ui);
                        if r.is_some() {
                            note_action = r;
                        }
                    }
                    ToolCalls::Invalid(err) => {
                        ui.label(format!("invalid tool call: {err:?}"));
                    }
                    ToolCalls::Query(search_call) => {
                        ui.allocate_ui_with_layout(
                            egui::vec2(ui.available_size().x, 32.0),
                            Layout::left_to_right(Align::Center),
                            |ui| {
                                Self::search_call_ui(ctx, search_call, ui);
                            },
                        );
                    }
                }
            }
        });

        note_action
    }

    fn inputbox(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
        let i18n = &mut *app_ctx.i18n;
        let show_stop = self.flags.contains(DaveUiFlags::IsWorking)
            && !self.flags.contains(DaveUiFlags::IsRemote);
        let show_esc_hint = show_stop && self.flags.contains(DaveUiFlags::InterruptPending);

        let layout = InputboxLayout::new(self.input, i18n)
            .show_stop(show_stop)
            .show_esc_hint(show_esc_hint)
            .id(egui::Id::new(("dave_input", self.session_id)));

        let result = layout.show(ui);

        if let Some(r) = &result.text_response {
            dave_input_context(
                ui,
                r,
                app_ctx.clipboard,
                self.input,
                self.pending_images.as_deref_mut(),
            );

            // Request focus if flagged (e.g., after spawning a new agent or entering tentative state).
            // Skip on mobile to avoid popping up the virtual keyboard on every session switch.
            if *self.focus_requested {
                if !notedeck::ui::is_compiled_as_mobile() {
                    r.request_focus();
                }
                *self.focus_requested = false;
            }

            // Browser-style autofocus: grab focus when the chrome switches to
            // Dave for the first time and nothing was previously focused.
            notedeck_ui::autofocus(r, ui.ctx());

            // Unfocus text input when there's a pending permission request
            // UNLESS we're in tentative state (user needs to type message)
            let in_tentative_state = self.permission_message_state != PermissionMessageState::None;
            if self.flags.contains(DaveUiFlags::HasPendingPerm) && !in_tentative_state {
                r.surrender_focus();
            }

            if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift)
            {
                return DaveResponse::send();
            }
        }

        // Thumbnail strip second — in bottom_up this renders ABOVE the text row.
        // allocate_ui_with_layout pre-positions the rect at the bottom_up cursor
        // (unlike with_layout which takes the full available rect) and forces
        // top_down so image_thumbnail_strip's internal widgets render correctly.
        if let Some(pending) = &mut self.pending_images {
            if !pending.is_empty() {
                // Height must closely match content: in bottom_up, next_space
                // reserves this much vertical room above the text row.
                let strip_height = THUMBNAIL_SIZE + ui.spacing().item_spacing.y;
                ui.allocate_ui_with_layout(
                    egui::vec2(ui.available_width(), strip_height),
                    Layout::top_down(Align::Min),
                    |ui| {
                        image_thumbnail_strip(pending, ui);
                    },
                );
            }
        }

        if result.ask_clicked {
            return DaveResponse::send();
        }
        if result.stop_clicked {
            return DaveResponse::new(DaveAction::Interrupt);
        }

        DaveResponse::none()
    }

    #[profiling::function]
    fn user_chat(
        &self,
        msg: &crate::messages::UserMessage,
        is_queued: bool,
        ctx: &mut AppContext,
        ui: &mut egui::Ui,
    ) {
        ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
            let r = egui::Frame::new()
                .inner_margin(10.0)
                .corner_radius(10.0)
                .fill(ui.visuals().widgets.inactive.weak_bg_fill)
                .show(ui, |ui| {
                    for img in &msg.images {
                        ui.add(
                            egui::Image::from_bytes(img.egui_uri(), img.bytes.clone())
                                .max_size(egui::vec2(200.0, 200.0))
                                .corner_radius(4.0),
                        );
                    }
                    if !msg.text.is_empty() {
                        // Ref-aware markdown so a headway ref the user types
                        // (bare or backtick-wrapped) renders as its inline chip,
                        // matching the assistant surface. Chat rendering holds no
                        // transaction, so open one here and pass it in.
                        let mut note_ctx = ctx.note_context();
                        let txn = Transaction::new(note_ctx.ndb).expect("markdown txn");
                        markdown_ui::render_markdown_with_refs(ui, &mut note_ctx, &txn, &msg.text);
                    }
                    if is_queued {
                        ui.label(
                            egui::RichText::new("queued")
                                .small()
                                .color(ui.visuals().weak_text_color()),
                        );
                    }
                });
            notedeck_ui::context_menu::context_menu(&r.response, |ui| {
                if ui.button("Copy").clicked() {
                    ui.ctx().copy_text(msg.text.clone());
                    ui.close_menu();
                }
            });
        });
    }

    #[profiling::function]
    fn assistant_chat(&self, msg: &AssistantMessage, ctx: &mut AppContext, ui: &mut egui::Ui) {
        let elements = msg.parsed_elements();
        let partial = msg.partial();
        let buffer = msg.buffer();
        let text = msg.text().to_owned();
        // Chat rendering holds no transaction; open one here and thread it in so
        // the streamed markdown can resolve inline references.
        let mut note_ctx = ctx.note_context();
        let txn = Transaction::new(note_ctx.ndb).expect("markdown txn");
        let r = ui.scope(|ui| {
            markdown_ui::render_parsed_markdown(
                elements,
                partial,
                buffer,
                Some(&mut note_ctx),
                Some(&txn),
                ui,
            );
        });
        notedeck_ui::context_menu::context_menu(&r.response, |ui| {
            if ui.button("Copy").clicked() {
                ui.ctx().copy_text(text.clone());
                ui.close_menu();
            }
        });
    }
}

/// Paste text into the current input buffer using Dave's append behavior.
fn append_clipboard_text(clipboard: &mut egui_winit::clipboard::Clipboard, input: &mut String) {
    if let Some(text) = clipboard.get() {
        input.push_str(&text);
    }
}

/// Result of rendering an InputboxLayout.
pub struct InputboxResult {
    pub ask_clicked: bool,
    pub stop_clicked: bool,
    pub text_response: Option<egui::Response>,
}

/// Extracted inputbox layout used by both DaveUi and tests.
///
/// Renders the Ask/Stop buttons and text input in a right-to-left layout.
pub struct InputboxLayout<'a> {
    pub input: &'a mut String,
    pub ask_label: String,
    pub stop_label: String,
    pub hint_text: String,
    pub show_stop: bool,
    pub show_esc_hint: bool,
    pub id: Option<egui::Id>,
}

impl<'a> InputboxLayout<'a> {
    pub fn new(input: &'a mut String, i18n: &mut notedeck::Localization) -> Self {
        Self {
            input,
            ask_label: notedeck::tr!(i18n, "Ask", "Button to send message to Dave AI assistant"),
            stop_label: notedeck::tr!(i18n, "Stop", "Button to interrupt/stop the AI operation"),
            hint_text: notedeck::tr!(
                i18n,
                "Ask dave anything...",
                "Placeholder text for Dave AI input field"
            ),
            show_stop: false,
            show_esc_hint: false,
            id: None,
        }
    }

    pub fn new_default(input: &'a mut String) -> Self {
        Self {
            input,
            ask_label: "Ask".to_string(),
            stop_label: "Stop".to_string(),
            hint_text: "Ask dave anything...".to_string(),
            show_stop: false,
            show_esc_hint: false,
            id: None,
        }
    }

    pub fn show_stop(mut self, show: bool) -> Self {
        self.show_stop = show;
        self
    }

    pub fn show_esc_hint(mut self, show: bool) -> Self {
        self.show_esc_hint = show;
        self
    }

    pub fn id(mut self, id: egui::Id) -> Self {
        self.id = Some(id);
        self
    }

    pub fn show(self, ui: &mut egui::Ui) -> InputboxResult {
        let line_count = self.input.lines().count().max(1).clamp(1, 8);
        let line_height = 20.0;
        let base_height = notedeck::tokens::BUTTON_LG;
        let input_height = base_height + (line_count as f32 * line_height);
        let button_size = egui::vec2(60.0, notedeck::tokens::BUTTON_LG);

        ui.allocate_ui(egui::vec2(ui.available_width(), input_height), |ui| {
            ui.horizontal(|ui| {
                ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
                    let ask_clicked = ui
                        .add(egui::Button::new(&self.ask_label).min_size(button_size))
                        .clicked();

                    let mut stop_clicked = false;
                    if self.show_stop {
                        stop_clicked = ui
                            .add(egui::Button::new(&self.stop_label).min_size(button_size))
                            .clicked();

                        if self.show_esc_hint {
                            ui.label(
                                egui::RichText::new("Press Esc again to stop")
                                    .color(ui.visuals().warn_fg_color),
                            );
                        }
                    }

                    let text_response = egui::ScrollArea::vertical()
                        .max_height(ui.available_height())
                        .show(ui, |ui| {
                            let mut edit = egui::TextEdit::multiline(self.input)
                                .desired_width(f32::INFINITY)
                                .return_key(KeyboardShortcut::new(
                                    Modifiers {
                                        shift: true,
                                        ..Default::default()
                                    },
                                    Key::Enter,
                                ))
                                .hint_text(egui::RichText::new(&self.hint_text).weak())
                                .frame(false);

                            if let Some(id) = self.id {
                                edit = edit.id_source(id);
                            }

                            ui.add(edit)
                        })
                        .inner;

                    InputboxResult {
                        ask_clicked,
                        stop_clicked,
                        text_response: Some(text_response),
                    }
                })
                .inner
            })
            .inner
        })
        .inner
    }
}

/// Dave-specific input context menu that prefers image paste before text paste.
fn dave_input_context(
    ui: &mut egui::Ui,
    response: &egui::Response,
    clipboard: &mut egui_winit::clipboard::Clipboard,
    input: &mut String,
    pending_images: Option<&mut Vec<ImageAttachment>>,
) {
    response.context_menu(|ui| {
        if ui.button("Paste").clicked() {
            let pasted_image = pending_images.map(try_paste_image).unwrap_or(false);
            if !pasted_image {
                append_clipboard_text(clipboard, input);
            }
            ui.close_menu();
        }

        if ui.button("Copy").clicked() {
            clipboard.set_text(input.to_owned());
            ui.close_menu();
        }

        if ui.button("Cut").clicked() {
            clipboard.set_text(input.to_owned());
            input.clear();
            ui.close_menu();
        }
    });

    if response.middle_clicked() {
        append_clipboard_text(clipboard, input);
    }

    notedeck_ui::include_input(ui, response);
}

/// Try to paste an image from the clipboard into `pending`.
/// Returns `true` if an image was staged; `false` if the clipboard had no image.
fn try_paste_image(pending: &mut Vec<ImageAttachment>) -> bool {
    let mut clipboard = match arboard::Clipboard::new() {
        Ok(c) => c,
        Err(err) => {
            tracing::debug!("paste_image: clipboard open failed: {}", err);
            return false;
        }
    };
    let img_data = match clipboard.get_image() {
        Ok(d) => d,
        Err(err) => {
            tracing::debug!("paste_image: no image in clipboard: {}", err);
            return false;
        }
    };
    let w = img_data.width as u32;
    let h = img_data.height as u32;
    tracing::debug!("paste_image: got {}x{} image from clipboard", w, h);
    let Some(rgba) = image::RgbaImage::from_raw(w, h, img_data.bytes.into_owned()) else {
        tracing::warn!("paste_image: RgbaImage::from_raw failed (bad dimensions?)");
        return false;
    };
    let mut png_bytes = Vec::new();
    let mut cursor = std::io::Cursor::new(&mut png_bytes);
    if let Err(err) = rgba.write_to(&mut cursor, image::ImageFormat::Png) {
        tracing::warn!("paste_image: PNG encode failed: {}", err);
        return false;
    }
    tracing::debug!("paste_image: encoded {} PNG bytes", png_bytes.len());
    pending.push(ImageAttachment::new(png_bytes, "image/png"));
    true
}

const THUMBNAIL_SIZE: f32 = 100.0;

/// Render a horizontal strip of image thumbnails with ✕ remove buttons.
fn image_thumbnail_strip(pending: &mut Vec<ImageAttachment>, ui: &mut egui::Ui) {
    if pending.is_empty() {
        return;
    }

    let mut remove_idx = None;
    ui.horizontal(|ui| {
        ui.spacing_mut().item_spacing.x = 4.0;
        for (i, img) in pending.iter().enumerate() {
            let (rect, _) = ui.allocate_exact_size(
                egui::vec2(THUMBNAIL_SIZE, THUMBNAIL_SIZE),
                egui::Sense::hover(),
            );
            ui.put(
                rect,
                egui::Image::from_bytes(img.egui_uri(), img.bytes.clone())
                    .fit_to_exact_size(egui::vec2(THUMBNAIL_SIZE, THUMBNAIL_SIZE))
                    .corner_radius(6.0),
            );
            // Small × button in the top-right corner
            let btn_size = 18.0;
            let btn_rect = egui::Rect::from_min_size(
                egui::pos2(rect.right() - btn_size - 2.0, rect.top() + 2.0),
                egui::vec2(btn_size, btn_size),
            );
            if ui
                .put(
                    btn_rect,
                    egui::Button::new(egui::RichText::new("×").size(12.0).strong())
                        .corner_radius(btn_size / 2.0)
                        .min_size(egui::vec2(btn_size, btn_size)),
                )
                .clicked()
            {
                remove_idx = Some(i);
            }
        }
    });
    if let Some(idx) = remove_idx {
        pending.remove(idx);
    }
}

/// Extract a concise, human-readable summary of what a tool call operates on
/// (bash command, file path, or a single argument), borrowing from the tool
/// input so it stays allocation-free in the per-frame render path.
fn permission_operation_detail(tool_input: &serde_json::Value) -> Option<&str> {
    let obj = tool_input.as_object()?;

    // Bash and friends: the command being run.
    if let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) {
        return Some(cmd);
    }

    // Edit/Write: the file being changed.
    if let Some(path) = obj.get("file_path").and_then(|v| v.as_str()) {
        return Some(path);
    }

    // Single-argument tools: the lone value.
    if obj.len() == 1 {
        if let Some(value) = obj.values().next().and_then(|v| v.as_str()) {
            return Some(value);
        }
    }

    None
}

/// Render a responded (Allowed/Denied) permission request as a collapsible
/// row: a status header with a disclosure chevron that expands to reveal what
/// was actually allowed/denied (the edit diff, or the bash command / argument),
/// so the operation stays inspectable after the fact without cluttering chat.
fn responded_permission_ui(
    request: &PermissionRequest,
    label: &str,
    label_color: egui::Color32,
    inner_margin: f32,
    corner_radius: f32,
    ui: &mut egui::Ui,
) {
    // The row is only expandable if there's an operation to reveal. Edit/Write
    // carry a file_path, so `detail.is_some()` already covers them.
    let detail = permission_operation_detail(&request.tool_input);
    let expandable = detail.is_some();

    egui::Frame::new()
        .fill(ui.visuals().widgets.noninteractive.bg_fill)
        .inner_margin(inner_margin)
        .corner_radius(corner_radius)
        .show(ui, |ui| {
            ui.vertical(|ui| {
                // Persisted disclosure state, keyed by request id. Auto-accepted
                // rows (runtime allowlist / Auto Accept All) start expanded so
                // the user can review what they never approved up front; manually
                // approved rows start collapsed. A user toggle overrides either.
                let expand_id = ui.id().with(("responded_perm", request.id));
                let mut expanded = expandable
                    && ui.data(|d| d.get_temp(expand_id).unwrap_or(request.auto_accepted));

                let header = responded_permission_header_ui(
                    request,
                    label,
                    label_color,
                    expanded,
                    expandable,
                    ui,
                );

                // Clicking anywhere on the header toggles the disclosure.
                if expandable && header.clicked() {
                    expanded = !expanded;
                    ui.data_mut(|d| d.insert_temp(expand_id, expanded));
                }

                if !expanded {
                    return;
                }

                // Prefer a rich diff for file edits; otherwise show the raw
                // operation (bash command / single argument) in monospace.
                if let Some(file_update) =
                    FileUpdate::from_tool_call(&request.tool_name, &request.tool_input)
                {
                    diff::file_path_header(&file_update, ui);
                    diff::file_update_ui(&file_update, false, ui);
                } else if let Some(detail) = detail {
                    ui.add(
                        egui::Label::new(
                            egui::RichText::new(detail)
                                .monospace()
                                .size(11.0)
                                .color(ui.visuals().weak_text_color()),
                        )
                        .wrap_mode(egui::TextWrapMode::Wrap),
                    );
                }
            });
        });
}

/// Header row for a responded permission: optional disclosure chevron, the
/// status label (Allowed/Denied), and the tool name. Returns a click response
/// covering the whole row so the caller can toggle the disclosure.
fn responded_permission_header_ui(
    request: &PermissionRequest,
    label: &str,
    label_color: egui::Color32,
    expanded: bool,
    expandable: bool,
    ui: &mut egui::Ui,
) -> egui::Response {
    let header = ui.horizontal(|ui| {
        if expandable {
            notedeck_ui::header::disclosure_chevron(
                ui,
                expanded,
                egui::Stroke::new(1.5_f32, ui.visuals().weak_text_color()),
            );
        }
        ui.label(egui::RichText::new(label).color(label_color).strong());
        ui.label(egui::RichText::new(&request.tool_name).color(ui.visuals().text_color()));
    });

    if !expandable {
        return header.response;
    }

    // Re-sensing the horizontal layout's response (`.interact(Sense::click())`)
    // doesn't reliably register hover/click, so allocate a dedicated clickable
    // region over the row's rect with a stable id keyed by the request.
    let click_id = ui.id().with(("responded_perm_header", request.id));
    ui.interact(header.response.rect, click_id, egui::Sense::click())
        .on_hover_cursor(egui::CursorIcon::PointingHand)
}

/// Send button + clickable accept/deny toggle shown when in tentative state.
fn tentative_send_ui(
    state: PermissionMessageState,
    accept_label: &str,
    deny_label: &str,
    ui: &mut egui::Ui,
    action: &mut Option<DaveAction>,
) {
    if ui
        .add(egui::Button::new(egui::RichText::new("Send").strong()))
        .clicked()
    {
        *action = Some(DaveAction::Send);
    }

    match state {
        PermissionMessageState::TentativeAccept => {
            if ui
                .link(
                    egui::RichText::new(format!("✓ Will {accept_label}"))
                        .color(egui::Color32::from_rgb(100, 180, 100))
                        .strong(),
                )
                .clicked()
            {
                *action = Some(DaveAction::TentativeDeny);
            }
        }
        PermissionMessageState::TentativeDeny => {
            if ui
                .link(
                    egui::RichText::new(format!("✗ Will {deny_label}"))
                        .color(egui::Color32::from_rgb(200, 100, 100))
                        .strong(),
                )
                .clicked()
            {
                *action = Some(DaveAction::TentativeAccept);
            }
        }
        PermissionMessageState::None => {}
    }
}

/// Clickable "+ msg [⇧]" link that enters tentative accept mode.
/// Highlights in warn color when Shift is held on desktop.
fn add_msg_link(ui: &mut egui::Ui, shift_held: bool, action: &mut Option<DaveAction>) {
    let color = if shift_held {
        ui.visuals().warn_fg_color
    } else {
        ui.visuals().weak_text_color()
    };
    if ui
        .link(egui::RichText::new("+ msg [⇧]").color(color).small())
        .clicked()
    {
        *action = Some(DaveAction::TentativeAccept);
    }
}

/// Format a wall-clock unix-seconds timestamp as a relative time string
/// (e.g. "just now", "3m ago"). `saturating_sub` renders a future-dated
/// timestamp (clock skew) as "just now" rather than underflowing.
fn format_relative_time(unix_secs: u64) -> String {
    let elapsed = crate::session::now_unix().saturating_sub(unix_secs);
    if elapsed < 60 {
        "just now".to_string()
    } else if elapsed < 3600 {
        format!("{}m ago", elapsed / 60)
    } else if elapsed < 86400 {
        format!("{}h ago", elapsed / 3600)
    } else {
        format!("{}d ago", elapsed / 86400)
    }
}

/// Renders the status bar containing git status, run button, and toggle badges.
impl DaveUi<'_> {
    fn status_bar_ui(&mut self, ui: &mut egui::Ui) -> Option<DaveAction> {
        let is_remote = self.flags.contains(DaveUiFlags::IsRemote);
        let is_agentic = self.ai_mode == AiMode::Agentic;
        let cwd = self.details.and_then(|d| d.cwd.as_deref());

        let snapshot = self
            .git_status
            .as_deref()
            .and_then(git_status_ui::StatusSnapshot::from_cache);

        ui.vertical(|ui| {
            let bar_action = ui
                .horizontal(|ui| {
                    ui.spacing_mut().item_spacing.x = 6.0;

                    // Run config buttons — leftmost elements, local sessions only
                    let mut action = if !is_remote {
                        let a = cwd.and_then(|p| {
                            run_ui::run_configs_ui(self.run_configs, self.running_config_ids, p, ui)
                        });
                        if cwd.is_some() {
                            ui.separator();
                        }
                        a
                    } else {
                        None
                    };

                    if let Some(gs) = self.git_status.as_deref_mut() {
                        git_status_ui::git_status_content_ui(gs, &snapshot, ui);
                    }

                    let right = ui
                        .with_layout(Layout::right_to_left(Align::Center), |ui| {
                            let badge_action = if is_agentic {
                                toggle_badges_ui(
                                    ui,
                                    self.permission_mode,
                                    self.flags.contains(DaveUiFlags::AutoAcceptAll),
                                    self.flags.contains(DaveUiFlags::AutoStealFocus),
                                    self.focus_queue_info,
                                )
                            } else {
                                None
                            };
                            if is_agentic {
                                if let Some(ts) = self.last_activity {
                                    ui.label(
                                        egui::RichText::new(format_relative_time(ts))
                                            .size(10.0)
                                            .color(ui.visuals().weak_text_color()),
                                    );
                                }
                                usage_bar_ui(self.usage, self.context_window, ui);
                            }
                            badge_action
                        })
                        .inner;

                    action = action.or(right);
                    action
                })
                .inner;

            if let Some(gs) = self.git_status.as_deref() {
                git_status_ui::git_expanded_files_ui(gs, &snapshot, ui);
            }

            bar_action
        })
        .inner
    }
}

/// Format a token count in a compact human-readable form (e.g. "45K", "1.2M")
fn format_tokens(tokens: u64) -> String {
    if tokens >= 1_000_000 {
        format!("{:.1}M", tokens as f64 / 1_000_000.0)
    } else if tokens >= 1_000 {
        format!("{}K", tokens / 1_000)
    } else {
        tokens.to_string()
    }
}

/// Renders the usage fill bar showing context window consumption.
fn usage_bar_ui(
    usage: Option<&crate::messages::UsageInfo>,
    context_window: u64,
    ui: &mut egui::Ui,
) {
    let total = usage.map(|u| u.context_tokens()).unwrap_or(0);
    if total == 0 {
        return;
    }
    let usage = usage.unwrap();
    let fraction = (total as f64 / context_window as f64).min(1.0) as f32;

    // Color based on fill level: green → yellow → red
    let bar_color = if fraction < 0.5 {
        egui::Color32::from_rgb(100, 180, 100)
    } else if fraction < 0.8 {
        egui::Color32::from_rgb(200, 180, 60)
    } else {
        egui::Color32::from_rgb(200, 80, 80)
    };

    let weak = ui.visuals().weak_text_color();

    // Cost label
    if let Some(cost) = usage.cost_usd {
        if cost > 0.0 {
            ui.add(egui::Label::new(
                egui::RichText::new(format!("${:.2}", cost))
                    .size(10.0)
                    .color(weak),
            ));
        }
    }

    // Token count label
    ui.add(egui::Label::new(
        egui::RichText::new(format!(
            "{} / {}",
            format_tokens(total),
            format_tokens(context_window)
        ))
        .size(10.0)
        .color(weak),
    ));

    // Fill bar
    let bar_width = 60.0;
    let bar_height = 8.0;
    let (rect, _) = ui.allocate_exact_size(egui::vec2(bar_width, bar_height), egui::Sense::hover());
    let painter = ui.painter_at(rect);

    // Background
    painter.rect_filled(rect, 3.0, ui.visuals().faint_bg_color);

    // Fill
    let fill_rect =
        egui::Rect::from_min_size(rect.min, egui::vec2(bar_width * fraction, bar_height));
    painter.rect_filled(fill_rect, 3.0, bar_color);
}

/// Render clickable permission mode and AUTO toggle badges. Returns an action if clicked.
fn toggle_badges_ui(
    ui: &mut egui::Ui,
    permission_mode: PermissionMode,
    auto_accept_all: bool,
    auto_steal_focus: bool,
    focus_queue_info: Option<(usize, usize, FocusPriority)>,
) -> Option<DaveAction> {
    let ctrl_held = ui.input(|i| i.modifiers.ctrl);
    let is_narrow = notedeck::ui::is_narrow(ui.ctx());
    let mut action = None;

    // NEXT badge for focus queue navigation (narrow/mobile only, rendered first = rightmost)
    if is_narrow {
        if let Some((_pos, _total, priority)) = focus_queue_info {
            let variant = match priority {
                FocusPriority::NeedsInput => super::badge::BadgeVariant::Warning,
                FocusPriority::Error => super::badge::BadgeVariant::Destructive,
                FocusPriority::Done => super::badge::BadgeVariant::Info,
            };
            // A proper chevron icon with a comfortable tap target so it's easy
            // to hit on narrow/touch layouts (the old ▶ glyph was tiny).
            let mut next_badge = super::badge::StatusBadge::new("Next focus")
                .icon(super::badge::BadgeIcon::ChevronRight)
                .variant(variant)
                .min_size(egui::vec2(40.0, 28.0));
            if ctrl_held {
                next_badge = next_badge.keybind("N");
            }
            if next_badge
                .show(ui)
                .on_hover_text("Next in focus queue (Ctrl+N)")
                .clicked()
            {
                action = Some(DaveAction::FocusQueueNext);
            }
        }
    }

    // AUTO badge (rendered first in right-to-left, so it appears rightmost)
    let mut auto_badge = super::badge::StatusBadge::new("AUTO").variant(if auto_steal_focus {
        super::badge::BadgeVariant::Info
    } else {
        super::badge::BadgeVariant::Default
    });
    if ctrl_held {
        auto_badge = auto_badge.keybind("\\");
    }
    if auto_badge
        .show(ui)
        .on_hover_text("Click or Ctrl+\\ to toggle auto-focus mode")
        .clicked()
    {
        action = Some(DaveAction::ToggleAutoSteal);
    }

    // Permission mode badge. Left-click / Ctrl+M cycles Default → Plan → Auto
    // Edit. "Auto Accept All" is a separate dave-side toggle (not a CLI mode);
    // when on it takes over the badge as a red "AUTO ACCEPT" and is reachable
    // only from the right-click menu or Ctrl+Shift+M, so it can't be hit by
    // accidental cycling.
    let (label, variant) = if auto_accept_all {
        ("AUTO ACCEPT", BadgeVariant::Destructive)
    } else {
        match permission_mode {
            PermissionMode::Plan => ("PLAN", BadgeVariant::Info),
            PermissionMode::AcceptEdits => ("AUTO EDIT", BadgeVariant::Warning),
            _ => ("PLAN", BadgeVariant::Default),
        }
    };
    let mut mode_badge = StatusBadge::new(label).variant(variant);
    if ctrl_held {
        mode_badge = mode_badge.keybind("M");
    }
    let mode_resp = mode_badge.show(ui).on_hover_text(
        "Click / Ctrl+M to cycle: Default → Plan → Auto Edit · right-click for more",
    );
    notedeck_ui::context_menu::context_menu(&mode_resp, |ui| {
        ui.label(egui::RichText::new("Permission mode").small().weak());
        if ui.button("Default").clicked() {
            action = Some(DaveAction::SetPermissionMode(PermissionMode::Default));
            ui.close_menu();
        }
        if ui.button("Plan").clicked() {
            action = Some(DaveAction::SetPermissionMode(PermissionMode::Plan));
            ui.close_menu();
        }
        if ui.button("Auto Edit").clicked() {
            action = Some(DaveAction::SetPermissionMode(PermissionMode::AcceptEdits));
            ui.close_menu();
        }
        ui.separator();
        // Dangerous: dave auto-accepts EVERY permission request this session
        // (any backend). Rendered in the theme's error color, checkmarked when
        // on, and separated so it reads as a deliberate, distinct toggle.
        let danger = ui.visuals().error_fg_color;
        let label = if auto_accept_all {
            "⚠ Auto Accept All  ✓"
        } else {
            "⚠ Auto Accept All"
        };
        ui.horizontal(|ui| {
            if ui
                .button(egui::RichText::new(label).color(danger))
                .on_hover_text(
                    "Auto-accept ALL tool calls this session, on any backend. Use with care.",
                )
                .clicked()
            {
                action = Some(DaveAction::ToggleAutoAcceptAll);
                ui.close_menu();
            }
            super::keybind_hint::keybind_hint(ui, "⌃⇧M");
        });
    });
    if mode_resp.clicked() {
        action = Some(DaveAction::CyclePermissionMode);
    }

    // COMPACT badge
    let compact_badge =
        super::badge::StatusBadge::new("COMPACT").variant(super::badge::BadgeVariant::Default);
    if compact_badge
        .show(ui)
        .on_hover_text("Click to compact context")
        .clicked()
    {
        action = Some(DaveAction::Compact);
    }

    action
}

/// Full CWD string from the session header, for tooltip display on hover.
struct HeaderCwd {
    full: Option<String>,
}

fn session_header_ui(
    ui: &mut egui::Ui,
    details: &SessionDetails,
    backend_type: BackendType,
) -> HeaderCwd {
    let mut header_cwd = HeaderCwd { full: None };

    ui.horizontal(|ui| {
        // Backend icon
        if backend_type.is_agentic() {
            let icon = crate::ui::backend_icon(backend_type).max_height(16.0);
            ui.add(icon);
        }

        ui.vertical(|ui| {
            ui.spacing_mut().item_spacing.y = 1.0;

            // Title — normal end-truncation
            ui.add(
                egui::Label::new(egui::RichText::new(details.display_title()).size(13.0))
                    .wrap_mode(egui::TextWrapMode::Truncate),
            );

            // Subtitle line: model + cwd
            let model_text = details.display_model();
            let cwd_text = details.cwd.as_ref().map(|cwd| {
                let cwd_display = if details.home_dir.is_empty() {
                    crate::path_utils::abbreviate_path(cwd)
                } else {
                    crate::path_utils::abbreviate_with_home(cwd, &details.home_dir)
                };
                if details.hostname.is_empty() {
                    cwd_display
                } else {
                    format!("{}:{}", details.hostname, cwd_display)
                }
            });

            let subtitle = match (model_text, &cwd_text) {
                (Some(model), Some(cwd)) => Some(format!("{} \u{00B7} {}", model, cwd)),
                (Some(model), None) => Some(model.to_string()),
                (None, Some(cwd)) => Some(cwd.clone()),
                (None, None) => None,
            };

            if let Some(text) = subtitle {
                ui.add(
                    egui::Label::new(egui::RichText::new(text).monospace().size(10.0).weak())
                        .wrap_mode(egui::TextWrapMode::Truncate),
                );

                header_cwd.full = cwd_text;
            }
        });
    });

    header_cwd
}

#[cfg(test)]
mod tests {
    use super::{toggle_badges_ui, DaveAction, DaveUi};
    use crate::config::AiMode;
    use crate::messages::{
        PermissionRequest, PermissionResponse, PermissionResponseType, QuestionAnswer,
    };
    use claude_agent_sdk_rs::PermissionMode;
    use egui_kittest::{kittest::Queryable, Harness};
    use serde_json::json;
    use std::collections::HashMap;
    use uuid::Uuid;

    /// Harness state for exercising the permission-mode badge + its right-click
    /// menu in isolation.
    struct BadgeHarnessState {
        mode: PermissionMode,
        auto_accept_all: bool,
        action: Option<DaveAction>,
    }

    fn badge_harness(
        mode: PermissionMode,
        auto_accept_all: bool,
    ) -> Harness<'static, BadgeHarnessState> {
        Harness::new_ui_state(
            |ui, state: &mut BadgeHarnessState| {
                if let Some(action) =
                    toggle_badges_ui(ui, state.mode, state.auto_accept_all, false, None)
                {
                    state.action = Some(action);
                }
            },
            BadgeHarnessState {
                mode,
                auto_accept_all,
                action: None,
            },
        )
    }

    /// Right-click the mode badge and click the center of it to open the menu.
    fn right_click(harness: &mut Harness<'static, BadgeHarnessState>, label: &str) {
        let bounds = harness
            .get_by_label(label)
            .raw_bounds()
            .expect("badge bounds");
        let center = egui::pos2(
            ((bounds.x0 + bounds.x1) / 2.0) as f32,
            ((bounds.y0 + bounds.y1) / 2.0) as f32,
        );
        harness
            .input_mut()
            .events
            .push(egui::Event::PointerMoved(center));
        for pressed in [true, false] {
            harness.input_mut().events.push(egui::Event::PointerButton {
                pos: center,
                button: egui::PointerButton::Secondary,
                pressed,
                modifiers: egui::Modifiers::NONE,
            });
        }
        harness.step();
    }

    #[test]
    fn mode_badge_left_click_cycles() {
        // Left-clicking the badge (shows "PLAN" in Default) cycles — it must NOT
        // jump straight to a specific mode.
        let mut harness = badge_harness(PermissionMode::Default, false);
        harness.run();
        harness.get_by_label("PLAN").click();
        harness.run();
        assert!(
            matches!(
                harness.state().action,
                Some(DaveAction::CyclePermissionMode)
            ),
            "left-click should cycle, never set a specific mode"
        );
    }

    #[test]
    fn auto_accept_all_reachable_only_via_right_click_menu() {
        let mut harness = badge_harness(PermissionMode::Default, false);
        harness.run();

        // Open the mode badge's context menu and pick the dangerous entry.
        right_click(&mut harness, "PLAN");
        harness.get_by_label("⚠ Auto Accept All").click();
        harness.run();

        assert!(
            matches!(
                harness.state().action,
                Some(DaveAction::ToggleAutoAcceptAll)
            ),
            "the menu's Auto Accept All item should toggle the dave-side flag"
        );
    }

    #[test]
    fn auto_accept_all_takes_over_the_badge() {
        // With the dave-side flag on, the badge reads "AUTO ACCEPT" (Destructive)
        // regardless of the underlying permission mode.
        let mut harness = badge_harness(PermissionMode::Plan, true);
        harness.run();
        // Queryable by its text means the label rendered.
        let _ = harness.get_by_label("AUTO ACCEPT");
    }

    struct PermissionUiHarnessState {
        request: PermissionRequest,
        input: String,
        focus_requested: bool,
        question_answers: HashMap<Uuid, Vec<QuestionAnswer>>,
        question_index: HashMap<Uuid, usize>,
        action: Option<DaveAction>,
    }

    impl PermissionUiHarnessState {
        fn new(request: PermissionRequest) -> Self {
            Self {
                request,
                input: String::new(),
                focus_requested: false,
                question_answers: HashMap::new(),
                question_index: HashMap::new(),
                action: None,
            }
        }
    }

    #[test]
    fn approval_prompt_ui_allows_request() {
        let expected_request_id = Uuid::new_v4();
        let request = PermissionRequest::pending(
            expected_request_id,
            "SaveIssue".to_string(),
            json!({
                "questions": [{
                    "header": "Approve app tool call?",
                    "question": "Allow this action?"
                }]
            }),
        );

        let mut harness = Harness::new_ui_state(
            |ui, state: &mut PermissionUiHarnessState| {
                let mut dave_ui = DaveUi::new(
                    false,
                    1,
                    &[],
                    &mut state.input,
                    &mut state.focus_requested,
                    AiMode::Agentic,
                );
                if let Some(action) = dave_ui.permission_request_ui(&state.request, None, ui) {
                    state.action = Some(action);
                }
            },
            PermissionUiHarnessState::new(request),
        );

        harness.run();
        harness.get_by_label("SaveIssue");
        harness.get_by_label("Allow this action?");
        harness.get_by_label("Allow").click();
        harness.run();

        match harness.state().action.as_ref() {
            Some(DaveAction::PermissionResponse {
                request_id,
                response,
            }) => {
                assert_eq!(*request_id, expected_request_id);
                assert!(matches!(
                    response,
                    PermissionResponse::Allow { message: None }
                ));
            }
            other => panic!("expected PermissionResponse action, got {:?}", other),
        }
    }

    #[test]
    fn permission_request_ui_exit_button_emits_exit_tool_call_action() {
        let expected_request_id = Uuid::new_v4();
        let request = PermissionRequest::pending(
            expected_request_id,
            "Bash".to_string(),
            json!({
                "command": "rm -rf /tmp/scratch"
            }),
        );

        let mut harness = Harness::new_ui_state(
            |ui, state: &mut PermissionUiHarnessState| {
                let mut dave_ui = DaveUi::new(
                    false,
                    1,
                    &[],
                    &mut state.input,
                    &mut state.focus_requested,
                    AiMode::Agentic,
                );
                if let Some(action) = dave_ui.permission_request_ui(&state.request, None, ui) {
                    state.action = Some(action);
                }
            },
            PermissionUiHarnessState::new(request),
        );

        harness.run();
        harness.get_by_label("Exit").click();
        harness.run();

        match harness.state().action.as_ref() {
            Some(DaveAction::ExitToolCall { request_id }) => {
                assert_eq!(*request_id, expected_request_id);
            }
            other => panic!("expected ExitToolCall action, got {:?}", other),
        }
    }

    #[test]
    fn question_set_prompt_ui_submits_selected_answer() {
        let expected_request_id = Uuid::new_v4();
        let request = PermissionRequest::pending(
            expected_request_id,
            "AskUserQuestion".to_string(),
            json!({
                "questions": [{
                    "header": "Theme",
                    "question": "Pick a theme",
                    "multiSelect": false,
                    "options": [{
                        "label": "Light",
                        "description": "Bright background"
                    }]
                }]
            }),
        );

        let mut harness = Harness::new_ui_state(
            |ui, state: &mut PermissionUiHarnessState| {
                let mut dave_ui = DaveUi::new(
                    false,
                    1,
                    &[],
                    &mut state.input,
                    &mut state.focus_requested,
                    AiMode::Agentic,
                )
                .question_answers(&mut state.question_answers)
                .question_index(&mut state.question_index);
                if let Some(action) = dave_ui.permission_request_ui(&state.request, None, ui) {
                    state.action = Some(action);
                }
            },
            PermissionUiHarnessState::new(request),
        );

        harness.run();
        harness.get_by_label("Pick a theme");
        harness.get_by_label("Light");
        harness.get_by_label("Bright background");
        harness.press_key(egui::Key::Num1);
        harness.step();
        harness.get_by_label("Submit").click();
        harness.run();

        match harness.state().action.as_ref() {
            Some(DaveAction::QuestionResponse {
                request_id,
                answers,
            }) => {
                assert_eq!(*request_id, expected_request_id);
                assert_eq!(answers.len(), 1);
                assert_eq!(answers[0].selected, vec![0]);
                assert_eq!(answers[0].other_text, None);
            }
            other => panic!("expected QuestionResponse action, got {:?}", other),
        }
    }

    /// Build a harness that renders a single (already-responded) permission
    /// request through the real `permission_request_ui`.
    fn responded_permission_harness(
        request: PermissionRequest,
    ) -> Harness<'static, PermissionUiHarnessState> {
        Harness::new_ui_state(
            |ui, state: &mut PermissionUiHarnessState| {
                let mut dave_ui = DaveUi::new(
                    false,
                    1,
                    &[],
                    &mut state.input,
                    &mut state.focus_requested,
                    AiMode::Agentic,
                );
                if let Some(action) = dave_ui.permission_request_ui(&state.request, None, ui) {
                    state.action = Some(action);
                }
            },
            PermissionUiHarnessState::new(request),
        )
    }

    /// Primary-click the center of a labelled node. `.click()` on a plain
    /// egui `Label` is a no-op in accesskit (labels expose no default action),
    /// so a disclosure driven by `Response::interact(Sense::click())` must be
    /// exercised with a real positional pointer event instead.
    fn click_label(harness: &mut Harness<'static, PermissionUiHarnessState>, label: &str) {
        let bounds = harness
            .get_by_label(label)
            .raw_bounds()
            .expect("label bounds");
        let center = egui::pos2(
            ((bounds.x0 + bounds.x1) / 2.0) as f32,
            ((bounds.y0 + bounds.y1) / 2.0) as f32,
        );
        harness
            .input_mut()
            .events
            .push(egui::Event::PointerMoved(center));
        for pressed in [true, false] {
            harness.input_mut().events.push(egui::Event::PointerButton {
                pos: center,
                button: egui::PointerButton::Primary,
                pressed,
                modifiers: egui::Modifiers::NONE,
            });
        }
        harness.step();
    }

    #[test]
    fn allowed_bash_widget_reveals_command_when_expanded() {
        // After accepting, the row is collapsed to "Allowed  Bash"; expanding
        // it must reveal *what* was allowed — the bash command.
        let request = PermissionRequest::new(
            Uuid::new_v4(),
            "Bash".to_string(),
            json!({ "command": "cargo test --all" }),
            None,
            Some(PermissionResponseType::Allowed),
            None,
        );
        let mut harness = responded_permission_harness(request);
        harness.run();

        harness.get_by_label("Allowed");
        harness.get_by_label("Bash");
        // Collapsed by default: the command is hidden until disclosed.
        assert!(harness.query_by_label("cargo test --all").is_none());

        // Clicking the header expands the row.
        click_label(&mut harness, "Bash");
        harness.run();
        harness.get_by_label("cargo test --all");
    }

    #[test]
    fn denied_edit_widget_reveals_file_when_expanded() {
        // Likewise for a denied Edit: expanding reveals the edited file path.
        let request = PermissionRequest::new(
            Uuid::new_v4(),
            "Edit".to_string(),
            json!({
                "file_path": "crates/notedeck_dave/src/lib.rs",
                "old_string": "a",
                "new_string": "b",
            }),
            None,
            Some(PermissionResponseType::Denied),
            None,
        );
        let mut harness = responded_permission_harness(request);
        harness.run();

        harness.get_by_label("Denied");
        // Collapsed by default: the path is hidden until disclosed.
        assert!(harness
            .query_by_label("crates/notedeck_dave/src/lib.rs")
            .is_none());

        // Clicking the header expands the row to show the file + diff.
        click_label(&mut harness, "Denied");
        harness.run();
        harness.get_by_label("crates/notedeck_dave/src/lib.rs");
    }

    #[test]
    fn auto_accepted_widget_starts_expanded() {
        // An auto-accepted row (runtime allowlist / Auto Accept All) was never
        // reviewed up front, so it starts EXPANDED — the command is visible
        // without any click.
        let request = PermissionRequest::pending(
            Uuid::new_v4(),
            "Bash".to_string(),
            json!({ "command": "cargo test --all" }),
        )
        .auto_accept();
        assert!(request.auto_accepted);
        let mut harness = responded_permission_harness(request);
        harness.run();

        harness.get_by_label("Allowed");
        harness.get_by_label("Bash");
        // Expanded by default: the command is shown without clicking.
        harness.get_by_label("cargo test --all");
    }

    /// Visualize the responded permission widgets. Ignored by default; render
    /// with `scripts/snapshot-test snapshot_responded_permission_widgets`.
    #[test]
    #[ignore] // requires lavapipe — run via scripts/snapshot-test
    fn snapshot_responded_permission_widgets() {
        let requests = vec![
            PermissionRequest::new(
                Uuid::new_v4(),
                "Bash".to_string(),
                json!({ "command": "cargo test --all -- --nocapture" }),
                None,
                Some(PermissionResponseType::Allowed),
                None,
            ),
            PermissionRequest::new(
                Uuid::new_v4(),
                "Edit".to_string(),
                json!({
                    "file_path": "crates/notedeck_dave/src/ui/badge.rs",
                    "old_string": "a",
                    "new_string": "b",
                }),
                None,
                Some(PermissionResponseType::Denied),
                None,
            ),
        ];

        let mut harness = Harness::builder()
            .with_size(egui::Vec2::new(460.0, 180.0))
            .renderer(notedeck::software_renderer())
            .build_ui(move |ui| {
                let mut input = String::new();
                let mut focus = false;
                for request in &requests {
                    let mut dave_ui =
                        DaveUi::new(false, 1, &[], &mut input, &mut focus, AiMode::Agentic);
                    dave_ui.permission_request_ui(request, None, ui);
                    ui.add_space(8.0);
                }
            });

        harness.run();
        harness.snapshot("responded_permission_widgets");
    }

    /// Visualize the responded permission widgets in their *expanded* state —
    /// the Bash command revealed inline and the denied Edit's file diff. Render
    /// with `scripts/snapshot-test snapshot_responded_permission_widgets_expanded`.
    #[test]
    #[ignore] // requires lavapipe — run via scripts/snapshot-test
    fn snapshot_responded_permission_widgets_expanded() {
        let requests = vec![
            PermissionRequest::new(
                Uuid::new_v4(),
                "Bash".to_string(),
                json!({ "command": "cargo test --all -- --nocapture" }),
                None,
                Some(PermissionResponseType::Allowed),
                None,
            ),
            PermissionRequest::new(
                Uuid::new_v4(),
                "Edit".to_string(),
                json!({
                    "file_path": "crates/notedeck_dave/src/ui/badge.rs",
                    "old_string": "let expandable = false;",
                    "new_string": "let expandable = detail.is_some();",
                }),
                None,
                Some(PermissionResponseType::Denied),
                None,
            ),
        ];

        let mut harness = Harness::builder()
            .with_size(egui::Vec2::new(460.0, 320.0))
            .renderer(notedeck::software_renderer())
            .build_ui(move |ui| {
                let mut input = String::new();
                let mut focus = false;
                for request in &requests {
                    let mut dave_ui =
                        DaveUi::new(false, 1, &[], &mut input, &mut focus, AiMode::Agentic);
                    dave_ui.permission_request_ui(request, None, ui);
                    ui.add_space(8.0);
                }
            });

        harness.run();
        // "Allowed" and "Denied" are each unique here, so clicking those status
        // labels expands each row's disclosure. Labels are no-op-clickable in
        // accesskit, so dispatch a real positional primary click.
        for status in ["Allowed", "Denied"] {
            let bounds = harness
                .get_by_label(status)
                .raw_bounds()
                .expect("status label bounds");
            let center = egui::pos2(
                ((bounds.x0 + bounds.x1) / 2.0) as f32,
                ((bounds.y0 + bounds.y1) / 2.0) as f32,
            );
            harness
                .input_mut()
                .events
                .push(egui::Event::PointerMoved(center));
            for pressed in [true, false] {
                harness.input_mut().events.push(egui::Event::PointerButton {
                    pos: center,
                    button: egui::PointerButton::Primary,
                    pressed,
                    modifiers: egui::Modifiers::NONE,
                });
            }
            harness.step();
        }
        harness.run();
        harness.snapshot("responded_permission_widgets_expanded");
    }
}
