//! Agent business types.
//!
//! Pure data types for agent session management. No UI or rendering logic.
//! The view-model that combines these with UI state is [`super::agent_view::AgentView`].
use crate::acp::meta::NotificationMeta;
use crate::acp::model_state::ModelState;
use crate::acp::tracker::{AcpUpdateTracker, TurnActivity};
use crate::scrollback::EntryId;
use crate::scrollback::state::ScrollbackState;
use agent_client_protocol as acp;
use kigi_acp_lib::AcpAgentTx;
use kigi_shell::extensions::notification::GoalClassifierVerdict;
use kigi_shell::sampling::types::ReasoningEffort;
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime};
/// Unique local identifier for an agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AgentId(pub usize);
/// Whether a queue entry is a regular prompt or a slash command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueueEntryKind {
    /// Regular user prompt — sent via `PromptRequest`.
    Prompt,
    /// Slash command (e.g., `/compact`) — dispatched as `ExtRequest` or local action.
    Command,
    /// Direct bash command — bypasses agent loop, executed by shell directly.
    BashCommand,
    /// Scheduled (cron) prompt -- injected by the scheduler via ACP notification.
    Cron,
}
impl QueueEntryKind {
    /// Short, stable label for telemetry / profiling logs.
    pub fn as_label(&self) -> &'static str {
        match self {
            Self::Prompt => "prompt",
            Self::Command => "command",
            Self::BashCommand => "bash_command",
            Self::Cron => "cron",
        }
    }
}
/// An entry waiting in the queue to be sent to the agent.
///
/// Each entry gets a monotonically increasing `id` for stable tracking
/// (e.g., when editing a queued prompt whose positional index shifts as
/// earlier prompts drain). The user-facing display uses the 1-based
/// positional index (`#1`, `#2`, …), never the internal `id`.
#[derive(Debug, Clone)]
pub struct QueuedPrompt {
    /// Monotonic ID, unique within this agent's session. Never reused.
    pub id: u64,
    /// The prompt text (or command text, e.g. "/compact").
    pub text: String,
    /// Whether this is a prompt or a slash command.
    pub kind: QueueEntryKind,
    /// Optional separate payload for the wire. When `Some`, this is sent
    /// instead of `text`. Used for skill injection where the display
    /// shows `/commit args` but the wire carries the skill XML content.
    pub wire_blocks: Option<Vec<acp::ContentBlock>>,
    /// Images attached to this prompt. Drained from `PromptWidget` at
    /// submission time. Preserved across queue text edits.
    pub images: Vec<crate::prompt_images::PastedImage>,
    /// Whether this prompt should display as a skill invocation (teal accent).
    /// Only meaningful when `wire_blocks` is `Some`.
    pub display_as_skill: bool,
    /// Recognized slash-token byte ranges into `text`, captured from the
    /// composer at submit time; empty = no token styling.
    pub skill_token_ranges: Vec<std::ops::Range<usize>>,
    /// Scheduler task ID for cron prompts. Used for per-task dedup.
    pub task_id: Option<String>,
    /// Human-readable schedule (e.g. "every 5 minutes") for cron prompts.
    /// Threaded into the system-reminder framing sent to the model.
    pub human_schedule: Option<String>,
    /// All chip elements captured from the textarea at send time.
    /// Threaded into `InFlightPrompt` so rewind restores collapsed chips.
    pub chip_elements: Vec<ChipElement>,
}
impl QueuedPrompt {
    /// Base row with every optional field at its default. Sites needing
    /// more use struct-update syntax (`QueuedPrompt { wire_blocks: …,
    /// ..QueuedPrompt::plain(id, text, kind) }`) so adding a field is a
    /// one-site change.
    pub fn plain(id: u64, text: impl Into<String>, kind: QueueEntryKind) -> Self {
        Self {
            id,
            text: text.into(),
            kind,
            wire_blocks: None,
            images: Vec::new(),
            display_as_skill: false,
            skill_token_ranges: Vec::new(),
            task_id: None,
            human_schedule: None,
            chip_elements: Vec::new(),
        }
    }
    /// Whether the wire payload is exactly the display text.
    ///
    /// `true` for plain rows (no `wire_blocks`) and for raw skill slash rows
    /// (`/find-session args` — a single Text block equal to `text`, expanded
    /// shell-side at delivery), so interjecting `text` loses nothing. `false`
    /// when the payload was expanded client-side (`/loop`):
    /// interjecting those by `text` would drop the expansion, and by payload
    /// would render the raw instruction.
    pub fn wire_matches_display(&self) -> bool {
        match self.wire_blocks.as_deref() {
            None => true,
            Some([acp::ContentBlock::Text(t)]) => t.text == self.text,
            Some(_) => false,
        }
    }
}
/// A command that is sent to the agent and tracked in the state machine.
///
/// These are distinct from UI-local slash commands (like `/theme`, `/help`)
/// which execute immediately without going through the queue or agent.
///
/// Each variant carries the data needed for execution and display.
/// Using an enum instead of a String gives us:
/// - Type safety (can't misspell command names)
/// - Variant-specific data (e.g., `/model` would carry target model)
/// - Proper rendering per command type
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentCommand {
    /// `/compact` — compact conversation history.
    Compact,
    /// Creating a git worktree (from the welcome screen `w` action).
    CreateWorktree,
    /// Resuming a session in a worktree (worktree + code restore).
    RestoreWorktree,
    /// Restoring code in same directory (non-worktree `--restore-code`).
    RestoreCode,
    /// Forking the current session into a peer (no-worktree path).
    /// Drives the spinner shown on the placeholder agent while the
    /// `kigi/session/fork` request is in flight.
    ForkSession,
}
impl AgentCommand {
    /// Human-readable label for the status line (e.g., "Compacting").
    pub fn display_name(&self) -> &'static str {
        match self {
            Self::Compact => "Compacting",
            Self::CreateWorktree => "Creating worktree",
            Self::RestoreWorktree => "Restoring session in worktree",
            Self::RestoreCode => "Restoring code",
            Self::ForkSession => "Forking session",
        }
    }
    /// The raw command text (e.g., "/compact").
    pub fn command_text(&self) -> &'static str {
        match self {
            Self::Compact => "/compact",
            Self::CreateWorktree => "worktree",
            Self::RestoreWorktree => "worktree",
            Self::RestoreCode => "restore",
            Self::ForkSession => "fork",
        }
    }
}
/// Maximum in-memory stdout per background task (10 MB).
pub const BG_TASK_MAX_STDOUT: usize = 10 * 1024 * 1024;
/// How long to wait for a kill response before auto-clearing `pending_kill`
/// so the user can retry. Applied to both bg tasks and subagents.
pub const PENDING_KILL_TIMEOUT_SECS: u64 = 10;
/// Status of a background task.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BgTaskStatus {
    /// Currently running.
    Running,
    /// Completed successfully (exit 0).
    Done,
    /// Failed (non-zero exit, signal, timeout, OOM, etc.).
    Failed,
}
/// Central state for a single background task.
///
/// Stored in `AgentSession::bg_tasks` keyed by `task_id`.
/// Both the scrollback `BgTaskBlock` and the bg task pane read from this.
#[derive(Debug, Clone)]
pub struct BgTaskState {
    pub task_id: String,
    pub tool_call_id: String,
    pub command: String,
    pub description: Option<String>,
    pub cwd: String,
    pub output_file: String,
    pub status: BgTaskStatus,
    pub start_time: SystemTime,
    pub end_time: Option<SystemTime>,
    pub exit_code: Option<i32>,
    pub signal: Option<String>,
    /// Accumulated stdout (full cumulative buffer from shell, max BG_TASK_MAX_STDOUT).
    ///
    /// Mutate via [`BgTaskState::set_stdout`] / [`BgTaskState::append_stdout`]
    /// so `stdout_line_count` and `truncated` stay in sync.
    pub stdout: String,
    /// Cached `stdout.lines().count()`. Kept in sync by [`Self::set_stdout`]
    /// and [`Self::append_stdout`] so the tasks-pane overlay doesn't have to
    /// memchr-scan up to `BG_TASK_MAX_STDOUT` bytes per visible task per
    /// render frame.
    pub stdout_line_count: usize,
    /// Whether the rolling buffer has dropped data. Either the shell-side
    /// `BashOutput.truncated` flag was set when the chunk arrived, or the
    /// TUI itself trimmed the buffer to stay under `BG_TASK_MAX_STDOUT` (in
    /// `set_stdout` / `append_stdout`). Once `true`, stays `true` — the
    /// real line count is at least `stdout_line_count`, hence the `(N+)`
    /// badge.
    pub truncated: bool,
    /// Kill request sent, awaiting task_completed.
    pub pending_kill: bool,
    /// When the kill request was sent. Used to auto-clear `pending_kill`
    /// after a timeout so the user can retry if the response is lost.
    pub kill_requested_at: Option<Instant>,
    /// Scrollback entry ID for the "Task started" block (for finish_running).
    pub scrollback_entry_id: Option<crate::scrollback::entry::EntryId>,
    /// True when this background task is a monitor (the `monitor` tool). The
    /// tasks pane renders monitors with a blue "Monitor" tag + neutral text
    /// (mirroring scheduled `/loop` rows) instead of bash-highlighting the
    /// command. Set from the `monitor_description` field of the
    /// `TaskBackgrounded` notification.
    pub is_monitor: bool,
    /// True when this task was restored from a `session/load` replay
    /// (`_meta.isReplay`) rather than started live in this client. Restored
    /// tasks are historical context: the tasks pane must not auto-open for
    /// them (on a cold resume they are dead and reconciled away within the
    /// same load; on a warm reconnect they are ambient, not new activity).
    pub restored_from_replay: bool,
}
impl BgTaskState {
    /// Elapsed duration (from start to end, or start to now if running).
    pub fn elapsed(&self) -> Duration {
        let end = self.end_time.unwrap_or_else(SystemTime::now);
        end.duration_since(self.start_time)
            .unwrap_or(Duration::ZERO)
    }
    /// Replace `stdout` with `new_stdout`.
    ///
    /// If `new_stdout` exceeds `BG_TASK_MAX_STDOUT`, keeps the head (snapped
    /// to the nearest char boundary so UTF-8 stays valid) and sets
    /// `truncated = true` — TUI-side dropping is treated the same as
    /// shell-side dropping for badge purposes. Always refreshes
    /// `stdout_line_count`.
    pub fn set_stdout(&mut self, new_stdout: String) {
        if new_stdout.len() <= BG_TASK_MAX_STDOUT {
            self.stdout = new_stdout;
        } else {
            let end =
                crate::render::line_utils::floor_char_boundary(&new_stdout, BG_TASK_MAX_STDOUT);
            self.stdout = new_stdout[..end].to_string();
            self.truncated = true;
        }
        self.stdout_line_count = self.stdout.lines().count();
    }
    /// Append `chunk` to `stdout`, inserting a `\n` separator first if the
    /// buffer is non-empty.
    ///
    /// If the resulting buffer exceeds `BG_TASK_MAX_STDOUT`, trims the head
    /// (snapped to the next char boundary) and sets `truncated = true`.
    /// Always refreshes `stdout_line_count`.
    pub fn append_stdout(&mut self, chunk: &str) {
        if !self.stdout.is_empty() {
            self.stdout.push('\n');
        }
        self.stdout.push_str(chunk);
        if self.stdout.len() > BG_TASK_MAX_STDOUT {
            let want_start = self.stdout.len() - BG_TASK_MAX_STDOUT;
            let mut start = want_start;
            while start < self.stdout.len() && !self.stdout.is_char_boundary(start) {
                start += 1;
            }
            self.stdout = self.stdout[start..].to_string();
            self.truncated = true;
        }
        self.stdout_line_count = self.stdout.lines().count();
    }
}
/// State for a scheduled (loop) task, displayed in the tasks pane.
#[derive(Debug, Clone)]
pub struct ScheduledTaskInfo {
    pub task_id: String,
    pub prompt: String,
    pub human_schedule: String,
    pub created_at: std::time::Instant,
    pub next_fire_at: Option<String>,
    /// Tag shown in the tasks pane (e.g. "loop", "check").
    pub tag: String,
}
/// Parsed goal status from `GoalUpdated` session notifications.
///
/// The six paused variants encode the *cause* of the pause directly (no
/// separate `pause_reason` field) so renderers can fan-out on a single
/// `match`. See [`Self::pause_label`] for the user-facing labels and
/// [`Self::is_paused`] for a cause-agnostic check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoalDisplayStatus {
    Active,
    /// User-initiated pause (Ctrl+C, `/goal pause`).
    UserPaused,
    /// Classifier run cap reached; paused the goal automatically.
    BackOffPaused,
    /// Verifier flagged the same gaps with no progress before the cap;
    /// paused the goal automatically. Distinct from `BackOffPaused` only
    /// in its user-facing label.
    NoProgressPaused,
    /// Infrastructure turn failure paused the goal automatically.
    InfraPaused,
    /// The model determined the goal is blocked in this environment;
    /// `pause_message` on [`GoalDisplayState`] carries the reason text.
    Blocked,
    BudgetLimited,
    Complete,
}
impl GoalDisplayStatus {
    /// Parse a status string from the `GoalUpdated` notification.
    ///
    /// Accepts the six paused variants; legacy `"paused"` is treated as
    /// [`Self::UserPaused`] so a new pager keeps working against an old
    /// shell.
    ///
    /// Any unknown string — the empty string, a future `*_paused` form,
    /// or any other status this pager cannot interpret — falls through to
    /// [`Self::UserPaused`]: an uninterpretable status renders as a
    /// resumable paused goal (no spinner, no live timer) rather than a
    /// self-driving `Active` one. Mirrors the shell's
    /// `GoalStatus::from_wire_str` fail-safe; `Active` is matched
    /// explicitly (only the canonical `"active"` token).
    pub fn parse(s: &str) -> Self {
        match s {
            "active" => Self::Active,
            "user_paused" => Self::UserPaused,
            "back_off_paused" => Self::BackOffPaused,
            "no_progress_paused" => Self::NoProgressPaused,
            "infra_paused" => Self::InfraPaused,
            "blocked" => Self::Blocked,
            "paused" => Self::UserPaused,
            "budget_limited" => Self::BudgetLimited,
            "complete" => Self::Complete,
            _ => Self::UserPaused,
        }
    }
    /// Short user-facing label for the status chip, the modal status row,
    /// and the modal paused-state hint line. Single source of truth so the
    /// three displays cannot drift.
    ///
    /// Returns the empty string for non-paused variants — they render
    /// through their own labels (e.g. `"Budget"`, `"Done"`) elsewhere.
    pub fn pause_label(&self) -> &'static str {
        match self {
            Self::UserPaused => "Paused",
            Self::BackOffPaused => "Paused (back-off)",
            Self::NoProgressPaused => "Paused (no progress)",
            Self::InfraPaused => "Paused (error)",
            Self::Blocked => "Paused (verification blocked)",
            Self::Active | Self::BudgetLimited | Self::Complete => "",
        }
    }
    /// True for any paused variant — cause-agnostic check used by the
    /// modal to decide whether to append the `/goal resume` hint.
    pub fn is_paused(&self) -> bool {
        match self {
            Self::UserPaused
            | Self::BackOffPaused
            | Self::NoProgressPaused
            | Self::InfraPaused
            | Self::Blocked => true,
            Self::Active | Self::BudgetLimited | Self::Complete => false,
        }
    }
}
/// Parsed goal phase from `GoalUpdated` session notifications.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoalDisplayPhase {
    Idle,
    Planning,
    Executing,
}
impl GoalDisplayPhase {
    /// Parse a phase string from the `GoalUpdated` notification.
    pub fn parse(s: &str) -> Self {
        match s {
            "planning" => Self::Planning,
            "executing" => Self::Executing,
            _ => Self::Idle,
        }
    }
}
/// Graph mode display state — the pager-side mirror of the
/// `GraphUpdated` session notification (see the shell's
/// `extensions/notification.rs`). Deliberately lean: the status chip
/// shows counts + the current node; details live in `/graph status`.
#[derive(Debug, Clone)]
pub struct GraphDisplayState {
    pub objective: String,
    /// Reuses the goal display-status vocabulary (same wire strings).
    pub status: GoalDisplayStatus,
    pub total_nodes: u32,
    pub achieved_nodes: u32,
    pub failed_nodes: u32,
    pub running_nodes: u32,
    pub current_node_title: Option<String>,
    pub token_budget: Option<i64>,
    pub tokens_spent: i64,
    pub pause_message: Option<String>,
}

/// Display state for an active goal, populated from `GoalUpdated`
/// session notifications emitted by the goal orchestrator.
#[derive(Debug, Clone)]
pub struct GoalDisplayState {
    pub goal_id: String,
    pub objective: String,
    pub status: GoalDisplayStatus,
    pub phase: GoalDisplayPhase,
    pub token_budget: Option<i64>,
    pub tokens_used: i64,
    pub elapsed_ms: u64,
    /// Wire compat: always 0 in simplified model.
    pub total_deliverables: u32,
    /// Wire compat: always 0 in simplified model.
    pub completed_deliverables: u32,
    /// Wire compat: always None in simplified model.
    pub current_deliverable_id: Option<u32>,
    /// Wire compat: always None in simplified model.
    pub current_deliverable_title: Option<String>,
    pub current_subagent_role: Option<String>,
    pub total_worker_rounds: u32,
    pub total_verify_rounds: u32,
    pub live_subagent_tokens: Option<u64>,
    /// Per-model marginal-token breakdown `(model_id, tokens)`, sorted by
    /// tokens descending. Mirror of the `GoalUpdated` wire field; the modal
    /// renders it under the active-subagent metrics block.
    pub live_tokens_by_model: Vec<(String, u64)>,
    pub live_context_pct: Option<u8>,
    pub live_turn_count: Option<u32>,
    pub live_tool_call_count: Option<u32>,
    pub last_event: Option<String>,
    pub last_event_detail: Option<String>,
    pub last_event_timestamp: Option<String>,
    /// Token baseline at goal creation time. Used with the pager's
    /// `context_state.used` to compute real-time token usage at render
    /// frequency, instead of waiting for `GoalUpdated` notifications.
    pub token_baseline: i64,
    /// Tokens from completed subagents (not in context_state.used).
    pub finished_subagent_tokens: i64,
    /// Retained for wire backwards compat; always empty in the simplified model.
    pub deliverables: Vec<()>,
    /// Human-readable explanation set when the goal entered a paused
    /// state with a meaningful reason (today only
    /// [`GoalDisplayStatus::Blocked`]). `None` for paused variants that
    /// don't carry a message (user / doom-loop / back-off pauses) and
    /// for any non-paused status. The shell guarantees this is `Some`
    /// only alongside a paused status string; the modal additionally
    /// gates rendering on `status.is_paused()` for defence in depth.
    pub pause_message: Option<String>,
    /// Number of classifier runs the shell has performed. `None` when
    /// no run has happened yet.
    pub classifier_runs_attempted: Option<u32>,
    /// Hard cap on classifier runs for this goal. `None` when not
    /// configured.
    pub classifier_max_runs: Option<u32>,
    /// Last verdict returned by the classifier, if any. Re-exported
    /// from the shell wire type — there is no separate pager-local
    /// enum (cf. `GoalDisplayStatus`) because the verdict is small,
    /// stable, and only carries two variants.
    pub last_classifier_verdict: Option<GoalClassifierVerdict>,
    /// Filesystem path to the latest classifier-details artifact.
    pub last_classifier_details_path: Option<String>,
    /// Whether `last_classifier_details_path` exists on disk, resolved ONCE
    /// on `GoalUpdated` receipt (not per render frame) so the modal never
    /// runs a blocking `stat(2)` on the UI hot path. `false` when the path
    /// is absent or missing.
    pub last_classifier_details_exists: bool,
    /// True while a classifier run is in flight. Derived from the
    /// wire field `verifying_completion: Option<bool>` (mapped to
    /// `bool` at the boundary so render code never has to
    /// `.unwrap_or(false)`).
    pub verifying_completion: bool,
    /// True while the goal planner subagent is running. Derived from
    /// the wire field `planning: Option<bool>` (mapped to `bool` at the
    /// boundary, same convention as `verifying_completion`).
    pub planning: bool,
    /// Wall-clock instant when this state was last updated from a GoalUpdated
    /// notification. Used to compute local elapsed delta between notifications
    /// so the pager can tick elapsed_ms at render frequency.
    pub received_at: std::time::Instant,
    /// Monotonic floor for the displayed elapsed time, carried across
    /// `GoalUpdated` rebuilds (seeded in `acp_handler` from the prior state).
    /// Without it the timer ticks backward when a notification's authoritative
    /// base is below the value the pager already extrapolated to. See
    /// [`Self::live_elapsed_ms`].
    pub elapsed_floor_ms: u64,
}
impl GoalDisplayState {
    /// Minimal state for tests that only need a present goal (e.g. occluder
    /// gating); field values are representative, not load-bearing.
    #[cfg(test)]
    pub(crate) fn test_stub() -> Self {
        Self {
            goal_id: "g-test".into(),
            objective: "test goal".into(),
            status: GoalDisplayStatus::Active,
            phase: GoalDisplayPhase::Executing,
            token_budget: None,
            tokens_used: 0,
            elapsed_ms: 0,
            total_deliverables: 0,
            completed_deliverables: 0,
            current_deliverable_id: None,
            current_deliverable_title: None,
            current_subagent_role: None,
            total_worker_rounds: 0,
            total_verify_rounds: 0,
            live_subagent_tokens: None,
            live_tokens_by_model: Vec::new(),
            live_context_pct: None,
            live_turn_count: None,
            live_tool_call_count: None,
            last_event: None,
            last_event_detail: None,
            last_event_timestamp: None,
            token_baseline: 0,
            finished_subagent_tokens: 0,
            deliverables: Vec::new(),
            pause_message: None,
            classifier_runs_attempted: None,
            classifier_max_runs: None,
            last_classifier_verdict: None,
            last_classifier_details_path: None,
            last_classifier_details_exists: false,
            verifying_completion: false,
            planning: false,
            received_at: std::time::Instant::now(),
            elapsed_floor_ms: 0,
        }
    }
    /// Return real-time token usage by combining the pager's context state
    /// (which updates on every streamed chunk) with the goal baseline and
    /// subagent tokens.  `active_subagent_tokens` is the sum of
    /// `tokens_used` from currently-running subagents so their consumption
    /// is reflected in real time (not just after they finish).
    pub fn live_tokens_used(&self, context_used: Option<u64>, active_subagent_tokens: u64) -> i64 {
        if self.status == GoalDisplayStatus::Active {
            let parent_delta = context_used
                .map(|u| (u as i64).saturating_sub(self.token_baseline).max(0))
                .unwrap_or(self.tokens_used);
            let candidate = parent_delta
                .saturating_add(self.finished_subagent_tokens)
                .saturating_add(active_subagent_tokens as i64);
            candidate.max(self.tokens_used)
        } else {
            self.tokens_used
        }
    }
    /// Return elapsed_ms adjusted with local wall-clock delta since the last
    /// GoalUpdated notification. This makes the timer tick smoothly at render
    /// frequency without requiring the shell to emit notifications every second.
    pub fn live_elapsed_ms(&self) -> u64 {
        let live = if self.status == GoalDisplayStatus::Active {
            self.elapsed_ms
                .saturating_add(self.received_at.elapsed().as_millis() as u64)
        } else {
            self.elapsed_ms
        };
        live.max(self.elapsed_floor_ms)
    }
}
/// What the agent is currently doing.
///
/// Enforces mutual exclusivity: the agent is either idle, running a turn,
/// or running a command — never two at once.
#[derive(Debug, Clone, Default)]
pub enum AgentState {
    /// Nothing happening. Queue can drain.
    #[default]
    Idle,
    /// A prompt turn is in progress.
    TurnRunning,
    /// A turn cancel has been sent; waiting for PromptResponse.
    TurnCancelling,
    /// A slash command is in flight.
    CommandRunning {
        command: AgentCommand,
        started_at: Instant,
    },
    /// A command cancel has been sent (future use).
    CommandCancelling { command: AgentCommand },
}
impl AgentState {
    /// Nothing is happening — safe to drain queue or start commands.
    pub fn is_idle(&self) -> bool {
        matches!(self, Self::Idle)
    }
    /// A prompt turn is actively running (not cancelling).
    pub fn is_turn_running(&self) -> bool {
        matches!(self, Self::TurnRunning)
    }
    /// Either a turn or command cancel is in progress.
    pub fn is_cancelling(&self) -> bool {
        matches!(self, Self::TurnCancelling | Self::CommandCancelling { .. })
    }
    /// Agent is busy (turn or command) — queue should not drain.
    pub fn is_busy(&self) -> bool {
        !self.is_idle()
    }
    /// The command currently in flight, if any.
    pub fn command_in_flight(&self) -> Option<&AgentCommand> {
        match self {
            Self::CommandRunning { command, .. } | Self::CommandCancelling { command } => {
                Some(command)
            }
            _ => None,
        }
    }
}
/// Per-agent business logic (ACP session, models, state).
///
/// External code should use the facade methods (`handle_update`,
/// `start_turn`, `finish_turn`, `turn_activity`) instead of accessing
/// the tracker directly.
pub struct AgentSession {
    pub id: AgentId,
    pub acp_tx: AcpAgentTx,
    pub session_id: Option<acp::SessionId>,
    pub models: ModelState,
    pub state: AgentState,
    pub cwd: PathBuf,
    /// Whether this session is running inside a git worktree.
    pub is_worktree: bool,
    /// `AgentId` of the parent session if this session was created via
    /// `/fork`. Display-only (status bar, future agent picker grouping);
    /// navigation does not consult it -- the session picker is the
    /// source of truth for navigation history.
    pub forked_from: Option<AgentId>,
    /// Prompts waiting to be sent. Drained front-to-back when
    /// `state` becomes [`AgentState::Idle`].
    pub pending_prompts: VecDeque<QueuedPrompt>,
    /// Next monotonic ID for [`QueuedPrompt`].
    pub(crate) next_queue_id: u64,
    /// Whether YOLO mode (auto-approve all permissions) is active.
    /// Read via `is_yolo()`, write via `set_yolo_mode_inner`.
    pub(crate) yolo_mode: bool,
    /// Whether Auto (LLM classifier) permission mode is active for this session.
    /// Display-only mirror of the applied permission mode, read via `is_auto()`.
    /// Kept in sync wherever the pager applies the mode; mutually exclusive with
    /// `yolo_mode` (yolo wins).
    pub(crate) auto_mode: bool,
    /// Prompt history for the current session, fetched from ACP
    /// (`kigi/prompt_history` scoped via `filter_session_id`). Most-recent-first.
    /// Fetched on session create/load; prompts sent in this session are
    /// additionally front-inserted locally on send.
    pub prompt_history: Vec<String>,
    /// True until the session's startup/load `kigi/prompt_history` fetch completes.
    pub prompt_history_loading: bool,
    /// Session is currently replaying historical updates from `session/load`.
    /// Used to suppress live-style redraw/render work until the load completes.
    pub loading_replay: bool,
    /// Last `--restore-code` outcome's `degree`, parsed from
    /// `_meta.codeRestore.degree` (non-worktree path) or `restoreDegree`
    /// (worktree path). Forward-compat hook: the field is set by both
    /// dispatch handlers but no rendering path consumes it yet — the
    /// type-safety anchor for the wire shape lives in
    /// [`crate::app::effects`]'s parser tests + the deserialise tests in
    /// `ResumeSessionInWorktreeResponse`. Adding a rendering consumer is
    /// out of scope for now.
    pub restore_degree: Option<kigi_workspace::session::git::RestoreDegree>,
    /// Set when a rate-limit `RetryState::Exhausted` fires, so the subsequent
    /// `TurnFailed` from the RPC error path can be suppressed (the retry
    /// handler already displayed a user-friendly message). Cleared on `finish_turn`.
    pub rate_limited: bool,
    /// Set when a `RetryState::Failed` with `error_type == "encrypted_content_mismatch"`
    /// fires, so the subsequent `TurnFailed` can be suppressed (the retry handler
    /// already displayed a user-friendly message). Cleared on `finish_turn`.
    pub model_incompatible: bool,
    pub(crate) tracker: AcpUpdateTracker,
    /// ACP-advertised slash commands. Seeded from `InitializeResponse.meta`,
    /// updated by `AvailableCommandsUpdate`. The prompt-side registry syncs
    /// when the generation counter changes.
    pub available_commands: Vec<acp::AvailableCommand>,
    /// Generation counter for `available_commands`. Bumped on every update
    /// (even if the list is identical). Prompt-side compares its synced
    /// generation to detect changes.
    ///
    /// - Bootstrap (from connection): starts at 1 so prompt-side (starting at 0)
    ///   triggers an initial sync.
    /// - Test/placeholder: starts at 0 (no initial sync needed).
    pub available_commands_generation: u64,
    /// Names of tools the agent has registered. `None` until the shell
    /// advertises a list via `AvailableCommandsUpdate.meta.tools`.
    /// `Some(_)` enables tool-gating in the slash registry; `None` keeps
    /// every command visible (avoids bootstrap flicker).
    pub available_tools: Option<HashSet<String>>,
    /// Whether a `/model` switch is in flight. Dims the status-bar model name
    /// and holds the queue drain (`maybe_drain_queue`) so a queued prompt isn't
    /// sent on the old harness mid-switch. Cleared on
    /// `SwitchModelComplete`, or by `begin_session_reload` when a reconnect
    /// drops the in-flight RPC — else a lost completion jams the queue forever.
    pub model_switch_pending: bool,
    /// Model the user chose this session via `/model` / the model picker, or
    /// the last successfully applied live remote `ModelChanged` (leader-mode
    /// fan-out). Survives reconnect (`begin_session_reload` does **not** clear
    /// it). History-replay silent-revert of a prior choice is suppressed on the
    /// shell side via `ReconnectState::user_selected_model`; the pager still
    /// applies live remote switches and updates this field to match.
    pub user_model_preference: Option<acp::ModelId>,
    /// `/model X [effort]` issued before the session was ready, applied on SessionCreated.
    pub deferred_model_switch: Option<(acp::ModelId, Option<ReasoningEffort>)>,
    /// Central bg task state, keyed by task_id.
    pub bg_tasks: BTreeMap<String, BgTaskState>,
    /// Correlation map: tool_call_id → task_id.
    /// Used to route stdout chunks (which arrive keyed by tool_call_id) to the
    /// correct bg task in `bg_tasks`.
    pub bg_tool_call_to_task: HashMap<String, String>,
    /// Active scheduled tasks, keyed by task_id.
    pub scheduled_tasks: HashMap<String, ScheduledTaskInfo>,
    /// Plain-text prompt currently in flight, captured at send time and
    /// cleared as soon as the server emits any activity (chunk, tool call,
    /// retry, etc.). Used by `do_cancel_turn` to "rewind" a prompt back to
    /// the input box if the user cancels before any response arrives.
    /// `None` for skill-injected prompts (cannot be reversed) and bash/cron.
    pub in_flight_prompt: Option<InFlightPrompt>,
    /// Stable id for the prompt currently in flight. Generated client-side
    /// at `Effect::SendPrompt` time and threaded through `PromptRequest._meta`
    /// to the agent, which echoes it back on every `SessionNotification` and
    /// `PromptResponse` it produces for that prompt.
    ///
    /// The acp_handler uses this to discriminate chunks for the active turn
    /// from chunks belonging to a turn the user already rewound: any update
    /// whose `meta.promptId` is set and doesn't match this id is silently
    /// dropped. `None` between turns.
    pub current_prompt_id: Option<String>,
    /// Whether this session was created via the `/new` slash command.
    /// Checked in the `SessionCreated` handler to decide whether to show
    /// the `/dashboard` discoverability tip. `false` for sessions created
    /// by `/resume`, welcome-screen picker, `/fork`, or worktree flows.
    pub created_via_new: bool,
}
/// Captured state for a prompt that has been sent but not yet acknowledged
/// by any server activity. See `AgentSession::in_flight_prompt`.
#[derive(Debug, Clone)]
pub struct InFlightPrompt {
    pub text: String,
    pub images: Vec<crate::prompt_images::PastedImage>,
    pub scrollback_entry: EntryId,
    /// All chip elements (paste blocks, @-file refs, image chips) that were
    /// active in the textarea at send time. Restored on rewind so collapsed
    /// chips render correctly instead of raw text.
    pub chip_elements: Vec<ChipElement>,
}
/// Snapshot of a textarea chip element for rewind restore.
/// Covers paste blocks, @-file refs, and image chips.
#[derive(Debug, Clone)]
pub struct ChipElement {
    pub range: std::ops::Range<usize>,
    pub kind: kigi_ratatui_textarea::ElementKind,
    pub display: Option<ratatui::text::Line<'static>>,
}
impl AgentSession {
    /// Whether YOLO mode is active. Prefer this over direct field access.
    pub fn is_yolo(&self) -> bool {
        self.yolo_mode
    }
    /// Whether Auto (LLM classifier) mode is active. Prefer this over direct
    /// field access. Mutually exclusive with `is_yolo()` (yolo wins).
    pub fn is_auto(&self) -> bool {
        self.auto_mode
    }
    /// Test-only setter for `yolo_mode` (the field is private; production toggles
    /// it via the permission-mode facade). Available to sibling crates' test
    /// builds through the test-only helpers.
    #[cfg(any(test, feature = "test-support"))]
    pub(crate) fn set_yolo_mode_for_test(&mut self, on: bool) {
        self.yolo_mode = on;
    }
    /// Test-only setter for `auto_mode`. See [`Self::set_yolo_mode_for_test`].
    #[cfg(any(test, feature = "test-support"))]
    pub(crate) fn set_auto_mode_for_test(&mut self, on: bool) {
        self.auto_mode = on;
    }
    /// Process an ACP session update. Returns true if scrollback was modified.
    pub fn handle_update(
        &mut self,
        update: acp::SessionUpdate,
        meta: &NotificationMeta,
        scrollback: &mut ScrollbackState,
    ) -> bool {
        self.tracker.set_session_cwd(&self.cwd);
        self.tracker.handle_update(update, meta, scrollback)
    }
    /// Start a new turn: set state to TurnRunning, prepare tracker.
    ///
    /// Called by `maybe_drain_queue` when a prompt is being sent.
    pub fn start_turn(&mut self, scrollback: &mut ScrollbackState) {
        self.tracker.finish_turn(scrollback);
        self.tracker.set_session_cwd(&self.cwd);
        self.tracker.expect_user_echo();
        self.state = AgentState::TurnRunning;
        self.in_flight_prompt = None;
    }
    /// Finish the current turn: cleanup tracker, set state to Idle.
    ///
    /// Called when `PromptResponse` is received.
    pub fn finish_turn(&mut self, scrollback: &mut ScrollbackState) {
        self.tracker.finish_turn(scrollback);
        self.state = AgentState::Idle;
        self.rate_limited = false;
        self.model_incompatible = false;
        self.in_flight_prompt = None;
        self.current_prompt_id = None;
    }
    /// Whether any background task is still running (vs. completed/failed).
    /// Used to defer the automatic away-recap: a running task can wake the
    /// agent (auto-wake on completion), so we don't pre-generate a recap while
    /// one is live and could change the session out from under it.
    pub fn has_running_bg_tasks(&self) -> bool {
        self.bg_tasks
            .values()
            .any(|t| t.status == BgTaskStatus::Running)
    }
    /// Cancel the current turn: cleanup tracker, set state to Cancelling.
    pub fn cancel_turn(&mut self, scrollback: &mut ScrollbackState) {
        self.tracker.finish_turn(scrollback);
        self.state = AgentState::TurnCancelling;
    }
    /// Current activity within a running turn (for turn status line display).
    ///
    /// Returns `None` when not in `TurnRunning` state.
    pub fn turn_activity(&self) -> Option<TurnActivity> {
        if matches!(self.state, AgentState::TurnRunning) {
            self.tracker.activity()
        } else {
            None
        }
    }
    /// Set a compaction-related activity override on the tracker.
    ///
    /// Called from ACP handler when compaction ExtNotifications arrive.
    pub fn set_compaction_activity(&mut self, activity: Option<TurnActivity>) {
        self.tracker.set_compaction_activity(activity);
    }
    pub fn defer_compaction(
        &mut self,
        tokens_before: Option<u64>,
        estimate_after: u64,
        elapsed_ms: Option<i64>,
    ) {
        self.tracker
            .defer_compaction(tokens_before, estimate_after, elapsed_ms);
    }
    pub fn note_context_used(&mut self, used: u64) {
        self.tracker.note_context_used(used);
    }
    /// Set a retry-related activity override on the tracker.
    ///
    /// Called from ACP handler when `RetryState::Retrying` arrives.
    /// Auto-cleared when normal streaming data resumes.
    pub fn set_retry_activity(&mut self, activity: Option<TurnActivity>) {
        self.tracker.set_retry_activity(activity);
    }
    /// Start a slash command (e.g., /compact).
    pub fn start_command(&mut self, command: AgentCommand) {
        self.state = AgentState::CommandRunning {
            command,
            started_at: Instant::now(),
        };
    }
    /// Finish a running command, return to Idle.
    pub fn finish_command(&mut self) {
        self.state = AgentState::Idle;
    }
    /// Push a prompt onto the back of the queue. Returns the assigned ID.
    pub fn enqueue_prompt(&mut self, text: String) -> u64 {
        self.enqueue_entry(text, QueueEntryKind::Prompt)
    }
    /// Push a plain prompt carrying the composer's recognized slash-token
    /// ranges (mid-text skill highlighting in the scrollback echo).
    pub fn enqueue_prompt_with_skill_tokens(
        &mut self,
        text: String,
        skill_token_ranges: Vec<std::ops::Range<usize>>,
    ) -> u64 {
        self.enqueue_entry_at(text, QueueEntryKind::Prompt, false, skill_token_ranges)
    }
    /// Push a prompt onto the **front** of the queue. Returns the assigned ID.
    ///
    /// Sibling of [`enqueue_prompt`](Self::enqueue_prompt) -- same defaults,
    /// but `push_front` instead of `push_back`. Used by the `/fork` flow to
    /// inject the user's directive ahead of any prompts the user typed
    /// during the placeholder window so the directive runs first.
    pub fn enqueue_prompt_front(&mut self, text: String) -> u64 {
        self.enqueue_entry_at(text, QueueEntryKind::Prompt, true, Vec::new())
    }
    /// Requeue a failed plain prompt without dropping its attachments.
    pub fn enqueue_in_flight_prompt_front(&mut self, prompt: InFlightPrompt) -> u64 {
        let id = self.next_queue_id;
        self.next_queue_id += 1;
        self.pending_prompts.push_front(QueuedPrompt {
            images: prompt.images,
            chip_elements: prompt.chip_elements,
            ..QueuedPrompt::plain(id, prompt.text, QueueEntryKind::Prompt)
        });
        id
    }
    /// Push a slash command onto the back of the queue. Returns the assigned ID.
    pub fn enqueue_command(&mut self, text: String) -> u64 {
        self.enqueue_entry(text, QueueEntryKind::Command)
    }
    /// Push a direct bash command onto the back of the queue. Returns the assigned ID.
    pub fn enqueue_bash_command(&mut self, text: String) -> u64 {
        self.enqueue_entry(text, QueueEntryKind::BashCommand)
    }
    /// Push a scheduled (cron) prompt onto the back of the queue. Returns the assigned ID.
    pub fn enqueue_cron_prompt(
        &mut self,
        text: String,
        task_id: String,
        human_schedule: String,
    ) -> u64 {
        let id = self.next_queue_id;
        self.next_queue_id += 1;
        self.pending_prompts.push_back(QueuedPrompt {
            task_id: Some(task_id),
            human_schedule: Some(human_schedule),
            ..QueuedPrompt::plain(id, text, QueueEntryKind::Cron)
        });
        id
    }
    /// Push an entry with the given kind onto the back of the queue.
    pub fn enqueue_entry(&mut self, text: String, kind: QueueEntryKind) -> u64 {
        self.enqueue_entry_at(text, kind, false, Vec::new())
    }
    /// Internal: push an entry with the given kind onto the front (`front == true`)
    /// or back (`front == false`) of the queue. Single source of truth for the
    /// `QueuedPrompt` defaults shared by `enqueue_entry` and `enqueue_prompt_front`.
    fn enqueue_entry_at(
        &mut self,
        text: String,
        kind: QueueEntryKind,
        front: bool,
        skill_token_ranges: Vec<std::ops::Range<usize>>,
    ) -> u64 {
        let id = self.next_queue_id;
        self.next_queue_id += 1;
        let entry = QueuedPrompt {
            skill_token_ranges,
            ..QueuedPrompt::plain(id, text, kind)
        };
        if front {
            self.pending_prompts.push_front(entry);
        } else {
            self.pending_prompts.push_back(entry);
        }
        id
    }
    /// Pop the front prompt from the queue (next to send).
    pub fn dequeue_prompt(&mut self) -> Option<QueuedPrompt> {
        self.pending_prompts.pop_front()
    }
    /// Number of prompts currently queued.
    pub fn queue_len(&self) -> usize {
        self.pending_prompts.len()
    }
    /// Find the 0-based positional index of a prompt by its stable ID.
    pub fn queue_position(&self, id: u64) -> Option<usize> {
        self.pending_prompts.iter().position(|p| p.id == id)
    }
    /// Swap a prompt with its neighbor above (toward front of queue).
    pub fn swap_prompt_up(&mut self, id: u64) {
        if let Some(pos) = self.queue_position(id)
            && pos > 0
        {
            self.pending_prompts.swap(pos, pos - 1);
        }
    }
    /// Swap a prompt with its neighbor below (toward back of queue).
    pub fn swap_prompt_down(&mut self, id: u64) {
        if let Some(pos) = self.queue_position(id)
            && pos + 1 < self.pending_prompts.len()
        {
            self.pending_prompts.swap(pos, pos + 1);
        }
    }
}
#[cfg(test)]
mod tests {
    use super::*;
    fn test_session() -> AgentSession {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        AgentSession {
            id: AgentId(0),
            acp_tx: tx,
            session_id: None,
            models: ModelState::default(),
            state: AgentState::Idle,
            tracker: AcpUpdateTracker::new(),
            cwd: PathBuf::from("/tmp"),
            is_worktree: false,
            forked_from: None,
            pending_prompts: VecDeque::new(),
            next_queue_id: 0,
            yolo_mode: false,
            auto_mode: false,
            prompt_history: Vec::new(),
            prompt_history_loading: false,
            loading_replay: false,
            restore_degree: None,
            rate_limited: false,
            model_incompatible: false,
            available_commands: Vec::new(),
            available_commands_generation: 0,
            available_tools: None,
            model_switch_pending: false,
            user_model_preference: None,
            deferred_model_switch: None,
            bg_tasks: BTreeMap::new(),
            bg_tool_call_to_task: HashMap::new(),
            scheduled_tasks: HashMap::new(),
            in_flight_prompt: None,
            current_prompt_id: None,
            created_via_new: false,
        }
    }
    #[test]
    fn goal_display_status_parse_known_values() {
        assert_eq!(
            GoalDisplayStatus::parse("active"),
            GoalDisplayStatus::Active
        );
        assert_eq!(
            GoalDisplayStatus::parse("user_paused"),
            GoalDisplayStatus::UserPaused
        );
        assert_eq!(
            GoalDisplayStatus::parse("doom_loop_paused"),
            GoalDisplayStatus::UserPaused
        );
        assert_eq!(
            GoalDisplayStatus::parse("back_off_paused"),
            GoalDisplayStatus::BackOffPaused
        );
        assert_eq!(
            GoalDisplayStatus::parse("no_progress_paused"),
            GoalDisplayStatus::NoProgressPaused
        );
        assert_eq!(
            GoalDisplayStatus::parse("infra_paused"),
            GoalDisplayStatus::InfraPaused
        );
        assert_eq!(
            GoalDisplayStatus::parse("blocked"),
            GoalDisplayStatus::Blocked
        );
        assert_eq!(
            GoalDisplayStatus::parse("budget_limited"),
            GoalDisplayStatus::BudgetLimited
        );
        assert_eq!(
            GoalDisplayStatus::parse("complete"),
            GoalDisplayStatus::Complete
        );
    }
    #[test]
    fn goal_display_status_parse_legacy_paused_is_user_paused() {
        assert_eq!(
            GoalDisplayStatus::parse("paused"),
            GoalDisplayStatus::UserPaused
        );
    }
    #[test]
    fn goal_display_status_parse_future_paused_fallback() {
        assert_eq!(
            GoalDisplayStatus::parse("error_paused"),
            GoalDisplayStatus::UserPaused
        );
        assert_eq!(
            GoalDisplayStatus::parse("foo_bar_paused"),
            GoalDisplayStatus::UserPaused
        );
    }
    #[test]
    fn goal_display_status_parse_unknown_defaults_to_user_paused() {
        assert_eq!(
            GoalDisplayStatus::parse("unknown"),
            GoalDisplayStatus::UserPaused
        );
        assert_eq!(GoalDisplayStatus::parse(""), GoalDisplayStatus::UserPaused);
        assert_eq!(
            GoalDisplayStatus::parse("ACTIVE"),
            GoalDisplayStatus::UserPaused,
        );
        assert_eq!(
            GoalDisplayStatus::parse("paused_eventually"),
            GoalDisplayStatus::UserPaused
        );
        assert_eq!(
            GoalDisplayStatus::parse("active"),
            GoalDisplayStatus::Active
        );
    }
    #[test]
    fn pause_label_is_consistent_across_renderers() {
        assert_eq!(GoalDisplayStatus::UserPaused.pause_label(), "Paused");
        assert_eq!(
            GoalDisplayStatus::BackOffPaused.pause_label(),
            "Paused (back-off)"
        );
        assert_eq!(
            GoalDisplayStatus::NoProgressPaused.pause_label(),
            "Paused (no progress)"
        );
        assert_eq!(
            GoalDisplayStatus::InfraPaused.pause_label(),
            "Paused (error)"
        );
        assert_eq!(
            GoalDisplayStatus::Blocked.pause_label(),
            "Paused (verification blocked)"
        );
        assert_eq!(GoalDisplayStatus::Active.pause_label(), "");
        assert_eq!(GoalDisplayStatus::BudgetLimited.pause_label(), "");
        assert_eq!(GoalDisplayStatus::Complete.pause_label(), "");
    }
    #[test]
    fn is_paused_matches_only_paused_variants() {
        assert!(GoalDisplayStatus::UserPaused.is_paused());
        assert!(GoalDisplayStatus::BackOffPaused.is_paused());
        assert!(GoalDisplayStatus::NoProgressPaused.is_paused());
        assert!(GoalDisplayStatus::InfraPaused.is_paused());
        assert!(GoalDisplayStatus::Blocked.is_paused());
        assert!(!GoalDisplayStatus::Active.is_paused());
        assert!(!GoalDisplayStatus::BudgetLimited.is_paused());
        assert!(!GoalDisplayStatus::Complete.is_paused());
    }
    #[test]
    fn goal_display_phase_parse_known_values() {
        assert_eq!(GoalDisplayPhase::parse("idle"), GoalDisplayPhase::Idle);
        assert_eq!(
            GoalDisplayPhase::parse("planning"),
            GoalDisplayPhase::Planning
        );
        assert_eq!(
            GoalDisplayPhase::parse("executing"),
            GoalDisplayPhase::Executing
        );
        assert_eq!(
            GoalDisplayPhase::parse("step_verifying"),
            GoalDisplayPhase::Idle
        );
        assert_eq!(
            GoalDisplayPhase::parse("final_verifying"),
            GoalDisplayPhase::Idle
        );
    }
    #[test]
    fn goal_display_phase_parse_unknown_defaults_to_idle() {
        assert_eq!(GoalDisplayPhase::parse(""), GoalDisplayPhase::Idle);
        assert_eq!(GoalDisplayPhase::parse("running"), GoalDisplayPhase::Idle);
    }
    #[test]
    fn enqueue_assigns_monotonic_ids() {
        let mut s = test_session();
        let id0 = s.enqueue_prompt("first".into());
        let id1 = s.enqueue_prompt("second".into());
        let id2 = s.enqueue_prompt("third".into());
        assert_eq!(id0, 0);
        assert_eq!(id1, 1);
        assert_eq!(id2, 2);
        assert_eq!(s.queue_len(), 3);
    }
    #[test]
    fn dequeue_returns_fifo_order() {
        let mut s = test_session();
        s.enqueue_prompt("first".into());
        s.enqueue_prompt("second".into());
        let p = s.dequeue_prompt().unwrap();
        assert_eq!(p.text, "first");
        assert_eq!(p.id, 0);
        let p = s.dequeue_prompt().unwrap();
        assert_eq!(p.text, "second");
        assert_eq!(p.id, 1);
        assert!(s.dequeue_prompt().is_none());
    }
    #[test]
    fn queue_position_tracks_by_id() {
        let mut s = test_session();
        let _id0 = s.enqueue_prompt("a".into());
        let id1 = s.enqueue_prompt("b".into());
        let id2 = s.enqueue_prompt("c".into());
        assert_eq!(s.queue_position(id1), Some(1));
        assert_eq!(s.queue_position(id2), Some(2));
        s.dequeue_prompt();
        assert_eq!(s.queue_position(id1), Some(0));
        assert_eq!(s.queue_position(id2), Some(1));
    }
    #[test]
    fn queue_position_returns_none_for_drained() {
        let mut s = test_session();
        let id0 = s.enqueue_prompt("gone".into());
        s.dequeue_prompt();
        assert_eq!(s.queue_position(id0), None);
    }
    #[test]
    fn swap_prompt_up() {
        let mut s = test_session();
        let id_a = s.enqueue_prompt("a".into());
        let id_b = s.enqueue_prompt("b".into());
        let id_c = s.enqueue_prompt("c".into());
        s.swap_prompt_up(id_b);
        assert_eq!(s.pending_prompts[0].id, id_b);
        assert_eq!(s.pending_prompts[1].id, id_a);
        assert_eq!(s.pending_prompts[2].id, id_c);
        s.swap_prompt_up(id_b);
        assert_eq!(s.pending_prompts[0].id, id_b);
    }
    #[test]
    fn swap_prompt_down() {
        let mut s = test_session();
        let id_a = s.enqueue_prompt("a".into());
        let id_b = s.enqueue_prompt("b".into());
        let id_c = s.enqueue_prompt("c".into());
        s.swap_prompt_down(id_b);
        assert_eq!(s.pending_prompts[0].id, id_a);
        assert_eq!(s.pending_prompts[1].id, id_c);
        assert_eq!(s.pending_prompts[2].id, id_b);
        s.swap_prompt_down(id_b);
        assert_eq!(s.pending_prompts[2].id, id_b);
    }
    #[test]
    fn ids_never_reuse_after_drain() {
        let mut s = test_session();
        s.enqueue_prompt("first".into());
        s.dequeue_prompt();
        let id = s.enqueue_prompt("second".into());
        assert_eq!(id, 1);
    }
    #[test]
    fn enqueue_cron_stores_cron_kind() {
        let mut s = test_session();
        s.enqueue_cron_prompt(
            "check status".into(),
            "task-1".into(),
            "every 5 minutes".into(),
        );
        assert_eq!(s.queue_len(), 1);
        let entry = s.dequeue_prompt().unwrap();
        assert_eq!(entry.text, "check status");
        assert_eq!(entry.kind, QueueEntryKind::Cron);
        assert_eq!(entry.task_id.as_deref(), Some("task-1"));
        assert_eq!(entry.human_schedule.as_deref(), Some("every 5 minutes"));
    }
    #[test]
    fn enqueue_bash_command_stores_bash_kind() {
        let mut s = test_session();
        s.enqueue_bash_command("ls -la".into());
        assert_eq!(s.queue_len(), 1);
        let entry = s.dequeue_prompt().unwrap();
        assert_eq!(entry.text, "ls -la");
        assert_eq!(entry.kind, QueueEntryKind::BashCommand);
    }
    #[test]
    fn mixed_queue_drains_fifo_across_kinds() {
        let mut s = test_session();
        s.enqueue_prompt("prompt1".into());
        s.enqueue_bash_command("echo hi".into());
        s.enqueue_command("/compact".into());
        s.enqueue_bash_command("pwd".into());
        let e1 = s.dequeue_prompt().unwrap();
        assert_eq!(e1.kind, QueueEntryKind::Prompt);
        assert_eq!(e1.text, "prompt1");
        let e2 = s.dequeue_prompt().unwrap();
        assert_eq!(e2.kind, QueueEntryKind::BashCommand);
        assert_eq!(e2.text, "echo hi");
        let e3 = s.dequeue_prompt().unwrap();
        assert_eq!(e3.kind, QueueEntryKind::Command);
        assert_eq!(e3.text, "/compact");
        let e4 = s.dequeue_prompt().unwrap();
        assert_eq!(e4.kind, QueueEntryKind::BashCommand);
        assert_eq!(e4.text, "pwd");
        assert!(s.dequeue_prompt().is_none());
    }
    #[test]
    fn swap_works_across_entry_kinds() {
        let mut s = test_session();
        let id_p = s.enqueue_prompt("prompt".into());
        let id_b = s.enqueue_bash_command("ls".into());
        s.swap_prompt_up(id_b);
        assert_eq!(s.pending_prompts[0].id, id_b);
        assert_eq!(s.pending_prompts[0].kind, QueueEntryKind::BashCommand);
        assert_eq!(s.pending_prompts[1].id, id_p);
        assert_eq!(s.pending_prompts[1].kind, QueueEntryKind::Prompt);
    }
    /// `wire_matches_display` splits interjectable rows (no payload, or a raw
    /// skill slash payload equal to the display text) from client-expanded
    /// payloads (`/loop`) that must run as their own turn.
    #[test]
    fn wire_matches_display_classifies_payload_shapes() {
        let text_block = |t: &str| acp::ContentBlock::Text(acp::TextContent::new(t.to_string()));
        let plain = QueuedPrompt::plain(1, "hello", QueueEntryKind::Prompt);
        assert!(plain.wire_matches_display(), "no payload = display");
        let raw_skill = QueuedPrompt {
            wire_blocks: Some(vec![text_block("/commit fix")]),
            ..QueuedPrompt::plain(2, "/commit fix", QueueEntryKind::Prompt)
        };
        assert!(raw_skill.wire_matches_display(), "raw slash payload");
        let expanded = QueuedPrompt {
            wire_blocks: Some(vec![text_block("<skill>body</skill>")]),
            ..QueuedPrompt::plain(3, "/loop cat", QueueEntryKind::Prompt)
        };
        assert!(!expanded.wire_matches_display(), "expanded payload");
        let multi_block = QueuedPrompt {
            wire_blocks: Some(vec![text_block("/commit fix"), text_block("more")]),
            ..QueuedPrompt::plain(4, "/commit fix", QueueEntryKind::Prompt)
        };
        assert!(!multi_block.wire_matches_display(), "multi-block payload");
    }
    #[test]
    fn enqueue_prompt_wire_blocks_defaults_to_none() {
        let mut s = test_session();
        s.enqueue_prompt("hello".into());
        let p = s.dequeue_prompt().unwrap();
        assert!(p.wire_blocks.is_none());
        assert!(p.skill_token_ranges.is_empty());
    }
    #[test]
    fn enqueue_prompt_with_skill_tokens_preserves_ranges() {
        let mut s = test_session();
        s.enqueue_prompt_with_skill_tokens(
            "great /commit now".into(),
            std::iter::once(6..13).collect(),
        );
        let p = s.dequeue_prompt().unwrap();
        assert_eq!(p.skill_token_ranges, vec![6..13]);
        assert_eq!(p.kind, QueueEntryKind::Prompt);
        assert!(p.wire_blocks.is_none());
    }
    #[test]
    fn enqueue_prompt_front_into_empty_queue() {
        let mut s = test_session();
        let id = s.enqueue_prompt_front("first".into());
        assert_eq!(id, 0);
        assert_eq!(s.queue_len(), 1);
        let p = s.dequeue_prompt().unwrap();
        assert_eq!(p.text, "first");
        assert_eq!(p.kind, QueueEntryKind::Prompt);
        assert!(p.wire_blocks.is_none());
        assert!(p.images.is_empty());
        assert!(!p.display_as_skill);
        assert!(p.task_id.is_none());
    }
    #[test]
    fn enqueue_in_flight_prompt_front_preserves_images_and_chips() {
        let mut session = test_session();
        let image = crate::prompt_images::from_clipboard_data(&crate::clipboard::ImageData {
            data: vec![1, 2, 3],
            mime_type: "image/png".into(),
        });
        session.enqueue_in_flight_prompt_front(InFlightPrompt {
            text: "look [Image #1]".into(),
            images: vec![image],
            scrollback_entry: EntryId::new(1),
            chip_elements: vec![ChipElement {
                range: 5..15,
                kind: crate::views::prompt_widget::KIND_IMAGE,
                display: None,
            }],
        });
        let queued = session.dequeue_prompt().unwrap();
        assert_eq!(queued.images.len(), 1);
        assert_eq!(queued.chip_elements.len(), 1);
    }
    #[test]
    fn enqueue_prompt_front_prepends_directive_before_user_prompts() {
        let mut s = test_session();
        let user_a = s.enqueue_prompt("user-a".into());
        let user_b = s.enqueue_prompt("user-b".into());
        let directive = s.enqueue_prompt_front("/fork directive".into());
        assert!(directive > user_b && user_b > user_a);
        assert_eq!(s.queue_len(), 3);
        let texts: Vec<String> =
            std::iter::from_fn(|| s.dequeue_prompt().map(|p| p.text)).collect();
        assert_eq!(texts, vec!["/fork directive", "user-a", "user-b"]);
    }
    #[test]
    fn enqueue_prompt_front_assigns_monotonic_ids() {
        let mut s = test_session();
        let id0 = s.enqueue_prompt_front("a".into());
        let id1 = s.enqueue_prompt_front("b".into());
        let id2 = s.enqueue_prompt_front("c".into());
        assert_eq!((id0, id1, id2), (0, 1, 2));
        let texts: Vec<String> =
            std::iter::from_fn(|| s.dequeue_prompt().map(|p| p.text)).collect();
        assert_eq!(texts, vec!["c", "b", "a"]);
    }
    #[test]
    fn dequeue_preserves_wire_blocks() {
        let mut s = test_session();
        let id = s.next_queue_id;
        s.next_queue_id += 1;
        let blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(
            "<skill>test</skill>",
        ))];
        s.pending_prompts.push_back(QueuedPrompt {
            wire_blocks: Some(blocks.clone()),
            display_as_skill: true,
            ..QueuedPrompt::plain(id, "/commit fix", QueueEntryKind::Prompt)
        });
        let p = s.dequeue_prompt().unwrap();
        assert!(p.wire_blocks.is_some());
        let wb = p.wire_blocks.unwrap();
        assert_eq!(wb.len(), 1);
    }
    #[test]
    fn swap_preserves_wire_blocks() {
        let mut s = test_session();
        let id_normal = s.enqueue_prompt("normal".into());
        let id_skill = s.next_queue_id;
        s.next_queue_id += 1;
        let blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(
            "<skill>body</skill>",
        ))];
        s.pending_prompts.push_back(QueuedPrompt {
            wire_blocks: Some(blocks),
            display_as_skill: true,
            ..QueuedPrompt::plain(id_skill, "/commit", QueueEntryKind::Prompt)
        });
        s.swap_prompt_up(id_skill);
        assert_eq!(s.pending_prompts[0].id, id_skill);
        assert!(s.pending_prompts[0].wire_blocks.is_some());
        assert_eq!(s.pending_prompts[1].id, id_normal);
        assert!(s.pending_prompts[1].wire_blocks.is_none());
    }
    #[test]
    fn mixed_queue_with_wire_blocks_drains_fifo() {
        let mut s = test_session();
        s.enqueue_prompt("plain".into());
        let id = s.next_queue_id;
        s.next_queue_id += 1;
        s.pending_prompts.push_back(QueuedPrompt {
            wire_blocks: Some(vec![acp::ContentBlock::Text(acp::TextContent::new(
                "skill body",
            ))]),
            display_as_skill: true,
            ..QueuedPrompt::plain(id, "/commit fix", QueueEntryKind::Prompt)
        });
        s.enqueue_bash_command("ls".into());
        let e1 = s.dequeue_prompt().unwrap();
        assert!(e1.wire_blocks.is_none());
        assert_eq!(e1.text, "plain");
        let e2 = s.dequeue_prompt().unwrap();
        assert!(e2.wire_blocks.is_some());
        assert_eq!(e2.text, "/commit fix");
        let e3 = s.dequeue_prompt().unwrap();
        assert!(e3.wire_blocks.is_none());
        assert_eq!(e3.kind, QueueEntryKind::BashCommand);
    }
}
