//! Session lifecycle: bind/reload/replay bookkeeping, turn activity
//! resolution, context updates, and app-scoped gates.
#[cfg(test)]
use super::test_agent_view;
use super::{
    ActivePane, AgentView, InlineMediaHitAreas, InputMode, PaneAreas, PromptInputMode, PromptMode,
    SELF_ORIGINATED_PROMPT_CAP, SessionReload,
};
use crate::app::agent::AgentSession;
use crate::app::app_view::InputOutcome;
use crate::scrollback::state::ScrollbackState;
use crate::scrollback::text_selection::ResolvedSelectionModel;
use crate::views::prompt_widget::PromptWidget;
use crate::views::queue_pane::QueuePane;
use crate::views::subagent_catalog_pane::SubagentCatalogPane;
use crate::views::tasks_pane::TasksPane;
use crate::views::todo_pane::TodoPane;
use ratatui::layout::Rect;
use std::collections::{HashMap, HashSet, VecDeque};
use std::time::Instant;
impl AgentView {
    /// Bind this view to a root session id, resetting the per-session
    /// reconnect cursor and both dedup highwaters (ACP + xAI) when the id
    /// actually changes — all three are meaningless against another session's
    /// event-id history (a stale cursor relies on exact-match failure for
    /// safety; a stale highwater could dedup-drop the new session's events
    /// outright).
    pub(crate) fn bind_session_id(&mut self, session_id: agent_client_protocol::SessionId) {
        if self.session.session_id.as_ref() != Some(&session_id) {
            self.last_seen_event_id = None;
            self.last_applied_event_seq = None;
            self.last_applied_xai_event_seq = None;
        }
        self.session.session_id = Some(session_id);
    }
    /// Record a prompt id this client originated (sent to the agent as the turn
    /// driver). Used by the ACP gate to keep `attached_as_viewer` per-turn
    /// accurate. Bounded FIFO; a no-op for ids already tracked.
    pub fn note_self_originated_prompt(&mut self, prompt_id: &str) {
        if self.is_self_originated_prompt(prompt_id) {
            return;
        }
        self.self_originated_prompt_ids
            .push_back(prompt_id.to_string());
        while self.self_originated_prompt_ids.len() > SELF_ORIGINATED_PROMPT_CAP {
            self.self_originated_prompt_ids.pop_front();
        }
    }
    /// Whether `prompt_id` is a turn THIS client originated (vs. one another
    /// client drives, or a server-initiated turn).
    pub fn is_self_originated_prompt(&self, prompt_id: &str) -> bool {
        self.self_originated_prompt_ids
            .iter()
            .any(|p| p == prompt_id)
    }
    /// Create a new agent view with default UI state.
    ///
    /// The prompt widget is initialized with the session's working directory.
    pub fn new(session: AgentSession, scrollback: ScrollbackState) -> Self {
        let prompt = PromptWidget::new_with_cwd(&session.cwd);
        let mut view = Self {
            session,
            scrollback,
            prompt,
            tip_typing_dismissed: false,
            todo: TodoPane::new(),
            tasks: TasksPane::new(),
            catalog: SubagentCatalogPane::new(),
            queue: QueuePane::new(),
            shared_queue: Vec::new(),
            attached_as_viewer: false,
            self_originated_prompt_ids: VecDeque::new(),
            last_applied_event_seq: None,
            last_applied_xai_event_seq: None,
            last_seen_event_id: None,
            session_reload: None,
            unexpected_replay_drops: 0,
            replayed_terminal_prompts: HashSet::new(),
            active_pane: ActivePane::Prompt,
            prompt_mode: PromptMode::Normal,
            prompt_input_mode: PromptInputMode::Normal,
            multiline_mode: false,
            vim_mode: crate::appearance::cache::load_vim_mode(),
            input_mode: InputMode::Vim,
            bash_turn: false,
            cron_task_id: None,
            stashed_prompt: None,
            reauth_stashed_prompt: None,
            active_modal: None,
            modal_buttons: Vec::new(),
            modal_hovered_key: None,
            context_state: None,
            chat_kind: false,
            app_chat_mode: false,
            goal_state: None,
            graph_state: None,
            parked_wait_marker_for: None,
            end_work_announced: false,
            pending_stop_hooks: None,
            last_cleared_goal_id: None,
            show_goal_detail: false,
            turn_start_ms: None,
            wake_turn_start: None,
            turn_started_at: None,
            first_activity_logged_for: None,
            turn_paused_duration: std::time::Duration::ZERO,
            self_interjection_ids: std::collections::HashSet::new(),
            last_active_at: Some(Instant::now()),
            current_branch: None,
            is_worktree: false,
            main_repo: None,
            worktree_label: None,
            activity_started_at: None,
            last_activity: None,
            pane_areas: PaneAreas::default(),
            hovered_entry: None,
            pending_text_drag: None,
            drag_selection: None,
            pending_block_drag: None,
            block_drag_selection: None,
            deferred_text_press: None,
            persistent_text_selection: None,
            table_selection_geometry: None,
            selection_created_at: None,
            last_drag_mouse: None,
            drag_autoscroll: None,
            left_mouse_down: false,
            plan_prompt_mouse_drag: false,
            last_scrollback_selection_model: ResolvedSelectionModel::default(),
            last_scrollback_selection_boundaries: Default::default(),
            last_link_overlay: Default::default(),
            frame_occluder_rects: Vec::new(),
            visible_link_map: Default::default(),
            scrollback_visible_link_count: 0,
            highlighted_link_idx: None,
            hovered_link_idx: None,
            last_pointer_on_link: false,
            last_btw_selection_model: ResolvedSelectionModel::default(),
            last_btw_area: Rect::default(),
            pending_scrollback_click: None,
            pending_link_click: None,
            media_link_paths: Vec::new(),
            media_link_paths_gen: None,
            last_mouse_pos: (0, 0),
            last_mouse_moved_at: None,
            last_click: None,
            last_text_click: None,
            last_clipboard_toast_at: None,
            last_context_click_at: None,
            hovered_prompt: false,
            hit_badge: Default::default(),
            hit_context: Default::default(),
            hit_todo_close: Default::default(),
            hit_bg_close: Default::default(),
            hit_subagent_close: Default::default(),
            hit_catalog_close: Default::default(),
            hit_bg_status: Default::default(),
            hit_goal_status: Default::default(),
            hit_goal_close: Default::default(),
            hit_bg_button: Default::default(),
            last_bg_click: None,
            hit_queue_close: Default::default(),
            hit_queue_badge: Default::default(),
            hit_plan_button: Default::default(),
            hit_plan_approval_status: Default::default(),
            hit_follow_indicator: Default::default(),
            hit_cwd: Default::default(),
            hit_cancel_button: Default::default(),
            hit_scrollbar: Default::default(),
            scrollbar_dragging: false,
            dropdown_items_area: None,
            slash_dropdown_items_area: None,
            slash_dropdown_hit: Default::default(),
            completion_dropdown_items_area: None,
            history_dropdown_area: None,
            last_prompt_click_ms: None,
            line_viewer: None,
            image_viewer: None,
            image_load_rx: None,
            video_viewer: None,
            gboom: None,
            inline_media_cache: std::collections::HashMap::new(),
            inline_media_ids: std::collections::HashMap::new(),
            inline_media_iterm_emitted: std::collections::HashMap::new(),
            next_inline_media_id: 2,
            inline_video: None,
            video_load_rx: None,
            mermaid: None,
            edit_hl: None,
            inline_media_active: false,
            last_placed_ids: HashSet::new(),
            last_terminal_size: (0, 0),
            terminal_size_stale: false,
            inline_media_hits: InlineMediaHitAreas::default(),
            extensions_modal: None,
            agents_modal: None,
            persona_detail: None,
            btw_state: None,
            btw_focused: false,
            hit_btw_close: Default::default(),
            toast: None,
            ephemeral_tip: Default::default(),
            word_select_tip_prompt_snapshot: None,
            last_word_select_probe: None,
            sticky_toast: None,
            mode_switch_banner: None,
            block_viewer: None,
            scrollback_search: None,
            hit_sb_copy: Default::default(),
            hit_sb_view: Default::default(),
            question_view: None,
            hit_question_scrollbar: Default::default(),
            hovered_question_item: None,
            question_scrollbar_dragging: false,
            last_question_click: None,
            inline_prompt_area: None,
            question_nav_buttons: Vec::new(),
            hovered_question_button: None,
            question_scroll_region: None,
            plan_mode_active: false,
            plan_mode_pending: None,
            deferred_session_mode: None,
            pending_extensions_fetch: false,
            in_dashboard_overlay: false,
            mcp_init_progress: None,
            acp_synced_generation: 0,
            hovered_permission_item: None,
            last_permission_click: None,
            permission_queue: VecDeque::new(),
            next_perm_req_id: 0,
            permission_stashed_prompt: None,
            plan_approval_view: None,
            latest_inline_plan_content: None,
            plan_comments: Vec::new(),
            plan_next_comment_id: 0,
            casual_commenting_range: None,
            casual_editing_comment_id: None,
            casual_stashed_prompt: None,
            cancel_turn_view: None,
            cancel_turn_buttons: Vec::new(),
            cancel_subagents_preference: None,
            cancel_trigger_hint: None,
            rewind_state: None,
            rewind_points: None,
            inline_edit: None,
            pending_inline_resubmit: None,
            jump_state: None,
            timeline_rail: None,
            timeline_hover: None,
            timeline_hover_preview: None,
            session_agent_name: None,
            subagent_sessions: HashMap::new(),
            subagent_views: HashMap::new(),
            active_subagent: None,
            is_subagent_view: false,
            hit_subagent_frame_close: Default::default(),
            input_log: crate::input_log::InputRingBuffer::new(),
            esc_pressed_at: None,
            pending_first_prompt: None,
            pending_fork_banner: None,
            loading_placeholder_id: None,
            pending_recap_entry: None,
            display_name: None,
            generated_session_title: None,
            pending_effects: Vec::new(),
            paste_probe_in_flight: 0,
            deferred_send: None,
            pending_turn_end_reconcile: None,
            expect_send_now_cancel: None,
            optimistic_queue_ids: std::collections::HashSet::new(),
            send_now_awaiting_confirm: None,
            send_now_painted_blocks: std::collections::HashMap::new(),
            follow_without_jump_prompt_id: None,
            follow_ups: None,
            follow_up_shown_prompt_id: None,
            follow_up_chips: Vec::new(),
            hovered_follow_up_chip: None,
            follow_up_seen: HashMap::new(),
            follow_up_next_gen: 0,
            follow_up_pending: HashMap::new(),
            follow_up_pending_order: VecDeque::new(),
            pending_adoption_updates: Vec::new(),
        };
        let mode = if crate::appearance::cache::load_simple_mode() {
            InputMode::Simple
        } else {
            InputMode::Vim
        };
        view.set_input_mode(mode);
        view
    }
    /// Clear `turn_started_at` and stamp `last_active_at` to "now".
    ///
    /// Call this from every site that ends a turn (success, failure,
    /// cancellation, reconnect cleanup). Centralised so the two
    /// fields cannot drift apart at the ~10 termination call sites
    /// across `dispatch.rs` and `event_loop.rs`.
    pub fn mark_turn_finished(&mut self) {
        self.turn_started_at = None;
        self.turn_paused_duration = std::time::Duration::ZERO;
        self.last_active_at = Some(Instant::now());
    }
    /// Enter a `session/load` replay window: flip `loading_replay` on and reset
    /// every field coupled to that transition together, so no site can drift
    /// (e.g. reset one coupled field but miss another). Called at every
    /// replay-window entry: the fresh/restore load ctor paths and the
    /// reconnect/fork reuse paths.
    pub(crate) fn begin_replay_window(&mut self) {
        self.session.loading_replay = true;
        self.replayed_terminal_prompts.clear();
        self.unexpected_replay_drops = 0;
        self.pending_stop_hooks = None;
        self.end_work_announced = false;
        self.wake_turn_start = None;
        self.clear_send_now_expectation();
        self.optimistic_queue_ids.clear();
        self.send_now_awaiting_confirm = None;
        self.send_now_painted_blocks.clear();
    }
    /// Open a reconnect reload window: stash the current transcript/tracker
    /// and point the live fields at fresh state for the incoming
    /// `session/load` replay. The transcript is NOT cleared — it stays
    /// recoverable until [`finish_session_reload`](Self::finish_session_reload)
    /// decides the outcome.
    pub(crate) fn begin_session_reload(&mut self, generation: u64) {
        self.dismiss_jump_picker();
        if let Some(prev) = self.session_reload.take() {
            tracing::warn!(
                generation,
                prev_generation = prev.generation,
                "session reload superseded without finalize; restoring previous stash first"
            );
            if self.apply_reload_outcome(prev, false) {
                crate::memory_release::release_retained_memory_with("reload-supersede");
            }
        }
        while self.scrollback.in_batch() {
            self.scrollback.end_batch();
        }
        if let Some(pid) = self.loading_placeholder_id.take() {
            self.scrollback.remove_entry(pid);
        }
        if let Some(rid) = self.pending_recap_entry.take() {
            self.scrollback.remove_entry(rid);
        }
        self.session.model_switch_pending = false;
        self.pending_adoption_updates.clear();
        let fresh = self.scrollback.fresh_continuation();
        self.session_reload = Some(SessionReload {
            generation,
            scrollback: std::mem::replace(&mut self.scrollback, fresh),
            tracker: std::mem::replace(
                &mut self.session.tracker,
                crate::acp::tracker::AcpUpdateTracker::new(),
            ),
            todo: std::mem::take(&mut self.todo),
            last_seen_event_id: self.last_seen_event_id.clone(),
            last_applied_event_seq: self.last_applied_event_seq,
            last_applied_xai_event_seq: self.last_applied_xai_event_seq,
            saw_replay: false,
            saw_todo_update: false,
        });
        self.loading_placeholder_id = Some(self.scrollback.push_block(
            crate::scrollback::block::RenderBlock::system("Reloading session after reconnect..."),
        ));
        self.scrollback.begin_batch();
        self.begin_replay_window();
    }
    /// Record that an `isReplay` update applied while a reload window is open.
    /// No-op otherwise.
    pub(crate) fn mark_reload_replay_seen(&mut self) {
        if let Some(reload) = self.session_reload.as_mut() {
            reload.saw_replay = true;
        }
    }
    /// Record that a Plan update applied while a reload window is open.
    /// No-op otherwise.
    pub(crate) fn mark_reload_todo_update(&mut self) {
        if let Some(reload) = self.session_reload.as_mut() {
            reload.saw_todo_update = true;
        }
    }
    /// Start a locally-tracked turn: close the between-turns status window
    /// (completions inside a turn push chips only), then enter TurnRunning.
    /// Every real turn start must route through here so no caller can miss
    /// the close. Deliberately NOT used by server-initiated synthetic turns
    /// (auto-wake / actor runs): they never call `start_turn`, and their
    /// completions still deserve status lines.
    pub(crate) fn start_turn_boundary(&mut self, starting_prompt_id: Option<&str>) {
        self.end_work_announced = false;
        if self
            .expect_send_now_cancel
            .as_deref()
            .is_some_and(|id| Some(id) != starting_prompt_id)
        {
            self.expect_send_now_cancel = None;
        }
        self.session.start_turn(&mut self.scrollback);
    }
    /// Adopt the in-flight turn another client is driving, conveyed by the
    /// `session/load` response meta (`kigi/runningPromptId`): enter
    /// TurnRunning and match subsequent live deltas. No user-prompt block is
    /// pushed — the turn's prompt and prior chunks arrived via the replay.
    pub(crate) fn adopt_running_prompt(&mut self, prompt_id: String) {
        self.start_turn_boundary(Some(&prompt_id));
        self.session.tracker.clear_user_echo_skip();
        self.session.current_prompt_id = Some(prompt_id.clone());
        self.turn_started_at = Some(Instant::now());
        self.scrollback.enable_follow_with_preserve();
        self.flush_pending_follow_ups(&prompt_id);
    }
    /// Finalize any open reload window as FAILED, regardless of generation.
    ///
    /// For load initiations that take over the agent (fork/worktree/restore
    /// binding a new session): the stash belongs to the superseded
    /// pre-reconnect state, and an open window would corrupt the incoming
    /// load's batch/replay bookkeeping — and defer its results. The window's
    /// pending re-init completion later no-ops (generation gone).
    pub(crate) fn abort_session_reload(&mut self) {
        if let Some(reload) = self.session_reload.take()
            && self.apply_reload_outcome(reload, false)
        {
            crate::memory_release::release_retained_memory_with("reload-abort");
        }
    }
    /// Finalize the reload window opened for `generation`.
    ///
    /// Returns `false` (untouched state) when no window with that generation
    /// is open — the agent was never reloading, or a newer reconnect already
    /// superseded it.
    pub(crate) fn finish_session_reload(&mut self, generation: u64, success: bool) -> bool {
        match self.session_reload.take() {
            Some(reload) if reload.generation == generation => {
                if self.apply_reload_outcome(reload, success) {
                    crate::memory_release::release_retained_memory_with("reload-finalize");
                }
                true
            }
            Some(other) => {
                tracing::warn!(
                    generation,
                    open_generation = other.generation,
                    "ignoring session reload finalize for a superseded generation"
                );
                self.session_reload = Some(other);
                false
            }
            None => false,
        }
    }
    /// Whether a running prompt reported on a `session/load` (resume /
    /// reconnect) is adoptable by THIS agent: the pure synthetic-turn guard
    /// ([`acp_handler::should_adopt_running_prompt`]) AND not terminal-in-replay.
    /// A turn whose durable `TurnCompleted` already arrived in this load's replay
    /// (recorded in [`Self::replayed_terminal_prompts`]) has ended; adopting it
    /// would re-strand the viewer on "Waiting…".
    ///
    /// [`acp_handler::should_adopt_running_prompt`]: crate::app::acp_handler::should_adopt_running_prompt
    pub(crate) fn should_adopt_running_prompt(&self, prompt_id: &str) -> bool {
        crate::app::acp_handler::should_adopt_running_prompt(prompt_id)
            && !self.replayed_terminal_prompts.contains(prompt_id)
    }
    /// Finalize a reconnect-reload window and, iff the running prompt is
    /// adoptable, adopt it. Returns whether the window finalized.
    ///
    /// Adoption is gated by [`Self::should_adopt_running_prompt`] and ordered
    /// AFTER finalize so the finalize side effect (force-idle + window resolve)
    /// always runs even when adoption is skipped for a synthetic / non-adoptable
    /// / terminal-in-replay running id. The reconnect loop in `event_loop.rs`
    /// calls this per agent.
    pub(crate) fn finalize_reload_and_maybe_adopt(
        &mut self,
        generation: u64,
        ok: bool,
        running_prompt_id: Option<String>,
    ) -> bool {
        let finalized = self.finish_session_reload(generation, ok);
        if finalized
            && let Some(pid) = running_prompt_id
            && self.should_adopt_running_prompt(&pid)
        {
            self.adopt_running_prompt(pid);
        }
        finalized
    }
    /// Resolve a closed window per the [`SessionReload`] outcome trichotomy.
    ///
    /// Returns whether a heavy transient was dropped — the stashed pre-reload
    /// scrollback (success + full replay) or the staged partial replay
    /// (failure). The success+cursor branch *reuses* the stash and moves the
    /// tail entries into it: nothing multi-MB drops, so callers must NOT
    /// purge for it (a full-arena purge there would madvise away warm pages
    /// on the most common reconnect outcome, once per open tab).
    #[must_use = "purge retained memory iff a heavy transient dropped"]
    fn apply_reload_outcome(&mut self, reload: SessionReload, success: bool) -> bool {
        if let Some(pid) = self.loading_placeholder_id.take() {
            self.scrollback.remove_entry(pid);
        }
        let dropped_heavy;
        if success && reload.saw_replay {
            self.scrollback.end_batch();
            dropped_heavy = true;
        } else if success {
            let tail = std::mem::replace(&mut self.scrollback, reload.scrollback);
            self.scrollback.append_entries_from(tail);
            if !reload.saw_todo_update {
                self.todo = reload.todo;
            }
            dropped_heavy = false;
        } else {
            let floor = self.scrollback.id_floor();
            let staging_generations = self.scrollback.invalidation_generations();
            self.scrollback = reload.scrollback;
            self.scrollback.raise_id_floor(floor);
            self.scrollback
                .raise_invalidation_floor(staging_generations);
            self.session.tracker = reload.tracker;
            self.todo = reload.todo;
            self.last_seen_event_id = reload.last_seen_event_id;
            self.last_applied_event_seq = reload.last_applied_event_seq;
            self.last_applied_xai_event_seq = reload.last_applied_xai_event_seq;
            dropped_heavy = true;
        }
        self.session.loading_replay = false;
        self.session.prompt_history_loading = false;
        self.session.tracker.clear_user_echo_skip();
        self.session.finish_turn(&mut self.scrollback);
        self.scrollback.finish_all_running();
        if let Some(id) = self.pending_recap_entry.take() {
            self.scrollback.remove_entry(id);
        }
        self.mark_turn_finished();
        self.activity_started_at = None;
        self.last_activity = None;
        self.reset_follow_ups_for_reload();
        dropped_heavy
    }
    /// Effective turn elapsed time, excluding time spent in question views.
    ///
    /// Subtracts both the accumulated `turn_paused_duration` (from previously
    /// closed question views) and the time elapsed since the current question
    /// view opened (if one is active).
    pub fn turn_elapsed(&self) -> Option<std::time::Duration> {
        let raw = self.turn_started_at?.elapsed();
        let mut paused = self.turn_paused_duration;
        if let Some(qv) = &self.question_view {
            paused += qv.opened_at.elapsed();
        }
        Some(raw.saturating_sub(paused))
    }
    /// Turn activity for the status spinner, with the implicit "no activity"
    /// gap during a running inference turn resolved into an explicit
    /// [`WaitingReason`] so the spinner names *what* we're waiting on.
    ///
    /// The tracker already returns `Waiting(TaskOutput/TasksComplete/Sleep)`,
    /// and `Waiting(Subagent)` for a foreground `task` call from the moment it's
    /// issued. This fills in the remaining gap: if no tracker activity but a
    /// foreground subagent is registered as running, it's still `Subagent`
    /// (covers any window where the task tool call has cleared but the child is
    /// live); otherwise the model itself (`Model`). Bash turns keep `None` so
    /// the status line renders its own "Running…".
    ///
    /// For `Waiting(TaskOutput { task_ids, .. })`, also resolves a display
    /// `subject` from live bg-task / subagent state (description preferred,
    /// else command) so the spinner can read `{description}…`.
    pub(crate) fn resolve_turn_activity(&self) -> Option<crate::acp::tracker::TurnActivity> {
        use crate::acp::tracker::{TurnActivity, WaitingReason};
        use crate::app::agent::AgentState;
        if let Some(activity) = self.session.turn_activity() {
            return Some(self.enrich_waiting_activity(activity));
        }
        if !matches!(self.session.state, AgentState::TurnRunning) {
            return None;
        }
        if self.bash_turn {
            return None;
        }
        let reason = if self.has_running_foreground_subagent() {
            WaitingReason::Subagent
        } else {
            WaitingReason::Model
        };
        Some(TurnActivity::Waiting(reason))
    }
    /// Fill in a `TaskOutput` wait's display subject from live task state.
    fn enrich_waiting_activity(
        &self,
        activity: crate::acp::tracker::TurnActivity,
    ) -> crate::acp::tracker::TurnActivity {
        use crate::acp::tracker::{TurnActivity, WaitingReason};
        match activity {
            TurnActivity::Waiting(WaitingReason::TaskOutput {
                task_ids, waits, ..
            }) => {
                let subject = self.subject_for_wait_tasks(&task_ids);
                TurnActivity::Waiting(WaitingReason::TaskOutput {
                    task_ids,
                    subject,
                    waits,
                })
            }
            other => other,
        }
    }
    /// Best user-facing name for the tasks being waited on.
    ///
    /// Uses the first resolvable subject. Multi-id waits always reflect the
    /// full `task_ids` length (`"first + N more"` with `N = task_ids.len()-1`)
    /// so partial resolution still reads as multi-task. Unknown ids → `None`
    /// (spinner falls back to the generic label).
    fn subject_for_wait_tasks(&self, task_ids: &[String]) -> Option<String> {
        use crate::acp::tracker::{MAX_ACTIVITY_SUBJECT_CHARS, clamp_activity_subject};
        if task_ids.is_empty() {
            return None;
        }
        let first = task_ids
            .iter()
            .find_map(|id| self.lookup_task_subject(id))?;
        if task_ids.len() == 1 {
            let first = clamp_activity_subject(&first);
            return (!first.is_empty()).then_some(first);
        }
        let n = task_ids.len() - 1;
        let suffix = format!(" + {n} more");
        let budget = MAX_ACTIVITY_SUBJECT_CHARS
            .saturating_sub(suffix.chars().count())
            .max(8);
        let base: String = first
            .lines()
            .map(str::trim)
            .find(|l| !l.is_empty())
            .unwrap_or(first.trim())
            .chars()
            .take(budget)
            .collect();
        if base.is_empty() {
            None
        } else {
            Some(format!("{base}{suffix}"))
        }
    }
    /// Resolve one task id to a display subject (description preferred, else
    /// a *short* command / subagent description).
    ///
    /// Long bare commands are intentionally not used as subjects — the spinner
    /// falls back to the generic `"Waiting on task output…"` instead of
    /// stuffing a wall of shell into the status line. Descriptions are kept
    /// but clamped by the caller via [`clamp_activity_subject`].
    fn lookup_task_subject(&self, task_id: &str) -> Option<String> {
        use crate::acp::tracker::MAX_ACTIVITY_SUBJECT_CHARS;
        fn first_nonempty_line(s: &str) -> &str {
            s.lines()
                .map(str::trim)
                .find(|line| !line.is_empty())
                .unwrap_or(s)
        }
        if let Some(task) = self.session.bg_tasks.get(task_id) {
            if let Some(desc) = task
                .description
                .as_deref()
                .map(str::trim)
                .filter(|s| !s.is_empty())
            {
                return Some(first_nonempty_line(desc).to_string());
            }
            let cmd = first_nonempty_line(task.command.trim());
            if !cmd.is_empty() && cmd.chars().count() <= MAX_ACTIVITY_SUBJECT_CHARS {
                return Some(cmd.to_string());
            }
        }
        if let Some(info) = self.subagent_sessions.get(task_id) {
            let desc = info.description.trim();
            if !desc.is_empty() {
                return Some(first_nonempty_line(desc).to_string());
            }
        }
        self.subagent_sessions
            .values()
            .find(|info| info.subagent_id.as_ref() == task_id)
            .and_then(|info| {
                let desc = info.description.trim();
                if desc.is_empty() {
                    None
                } else {
                    Some(first_nonempty_line(desc).to_string())
                }
            })
    }
    /// Whether a foreground subagent (`task`/`spawn_subagent`, not
    /// `run_in_background`) is currently running. The parent turn is blocked on
    /// it, so the spinner should read "Waiting on subagent…".
    fn has_running_foreground_subagent(&self) -> bool {
        self.subagent_sessions
            .values()
            .any(|s| s.is_running() && !s.is_background)
    }
    /// Update context state with a full snapshot from live callers.
    ///
    /// No-op for gateway/chat-kind sessions — local GetSessionInfo / sampler
    /// breakdowns must not populate the context bar (remote owns context).
    pub fn apply_full_context_info(&mut self, next: kigi_shell::session::ContextInfo) {
        if self.chat_kind {
            self.context_state = None;
            return;
        }
        self.context_state = Some(next);
    }
    /// Update context state from a streaming notification carrying only
    /// `used` and `total` fields.
    ///
    /// No-op for gateway/chat-kind sessions (same policy as
    /// [`Self::apply_full_context_info`]).
    pub fn apply_context_used(&mut self, used: u64, total: u64) {
        if self.chat_kind {
            self.context_state = None;
            return;
        }
        let total = if total > 0 {
            total
        } else {
            self.context_state.as_ref().map(|s| s.total).unwrap_or(0)
        };
        match self.context_state.as_mut() {
            Some(snap) => {
                snap.used = used;
                if total > 0 {
                    snap.total = total;
                }
                snap.usage_pct = kigi_token_estimation::usage_percentage_u8(used, snap.total);
                snap.free_tokens = kigi_token_estimation::free_tokens(snap.total, used);
            }
            None => {
                self.context_state = Some(kigi_shell::session::ContextInfo::from_notification(
                    used, total,
                ));
            }
        }
    }
    /// Record a key event to the input flight recorder.
    ///
    /// Zero heap allocations — stores raw `Copy` types in the ring buffer.
    /// Formatting into strings happens only during dump (`snapshot_entries`).
    pub(crate) fn record_input(
        &mut self,
        key: &crossterm::event::KeyEvent,
        outcome: &InputOutcome,
    ) {
        use crate::input_log::{ActivePaneSnapshot, OutcomeSnapshot, RawInputEntry};
        use std::time::{SystemTime, UNIX_EPOCH};
        let delta = std::mem::take(&mut self.prompt.last_input_delta);
        let pane = match self.active_pane {
            ActivePane::Scrollback => ActivePaneSnapshot::Scrollback,
            ActivePane::Todo => ActivePaneSnapshot::Todo,
            ActivePane::Queue => ActivePaneSnapshot::Queue,
            ActivePane::Prompt => ActivePaneSnapshot::Prompt,
            ActivePane::Tasks => ActivePaneSnapshot::Tasks,
            ActivePane::Catalog => ActivePaneSnapshot::Catalog,
        };
        let outcome_snap = match outcome {
            InputOutcome::Changed | InputOutcome::ArmPending { .. } => OutcomeSnapshot::Changed,
            InputOutcome::Unchanged => OutcomeSnapshot::Unchanged,
            InputOutcome::Action(_)
            | InputOutcome::ActionThenForward(_)
            | InputOutcome::ActionPair(_, _) => OutcomeSnapshot::Action,
        };
        self.input_log.push(RawInputEntry {
            wall_ts: SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap_or_default()
                .as_millis() as u64,
            key_code: key.code,
            key_modifiers: key.modifiers,
            key_kind: key.kind,
            active_pane: pane,
            outcome: outcome_snap,
            cursor_before: delta.cursor_before,
            cursor_after: delta.cursor_after,
            text_len_before: delta.text_len_before,
            text_len_after: delta.text_len_after,
            sel_before: delta.had_selection_before,
            sel_after: delta.had_selection_after,
            textarea_changed: delta.textarea_changed,
        });
    }
    /// Show or hide the `/usage` slash command in this agent's registry.
    pub fn set_usage_visible(&mut self, visible: bool) {
        self.prompt
            .slash_controller
            .registry_mut()
            .set_usage_visible(visible);
    }
    /// Replace the restricted slash-command deny list in this agent's
    /// registry (e.g. `/usage` denied on the free / X Basic tiers). Deny
    /// wins over every `set_*_visible` gate.
    pub fn set_restricted_commands(&mut self, names: &[String]) {
        self.prompt.set_restricted_commands(names);
    }
    /// Show or hide the `/dashboard` slash command in this agent's registry.
    /// Driven by the dashboard feature flag
    /// (`crate::views::dashboard::dashboard_enabled()`) at agent-creation
    /// time — independent of leader mode.
    pub fn set_dashboard_visible(&mut self, visible: bool) {
        self.prompt
            .slash_controller
            .registry_mut()
            .set_dashboard_visible(visible);
    }
    /// One place for the app-scoped gates a new/adopted session inherits so the session-creation sites cannot drift.
    pub(crate) fn apply_app_scoped_gates(
        &mut self,
        usage_visible: bool,
        chat_mode: bool,
        screen_mode: crate::app::ScreenMode,
        restricted_commands: &[String],
    ) {
        self.set_usage_visible(usage_visible);
        self.app_chat_mode = chat_mode;
        self.prompt.set_screen_mode(screen_mode);
        self.set_dashboard_visible(crate::views::dashboard::dashboard_enabled());
        self.set_restricted_commands(restricted_commands);
    }
    /// Show or hide the `/recap` slash command in this agent's registry.
    pub fn set_session_recap_available(&mut self, available: bool) {
        self.prompt.set_recap_visible(available);
    }
}
#[cfg(test)]
mod resolve_turn_activity_tests {
    use super::*;
    use crate::acp::tracker::{TurnActivity, WaitingReason};
    use crate::app::agent::AgentState;
    fn running_view() -> AgentView {
        let mut view = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"));
        view.session.state = AgentState::TurnRunning;
        view
    }
    #[test]
    fn idle_turn_has_no_activity() {
        let view = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"));
        assert_eq!(view.resolve_turn_activity(), None);
    }
    #[test]
    fn running_with_no_stream_waits_on_model() {
        let view = running_view();
        assert_eq!(
            view.resolve_turn_activity(),
            Some(TurnActivity::Waiting(WaitingReason::Model))
        );
    }
    #[test]
    fn bash_turn_stays_none() {
        let mut view = running_view();
        view.bash_turn = true;
        assert_eq!(view.resolve_turn_activity(), None);
    }
    #[test]
    fn real_activity_passes_through() {
        let mut view = running_view();
        view.session
            .set_compaction_activity(Some(TurnActivity::AutoCompacting));
        assert_eq!(
            view.resolve_turn_activity(),
            Some(TurnActivity::AutoCompacting)
        );
    }
    /// When waiting on task output, the spinner subject is the bg task's
    /// description (preferred over the raw command).
    #[test]
    fn task_output_wait_uses_bg_task_description() {
        use crate::acp::meta::NotificationMeta;
        use crate::app::agent::{BgTaskState, BgTaskStatus};
        use agent_client_protocol as acp;
        use std::sync::Arc;
        use std::time::SystemTime;
        let mut view = running_view();
        view.session.bg_tasks.insert(
            "bg-1".into(),
            BgTaskState {
                task_id: "bg-1".into(),
                tool_call_id: "tc-1".into(),
                command: "cargo test --release".into(),
                description: Some("run release tests".into()),
                cwd: String::new(),
                output_file: String::new(),
                status: BgTaskStatus::Running,
                start_time: SystemTime::now(),
                end_time: None,
                exit_code: None,
                signal: None,
                stdout: String::new(),
                stdout_line_count: 0,
                truncated: false,
                pending_kill: false,
                kill_requested_at: None,
                scrollback_entry_id: None,
                is_monitor: false,
                restored_from_replay: false,
            },
        );
        let meta = NotificationMeta::default();
        view.session.handle_update(
            acp::SessionUpdate::ToolCall(
                acp::ToolCall::new(
                    acp::ToolCallId::new(Arc::from("wait-1")),
                    "get_command_or_subagent_output",
                )
                .kind(acp::ToolKind::Other)
                .status(acp::ToolCallStatus::Pending)
                .content(vec![])
                .locations(vec![]),
            ),
            &meta,
            &mut view.scrollback,
        );
        view.session.handle_update(
            acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
                acp::ToolCallId::new(Arc::from("wait-1")),
                acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!(
                    { "task_ids" : ["bg-1"], "timeout_ms" : 30_000, }
                ))),
            )),
            &meta,
            &mut view.scrollback,
        );
        let activity = view.resolve_turn_activity();
        assert_eq!(
            activity,
            Some(TurnActivity::Waiting(WaitingReason::TaskOutput {
                task_ids: vec!["bg-1".into()],
                subject: Some("run release tests".into()),
                waits: true,
            }))
        );
        assert_eq!(activity.as_ref().unwrap().as_label(), "waiting_task_output");
        let TurnActivity::Waiting(reason) = activity.unwrap() else {
            panic!("expected waiting activity");
        };
        assert_eq!(reason.label(), "run release tests…");
    }
    /// Without a description, a short command is used as the subject.
    #[test]
    fn task_output_wait_falls_back_to_short_command() {
        use crate::acp::meta::NotificationMeta;
        use crate::app::agent::{BgTaskState, BgTaskStatus};
        use agent_client_protocol as acp;
        use std::sync::Arc;
        use std::time::SystemTime;
        let mut view = running_view();
        view.session.bg_tasks.insert(
            "bg-2".into(),
            BgTaskState {
                task_id: "bg-2".into(),
                tool_call_id: "tc-2".into(),
                command: "sleep 30".into(),
                description: None,
                cwd: String::new(),
                output_file: String::new(),
                status: BgTaskStatus::Running,
                start_time: SystemTime::now(),
                end_time: None,
                exit_code: None,
                signal: None,
                stdout: String::new(),
                stdout_line_count: 0,
                truncated: false,
                pending_kill: false,
                kill_requested_at: None,
                scrollback_entry_id: None,
                is_monitor: false,
                restored_from_replay: false,
            },
        );
        let meta = NotificationMeta::default();
        view.session.handle_update(
            acp::SessionUpdate::ToolCall(
                acp::ToolCall::new(acp::ToolCallId::new(Arc::from("wait-2")), "get_task_output")
                    .kind(acp::ToolKind::Other)
                    .status(acp::ToolCallStatus::Pending)
                    .content(vec![])
                    .raw_input(Some(serde_json::json!(
                        { "task_ids" : ["bg-2"], "timeout_ms" : 5_000, }
                    )))
                    .locations(vec![]),
            ),
            &meta,
            &mut view.scrollback,
        );
        let activity = view.resolve_turn_activity().expect("activity");
        let TurnActivity::Waiting(reason) = activity else {
            panic!("expected waiting: {activity:?}");
        };
        assert_eq!(reason.label(), "sleep 30…");
    }
    /// Multi-id waits use full task_ids.len() for "+ N more", not just resolved count.
    #[test]
    fn task_output_wait_multi_id_uses_full_task_count() {
        use crate::acp::meta::NotificationMeta;
        use crate::app::agent::{BgTaskState, BgTaskStatus};
        use agent_client_protocol as acp;
        use std::sync::Arc;
        use std::time::SystemTime;
        let mut view = running_view();
        view.session.bg_tasks.insert(
            "bg-a".into(),
            BgTaskState {
                task_id: "bg-a".into(),
                tool_call_id: "tc-a".into(),
                command: "echo a".into(),
                description: Some("alpha task".into()),
                cwd: String::new(),
                output_file: String::new(),
                status: BgTaskStatus::Running,
                start_time: SystemTime::now(),
                end_time: None,
                exit_code: None,
                signal: None,
                stdout: String::new(),
                stdout_line_count: 0,
                truncated: false,
                pending_kill: false,
                kill_requested_at: None,
                scrollback_entry_id: None,
                is_monitor: false,
                restored_from_replay: false,
            },
        );
        let meta = NotificationMeta::default();
        view.session.handle_update(
            acp::SessionUpdate::ToolCall(
                acp::ToolCall::new(
                    acp::ToolCallId::new(Arc::from("wait-multi")),
                    "get_task_output",
                )
                .kind(acp::ToolKind::Other)
                .status(acp::ToolCallStatus::Pending)
                .content(vec![])
                .raw_input(Some(serde_json::json!(
                    { "task_ids" : ["bg-a", "missing-b", "missing-c"],
                    "timeout_ms" : 5_000, }
                )))
                .locations(vec![]),
            ),
            &meta,
            &mut view.scrollback,
        );
        let activity = view.resolve_turn_activity().expect("activity");
        let TurnActivity::Waiting(reason) = activity else {
            panic!("expected waiting: {activity:?}");
        };
        assert_eq!(
            reason.label(),
            "alpha task + 2 more…",
            "N more is based on full task_ids length, not resolved count"
        );
    }
    /// Long first subjects still keep the multi-task suffix after clamping.
    #[test]
    fn task_output_wait_multi_id_preserves_suffix_when_first_is_long() {
        use crate::acp::meta::NotificationMeta;
        use crate::acp::tracker::MAX_ACTIVITY_SUBJECT_CHARS;
        use crate::app::agent::{BgTaskState, BgTaskStatus};
        use agent_client_protocol as acp;
        use std::sync::Arc;
        use std::time::SystemTime;
        let long_desc = "L".repeat(80);
        let mut view = running_view();
        view.session.bg_tasks.insert(
            "bg-long".into(),
            BgTaskState {
                task_id: "bg-long".into(),
                tool_call_id: "tc-long".into(),
                command: "echo long".into(),
                description: Some(long_desc),
                cwd: String::new(),
                output_file: String::new(),
                status: BgTaskStatus::Running,
                start_time: SystemTime::now(),
                end_time: None,
                exit_code: None,
                signal: None,
                stdout: String::new(),
                stdout_line_count: 0,
                truncated: false,
                pending_kill: false,
                kill_requested_at: None,
                scrollback_entry_id: None,
                is_monitor: false,
                restored_from_replay: false,
            },
        );
        let meta = NotificationMeta::default();
        view.session.handle_update(
            acp::SessionUpdate::ToolCall(
                acp::ToolCall::new(
                    acp::ToolCallId::new(Arc::from("wait-long-multi")),
                    "get_task_output",
                )
                .kind(acp::ToolKind::Other)
                .status(acp::ToolCallStatus::Pending)
                .content(vec![])
                .raw_input(Some(serde_json::json!(
                    { "task_ids" : ["bg-long", "missing-b"], "timeout_ms" :
                    5_000, }
                )))
                .locations(vec![]),
            ),
            &meta,
            &mut view.scrollback,
        );
        let activity = view.resolve_turn_activity().expect("activity");
        let TurnActivity::Waiting(reason) = activity else {
            panic!("expected waiting: {activity:?}");
        };
        let label = reason.label();
        assert!(
            label.contains(" + 1 more"),
            "multi-task suffix must survive clamp: {label}"
        );
        assert!(label.ends_with('…'));
        let body = label.strip_suffix('…').unwrap();
        assert!(
            body.chars().count() <= MAX_ACTIVITY_SUBJECT_CHARS + 20,
            "unexpectedly long body: {body}"
        );
    }
    /// get_task_output often passes subagent_id, not the child_session_id map key.
    #[test]
    fn task_output_wait_resolves_subagent_by_subagent_id() {
        use crate::acp::meta::NotificationMeta;
        use crate::app::subagent::SubagentInfo;
        use agent_client_protocol as acp;
        use std::sync::Arc;
        use std::time::Instant;
        let mut view = running_view();
        let now = Instant::now();
        view.subagent_sessions.insert(
            "child-session-xyz".into(),
            SubagentInfo {
                subagent_id: Arc::from("sub-id-42"),
                child_session_id: Arc::from("child-session-xyz"),
                description: Arc::from("explore the auth module"),
                subagent_type: Arc::from("explore"),
                persona: None,
                role: None,
                model: None,
                context_source: None,
                resumed_from: None,
                capability_mode: None,
                context_normalized: false,
                parent_prompt_id: None,
                started_at: now,
                last_progress_at: now,
                finished: false,
                status: None,
                error: None,
                duration_ms: None,
                tool_calls: None,
                turns: None,
                turn_count: None,
                tool_call_count: None,
                tokens_used: None,
                context_window_tokens: None,
                context_usage_pct: None,
                tools_used: vec![],
                error_count: None,
                activity_label: None,
                is_background: true,
                pending_kill: false,
                kill_requested_at: None,
                scrollback_entry_id: None,
                prompt: None,
                child_cwd: None,
                worktree_path: None,
                child_updates_replayed: false,
            },
        );
        let meta = NotificationMeta::default();
        view.session.handle_update(
            acp::SessionUpdate::ToolCall(
                acp::ToolCall::new(
                    acp::ToolCallId::new(Arc::from("wait-sub")),
                    "get_command_or_subagent_output",
                )
                .kind(acp::ToolKind::Other)
                .status(acp::ToolCallStatus::Pending)
                .content(vec![])
                .raw_input(Some(serde_json::json!(
                    { "task_ids" : ["sub-id-42"], "timeout_ms" : 10_000, }
                )))
                .locations(vec![]),
            ),
            &meta,
            &mut view.scrollback,
        );
        let activity = view.resolve_turn_activity().expect("activity");
        let TurnActivity::Waiting(reason) = activity else {
            panic!("expected waiting: {activity:?}");
        };
        assert_eq!(reason.label(), "explore the auth module…");
    }
    /// Long bare commands are not used as subjects — keep the original label.
    #[test]
    fn task_output_wait_long_command_keeps_generic_label() {
        use crate::acp::meta::NotificationMeta;
        use crate::app::agent::{BgTaskState, BgTaskStatus};
        use agent_client_protocol as acp;
        use std::sync::Arc;
        use std::time::SystemTime;
        let long_cmd = "cargo test --release --workspace --all-features -- --nocapture".to_string();
        assert!(
            long_cmd.chars().count() > 40,
            "fixture must exceed the short-command threshold"
        );
        let mut view = running_view();
        view.session.bg_tasks.insert(
            "bg-3".into(),
            BgTaskState {
                task_id: "bg-3".into(),
                tool_call_id: "tc-3".into(),
                command: long_cmd,
                description: None,
                cwd: String::new(),
                output_file: String::new(),
                status: BgTaskStatus::Running,
                start_time: SystemTime::now(),
                end_time: None,
                exit_code: None,
                signal: None,
                stdout: String::new(),
                stdout_line_count: 0,
                truncated: false,
                pending_kill: false,
                kill_requested_at: None,
                scrollback_entry_id: None,
                is_monitor: false,
                restored_from_replay: false,
            },
        );
        let meta = NotificationMeta::default();
        view.session.handle_update(
            acp::SessionUpdate::ToolCall(
                acp::ToolCall::new(acp::ToolCallId::new(Arc::from("wait-3")), "get_task_output")
                    .kind(acp::ToolKind::Other)
                    .status(acp::ToolCallStatus::Pending)
                    .content(vec![])
                    .raw_input(Some(serde_json::json!(
                        { "task_ids" : ["bg-3"], "timeout_ms" : 5_000, }
                    )))
                    .locations(vec![]),
            ),
            &meta,
            &mut view.scrollback,
        );
        let activity = view.resolve_turn_activity().expect("activity");
        let TurnActivity::Waiting(reason) = activity else {
            panic!("expected waiting: {activity:?}");
        };
        assert_eq!(
            reason.label(),
            "Waiting on task output…",
            "long command without description must not become the spinner subject"
        );
        assert_eq!(
            reason,
            WaitingReason::TaskOutput {
                task_ids: vec!["bg-3".into()],
                subject: None,
                waits: true,
            }
        );
    }
}
#[cfg(test)]
mod status_window_tests {
    use super::super::test_agent_view;
    #[test]
    fn start_turn_boundary_closes_status_window() {
        let mut agent = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"));
        agent.end_work_announced = true;
        agent.start_turn_boundary(None);
        assert!(
            !agent.end_work_announced,
            "a real turn start closes the between-turns status window"
        );
        assert!(agent.session.state.is_turn_running());
    }
    #[test]
    fn begin_replay_window_closes_status_window() {
        let mut agent = test_agent_view(Some("s1"), std::path::PathBuf::from("/tmp"));
        agent.end_work_announced = true;
        agent.begin_replay_window();
        assert!(
            !agent.end_work_announced,
            "a replay window closes the between-turns status window"
        );
    }
}
