use serde::Serialize;

/// Structured events parsed from PTY output
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "type")]
pub enum ParsedEvent {
    #[serde(rename = "rate-limit")]
    RateLimit {
        pattern_name: String,
        matched_text: String,
        retry_after_ms: Option<u64>,
    },
    #[serde(rename = "status-line")]
    StatusLine {
        task_name: String,
        full_line: String,
        time_info: Option<String>,
        token_info: Option<String>,
    },
    #[serde(rename = "pr-url")]
    PrUrl {
        number: u32,
        url: String,
        platform: String, // "github" or "gitlab"
    },
    #[serde(rename = "progress")]
    Progress {
        state: u8, // 0=remove, 1=normal, 2=error, 3=indeterminate
        value: u8, // 0-100
    },
    /// Agent is waiting for user input (question, confirmation, menu)
    #[serde(rename = "question")]
    Question {
        prompt_text: String,
        /// True for high-confidence detections (explicit regex match on known patterns).
        /// False for low-confidence detections (silence-based `?` heuristic).
        /// Desktop uses this to skip the "busy = false positive" guard on confident questions.
        confident: bool,
    },
    /// Claude Code usage limit: "You've used X% of your weekly/session limit"
    #[serde(rename = "usage-limit")]
    UsageLimit {
        percentage: u8,
        limit_type: String, // "weekly" or "session"
    },
    /// Claude Code usage exhausted: "You're out of (extra) usage · resets TIME (TZ)"
    #[serde(rename = "usage-exhausted")]
    UsageExhausted {
        /// Raw reset time text (e.g. "8pm (Europe/Madrid)"), None if not present
        reset_time: Option<String>,
    },
    /// Plan file detected in terminal output (e.g. plans/foo.md, .claude/plans/bar.md)
    #[serde(rename = "plan-file")]
    PlanFile { path: String },
    /// User submitted a line of input via the PTY (reconstructed from keystrokes)
    #[serde(rename = "user-input")]
    UserInput { content: String },
    /// API error from an agent (5xx server error, auth failure, etc.)
    #[serde(rename = "api-error")]
    ApiError {
        pattern_name: String,
        matched_text: String,
        error_kind: String, // "server", "auth", "unknown"
    },
    /// Tool-error turn end: agent printed `Error: Exit code N` and the session
    /// went idle (only chrome chunks) without recovering. Fires `playError()`
    /// on the frontend. Emitted by the silence timer in pty.rs, not by parser.
    #[serde(rename = "tool-error")]
    ToolError { matched_text: String },
    /// Agent-declared intent: what the LLM is currently working on.
    /// Emitted via `intent: <text>` or `intent: <text> (<tab title>)` at column 0.
    #[serde(rename = "intent")]
    Intent {
        text: String,
        /// Optional short title (max ~3 words) for use as tab name
        #[serde(skip_serializing_if = "Option::is_none")]
        title: Option<String>,
    },
    /// Suggested follow-up actions for the user to choose from.
    /// Emitted via `suggest: A | B | C` at column 0.
    #[serde(rename = "suggest")]
    Suggest { items: Vec<String> },
    /// Slash command autocomplete menu detected on bottom screen rows.
    /// Fired when the user types / in an agent TUI and a menu appears.
    #[serde(rename = "slash-menu")]
    SlashMenu { items: Vec<SlashMenuItem> },
    /// Numbered choice dialog rendered below the prompt line (edit-confirmation,
    /// bash-confirmation, apply-patch, etc.). Cross-agent: Claude Code, Codex,
    /// Aider, Gemini all follow the same "title? / N. option" layout.
    #[serde(rename = "choice-prompt")]
    ChoicePrompt {
        title: String,
        options: Vec<ChoiceOption>,
        #[serde(skip_serializing_if = "Option::is_none")]
        dismiss_key: Option<String>,
        #[serde(skip_serializing_if = "Option::is_none")]
        amend_key: Option<String>,
    },
    /// Claude Code sub-task indicator: `›› task · N local agents` or `›› task · 1 bash`.
    /// Count > 0 means the agent has background work in progress; 0 means all sub-tasks finished.
    #[serde(rename = "active-subtasks")]
    ActiveSubtasks {
        count: u32,
        task_type: String, // "local agents", "bash", "background tasks", etc.
    },
    /// Shell activity state derived from PTY output timing.
    /// Emitted by the reader thread on real-output→busy and idle transitions.
    /// The frontend consumes this instead of deriving busy/idle from raw PTY data.
    #[serde(rename = "shell-state")]
    ShellState {
        state: String, // "busy" | "idle"
        #[serde(skip_serializing_if = "Option::is_none")]
        agent_type: Option<String>,
    },
    /// Agent-emitted block boundary via OSC 7770;block=start|end.
    /// Lets AI agents (Claude Code etc.) define their own block regions
    /// independent of shell OSC 133 markers.
    #[serde(rename = "agent-block")]
    AgentBlock {
        action: String, // "start" | "end"
        line: i64,
        #[serde(skip_serializing_if = "Option::is_none")]
        exit_code: Option<i32>,
    },
    /// Agent failed to start because of a session-id conflict or missing session.
    /// Emitted when Claude Code prints startup errors like
    /// "Session ID X is already in use." or "No conversation found with session ID: X".
    /// The PTY reader consumes this to auto-reset `TUIC_SESSION` so the shell
    /// wrapper's `--session-id` injection stops wedging the terminal across
    /// repeated invocations.
    #[serde(rename = "agent-session-conflict")]
    AgentSessionConflict {
        matched_text: String,
        /// "in-use" (session id already locked) or "not-found" (session id not resumable)
        kind: String,
    },
}

/// Payload for ParsedEvent::ChoicePrompt. Separate struct so it can be reused
/// as a SessionState field without duplicating shape.
#[derive(Clone, Debug, Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct ChoicePromptPayload {
    pub title: String,
    pub options: Vec<ChoiceOption>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dismiss_key: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub amend_key: Option<String>,
}

/// A single option in a numbered choice dialog (edit-confirm, bash-confirm, etc.).
#[derive(Clone, Debug, Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct ChoiceOption {
    /// Keypress the agent expects to select this option (e.g. "1", "2", "3").
    pub key: String,
    /// Primary label shown to the user (e.g. "Yes", "No", "Yes, allow all edits").
    pub label: String,
    /// Whether this option is currently highlighted (cursor position).
    pub highlighted: bool,
    /// Best-effort flag: label contains a "reject/cancel/no" verb.
    pub destructive: bool,
    /// Optional parenthetical hint (e.g. "(shift+tab)"), stripped from label.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hint: Option<String>,
}

/// A single item in a slash command autocomplete menu.
#[derive(Clone, Debug, Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct SlashMenuItem {
    pub command: String,
    pub description: String,
    pub highlighted: bool,
}

/// OutputParser: detects structured events in PTY output text.
/// Designed to run in the Rust reader thread, eliminating JS regex overhead.
/// Borrows pre-compiled patterns from lazy_static globals — zero per-instance allocation.
pub struct OutputParser {
    rate_limit_patterns: &'static [RateLimitPattern],
    api_error_patterns: &'static [ApiErrorPattern],
    /// Dedup: last emitted suggest items to suppress re-emission on scroll.
    last_suggest_items: Option<Vec<String>>,
    /// Dedup: last emitted api-error matched text to suppress re-emission
    /// when the same error remains visible in the terminal buffer.
    last_api_error_match: Option<String>,
    /// Dedup: true after first session conflict detected — suppresses re-emission
    /// when the same error text remains visible in the VT log buffer.
    session_conflict_fired: bool,
}

struct RateLimitPattern {
    name: &'static str,
    regex: regex::Regex,
    retry_after_ms: Option<u64>,
    has_retry_capture: bool,
}

struct ApiErrorPattern {
    name: &'static str,
    regex: regex::Regex,
    error_kind: &'static str, // "server", "auth", "unknown"
}

lazy_static::lazy_static! {
    /// Pre-built rate limit patterns — compiled once at first use.
    static ref RATE_LIMIT_PATTERNS: Vec<RateLimitPattern> = build_rate_limit_patterns();
    /// Pre-built API error patterns — compiled once at first use.
    static ref API_ERROR_PATTERNS: Vec<ApiErrorPattern> = build_api_error_patterns();
    /// Claude Code session-id conflict on startup: the value injected via
    /// `--session-id` is already held by another process (or a stale lock).
    static ref SESSION_IN_USE_RE: regex::Regex =
        regex::Regex::new(r"Session ID\s+[0-9a-fA-F-]{8,}\s+is already in use").unwrap();
    /// Claude Code `--resume <id>` against a session file that no longer exists
    /// (wrong config dir, pruned history, etc.).
    static ref SESSION_NOT_FOUND_RE: regex::Regex =
        regex::Regex::new(r"No conversation found with session ID:\s*[0-9a-fA-F-]{8,}").unwrap();
}

impl OutputParser {
    pub fn new() -> Self {
        Self {
            rate_limit_patterns: &RATE_LIMIT_PATTERNS,
            api_error_patterns: &API_ERROR_PATTERNS,
            last_suggest_items: None,
            last_api_error_match: None,
            session_conflict_fired: false,
        }
    }

    /// Parse a chunk of PTY output and return any detected events.
    ///
    /// Strips ANSI escape sequences via the vt100 crate before parsing.
    /// Only available in tests — the production pipeline uses [`parse_clean_lines`].
    #[cfg(test)]
    pub fn parse(&mut self, text: &str) -> Vec<ParsedEvent> {
        let mut events = Vec::new();

        // OSC 9;4 progress (cheap byte scan first, no stripping needed)
        if let Some(evt) = parse_osc94(text) {
            events.push(evt);
        }

        // PR/MR URL detection (no stripping needed)
        if let Some(evt) = parse_pr_url(text) {
            events.push(evt);
        }

        // Strip ANSI escape sequences for parsers that need clean text.
        // Uses the vt100 crate (already a dependency) to render a virtual screen
        // and extract clean rows, avoiding the strip_ansi_escapes crate.
        let clean = strip_ansi_via_vt100(text);

        // Status line detection
        if let Some(evt) = parse_status_line(&clean) {
            events.push(evt);
        }

        // Active sub-task indicator (Claude Code ›› lines)
        if let Some(evt) = parse_active_subtasks(&clean) {
            events.push(evt);
        }

        // Rate limit detection (operates on clean text to avoid ANSI escapes bridging unrelated tokens)
        if let Some(evt) = self.parse_rate_limit(&clean) {
            events.push(evt);
        }

        // API error detection (5xx, auth errors — distinct from rate limits)
        if let Some(evt) = self.parse_api_error(&clean) {
            events.push(evt);
        }

        // Usage limit detection
        if let Some(evt) = parse_usage_limit(&clean) {
            events.push(evt);
        }

        // Usage exhaustion detection
        if let Some(evt) = parse_usage_exhausted(&clean) {
            events.push(evt);
        }

        // Question/attention detection
        if let Some(evt) = parse_question(&clean) {
            events.push(evt);
        }

        // Plan file detection
        if let Some(evt) = parse_plan_file(&clean) {
            events.push(evt);
        }

        // Intent declaration: `intent: text` at column 0
        // Test-only parse assumes agent context (agent_active=true)
        if let Some(evt) = parse_intent(&clean, true) {
            events.push(evt);
        }

        // Suggest follow-up actions: `suggest: A | B | C` at column 0
        if let Some(evt) = parse_suggest(&clean, true) {
            events.push(evt);
        }

        if !self.session_conflict_fired {
            if let Some(evt) = parse_agent_session_conflict(&clean) {
                self.session_conflict_fired = true;
                events.push(evt);
            }
        }

        events
    }

    /// Parse already-clean VtLogBuffer rows and return any detected events.
    ///
    /// Unlike [`parse`], this method receives rows that have already been rendered
    /// by the VT100 parser — no ANSI stripping is needed.  Each row is processed
    /// independently so parsers see a single, stable line rather than a raw chunk
    /// that may span cursor movements.
    ///
    /// OSC 9;4 progress events are NOT emitted here: those sequences are consumed
    /// by the vt100 crate and invisible in clean rows; they remain on the raw stream.
    pub fn parse_clean_lines(
        &mut self,
        rows: &[crate::state::ChangedRow],
        agent_active: bool,
    ) -> Vec<ParsedEvent> {
        let mut events = Vec::new();
        // Join rows into a single string so multi-line parsers (rate_limit, etc.) work.
        // Individual row texts are already clean — no ANSI stripping required.
        // Strip backtick characters: Claude Code renders file paths and tokens as
        // markdown inline code (`path`), which leaves literal backticks in the clean
        // text. Removing them lets all parsers match paths/tokens without needing
        // backtick-aware regexes — plugins benefit too via structured events.
        let mut joined = String::new();
        for (i, r) in rows.iter().enumerate() {
            if i > 0 {
                joined.push('\n');
            }
            if r.text.contains('`') {
                for ch in r.text.chars() {
                    if ch != '`' {
                        joined.push(ch);
                    }
                }
            } else {
                joined.push_str(&r.text);
            }
        }

        // PR/MR URL detection (operates on text directly)
        if let Some(evt) = parse_pr_url(&joined) {
            events.push(evt);
        }

        // Status line — iterates lines internally
        if let Some(evt) = parse_status_line(&joined) {
            events.push(evt);
        }

        // Active sub-task indicator (Claude Code ›› lines)
        if let Some(evt) = parse_active_subtasks(&joined) {
            events.push(evt);
        }

        // Rate limit and API error — pattern-match on joined text
        if let Some(evt) = self.parse_rate_limit(&joined) {
            events.push(evt);
        }
        if let Some(evt) = self.parse_api_error(&joined) {
            events.push(evt);
        }

        // Usage limit
        if let Some(evt) = parse_usage_limit(&joined) {
            events.push(evt);
        }

        // Usage exhaustion
        if let Some(evt) = parse_usage_exhausted(&joined) {
            events.push(evt);
        }

        // Question/attention — iterates lines internally
        if let Some(evt) = parse_question(&joined) {
            events.push(evt);
        }

        // Plan file
        if let Some(evt) = parse_plan_file(&joined) {
            events.push(evt);
        }

        // Intent and suggest.
        // Plain-prefix tokens (`intent:`, `suggest:` at column 0) are only parsed
        // when an agent is detected — this prevents false positives from regular
        // CLI tools that might output text starting with these keywords.
        if let Some(evt) = parse_intent(&joined, agent_active) {
            events.push(evt);
        }
        // Suggest follow-up actions: `suggest: [ A | B | C ]` on a single row.
        // The token is fully self-contained (bounded by `[ … ]`), so there is no
        // cross-chunk reassembly — dedup against the last emission to avoid
        // re-firing while the same suggest stays on screen.
        if let Some((evt, _canonical)) = parse_suggest_with_line(&joined, agent_active)
            && let ParsedEvent::Suggest { ref items } = evt
            && self.last_suggest_items.as_ref() != Some(items)
        {
            self.last_suggest_items = Some(items.clone());
            events.push(evt);
        }

        if !self.session_conflict_fired
            && let Some(evt) = parse_agent_session_conflict(&joined)
        {
            self.session_conflict_fired = true;
            events.push(evt);
        }

        // Reset dedup state on user-input (new agent cycle may produce new errors/suggestions).
        // Keep `last_suggest_items` intact: the old suggest text is still visible on screen
        // and changed-row detection will re-parse it after the input scrolls the viewport.
        // Resetting the dedup cache would cause stale chips to reappear on the next idle.
        if events
            .iter()
            .any(|e| matches!(e, ParsedEvent::UserInput { .. }))
        {
            self.last_api_error_match = None;
            self.session_conflict_fired = false;
        }

        events
    }

    fn parse_rate_limit(&self, text: &str) -> Option<ParsedEvent> {
        // Fast path: every rate-limit pattern requires at least one of these keywords.
        if !text.contains("rate_limit") && !text.contains("overloaded")
            && !text.contains("RateLimit") && !text.contains("429")
            && !text.contains("RESOURCE_EXHAUSTED") && !text.contains("etry") // Retry/retry
            && !text.contains("Rate Limit") && !text.contains("per minute")
        {
            return None;
        }
        for pattern in self.rate_limit_patterns {
            // Use captures() uniformly — group 0 is the full match (subsumes find())
            if let Some(caps) = pattern.regex.captures(text) {
                let m: regex::Match<'_> = caps.get(0).unwrap();
                // Guard: reject matches that appear inside source code or documentation.
                // Real API errors appear on their own line (e.g. "Error: rate_limit_error"),
                // not inside string literals, comments, regex patterns, or test assertions.
                if line_is_code_or_diff(line_containing_match(text, m.start())) {
                    continue;
                }

                let retry_after_ms = if pattern.has_retry_capture {
                    caps.get(1)
                        .and_then(|g| g.as_str().parse::<u64>().ok().map(|s| s * 1000))
                } else {
                    pattern.retry_after_ms
                };
                return Some(ParsedEvent::RateLimit {
                    pattern_name: pattern.name.to_string(),
                    matched_text: m.as_str().to_string(),
                    retry_after_ms: retry_after_ms.or(Some(60000)),
                });
            }
        }
        None
    }

    fn parse_api_error(&mut self, text: &str) -> Option<ParsedEvent> {
        // Fast path: every api-error pattern requires at least one of these keywords.
        if !text.contains("api_error")
            && !text.contains("authentication_error")
            && !text.contains("server_error")
            && !text.contains("UNAVAILABLE")
            && !text.contains("INTERNAL")
            && !text.contains("UNAUTHENTICATED")
            && !text.contains("litellm")
            && !text.contains("copilot")
            && !text.contains("provider_name")
            && !text.contains("base_resp")
            && !text.contains("stream error")
            && !text.contains("servers are down")
            && !text.contains("not able to authenticate")
            && !text.contains("request failed")
        {
            return None;
        }
        for pattern in self.api_error_patterns {
            if let Some(m) = pattern.regex.find(text) {
                // Guard: reject matches inside source code or documentation.
                if line_is_code_or_diff(line_containing_match(text, m.start())) {
                    continue;
                }
                // Dedup: suppress re-emission when the same error text is still
                // visible in the terminal buffer (e.g. prompt redraws, user typing).
                let matched = m.as_str();
                if self.last_api_error_match.as_deref() == Some(matched) {
                    return None;
                }
                self.last_api_error_match = Some(matched.to_string());
                return Some(ParsedEvent::ApiError {
                    pattern_name: pattern.name.to_string(),
                    matched_text: matched.to_string(),
                    error_kind: pattern.error_kind.to_string(),
                });
            }
        }
        None
    }
}

/// Returns true if a line looks like source code, documentation, or agent commentary
/// Combined guard: returns true if a line looks like source code, diff output,
/// or documentation context — not a real agent output line.
/// Extract the line containing a regex match from the full text.
fn line_containing_match(text: &str, match_start: usize) -> &str {
    let line = text[..match_start]
        .rfind('\n')
        .map(|nl| &text[nl + 1..])
        .unwrap_or(text);
    line.lines().next().unwrap_or(line)
}

fn line_is_code_or_diff(line: &str) -> bool {
    line_is_source_code(line) || line_is_diff_or_code_context(line)
}

/// Detect whether a line looks like source code rather than a real API error or
/// session conflict. Prevents false-positive detection when agents read/discuss
/// source files that contain error-code strings.
fn line_is_source_code(line: &str) -> bool {
    let trimmed = line.trim();
    // Rust raw string literals: r"...", r#"..."# (must be preceded by whitespace or line start)
    if trimmed.contains("r#\"") {
        return true;
    }
    // r" preceded by whitespace/punctuation (not alphanumeric) — avoids matching "error", "server", etc.
    if let Some(idx) = trimmed.find("r\"")
        && (idx == 0 || !trimmed.as_bytes()[idx - 1].is_ascii_alphanumeric())
    {
        return true;
    }
    // Line comments (Rust, JS, Python, shell)
    if trimmed.starts_with("//") || trimmed.starts_with('#') {
        return true;
    }
    // Function/const/let/var/test declarations (code context)
    if trimmed.starts_with("fn ")
        || trimmed.starts_with("const ")
        || trimmed.starts_with("let ")
        || trimmed.starts_with("rl(")
        || trimmed.starts_with("assert")
    {
        return true;
    }
    // Indented code (4+ leading spaces or tab) with string delimiters around the match
    let leading_ws = line.len() - line.trim_start().len();
    if leading_ws >= 4 && (trimmed.contains('"') || trimmed.contains('\'')) {
        return true;
    }
    // Markdown code fences or bullet points discussing patterns
    if trimmed.starts_with("```") || trimmed.starts_with("- ") || trimmed.starts_with("* ") {
        return true;
    }
    // Line contains pipe characters (markdown tables, test output)
    if trimmed.contains(" | ") && trimmed.starts_with('|') {
        return true;
    }
    false
}

fn build_rate_limit_patterns() -> Vec<RateLimitPattern> {
    // Patterns are checked in order; first match wins.
    // Only match structured error output (API error codes, HTTP status lines, error class names).
    // NEVER match plain English phrases — agents discuss rate limits in conversational output.
    vec![
        // Claude: specific API error codes (snake_case identifiers)
        rl(
            "claude-http-429",
            r"(?i)rate_limit_error",
            Some(60000),
            false,
        ),
        rl(
            "claude-overloaded",
            r"(?i)overloaded_error",
            Some(30000),
            false,
        ),
        // OpenAI / Cursor: specific error class names (PascalCase/structured)
        rl("openai-http-429", r"RateLimitError", Some(60000), false),
        // Cursor: exact API error message emitted by Cursor's backend (not conversational)
        rl(
            "cursor-rate-limit",
            r"User Provided API Key Rate Limit Exceeded",
            Some(60000),
            false,
        ),
        // Gemini: gRPC error code (UPPER_SNAKE_CASE)
        rl(
            "gemini-resource-exhausted",
            r"RESOURCE_EXHAUSTED",
            Some(60000),
            false,
        ),
        // HTTP status line — requires "429" adjacent to HTTP-like context
        // HTTP/ must be followed by a version (e.g. 1.1, 2) then whitespace then 429;
        // HTTP<space>429 (no version) also accepted. Prevents ANSI garbage bridging.
        rl(
            "http-429",
            r"(?i)\b429\b.{0,20}Too Many Requests|HTTP/\d[\d.]*\s+429|HTTP\s+429",
            Some(60000),
            false,
        ),
        // Retry-After HTTP header (colon-separated, very specific format)
        rl(
            "retry-after-header",
            r"(?i)Retry-After:\s*(\d+)",
            None,
            true,
        ),
        // OpenAI structured retry message (requires "Retry after N seconds" exact phrasing)
        rl(
            "openai-retry-after",
            r"Retry after (\d+) seconds?",
            None,
            true,
        ),
        // Token/request limit errors — require structured error context (quotes, colons, or error prefix)
        rl(
            "openai-tpm-limit",
            r"(?i)tokens per minute.*limit|TPM limit",
            Some(60000),
            false,
        ),
        rl(
            "openai-rpm-limit",
            r"(?i)requests per minute.*limit|RPM limit",
            Some(60000),
            false,
        ),
    ]
}

fn rl(
    name: &'static str,
    pattern: &str,
    retry_after_ms: Option<u64>,
    has_retry_capture: bool,
) -> RateLimitPattern {
    RateLimitPattern {
        name,
        regex: regex::Regex::new(pattern).unwrap(),
        retry_after_ms,
        has_retry_capture,
    }
}

fn build_api_error_patterns() -> Vec<ApiErrorPattern> {
    // Patterns for API errors that are NOT rate limits (5xx, auth, server errors).
    // Checked in order; first match wins.
    //
    // Two tiers:
    //   1. Agent-specific patterns (exact CLI output formats)
    //   2. Provider-level patterns (JSON error structures from any CLI that prints raw API responses)
    vec![
        // === Agent-specific patterns ===

        // Claude Code: API Error: 5xx with JSON body
        ae("claude-api-error", r#""type":"api_error""#, "server"),
        // Claude Code: authentication_error in JSON body (401) — also used by OpenAI
        ae(
            "claude-auth-error",
            r#""type":"authentication_error""#,
            "auth",
        ),
        // Gemini CLI: API Error: got status: UNAVAILABLE/INTERNAL
        ae(
            "gemini-server-error",
            r"API Error: got status: (?:UNAVAILABLE|INTERNAL)",
            "server",
        ),
        // Aider: litellm server/auth exceptions
        ae(
            "aider-server-error",
            r"litellm\.(?:InternalServerError|ServiceUnavailableError|APIError):",
            "server",
        ),
        ae("aider-auth-error", r"litellm\.AuthenticationError:", "auth"),
        // Aider: user-facing translated messages
        ae(
            "aider-server-msg",
            r"The API provider's servers are down or overloaded",
            "server",
        ),
        ae(
            "aider-auth-msg",
            r"The API provider is not able to authenticate you",
            "auth",
        ),
        // Codex CLI: stream error with retry exhaustion (non-429 status)
        ae(
            "codex-stream-error",
            r"stream error: exceeded retry limit, last status: [45]\d\d",
            "server",
        ),
        // Copilot CLI: token/auth failures
        // Note: "request failed unexpectedly" removed — too generic, triggers on Claude output
        ae(
            "copilot-auth-error",
            r"(?:Failed to get copilot token|copilot token.*expired)",
            "auth",
        ),
        // === Provider-level JSON error patterns ===
        // These fire when any CLI prints the raw API error response.

        // OpenAI: {"error":{"type":"server_error",...}}
        ae(
            "openai-server-error",
            r#""type"\s*:\s*"server_error""#,
            "server",
        ),
        // Google Gemini/Vertex: {"error":{"status":"INTERNAL"}} or "UNAVAILABLE"
        ae(
            "google-api-server",
            r#""status"\s*:\s*"(?:INTERNAL|UNAVAILABLE)""#,
            "server",
        ),
        // Google auth: {"error":{"status":"UNAUTHENTICATED"}} or API_KEY_INVALID
        ae(
            "google-api-auth",
            r#""status"\s*:\s*"UNAUTHENTICATED""#,
            "auth",
        ),
        // OpenRouter: error JSON with provider_name metadata and error code
        ae(
            "openrouter-server",
            r#""error"\s*:\s*\{[^}]*"provider_name"\s*:"#,
            "server",
        ),
        // MiniMax: {"base_resp":{"status_code":1013,...}} — non-zero status_code indicates error
        ae(
            "minimax-server",
            r#""base_resp"\s*:\s*\{[^}]*"status_code"\s*:\s*[1-9]"#,
            "server",
        ),
    ]
}

fn ae(name: &'static str, pattern: &str, error_kind: &'static str) -> ApiErrorPattern {
    ApiErrorPattern {
        name,
        regex: regex::Regex::new(pattern).unwrap(),
        error_kind,
    }
}

/// Parse OSC 9;4 progress sequences: \x1b]9;4;STATE;VALUE\x07
pub(crate) fn parse_osc94(text: &str) -> Option<ParsedEvent> {
    // Fast path: check for ESC ] before running regex
    if !text.contains("\x1b]9;4;") {
        return None;
    }
    lazy_static::lazy_static! {
        static ref OSC94_RE: regex::Regex =
            regex::Regex::new(r"\x1b\]9;4;(\d);(\d{1,3})(?:\x07|\x1b\\)").unwrap();
    }
    OSC94_RE.captures_iter(text).last().map(|caps| {
        let state: u8 = caps[1].parse().unwrap_or(0);
        let value: u8 = caps[2].parse().unwrap_or(0).min(100);
        ParsedEvent::Progress { state, value }
    })
}

/// Parse GitHub/GitLab PR/MR URLs
fn parse_pr_url(text: &str) -> Option<ParsedEvent> {
    // Fast path
    if !text.contains("github.com") && !text.contains("gitlab.com") {
        return None;
    }
    lazy_static::lazy_static! {
        static ref GH_RE: regex::Regex =
            regex::Regex::new(r"https?://github\.com/[^/\s]+/[^/\s]+/pull/(\d+)").unwrap();
        static ref GL_RE: regex::Regex =
            regex::Regex::new(r"https?://gitlab\.com/[^/\s]+/[^/\s]+/-/merge_requests/(\d+)").unwrap();
    }
    if let Some(caps) = GH_RE.captures(text) {
        let number: u32 = caps[1].parse().unwrap_or(0);
        return Some(ParsedEvent::PrUrl {
            number,
            url: caps[0].to_string(),
            platform: "github".to_string(),
        });
    }
    if let Some(caps) = GL_RE.captures(text) {
        let number: u32 = caps[1].parse().unwrap_or(0);
        return Some(ParsedEvent::PrUrl {
            number,
            url: caps[0].to_string(),
            platform: "gitlab".to_string(),
        });
    }
    None
}

/// Strip ANSI escape sequences from raw PTY text.
///
/// Uses `strip-ansi-escapes` to remove all CSI/OSC/ESC sequences.
/// Only used by the test-only [`OutputParser::parse`] method.
#[cfg(test)]
fn strip_ansi_via_vt100(text: &str) -> String {
    let stripped = strip_ansi_escapes::strip(text);
    String::from_utf8_lossy(&stripped)
        .lines()
        .filter(|l| !l.trim().is_empty())
        .collect::<Vec<_>>()
        .join("\n")
}

/// Parse status line patterns from pre-stripped terminal output.
fn parse_status_line(clean: &str) -> Option<ParsedEvent> {
    // Fast path: skip chunks that cannot contain any status line marker.
    // Checks for: '*' (Claude), "[Running]", "Tokens:" (Aider), or multi-byte UTF-8:
    //   0xC2 = lead byte for · (U+00B7, Claude middle dot)
    //   0xE2 = lead byte for braille U+2800, dingbat asterisks U+2720-273F,
    //          block elements ░█, bullets •◦, ∴ (U+2234), ● (U+25CF), ○ (U+25CB)
    if !clean.contains('*')
        && !clean.contains("[Running]")
        && !clean.contains("Tokens:")
        && !clean.contains("Ctrl+C")
        && !clean.as_bytes().contains(&0xe2)
        && !clean.as_bytes().contains(&0xc2)
    {
        return None;
    }

    lazy_static::lazy_static! {
        // All status-line patterns are anchored to ^\s* because agent status lines
        // are always the first thing on the visible line (agents use \r to overwrite).
        // Without anchoring, `*` in code/output would false-positive.

        // Claude Code: "* Task name... (time)" or "✢Task name… (time)" or "· Verb…"
        // Accepts ASCII *, middle dot · (U+00B7), and dingbat asterisks U+2720-273F.
        static ref CLAUDE_STATUS_RE: regex::Regex =
            regex::Regex::new(r"^\s*[*\u{00B7}\u{2720}-\u{273F}]\s*([^.…\n]+)(?:\.{2,3}|…)").unwrap();
        // "[Running] Task name"
        static ref RUNNING_STATUS_RE: regex::Regex =
            regex::Regex::new(r"(?i)^\s*\[Running\]\s+(.+)").unwrap();
        // Braille spinner: "⠋ Task name" (Gemini CLI dots, generic)
        static ref SPINNER_STATUS_RE: regex::Regex =
            regex::Regex::new(r"^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+([^.…]+)").unwrap();
        // Aider Knight Rider scanner: "░█" or "█░" prefix followed by spaces + task text
        // The scanner chars (░ U+2591, █ U+2588) bounce back and forth across the line.
        static ref AIDER_SPINNER_RE: regex::Regex =
            regex::Regex::new(r"^\s*[\u{2588}\u{2591}#=]{1,2}\s{2,}(.+)").unwrap();
        // Aider token report: "Tokens: 5.2k sent, 1.3k received."
        static ref AIDER_TOKENS_RE: regex::Regex =
            regex::Regex::new(r"^\s*Tokens:\s+(.+?)\.$").unwrap();
        // Codex CLI bullet spinner: "• Working (5s • esc to interrupt)" or "◦ Working (12s)"
        // • (U+2022) and ◦ (U+25E6) alternate as a blink spinner.
        // Requires parenthesized time suffix to avoid false positives from markdown bullets.
        static ref CODEX_BULLET_RE: regex::Regex =
            regex::Regex::new(r"^\s*[\u{2022}\u{25E6}]\s+(\w[^(]*?)\s*\(\d+[smh]").unwrap();
        // Goose CLI spinner: "Honking thoughtfully...  (Ctrl+C to interrupt)"
        // Random whimsical messages followed by "..." and "(Ctrl+C to interrupt)" suffix.
        static ref GOOSE_SPINNER_RE: regex::Regex =
            regex::Regex::new(r"^\s*([A-Z][^.…\n]{2,}?)\.{2,3}\s+\(Ctrl\+C to interrupt\)").unwrap();
        // Copilot CLI indicators: ∴ (U+2234 thinking), ● (U+25CF active), ○ (U+25CB queued)
        // Format: "∴ Thinking…" or "● Read file..." or "○ Verify..."
        static ref COPILOT_STATUS_RE: regex::Regex =
            regex::Regex::new(r"^\s*[\u{2234}\u{25CF}\u{25CB}]\s+([^.…\n]+)(?:\.{2,3}|…)").unwrap();
        // Time info — captures compound times like "4m 55s" or "1m32s"
        static ref TIME_RE: regex::Regex =
            regex::Regex::new(r"\((\d+[smh](?:\s*\d+[smh])*)").unwrap();
        // Token info
        static ref TOKEN_RE: regex::Regex =
            regex::Regex::new(r"(?i)(\d+(?:[.,]\d+)?k?\s*tokens)").unwrap();
    }

    for line in clean.lines() {
        let trimmed = line.trim();
        // Skip lines that look like diff output, code, or documentation
        if line_is_diff_or_code_context(line) {
            continue;
        }
        // Skip C-style block comment lines (/* ... */ or lines ending with */)
        if trimmed.starts_with("/*") || trimmed.ends_with("*/") {
            continue;
        }

        // Try each pattern (order matters: more specific first)
        let patterns: &[&regex::Regex] = &[
            &CLAUDE_STATUS_RE,
            &RUNNING_STATUS_RE,
            &AIDER_TOKENS_RE,
            &AIDER_SPINNER_RE,
            &GOOSE_SPINNER_RE,
            &COPILOT_STATUS_RE,
            &CODEX_BULLET_RE,
            &SPINNER_STATUS_RE,
        ];
        for pattern in patterns {
            if let Some(caps) = pattern.captures(line) {
                let task_name = caps[1].trim().to_string();
                if task_name.len() < 3 {
                    continue;
                }
                // Reject task names containing code/data artifacts — real agent
                // status lines are natural language (e.g. "Reading files").
                if task_name.contains('[')
                    || task_name.contains(']')
                    || task_name.contains('"')
                    || task_name.contains('\'')
                    || task_name.contains('|')
                    || task_name.contains('{')
                    || task_name.contains('}')
                    || task_name.contains('\\')
                    || task_name.contains('/')
                {
                    continue;
                }
                let time_info = TIME_RE.captures(line).map(|c| c[1].to_string());
                // For Aider token reports the whole line IS the token info
                let token_info = if std::ptr::eq(*pattern, &*AIDER_TOKENS_RE) {
                    Some(task_name.clone())
                } else {
                    TOKEN_RE.captures(line).map(|c| c[1].to_string())
                };
                return Some(ParsedEvent::StatusLine {
                    task_name,
                    full_line: line.trim().to_string(),
                    time_info,
                    token_info,
                });
            }
        }
    }
    None
}

/// Detect Claude Code active sub-task indicators from the mode line.
///
/// Supports all observed formats (order-agnostic):
/// 1. Old: `⏵⏵ <mode> · N <type>` — markers first, count last
/// 2. New: `N <type> · ⏵⏵ <mode>` — count first, markers last
/// 3. Bare: `⏵⏵ <mode>` — markers only, no count (count = 0)
///
/// The function looks for `⏵⏵`/`››` anywhere in the line (confirms it's a
/// mode line), then searches for `N <type>` anywhere in the same line
/// regardless of position relative to the markers.
fn parse_active_subtasks(clean: &str) -> Option<ParsedEvent> {
    // Fast path: requires ›› (U+203A), ⏵⏵ (U+23F5), or a digit (bare count)
    let has_mode_marker = clean.contains('\u{203A}') || clean.contains('\u{23F5}');
    let has_digit = clean.bytes().any(|b| b.is_ascii_digit());
    if !has_mode_marker && !has_digit {
        return None;
    }

    lazy_static::lazy_static! {
        // Detect ⏵⏵ or ›› anywhere in the line (confirms mode line)
        static ref MODE_MARKER_RE: regex::Regex =
            regex::Regex::new(r"(?:\u{203A}\u{203A}|\u{23F5}\u{23F5})").unwrap();
        // Extract count + type from anywhere in the line: "N <type>"
        // Separated from mode by · (U+00B7). Matches: "· 2 local agents" or "2 local agents ·"
        static ref SUBTASK_COUNT_RE: regex::Regex =
            regex::Regex::new(r"(\d+)\s+([\w][\w\s]*?)(?:\s*(?:\u{00B7}|\(|$))").unwrap();
        // Bare subprocess count without mode marker (e.g. "  1 shell").
        // Restricted to known subprocess types to avoid false positives.
        static ref BARE_SUBTASK_RE: regex::Regex =
            regex::Regex::new(r"^\s*(\d+)\s+((?:local )?agents?|shells?|bash|background tasks?)\s*$").unwrap();
    }

    for line in clean.lines() {
        let trimmed = line.trim();

        // Path 1: mode marker present (⏵⏵ or ››)
        if MODE_MARKER_RE.is_match(trimmed) {
            if let Some(caps) = SUBTASK_COUNT_RE.captures(trimmed) {
                let count: u32 = caps[1].parse().unwrap_or(0);
                let task_type = caps[2].trim().to_string();
                if count > 0 {
                    return Some(ParsedEvent::ActiveSubtasks { count, task_type });
                }
                return Some(ParsedEvent::ActiveSubtasks {
                    count: 0,
                    task_type: String::new(),
                });
            }
            // Mode line present but no count → sub-tasks finished
            return Some(ParsedEvent::ActiveSubtasks {
                count: 0,
                task_type: String::new(),
            });
        }

        // Path 2: bare count without mode marker (e.g. "  1 shell")
        if let Some(caps) = BARE_SUBTASK_RE.captures(trimmed) {
            let count: u32 = caps[1].parse().unwrap_or(0);
            let task_type = caps[2].trim().to_string();
            return Some(ParsedEvent::ActiveSubtasks { count, task_type });
        }
    }
    None
}

/// Detect Claude Code usage limit messages from pre-stripped text:
/// "You've used 78% of your weekly limit" or "You've used 45% of your session limit"
fn parse_usage_limit(clean: &str) -> Option<ParsedEvent> {
    // Fast path
    if !clean.contains("% of your") {
        return None;
    }
    lazy_static::lazy_static! {
        static ref USAGE_LIMIT_RE: regex::Regex =
            regex::Regex::new(r"(?i)You['\u{2019}]ve used (\d{1,3})% of your (weekly|session) limit").unwrap();
    }
    for line in clean.lines() {
        if let Some(caps) = USAGE_LIMIT_RE.captures(line) {
            let percentage: u8 = caps[1].parse().unwrap_or(0).min(100);
            let limit_type = caps[2].to_lowercase();
            return Some(ParsedEvent::UsageLimit {
                percentage,
                limit_type,
            });
        }
    }
    None
}

/// Detect Claude Code usage exhaustion from pre-stripped text:
/// "You're out of extra usage · resets 8pm (Europe/Madrid)"
/// "You're out of usage · resets Feb 21 at 9am (US/Eastern)"
fn parse_usage_exhausted(clean: &str) -> Option<ParsedEvent> {
    // Fast path
    if !clean.contains("out of") || !clean.contains("usage") {
        return None;
    }
    lazy_static::lazy_static! {
        static ref EXHAUSTED_RE: regex::Regex =
            regex::Regex::new(r"(?i)out of (?:extra )?usage").unwrap();
        static ref RESETS_RE: regex::Regex =
            regex::Regex::new(r"(?:\u{00b7}|·)\s*resets\s+(.+)$").unwrap();
    }
    for line in clean.lines() {
        if EXHAUSTED_RE.is_match(line) {
            let reset_time = RESETS_RE
                .captures(line)
                .map(|caps| caps[1].trim().to_string());
            return Some(ParsedEvent::UsageExhausted { reset_time });
        }
    }
    None
}

/// Claude Code startup session-id conflict.
///
/// Fires on two distinct startup failures:
///   * `Session ID <uuid> is already in use.` — the injected `--session-id`
///     collides with a running process or a stale lock.
///   * `No conversation found with session ID: <uuid>` — a `--resume <uuid>`
///     pointed at a session file that doesn't exist (usually wrong config dir).
///
/// Both wedge the shell: the auto-injecting wrapper keeps re-using the same
/// `$TUIC_SESSION`, so every subsequent `claude` invocation in the tab hits
/// the same error. The PTY reader consumes this event to auto-reset the env
/// var (see `pty.rs`) so the next invocation gets a fresh UUID.
fn parse_agent_session_conflict(clean: &str) -> Option<ParsedEvent> {
    // Fast path: the substring "session ID" (case-insensitive) is the only
    // common thread. Bail early on chunks that can't possibly match.
    if !clean.contains("Session ID") && !clean.contains("session ID") {
        return None;
    }
    let try_match = |re: &regex::Regex, kind: &str| -> Option<ParsedEvent> {
        let m = re.find(clean)?;
        // Guard: reject matches that live inside source code, diff hunks, or
        // markdown fences — this exact file will match its own regex when an
        // agent pastes it back into a terminal.
        if line_is_code_or_diff(line_containing_match(clean, m.start())) {
            return None;
        }
        // Reject matches inside markdown fenced code blocks (``` ... ```)
        let fences_before = clean[..m.start()]
            .lines()
            .filter(|l| l.trim().starts_with("```"))
            .count();
        if fences_before % 2 == 1 {
            return None;
        }
        Some(ParsedEvent::AgentSessionConflict {
            matched_text: m.as_str().to_string(),
            kind: kind.to_string(),
        })
    };
    try_match(&SESSION_IN_USE_RE, "in-use")
        .or_else(|| try_match(&SESSION_NOT_FOUND_RE, "not-found"))
}

/// Question detection: most detection is handled by the silence-based detector
/// in pty.rs (last line ending with `?` + 10s of silence = real question).
///
/// Only Ink interactive menu footers are detected instantly — "Enter to select"
/// is ultra-specific to real interactive prompts and never appears in streaming
/// agent output, so it doesn't suffer from the false-positive problem that
/// killed all the other regex patterns.
fn parse_question(clean: &str) -> Option<ParsedEvent> {
    lazy_static::lazy_static! {
        static ref INK_FOOTER_RE: regex::Regex =
            regex::Regex::new(r"Enter to select").unwrap();
        // cliclack interactive prompt: "◆  Do you allow this tool call?"
        // ◆ (U+25C6) is the cliclack prompt marker — ultra-specific, no false positives.
        static ref CLICLACK_PROMPT_RE: regex::Regex =
            regex::Regex::new(r"^\s*\u{25C6}\s+(.+)").unwrap();
    }
    for line in clean.lines() {
        let trimmed = line.trim();
        if INK_FOOTER_RE.is_match(trimmed) && !line_is_diff_or_code_context(line) {
            return Some(ParsedEvent::Question {
                prompt_text: trimmed.to_string(),
                confident: true,
            });
        }
        if let Some(caps) = CLICLACK_PROMPT_RE.captures(line) {
            let text = caps[1].trim();
            if text.len() >= 5 {
                return Some(ParsedEvent::Question {
                    prompt_text: text.to_string(),
                    confident: true,
                });
            }
        }
    }
    None
}

/// Returns true if a line looks like diff output, code context, or documentation
/// Returns true if a line looks like diff output, code context, or documentation
/// rather than a genuine interactive prompt. Applied to ALL question regex matches
/// to prevent false positives from diff hunks containing question-like patterns.
fn line_is_diff_or_code_context(line: &str) -> bool {
    let trimmed = line.trim();

    // Line-number prefix from code listings: "462 -...", "75 +-...", "465 //...", "1226    assert!(..."
    // Distinguished from HTTP status codes ("429 Too Many Requests") by requiring either:
    //   - diff markers (+, -, //) after the number, OR
    //   - 2+ spaces after the number (code listing indentation)
    if trimmed.len() > 3
        && trimmed.as_bytes()[0].is_ascii_digit()
        && let Some(pos) = trimmed.find(|c: char| !c.is_ascii_digit())
    {
        let after_digits = &trimmed[pos..];
        let rest = after_digits.trim_start();
        // Diff markers after line number
        if rest.starts_with('+') || rest.starts_with('-') || rest.starts_with("//") {
            return true;
        }
        // 2+ whitespace chars after digits = code listing with padded line numbers
        if after_digits.len() >= 2
            && after_digits.as_bytes()[0] == b' '
            && (after_digits.as_bytes()[1] == b' ' || after_digits.as_bytes()[1] == b'\t')
        {
            return true;
        }
    }

    // Unified diff lines: start with + or - followed by content
    // Real diff lines: "+  code", "- old line", "++ file", "-- file"
    if line.len() > 1 {
        let first = line.as_bytes()[0];
        let second = line.as_bytes()[1];
        if (first == b'+' || first == b'-')
            && (second == b' ' || second == b'\t' || second == first)
        {
            return true;
        }
    }

    // Claude Code diff summary blocks: "⏺⎿ Added16lines..."
    if trimmed.contains("⏺⎿") {
        return true;
    }
    // Diff summary: "Added16lines" or "removed2lines" (no space between number and "lines")
    // Pattern: keyword followed immediately by digits then "lines" — unique to diff summaries
    if (trimmed.contains("Added") || trimmed.contains("removed"))
        && trimmed.contains("lines")
        && trimmed.chars().any(|c| c.is_ascii_digit())
    {
        // Extra check: the digit must be adjacent to "lines" (no space)
        if let Some(pos) = trimmed.find("lines")
            && pos > 0
            && trimmed.as_bytes()[pos - 1].is_ascii_digit()
        {
            return true;
        }
    }

    // Lines containing "//" as code comments (but not URLs like http://)
    if trimmed.starts_with("//") || trimmed.contains(" //") {
        return true;
    }

    // Lines with markdown bold/italic containing question-pattern keywords
    // (e.g., "**Hardcoded prompts**: ...")
    if trimmed.contains("**")
        && (trimmed.contains("prompts")
            || trimmed.contains("patterns")
            || trimmed.contains("detection"))
    {
        return true;
    }

    false
}

/// Detect plan file paths in pre-stripped terminal output.
/// Matches paths like `plans/foo.md`, `.claude/plans/bar.md`, absolute paths ending in plans/*.md
fn parse_plan_file(clean: &str) -> Option<ParsedEvent> {
    // Fast path: must contain "plans/" and ".md"
    if !clean.contains("plans/") || !clean.contains(".md") {
        return None;
    }
    lazy_static::lazy_static! {
        // Match plan file paths: optional leading path, then plans/<name>.md(x)
        // Captures the full path including any prefix directory.
        // Excludes <>, $, {}, `, * to avoid template placeholders, interpolation, and globs
        // Trailing punctuation (period, comma, etc.) after .md/.mdx is stripped
        static ref PLAN_RE: regex::Regex =
            regex::Regex::new(r#"(?:^|[\s'"`:])(/?(?:[^\s'"<>${}`*]+/)?plans/[^\s'"<>${}`*]+\.mdx?)[.,;:!?)}\]`]*(?:\s|$)"#).unwrap();
    }
    tracing::debug!(
        "[plan-file] fast-path hit, scanning lines (len={})",
        clean.len()
    );
    for line in clean.lines() {
        if line.contains("plans/") {
            tracing::debug!("[plan-file] candidate line: {:?}", line);
        }
        if let Some(caps) = PLAN_RE.captures(line) {
            let mut path = caps[1].to_string();
            // Expand leading ~/ to the user's home directory so the
            // frontend always receives an absolute path it can open.
            if path.starts_with("~/")
                && let Some(home) = dirs::home_dir()
            {
                path = format!("{}{}", home.display(), &path[1..]);
            }
            return Some(ParsedEvent::PlanFile { path });
        }
    }
    None
}

/// Regex matching any CSI escape sequence (SGR colors, cursor movement, erase,
/// Detect agent-declared intent tokens: `intent: <text>` or `intent: <text> (<title>)`
/// at column 0. Only parsed when an agent is active — prevents false positives from
/// prose like "The intent: of this code".
fn parse_intent(clean: &str, agent_active: bool) -> Option<ParsedEvent> {
    if !agent_active || !clean.contains("intent:") {
        return None;
    }
    lazy_static::lazy_static! {
        // Plain prefix: `intent:` at line start, with optional leading
        // horizontal whitespace and/or an Ink bullet glyph (● U+25CF /
        // ⏺ U+23FA). Ink-hosted agents (Claude Code) decorate the first
        // line of an assistant message with `● ` and indent every
        // continuation line by the bullet width, so plain-prefix tokens
        // emitted after the first line arrive as `  intent: ...`. The
        // leading whitespace must be horizontal only — any non-whitespace
        // character before the keyword is rejected.
        static ref INTENT_PLAIN_RE: regex::Regex =
            regex::Regex::new(r"(?m)^[\t ]*(?:[\x{25CF}\x{23FA}][\t ]+)?intent:[\t ]+(.+)$").unwrap();
        // Separate regex to split out the optional (title) suffix from the captured text
        static ref TITLE_RE: regex::Regex =
            regex::Regex::new(r"^(.*?)\(([^)]+)\)\s*$").unwrap();
    }

    let raw_match = INTENT_PLAIN_RE
        .captures(clean)
        .map(|caps| caps[1].trim().to_string());

    build_intent_event(raw_match, &TITLE_RE)
}

/// Shared logic for building an Intent event from a raw match string.
fn build_intent_event(raw_match: Option<String>, title_re: &regex::Regex) -> Option<ParsedEvent> {
    raw_match.and_then(|raw| {
        let raw = raw.trim();
        // Filter out meaningless intents: ellipsis, bare punctuation, template placeholders
        if raw == "..." || raw == "<text>" || raw.len() < 4 {
            return None;
        }
        // Try to extract optional (title) from the end
        let (text, title) = if let Some(tc) = title_re.captures(raw) {
            let body = tc[1].trim();
            let t = tc[2].trim();
            if body.is_empty() {
                // No text before title — treat the whole thing as text
                (raw.to_string(), None)
            } else {
                (body.to_string(), Some(t.to_string()))
            }
        } else {
            (raw.to_string(), None)
        };
        // Normalize whitespace: VtLogBuffer renders CUF cursor-forward codes as
        // multiple spaces when it renders the VT100 screen buffer to text.
        let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
        let title = title.map(|t| t.split_whitespace().collect::<Vec<_>>().join(" "));
        Some(ParsedEvent::Intent { text, title })
    })
}

lazy_static::lazy_static! {
    /// Bracketed suggest token, anchored at column 0 (optional leading
    /// whitespace and an Ink bullet glyph ● U+25CF / ⏺ U+23FA). Two constraints
    /// make a false capture impossible:
    ///   1. the capture excludes `\r`/`\n`, so it matches a single logical row,
    ///   2. the content contains NO nested `[`/`]`.
    /// A token wider than the terminal wraps across physical rows; that case is
    /// rejoined upstream by [`dewrap_suggest_brackets`] (bounded by the closing
    /// `]`, a small row window, and the same nested-bracket rejection) before
    /// this regex runs. A mermaid edge (`EP["…"] -->|x|`) or markdown table
    /// still fails to match — it lacks the column-0 `suggest: [` anchor or
    /// carries a nested `[`. Capture group 1 is the item list.
    static ref SUGGEST_RE: regex::Regex = regex::Regex::new(
        r"(?m)^[\t ]*(?:[\x{25CF}\x{23FA}][\t ]+)?suggest:[\t ]*\[([^\[\]\r\n]*)\]"
    ).unwrap();
}

/// Detect suggested follow-up actions: `suggest: [ A | B | C ]` at column 0.
/// The whole token lives on one row and its bracketed content holds no nested
/// brackets, so stray pipes/brackets in surrounding output (mermaid, markdown
/// tables, prose) can never be captured. Inner items are pipe-separated, 2–4
/// per the TUIC protocol. Only parsed when an agent is active.
#[cfg(test)]
fn parse_suggest(clean: &str, agent_active: bool) -> Option<ParsedEvent> {
    parse_suggest_with_line(clean, agent_active).map(|(evt, _)| evt)
}

/// Like [`parse_suggest`] but also returns the canonical `suggest: …` line that
/// started the match. The caller can persist this line across chunks so a
/// later PTY chunk that only carries a wrapped continuation (e.g. a naked
/// last-item tail word) can be re-joined and re-parsed into complete items.
fn parse_suggest_with_line(clean: &str, agent_active: bool) -> Option<(ParsedEvent, String)> {
    if !agent_active {
        return None;
    }
    // Pass 1: dewrap mid-word wrap of the keyword `suggest:` itself.
    // On very narrow terminals the VT buffer can split the word across two
    // physical rows (e.g. `sugges\nt:` or `suggest\n:`). We rejoin these so
    // the downstream regex can match at column 0.
    let kw_cow = dewrap_suggest_keyword(clean);
    // Pass 2: when the terminal is so narrow that `suggest: ` fills the row
    // entirely (≤9 cols for a plain prefix), the content lands on the next
    // physical row.  Remove the stray newline so SUGGEST_START_RE can see it.
    let content_cow = dewrap_suggest_content(kw_cow.as_ref());
    // Pass 3: the bracketed list itself wrapped across physical rows because the
    // token is wider than the terminal (e.g. a ~78-col `suggest: [ … ]` on a
    // 76-col window). Rejoin the newlines inside the first `[ … ]` so the
    // single-row SUGGEST_RE can match. Bounded by the closing `]`, a small row
    // window, and nested-bracket rejection so surrounding output is never
    // absorbed.
    let bracket_cow = dewrap_suggest_brackets(content_cow.as_ref());
    let clean = bracket_cow.as_ref();
    if !clean.contains("suggest:") {
        return None;
    }
    // Single match: column-0 `suggest: [ … ]` on one row, no nested brackets.
    let caps = SUGGEST_RE.captures(clean)?;
    let items: Vec<String> = caps[1]
        .split('|')
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect();
    // Protocol: 2–4 short actions (spec says exactly 3; the overlay binds number
    // keys 1–4). Reject anything outside that range.
    if items.len() < 2 || items.len() > 4 {
        return None;
    }

    // Canonical, normalized form. The caller persists it so a spinner-tick chunk
    // that re-renders the row can re-parse and re-dedup the same event.
    let canonical_line = format!("suggest: [ {} ]", items.join(" | "));
    Some((ParsedEvent::Suggest { items }, canonical_line))
}

/// Rejoin a `suggest:` line when the terminal is so narrow that the keyword
/// plus its trailing space/tab fills the entire physical row, pushing the
/// item content onto the next line.
///
/// Example (9-col terminal):
/// ```text
/// suggest: \nA | B | C
/// ```
/// → `suggest: A | B | C`
///
/// Returns `Cow::Borrowed` when no such split is detected.
fn dewrap_suggest_content(text: &str) -> std::borrow::Cow<'_, str> {
    if !text.contains("suggest:") || !text.contains('\n') {
        return std::borrow::Cow::Borrowed(text);
    }
    lazy_static::lazy_static! {
        // Matches a `suggest:` prefix line (with optional whitespace/bullet)
        // that ends with only whitespace before the newline — no item content.
        // Capture group 1: everything up to (but not including) trailing space+\n.
        static ref SUGGEST_TRAILING_RE: regex::Regex = regex::Regex::new(
            r"(?m)^([\t ]*(?:[\x{25CF}\x{23FA}][\t ]+)?suggest:[\t ]+)[ \t]*\n"
        )
        .unwrap();
    }
    if !SUGGEST_TRAILING_RE.is_match(text) {
        return std::borrow::Cow::Borrowed(text);
    }
    // Replace `suggest: <trailing spaces>\n` with `suggest: ` so the next
    // physical row's content is treated as the first item line.
    std::borrow::Cow::Owned(SUGGEST_TRAILING_RE.replace_all(text, "$1").to_string())
}

/// Rejoin a `suggest: [ … ]` token whose bracketed content wrapped across
/// physical terminal rows. The single-row [`SUGGEST_RE`] cannot match once the
/// VT buffer has split the list with `\n` (a token wider than the terminal,
/// e.g. ~78 cols of items on a 76-col window), so the overlay never fires.
/// We reconstruct the logical line by collapsing newlines (and the horizontal
/// whitespace the wrap leaves behind) inside the first `[ … ]` into single
/// spaces.
///
/// Because a terminal hard-wrap is column-based, it may break mid-word; the
/// space-collapse is therefore a best-effort rejoin (correct when the break
/// lands on a separator, slightly off mid-word). That is strictly better than
/// the prior behaviour of not rendering the overlay at all.
///
/// The same safety invariants the single-row regex relies on are preserved:
///   - we only act on a column-0 `suggest: [` anchor,
///   - we bail if a nested `[` appears before the closing `]` (mermaid/table),
///   - we scan at most [`MAX_WRAP_ROWS`] rows after the anchor, so a stray
///     `suggest: [` with no closing `]` cannot swallow unbounded output.
///
/// Returns `Cow::Borrowed` when no wrapped suggest is detected.
fn dewrap_suggest_brackets(text: &str) -> std::borrow::Cow<'_, str> {
    if !text.contains("suggest:") || !text.contains('\n') {
        return std::borrow::Cow::Borrowed(text);
    }
    lazy_static::lazy_static! {
        // Column-0 `suggest:` anchor up to and including the opening `[`.
        static ref SUGGEST_OPEN_RE: regex::Regex = regex::Regex::new(
            r"(?m)^[\t ]*(?:[\x{25CF}\x{23FA}][\t ]+)?suggest:[\t ]*\["
        )
        .unwrap();
        // A newline plus the horizontal whitespace surrounding it.
        static ref WRAP_WS_RE: regex::Regex = regex::Regex::new(r"[\t ]*\n[\t ]*").unwrap();
    }
    // 4 physical rows comfortably hold a max-size token (4 items × ≤40 chars +
    // separators + brackets ≈ 170 cols ≈ 3 wraps at a typical narrow width).
    const MAX_WRAP_ROWS: usize = 4;

    let Some(m) = SUGGEST_OPEN_RE.find(text) else {
        return std::borrow::Cow::Borrowed(text);
    };
    let content_start = m.end(); // byte index just after the opening `[`

    // Scan forward for the closing `]`, bailing on a nested `[` (preserve the
    // mermaid/table rejection) or after the row window is exhausted.
    let mut newline_count = 0usize;
    let mut close_idx = None;
    for (off, ch) in text[content_start..].char_indices() {
        match ch {
            ']' => {
                close_idx = Some(content_start + off);
                break;
            }
            '[' => return std::borrow::Cow::Borrowed(text),
            '\n' => {
                newline_count += 1;
                if newline_count > MAX_WRAP_ROWS {
                    return std::borrow::Cow::Borrowed(text);
                }
            }
            _ => {}
        }
    }
    let Some(close_idx) = close_idx else {
        // No closing `]` within reach — leave untouched so an incomplete token
        // does not parse.
        return std::borrow::Cow::Borrowed(text);
    };

    let content = &text[content_start..close_idx];
    if !content.contains('\n') {
        // Already single-row; the regex handles it without a rewrite.
        return std::borrow::Cow::Borrowed(text);
    }

    let collapsed = WRAP_WS_RE.replace_all(content, " ");
    let mut out = String::with_capacity(text.len());
    out.push_str(&text[..content_start]);
    out.push_str(&collapsed);
    out.push_str(&text[close_idx..]);
    std::borrow::Cow::Owned(out)
}

/// Rejoin a `suggest:` keyword that got split across a newline by terminal
/// auto-wrap (e.g. zoomed / narrow window). Handles every split position of
/// the literal word: `s\nuggest:`, `su\nggest:`, ..., `suggest\n:`.
///
/// Returns `Cow::Borrowed` when no wrap is detected so the caller pays no
/// allocation cost in the common (wide terminal) case.
fn dewrap_suggest_keyword(text: &str) -> std::borrow::Cow<'_, str> {
    if !text.contains('\n') {
        return std::borrow::Cow::Borrowed(text);
    }
    const SUGGEST: &str = "suggest:";

    // Every way "suggest:" can split across a newline at column 0:
    //   prefix (non-empty proper prefix of "suggest:") + "\n" + suffix
    //   where prefix + suffix == "suggest:".
    // Iterate 1..=7 split points: s|uggest:, su|ggest:, …, suggest|:.
    //
    // Stay Borrowed until we actually rewrite. PTY chunks land here on every
    // newline flush, so allocating a String up-front defeats the Cow return
    // whenever no suggest-keyword split is present (the common case).
    let mut current: std::borrow::Cow<'_, str> = std::borrow::Cow::Borrowed(text);

    for split in 1..SUGGEST.len() {
        let (prefix, suffix) = SUGGEST.split_at(split);
        let needle = format!("{prefix}\n{suffix}");
        if !current.contains(&needle) {
            continue;
        }

        let src: &str = current.as_ref();
        let mut buf = String::with_capacity(src.len());
        let mut last = 0;
        let mut changed_this_pass = false;
        for (idx, _) in src.match_indices(&needle) {
            // Only dewrap when the prefix begins at column 0 — optionally
            // after whitespace or a Claude Code `●`/`⏺` bullet marker.
            let line_start = src[..idx].rfind('\n').map_or(0, |n| n + 1);
            let leading = &src[line_start..idx];
            let leading_ok = leading
                .chars()
                .all(|c| c == ' ' || c == '\t' || c == '\u{25CF}' || c == '\u{23FA}');
            if !leading_ok {
                continue;
            }
            buf.push_str(&src[last..idx]);
            buf.push_str(prefix);
            buf.push_str(suffix);
            last = idx + needle.len();
            changed_this_pass = true;
        }
        if changed_this_pass {
            buf.push_str(&src[last..]);
            current = std::borrow::Cow::Owned(buf);
        }
    }

    current
}

/// Detect a slash command autocomplete menu from screen bottom rows.
///
/// Called separately from `parse_clean_lines` because it needs the *full*
/// screen snapshot (not just changed rows) — navigating the menu with arrows
/// only changes the highlighted row while the rest stays unchanged.
///
/// Expected format (Claude Code / Codex):
/// ```text
///    /help      Get help with using Claude Code
///  ❯ /review    Review your code
///    /clear     Clear conversation history
/// ```
///
/// Requires at least 2 consecutive matching rows from the bottom of the screen.
/// Highlighted item: detected by `❯` prefix or falls back to first item.
pub fn parse_slash_menu(screen_rows: &[String]) -> Option<ParsedEvent> {
    lazy_static::lazy_static! {
        // Each menu row: optional leading whitespace, optional ❯ marker, /command, 2+ spaces, description
        static ref MENU_ROW_RE: regex::Regex =
            regex::Regex::new(r"^\s*(?:❯\s+)?(/\S+)\s{2,}(.+)$").unwrap();
        // Continuation row: deeply indented text that wraps from a long description.
        // Requires 8+ leading spaces to distinguish from chrome lines (status bar,
        // permission toggle) which have only 2-4 spaces of indent.
        static ref CONTINUATION_RE: regex::Regex =
            regex::Regex::new(r"^\s{8,}\S").unwrap();
    }

    // Scan from the bottom to find the contiguous block of menu rows.
    // Menu items are at the bottom of the screen; stop at the first non-matching row.
    // Continuation lines (wrapped descriptions) are skipped — they don't start with /.
    let mut items: Vec<SlashMenuItem> = Vec::new();
    for row in screen_rows.iter().rev() {
        let trimmed = row.trim();
        if trimmed.is_empty() {
            // Skip trailing empty rows at screen bottom
            if items.is_empty() {
                continue;
            }
            break;
        }
        if let Some(caps) = MENU_ROW_RE.captures(row) {
            let command = caps[1].to_string();
            let description = caps[2].trim().to_string();
            let highlighted = row.contains('❯');
            items.push(SlashMenuItem {
                command,
                description,
                highlighted,
            });
        } else if CONTINUATION_RE.is_match(row) {
            // Wrapped description line — skip without breaking
            continue;
        } else {
            break;
        }
    }

    // Reverse to restore top-to-bottom order (we scanned bottom-to-top).
    items.reverse();

    if items.len() < 2 {
        return None;
    }

    // Fallback: if no item has ❯, highlight the first item.
    if !items.iter().any(|it| it.highlighted)
        && let Some(first) = items.first_mut()
    {
        first.highlighted = true;
    }

    Some(ParsedEvent::SlashMenu { items })
}

/// Detect a numbered choice dialog rendered below the prompt line.
///
/// Shape expected (Claude Code edit-confirm example):
/// ```text
/// Do you want to make this edit to CLAUDE.md?
/// ❯ 1. Yes
///   2. Yes, allow all edits during this session (shift+tab)
///   3. No
///
/// Esc to cancel · Tab to amend
/// ```
///
/// Strategy — scan bottom-up:
///   1. Skip trailing empty rows and optional footer hints (`Esc to .* · Tab .*`).
///   2. Collect contiguous numbered option rows (`  1. label`, `❯ 2. label`, etc.).
///   3. Walk up past blank rows to find the first non-empty row preceding the
///      block — that's the title. Title must end in `?` OR be preceded by a
///      `Do you want` / `Proceed` / `Continue` / `Should I` verb (guardrail
///      against markdown numbered lists).
///   4. Require ≥ 2 options to reduce false positives.
pub fn parse_choice_prompt(screen_rows: &[String]) -> Option<ParsedEvent> {
    lazy_static::lazy_static! {
        // Option: optional ❯/›/> marker, digit(s), . or ), space, label.
        static ref OPTION_RE: regex::Regex =
            regex::Regex::new(r"^\s*(?:[❯›>]\s*)?(\d+)[.)]\s+(.+?)\s*$").unwrap();
        // Footer: `Esc to cancel · Tab to amend` style.
        static ref FOOTER_RE: regex::Regex =
            regex::Regex::new(r"^\s*(?:Esc|esc|ESC)\s+to\s+(\S+).*?(?:·|\||•)\s*(?:Tab|tab|TAB)\s+to\s+(\S+)").unwrap();
        // Title sentinel: question mark OR imperative verb. Keeps us off markdown lists.
        static ref TITLE_VERB_RE: regex::Regex =
            regex::Regex::new(r"(?i)^\s*(?:do you want|proceed with|continue|should i|confirm|apply|allow)\b").unwrap();
        // Hint in parens at end of label: "(shift+tab)".
        static ref HINT_RE: regex::Regex =
            regex::Regex::new(r"\s*\(([^()]+)\)\s*$").unwrap();
    }

    if screen_rows.is_empty() {
        return None;
    }

    let mut idx = screen_rows.len();
    let mut dismiss_key: Option<String> = None;
    let mut amend_key: Option<String> = None;

    // Step 1: skip trailing blanks + optional footer.
    while idx > 0 {
        let row = screen_rows[idx - 1].trim();
        if row.is_empty() {
            idx -= 1;
            continue;
        }
        if let Some(caps) = FOOTER_RE.captures(&screen_rows[idx - 1]) {
            dismiss_key = Some(caps[1].to_string());
            amend_key = Some(caps[2].to_string());
            idx -= 1;
            continue;
        }
        break;
    }

    // Step 2: collect contiguous option rows bottom-up.
    let mut options_rev: Vec<ChoiceOption> = Vec::new();
    while idx > 0 {
        let raw = &screen_rows[idx - 1];
        if raw.trim().is_empty() {
            break;
        }
        if let Some(caps) = OPTION_RE.captures(raw) {
            let key = caps[1].to_string();
            let mut label = caps[2].trim().to_string();
            let highlighted = raw.contains('❯') || raw.contains('›');

            let hint = HINT_RE.captures(&label).map(|c| c[1].to_string());
            if hint.is_some() {
                label = HINT_RE.replace(&label, "").trim().to_string();
            }

            let destructive = {
                let lower = label.to_lowercase();
                matches!(
                    lower.as_str(),
                    "no" | "cancel" | "reject" | "abort" | "deny"
                ) || lower.starts_with("no,")
                    || lower.starts_with("no ")
                    || lower.starts_with("don't")
                    || lower.starts_with("do not")
            };

            options_rev.push(ChoiceOption {
                key,
                label,
                highlighted,
                destructive,
                hint,
            });
            idx -= 1;
        } else {
            break;
        }
    }

    if options_rev.len() < 2 {
        return None;
    }

    // Step 3: walk past blank rows, find title row.
    while idx > 0 && screen_rows[idx - 1].trim().is_empty() {
        idx -= 1;
    }
    if idx == 0 {
        return None;
    }
    let title_row = screen_rows[idx - 1].trim();
    let title_qualifies = title_row.ends_with('?') || TITLE_VERB_RE.is_match(title_row);
    if !title_qualifies {
        return None;
    }
    let title = title_row.to_string();

    // Step 4: restore top-to-bottom order.
    options_rev.reverse();
    let mut options = options_rev;

    // Fallback highlight: if none marked, highlight the first option.
    if !options.iter().any(|o| o.highlighted)
        && let Some(first) = options.first_mut()
    {
        first.highlighted = true;
    }

    Some(ParsedEvent::ChoicePrompt {
        title,
        options,
        dismiss_key,
        amend_key,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_rate_limit_claude_429() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Error: rate_limit_error - please try again");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::RateLimit { pattern_name, .. } => {
                assert_eq!(pattern_name, "claude-http-429");
            }
            _ => panic!("Expected RateLimit event"),
        }
    }

    #[test]
    fn test_rate_limit_with_retry_after() {
        let mut parser = OutputParser::new();
        let events = parser.parse("retry-after: 30");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::RateLimit { retry_after_ms, .. } => {
                assert_eq!(*retry_after_ms, Some(30000));
            }
            _ => panic!("Expected RateLimit event"),
        }
    }

    #[test]
    fn test_rate_limit_openai() {
        let mut parser = OutputParser::new();
        let events = parser.parse("RateLimitError: Too many requests");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::RateLimit { pattern_name, .. } => {
                assert_eq!(pattern_name, "openai-http-429");
            }
            _ => panic!("Expected RateLimit event"),
        }
    }

    #[test]
    fn test_rate_limit_gemini() {
        let mut parser = OutputParser::new();
        let events = parser.parse("RESOURCE_EXHAUSTED: quota exceeded");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::RateLimit { pattern_name, .. } => {
                assert_eq!(pattern_name, "gemini-resource-exhausted");
            }
            _ => panic!("Expected RateLimit event"),
        }
    }

    #[test]
    fn test_no_rate_limit() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Hello world, everything is fine");
        assert!(events.is_empty());
    }

    #[test]
    fn test_agent_session_conflict_in_use() {
        let mut parser = OutputParser::new();
        let events = parser
            .parse("Error: Session ID 48ab1308-3b52-4ed3-af0f-e67c8f4ee890 is already in use.");
        let conflict = events
            .iter()
            .find(|e| matches!(e, ParsedEvent::AgentSessionConflict { .. }))
            .expect("Expected AgentSessionConflict event");
        match conflict {
            ParsedEvent::AgentSessionConflict { kind, matched_text } => {
                assert_eq!(kind, "in-use");
                assert!(matched_text.contains("48ab1308"));
            }
            _ => unreachable!(),
        }
    }

    #[test]
    fn test_agent_session_conflict_not_found() {
        let mut parser = OutputParser::new();
        let events = parser
            .parse("No conversation found with session ID: ac9a1640-f3bf-4707-8b38-49ad6be4e2f1");
        let conflict = events
            .iter()
            .find(|e| matches!(e, ParsedEvent::AgentSessionConflict { .. }))
            .expect("Expected AgentSessionConflict event");
        match conflict {
            ParsedEvent::AgentSessionConflict { kind, .. } => {
                assert_eq!(kind, "not-found");
            }
            _ => unreachable!(),
        }
    }

    #[test]
    fn test_agent_session_conflict_ignores_source_code() {
        let mut parser = OutputParser::new();
        // Indented string literal like our own regex source — must NOT fire.
        let events = parser.parse(
            "    let msg = \"Session ID abc12345-0000-0000-0000-000000000000 is already in use\";",
        );
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::AgentSessionConflict { .. })),
            "source-code context should be rejected: {events:?}"
        );
    }

    #[test]
    fn test_agent_session_conflict_no_match() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Session ID is used but not quite in use here");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::AgentSessionConflict { .. })),
        );
    }

    #[test]
    fn test_agent_session_conflict_ignores_diff_hunk() {
        let mut parser = OutputParser::new();
        let events = parser.parse(
            "+        \"Error: Session ID abc12345-0000-0000-0000-000000000000 is already in use.\"",
        );
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::AgentSessionConflict { .. })),
            "diff hunk prefix should be rejected: {events:?}"
        );
    }

    #[test]
    fn test_agent_session_conflict_ignores_markdown_fence() {
        let mut parser = OutputParser::new();
        let events = parser.parse(
            "```\nError: Session ID abc12345-0000-0000-0000-000000000000 is already in use.\n```",
        );
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::AgentSessionConflict { .. })),
            "markdown fence should be rejected: {events:?}"
        );
    }

    #[test]
    fn test_agent_session_conflict_ignores_not_found_in_code() {
        let mut parser = OutputParser::new();
        let events = parser.parse(
            "    let msg = \"No conversation found with session ID: abc12345-0000-0000-0000-000000000000\";",
        );
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::AgentSessionConflict { .. })),
            "not-found variant in source-code should be rejected: {events:?}"
        );
    }

    #[test]
    fn test_github_pr_url() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Created PR: https://github.com/owner/repo/pull/42");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::PrUrl {
                number,
                platform,
                url,
            } => {
                assert_eq!(*number, 42);
                assert_eq!(platform, "github");
                assert!(url.contains("pull/42"));
            }
            _ => panic!("Expected PrUrl event"),
        }
    }

    #[test]
    fn test_gitlab_mr_url() {
        let mut parser = OutputParser::new();
        let events = parser.parse("MR: https://gitlab.com/org/repo/-/merge_requests/7");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::PrUrl {
                number, platform, ..
            } => {
                assert_eq!(*number, 7);
                assert_eq!(platform, "gitlab");
            }
            _ => panic!("Expected PrUrl event"),
        }
    }

    #[test]
    fn test_osc94_progress() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\x1b]9;4;1;75\x07");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::Progress { state, value } => {
                assert_eq!(*state, 1);
                assert_eq!(*value, 75);
            }
            _ => panic!("Expected Progress event"),
        }
    }

    #[test]
    fn test_osc94_progress_clear() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\x1b]9;4;0;0\x07");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::Progress { state, value } => {
                assert_eq!(*state, 0);
                assert_eq!(*value, 0);
            }
            _ => panic!("Expected Progress event"),
        }
    }

    #[test]
    fn test_osc94_progress_multiple_in_chunk() {
        let chunk = "\x1b]9;4;1;5\x07some output\x1b]9;4;1;50\x07more\x1b]9;4;1;100\x07";
        let event = parse_osc94(chunk);
        match event {
            Some(ParsedEvent::Progress { state, value }) => {
                assert_eq!(state, 1);
                assert_eq!(value, 100, "should return the LAST progress value in chunk");
            }
            _ => panic!("Expected Progress event"),
        }
    }

    #[test]
    fn test_status_line_claude() {
        let mut parser = OutputParser::new();
        let events = parser.parse("* Reading files... (12s)");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine {
                task_name,
                time_info,
                ..
            } => {
                assert_eq!(task_name, "Reading files");
                assert_eq!(time_info.as_deref(), Some("12s"));
            }
            _ => panic!("Expected StatusLine event"),
        }
    }

    #[test]
    fn test_status_line_with_tokens() {
        let mut parser = OutputParser::new();
        let events =
            parser.parse("* Updating synthesis phase... (57s \u{b7} \u{2193} 2.4k tokens)");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine {
                task_name,
                token_info,
                ..
            } => {
                assert_eq!(task_name, "Updating synthesis phase");
                assert!(token_info.is_some());
            }
            _ => panic!("Expected StatusLine event"),
        }
    }

    #[test]
    fn test_status_line_claude_dingbat() {
        // Claude Code v2.1.63+ uses ✢ (U+2722) instead of * for status lines
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{2722}Updating verification document\u{2026} (5m1s\u{b7} \u{2191} 4.6k tokens \u{b7} thought for 4s)");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine {
                task_name,
                time_info,
                token_info,
                ..
            } => {
                assert_eq!(task_name, "Updating verification document");
                assert_eq!(time_info.as_deref(), Some("5m1s"));
                assert!(token_info.is_some());
            }
            _ => panic!("Expected StatusLine event, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_running() {
        let mut parser = OutputParser::new();
        let events = parser.parse("[Running] npm test");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine { task_name, .. } => {
                assert_eq!(task_name, "npm test");
            }
            _ => panic!("Expected StatusLine event"),
        }
    }

    #[test]
    fn test_status_line_aider_spinner() {
        // Aider uses a Knight Rider scanner: "░█        Waiting for claude-3-5-sonnet"
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{2591}\u{2588}        Waiting for claude-3-5-sonnet");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine { task_name, .. } => {
                assert_eq!(task_name, "Waiting for claude-3-5-sonnet");
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_aider_token_report() {
        // Aider token report: "Tokens: 5.2k sent, 1.3k received."
        let mut parser = OutputParser::new();
        let events = parser.parse("Tokens: 5.2k sent, 1.3k received.");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine {
                task_name,
                token_info,
                ..
            } => {
                assert_eq!(task_name, "5.2k sent, 1.3k received");
                assert!(token_info.is_some(), "Expected token_info");
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_aider_token_report_with_cache() {
        // Aider with cache: "Tokens: 2,345 sent, 123 cache write, 456 cache hit, 789 received."
        let mut parser = OutputParser::new();
        let events =
            parser.parse("Tokens: 2,345 sent, 123 cache write, 456 cache hit, 789 received.");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine { task_name, .. } => {
                assert!(task_name.contains("sent"));
                assert!(task_name.contains("received"));
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_codex_working() {
        // Codex CLI: "• Working (5s • esc to interrupt)"
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{2022} Working (5s \u{2022} esc to interrupt)");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine {
                task_name,
                time_info,
                ..
            } => {
                assert_eq!(task_name, "Working");
                assert_eq!(time_info.as_deref(), Some("5s"));
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_codex_compound_time() {
        // Codex CLI with compound time: "• Working (4m 55s • esc to interrupt)"
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{2022} Working (4m 55s \u{2022} esc to interrupt)");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine {
                task_name,
                time_info,
                ..
            } => {
                assert_eq!(task_name, "Working");
                assert_eq!(time_info.as_deref(), Some("4m 55s"));
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_codex_working_hollow() {
        // Codex CLI alternate spinner: "◦ Working (12s)"
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{25E6} Working (12s)");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine {
                task_name,
                time_info,
                ..
            } => {
                assert_eq!(task_name, "Working");
                assert_eq!(time_info.as_deref(), Some("12s"));
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_goose_spinner() {
        // Goose CLI: "Honking thoughtfully...  (Ctrl+C to interrupt)"
        let mut parser = OutputParser::new();
        let events = parser.parse("Honking thoughtfully...  (Ctrl+C to interrupt)");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine { task_name, .. } => {
                assert_eq!(task_name, "Honking thoughtfully");
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_goose_thinking() {
        // Goose CLI fallback: "Thinking...  (Ctrl+C to interrupt)"
        let mut parser = OutputParser::new();
        let events = parser.parse("Thinking...  (Ctrl+C to interrupt)");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine { task_name, .. } => {
                assert_eq!(task_name, "Thinking");
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_gemini_braille_with_phrase() {
        // Gemini CLI: braille spinner + loading phrase
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{280B} Analyzing your codebase");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine { task_name, .. } => {
                assert_eq!(task_name, "Analyzing your codebase");
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_claude_middle_dot() {
        // Claude Code uses · (U+00B7 middle dot) as one of its spinner frames
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{00B7} Considering\u{2026}");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine { task_name, .. } => {
                assert_eq!(task_name, "Considering");
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_copilot_therefore() {
        // GitHub Copilot CLI uses ∴ (U+2234 THEREFORE) for thinking state
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{2234} Thinking\u{2026}");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine { task_name, .. } => {
                assert_eq!(task_name, "Thinking");
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_copilot_bullet() {
        // GitHub Copilot CLI uses ● (U+25CF) for active tool calls
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{25CF} Read file...");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine { task_name, .. } => {
                assert_eq!(task_name, "Read file");
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_amazon_q_thinking() {
        // Amazon Q: "⠹ Thinking..." (braille + ASCII dots, not Unicode ellipsis)
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{2839} Thinking...");
        assert_eq!(events.len(), 1);
        match &events[0] {
            ParsedEvent::StatusLine { task_name, .. } => {
                assert_eq!(task_name, "Thinking");
            }
            _ => panic!("Expected StatusLine, got {:?}", events[0]),
        }
    }

    #[test]
    fn test_status_line_rejects_glob_pattern() {
        // Code output like ("*/*") ... must NOT be captured as a status line.
        // The `*` is not at the start of the line.
        let mut parser = OutputParser::new();
        let events = parser.parse(r#"    ("*/*") ..."#);
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::StatusLine { .. })),
            "Glob pattern in code should not match status line: {:?}",
            events
        );
    }

    #[test]
    fn test_status_line_rejects_stats_suffix() {
        // Claude Code stats like ") | [C2 S31 K30 A30 M7 H..." must NOT match.
        let mut parser = OutputParser::new();
        let events = parser.parse("* ) | [C2 S31 K30 A30 M7 H...");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::StatusLine { .. })),
            "Stats suffix should be rejected by content validation: {:?}",
            events
        );
    }

    #[test]
    fn test_status_line_rejects_mid_line_asterisk() {
        // Asterisk mid-line in code should not trigger status-line detection
        let mut parser = OutputParser::new();
        let events = parser.parse(r#"  let result = a * b... done"#);
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::StatusLine { .. })),
            "Mid-line asterisk should not match: {:?}",
            events
        );
    }

    #[test]
    fn test_status_line_rejects_path_with_asterisk() {
        // Paths with wildcard should not trigger
        let mut parser = OutputParser::new();
        let events = parser.parse("src/**/*.ts...");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::StatusLine { .. })),
            "Path with asterisk should not match: {:?}",
            events
        );
    }

    // All question detection is silence-based only (10s idle after `?`-ending line).
    // No instant regex detection — it causes false positives from streaming output.
    #[test]
    fn test_no_instant_question_would_you_like() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Would you like to proceed?");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    #[test]
    fn test_no_instant_question_do_you_want() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Do you want to continue with this approach?");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    #[test]
    fn test_no_instant_question_menu_choice() {
        let mut parser = OutputParser::new();
        let events = parser.parse("❯ 1. Yes, clear context and bypass permissions");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    #[test]
    fn test_no_instant_question_yn_prompt() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Apply changes? [Y/n]");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    #[test]
    fn test_no_instant_question_inquirer_style() {
        let mut parser = OutputParser::new();
        let events = parser.parse("? Which template would you like to use?");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    #[test]
    fn test_no_question_normal_output() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Building project... done");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    // These phrases end with `?` and are handled by the silence-based detector
    // (10s idle threshold in pty.rs), NOT by instant regex detection.
    // Instant detection of generic question-like phrases causes massive false
    // positives during AI agent streaming output.
    #[test]
    fn test_no_instant_question_want_me_to() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Want me to commit these changes?");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "\"Want me to\" must NOT instant-detect (silence-based only)"
        );
    }

    #[test]
    fn test_no_instant_question_should_i() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Should I proceed with the refactor?");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "\"Should I\" must NOT instant-detect (silence-based only)"
        );
    }

    #[test]
    fn test_no_instant_question_what_would_you_like() {
        let mut parser = OutputParser::new();
        let events = parser.parse("What would you like me to do next?");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "\"What would you like\" must NOT instant-detect (silence-based only)"
        );
    }

    #[test]
    fn test_no_instant_question_something_else() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Something else?");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "\"Something else?\" must NOT instant-detect (silence-based only)"
        );
    }

    #[test]
    fn test_no_instant_question_what_do_you_think() {
        let mut parser = OutputParser::new();
        let events = parser.parse("What do you think?");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "\"What do you think\" must NOT instant-detect (silence-based only)"
        );
    }

    #[test]
    fn test_no_instant_question_whats_your_preference() {
        let mut parser = OutputParser::new();
        let events = parser.parse("What's your preference?");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "\"What's your\" must NOT instant-detect (silence-based only)"
        );
    }

    #[test]
    fn test_no_instant_question_shall_i() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Shall I run the tests first?");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "\"Shall I\" must NOT instant-detect (silence-based only)"
        );
    }

    #[test]
    fn test_no_question_should_in_prose() {
        let mut parser = OutputParser::new();
        // "should" in middle of prose — not a question prompt
        let events = parser.parse("The function should handle edge cases properly.");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "\"should\" in prose should NOT trigger question detection"
        );
    }

    #[test]
    fn test_no_question_want_in_prose() {
        let mut parser = OutputParser::new();
        let events = parser.parse("If you want to learn more about this pattern, see the docs.");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "\"want\" in prose should NOT trigger question detection"
        );
    }

    #[test]
    fn test_no_false_positives() {
        let mut parser = OutputParser::new();
        // Normal terminal output should produce no events
        let events = parser.parse("ls -la\ntotal 42\ndrwxr-xr-x  5 user staff 160 Jan 1 00:00 .\n");
        assert!(events.is_empty());
    }

    #[test]
    fn test_usage_limit_weekly() {
        let mut parser = OutputParser::new();
        let events = parser
            .parse("You've used 78% of your weekly limit · resets Feb 21 at 9am (Europe/Madrid)");
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::UsageLimit { percentage: 78, .. }))
        );
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::UsageLimit { .. }))
        {
            Some(ParsedEvent::UsageLimit {
                percentage,
                limit_type,
            }) => {
                assert_eq!(*percentage, 78);
                assert_eq!(limit_type, "weekly");
            }
            _ => panic!("Expected UsageLimit event"),
        }
    }

    #[test]
    fn test_usage_limit_session() {
        let mut parser = OutputParser::new();
        let events = parser.parse("You've used 45% of your session limit");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::UsageLimit { .. }))
        {
            Some(ParsedEvent::UsageLimit {
                percentage,
                limit_type,
            }) => {
                assert_eq!(*percentage, 45);
                assert_eq!(limit_type, "session");
            }
            _ => panic!("Expected UsageLimit event"),
        }
    }

    #[test]
    fn test_usage_limit_with_ansi() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\x1b[33mYou've used 90% of your weekly limit\x1b[0m");
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::UsageLimit { percentage: 90, .. }))
        );
    }

    #[test]
    fn test_usage_limit_smart_quote() {
        let mut parser = OutputParser::new();
        let events = parser.parse("You\u{2019}ve used 50% of your weekly limit");
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::UsageLimit { percentage: 50, .. }))
        );
    }

    // --- Usage exhaustion tests ---

    #[test]
    fn test_extra_usage_exhausted_with_time_and_tz() {
        let mut parser = OutputParser::new();
        let events = parser.parse("You're out of extra usage · resets 8pm (Europe/Madrid)");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::UsageExhausted { .. }))
        {
            Some(ParsedEvent::UsageExhausted { reset_time }) => {
                assert_eq!(reset_time.as_deref(), Some("8pm (Europe/Madrid)"));
            }
            _ => panic!("Expected UsageExhausted event, got: {:?}", events),
        }
    }

    #[test]
    fn test_extra_usage_exhausted_with_date_time_tz() {
        let mut parser = OutputParser::new();
        let events =
            parser.parse("You're out of extra usage · resets Feb 21 at 9am (Europe/Madrid)");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::UsageExhausted { .. }))
        {
            Some(ParsedEvent::UsageExhausted { reset_time }) => {
                assert_eq!(reset_time.as_deref(), Some("Feb 21 at 9am (Europe/Madrid)"));
            }
            _ => panic!("Expected UsageExhausted event, got: {:?}", events),
        }
    }

    #[test]
    fn test_extra_usage_exhausted_no_reset() {
        let mut parser = OutputParser::new();
        let events = parser.parse("You're out of extra usage");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::UsageExhausted { .. }))
        {
            Some(ParsedEvent::UsageExhausted { reset_time }) => {
                assert!(reset_time.is_none(), "expected no reset_time");
            }
            _ => panic!("Expected UsageExhausted event, got: {:?}", events),
        }
    }

    #[test]
    fn test_usage_limit_unchanged_with_reset_time() {
        // Existing UsageLimit should still work and NOT produce UsageExhausted
        let mut parser = OutputParser::new();
        let events = parser
            .parse("You've used 78% of your weekly limit · resets Feb 21 at 9am (Europe/Madrid)");
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::UsageLimit { percentage: 78, .. })),
            "should still produce UsageLimit"
        );
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::UsageExhausted { .. })),
            "should NOT produce UsageExhausted for percentage-based limits"
        );
    }

    #[test]
    fn test_extra_usage_exhausted_unparseable_reset() {
        let mut parser = OutputParser::new();
        let events = parser.parse("You're out of extra usage · resets soon");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::UsageExhausted { .. }))
        {
            Some(ParsedEvent::UsageExhausted { reset_time }) => {
                assert_eq!(reset_time.as_deref(), Some("soon"));
            }
            _ => panic!("Expected UsageExhausted event, got: {:?}", events),
        }
    }

    #[test]
    fn test_extra_usage_exhausted_with_ansi() {
        let mut parser = OutputParser::new();
        let events =
            parser.parse("\x1b[33mYou're out of extra usage · resets 8pm (Europe/Madrid)\x1b[0m");
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::UsageExhausted { .. })),
            "should parse through ANSI escapes"
        );
    }

    #[test]
    fn test_out_of_usage_without_extra() {
        // "out of usage" (without "extra") should also be caught
        let mut parser = OutputParser::new();
        let events = parser.parse("You're out of usage · resets 3pm (US/Eastern)");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::UsageExhausted { .. }))
        {
            Some(ParsedEvent::UsageExhausted { reset_time }) => {
                assert_eq!(reset_time.as_deref(), Some("3pm (US/Eastern)"));
            }
            _ => panic!("Expected UsageExhausted event, got: {:?}", events),
        }
    }

    // --- Ink SelectInput / broadened question detection tests ---

    #[test]
    fn test_no_instant_question_ink_cursor() {
        let mut parser = OutputParser::new();
        let events = parser.parse("› 1. Create a new story");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    #[test]
    fn test_no_instant_question_ascii_cursor() {
        let mut parser = OutputParser::new();
        let events = parser.parse("> 1. Yes, proceed with changes");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    #[test]
    fn test_instant_question_ink_footer() {
        // Ink interactive menu footer IS detected instantly — it's ultra-specific
        // and only appears when the agent is genuinely waiting for menu selection.
        let mut parser = OutputParser::new();
        let events = parser.parse("Enter to select · ↑/↓ to navigate · Esc to cancel");
        assert!(
            events.iter().any(|e| matches!(
                e,
                ParsedEvent::Question {
                    confident: true,
                    ..
                }
            )),
            "Ink footer should be detected as confident question"
        );
    }

    #[test]
    fn test_instant_question_ink_footer_partial() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Enter to select · ↑↓ to navigate");
        assert!(
            events.iter().any(|e| matches!(
                e,
                ParsedEvent::Question {
                    confident: true,
                    ..
                }
            )),
            "Partial Ink footer should also be detected"
        );
    }

    #[test]
    fn test_question_generic_question_mark_not_instant() {
        // Generic `?`-ending lines are NOT detected by the instant parser —
        // they are handled by the silence-based detector in pty.rs to avoid
        // false positives from streaming fragments like "ad?", "swap?", "?"
        let mut parser = OutputParser::new();
        let events = parser.parse("What should we do with this story?");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "Generic ?-ending lines should NOT trigger instant detection"
        );
    }

    #[test]
    fn test_question_generic_not_prose() {
        // Lines that look like prose/code should NOT trigger the generic ? match
        let mut parser = OutputParser::new();
        // Code comment
        assert!(
            !parser
                .parse("// should we handle this case?")
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "code comment should not trigger question detection"
        );
        // Markdown list item
        assert!(
            !parser
                .parse("- What about this edge case?")
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "markdown list should not trigger question detection"
        );
        // Indented code
        assert!(
            !parser
                .parse("    if condition.valid?")
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "indented code should not trigger question detection"
        );
        // Backtick-wrapped code
        assert!(
            !parser
                .parse("Have you tried `foo.bar()?` instead?")
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "backtick code should not trigger question detection"
        );
        // Long prose line
        let long = format!("{}?", "a".repeat(121));
        assert!(
            !parser
                .parse(&long)
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "long prose should not trigger question detection"
        );
    }

    #[test]
    fn test_instant_question_ink_full_menu_block() {
        // Full Ink menu blocks ARE detected as questions now — the "Enter to select"
        // footer is ultra-specific to real interactive menus.
        let mut parser = OutputParser::new();
        let block = "\
What should we do with this story?

  1. Create a new story
› 2. Update existing story
  3. Skip it
  4. Other

Enter to select · ↑/↓ to navigate · Esc to cancel";
        let events = parser.parse(block);
        assert!(
            events.iter().any(|e| matches!(
                e,
                ParsedEvent::Question {
                    confident: true,
                    ..
                }
            )),
            "Full Ink menu block should be detected as confident question"
        );
    }

    #[test]
    fn test_no_question_blockquote_with_question() {
        let mut parser = OutputParser::new();
        assert!(
            !parser
                .parse("> Do you agree with this approach?")
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    #[test]
    fn test_no_question_bold_markdown() {
        let mut parser = OutputParser::new();
        assert!(
            !parser
                .parse("**Should we refactor this?**")
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    #[test]
    fn test_no_question_shell_prompt_with_greater_than() {
        // Shell prompts like "> command" should NOT trigger menu detection
        let mut parser = OutputParser::new();
        assert!(
            !parser
                .parse("> git status")
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    #[test]
    fn test_no_question_enter_in_prose() {
        // Prose mentioning "Enter to select" in a different context should still match,
        // but "Press Enter to continue" should NOT
        let mut parser = OutputParser::new();
        assert!(
            !parser
                .parse("Press Enter to continue installing")
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. }))
        );
    }

    // --- Plan file detection tests ---

    fn get_plan_path(events: &[ParsedEvent]) -> Option<String> {
        events.iter().find_map(|e| match e {
            ParsedEvent::PlanFile { path } => Some(path.clone()),
            _ => None,
        })
    }

    #[test]
    fn test_plan_file_relative() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Plan saved to plans/my-feature.md");
        assert_eq!(
            get_plan_path(&events),
            Some("plans/my-feature.md".to_string())
        );
    }

    #[test]
    fn test_plan_file_dot_claude() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Writing plan: .claude/plans/auth-flow.md");
        assert_eq!(
            get_plan_path(&events),
            Some(".claude/plans/auth-flow.md".to_string())
        );
    }

    #[test]
    fn test_plan_file_absolute() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Created /Users/dev/project/plans/refactor.md");
        assert_eq!(
            get_plan_path(&events),
            Some("/Users/dev/project/plans/refactor.md".to_string())
        );
    }

    #[test]
    fn test_plan_file_claude_private() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Plan: .claude-private/plans/serene-waterfall.md");
        assert_eq!(
            get_plan_path(&events),
            Some(".claude-private/plans/serene-waterfall.md".to_string())
        );
    }

    #[test]
    fn test_plan_file_tilde_expanded() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Plan saved to ~/.claude/plans/graceful-rolling-quasar.md");
        let path = get_plan_path(&events).expect("should detect tilde plan path");
        // Tilde must be expanded to an absolute path
        assert!(!path.starts_with("~"), "tilde should be expanded: {path}");
        assert!(path.ends_with("/.claude/plans/graceful-rolling-quasar.md"));
        assert!(path.starts_with("/"), "path should be absolute: {path}");
    }

    #[test]
    fn test_plan_file_no_match() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Building project... done");
        assert!(get_plan_path(&events).is_none());
    }

    #[test]
    fn test_plan_file_not_md() {
        let mut parser = OutputParser::new();
        // "plans/foo.ts" should NOT match (not a markdown file)
        let events = parser.parse("Reading plans/foo.ts");
        assert!(get_plan_path(&events).is_none());
    }

    #[test]
    fn test_plan_file_template_placeholder_rejected() {
        let mut parser = OutputParser::new();
        // Template placeholders like <file> or <filename> should NOT match
        assert!(get_plan_path(&parser.parse("plans/<file>.md")).is_none());
        assert!(get_plan_path(&parser.parse("plans/<filename>.md")).is_none());
        assert!(get_plan_path(&parser.parse("Save to .claude/plans/<name>.md")).is_none());
    }

    #[test]
    fn test_plan_file_interpolation_rejected() {
        let mut parser = OutputParser::new();
        // Shell/JS interpolation and backticks should NOT match
        assert!(get_plan_path(&parser.parse("plans/new-${i}.md")).is_none());
        assert!(get_plan_path(&parser.parse("plans/${name}.md")).is_none());
        assert!(get_plan_path(&parser.parse("plans/`cmd`.md")).is_none());
        assert!(get_plan_path(&parser.parse("Save to plans/foo-${bar}-baz.md")).is_none());
    }

    #[test]
    fn test_plan_file_glob_rejected() {
        let mut parser = OutputParser::new();
        // Glob patterns should NOT match as plan files
        assert!(get_plan_path(&parser.parse("plans/*.md")).is_none());
        assert!(get_plan_path(&parser.parse("/repo/plans/*.md")).is_none());
        assert!(get_plan_path(&parser.parse("ls plans/*.md")).is_none());
    }

    #[test]
    fn test_plan_file_trailing_punctuation_stripped() {
        let mut parser = OutputParser::new();
        // Sentence-ending period should not be included in the path
        let events = parser.parse("Piano scritto in plans/wiz-memory-integration.md.");
        assert_eq!(
            get_plan_path(&events),
            Some("plans/wiz-memory-integration.md".to_string())
        );

        // Same with tilde path
        let events = parser.parse("Fatto, piano in ~/Gits/project/plans/my-plan.md.");
        let path = get_plan_path(&events).expect("should detect plan path with trailing period");
        assert!(
            path.ends_with("/plans/my-plan.md"),
            "trailing period should be stripped: {path}"
        );
    }

    #[test]
    fn test_plan_file_trailing_comma() {
        let mut parser = OutputParser::new();
        let events = parser.parse("See plans/foo.md, plans/bar.md for details");
        assert_eq!(get_plan_path(&events), Some("plans/foo.md".to_string()));
    }

    #[test]
    fn test_plan_file_backtick_wrapped() {
        let mut parser = OutputParser::new();
        // Claude Code renders paths in backticks (inline code markdown)
        let events = parser.parse("Piano scritto in `plans/document-organizer.md`.");
        assert_eq!(
            get_plan_path(&events),
            Some("plans/document-organizer.md".to_string())
        );
    }

    #[test]
    fn test_plan_file_backtick_wrapped_absolute() {
        let mut parser = OutputParser::new();
        let events = parser.parse("Plan saved to `/Users/dev/project/plans/my-plan.md`.");
        assert_eq!(
            get_plan_path(&events),
            Some("/Users/dev/project/plans/my-plan.md".to_string())
        );
    }

    // --- False positive prevention tests ---

    fn has_rate_limit(events: &[ParsedEvent]) -> bool {
        events
            .iter()
            .any(|e| matches!(e, ParsedEvent::RateLimit { .. }))
    }

    #[test]
    fn test_no_false_positive_conversational_rate_limit() {
        let mut parser = OutputParser::new();
        // Agent discussing rate limits in prose should NOT trigger detection
        assert!(!has_rate_limit(&parser.parse(
            "The rate limit detection was triggering false positives"
        )));
        assert!(!has_rate_limit(
            &parser.parse("I fixed the rate-limited pattern matching")
        ));
        assert!(!has_rate_limit(
            &parser.parse("We should handle too many requests gracefully")
        ));
        assert!(!has_rate_limit(
            &parser.parse("The rate limiting logic needs improvement")
        ));
    }

    #[test]
    fn test_no_false_positive_code_output() {
        let mut parser = OutputParser::new();
        // Code snippets mentioning rate limits should NOT trigger
        assert!(!has_rate_limit(&parser.parse(
            "rl(\"rate-limit-keyword\", r\"rate[- ]?limit\", Some(60000))"
        )));
        assert!(!has_rate_limit(
            &parser.parse("// Handle too many requests from the API")
        ));
        assert!(!has_rate_limit(
            &parser.parse("fn handle_rate_limit(retry_after: u64) {")
        ));
    }

    #[test]
    fn test_no_false_positive_tpm_rpm_acronyms() {
        let mut parser = OutputParser::new();
        // TPM/RPM in non-rate-limit context should NOT trigger
        assert!(!has_rate_limit(&parser.parse("TPM 2.0 module detected")));
        assert!(!has_rate_limit(
            &parser.parse("RPM package manager installed")
        ));
        assert!(!has_rate_limit(&parser.parse("The disk spins at 7200 RPM")));
    }

    #[test]
    fn test_http_429_real_errors_still_detected() {
        let mut parser = OutputParser::new();
        // Real HTTP 429 errors should still be detected
        assert!(has_rate_limit(
            &parser.parse("HTTP/1.1 429 Too Many Requests")
        ));
        assert!(has_rate_limit(&parser.parse("429 Too Many Requests")));
        assert!(has_rate_limit(&parser.parse("HTTP 429")));
    }

    #[test]
    fn test_real_api_errors_still_detected() {
        let mut parser = OutputParser::new();
        // Real API error codes should still be detected
        assert!(has_rate_limit(&parser.parse("Error: rate_limit_error")));
        assert!(has_rate_limit(
            &parser.parse("overloaded_error: service busy")
        ));
        assert!(has_rate_limit(
            &parser.parse("RateLimitError: exceeded quota")
        ));
        assert!(has_rate_limit(&parser.parse("RESOURCE_EXHAUSTED")));
        assert!(has_rate_limit(&parser.parse("Retry-After: 60")));
    }

    // --- Source code false-positive guard tests ---

    #[test]
    fn test_no_false_positive_rust_source_reading() {
        let mut parser = OutputParser::new();
        // Agent reading output_parser.rs — the exact lines that caused the bug
        assert!(!has_rate_limit(&parser.parse(
            r#"        rl("claude-http-429", r"(?i)rate_limit_error", Some(60000), false),"#
        )));
        assert!(!has_rate_limit(&parser.parse(
            r#"        rl("claude-overloaded", r"(?i)overloaded_error", Some(30000), false),"#
        )));
        assert!(!has_rate_limit(&parser.parse(
            r#"        rl("openai-http-429", r"RateLimitError", Some(60000), false),"#
        )));
        assert!(!has_rate_limit(&parser.parse(
            r#"        rl("gemini-resource-exhausted", r"RESOURCE_EXHAUSTED", Some(60000), false),"#
        )));
        assert!(!has_rate_limit(&parser.parse(
            r#"        rl("retry-after-header", r"(?i)Retry-After:\s*(\d+)", None, true),"#
        )));
    }

    #[test]
    fn test_no_false_positive_code_comments() {
        let mut parser = OutputParser::new();
        assert!(!has_rate_limit(&parser.parse("// Error: rate_limit_error")));
        assert!(!has_rate_limit(
            &parser.parse("# Handle RESOURCE_EXHAUSTED from Gemini")
        ));
    }

    #[test]
    fn test_no_false_positive_test_assertions() {
        let mut parser = OutputParser::new();
        assert!(!has_rate_limit(&parser.parse(
            r#"        assert!(has_rate_limit(&parser.parse("Error: rate_limit_error")));"#
        )));
        assert!(!has_rate_limit(&parser.parse(
            r#"        assert!(has_rate_limit(&parser.parse("RateLimitError: exceeded quota")));"#
        )));
    }

    #[test]
    fn test_no_false_positive_markdown_code_fences() {
        let mut parser = OutputParser::new();
        assert!(!has_rate_limit(&parser.parse(
            "```rust\nrl(\"claude-http-429\", r\"rate_limit_error\")"
        )));
        assert!(!has_rate_limit(
            &parser.parse("- `rate_limit_error` — Claude API error code")
        ));
        assert!(!has_rate_limit(
            &parser.parse("* Pattern `RateLimitError` matches OpenAI errors")
        ));
    }

    #[test]
    fn test_no_false_positive_markdown_table() {
        let mut parser = OutputParser::new();
        assert!(!has_rate_limit(&parser.parse(
            "| `claude-http-429` | `rate_limit_error` | Claude API |"
        )));
    }

    #[test]
    fn test_line_is_source_code_fn() {
        // Direct unit tests for the guard function
        assert!(line_is_source_code(
            r#"        rl("claude-http-429", r"(?i)rate_limit_error", Some(60000), false),"#
        ));
        assert!(line_is_source_code("// rate_limit_error handling"));
        assert!(line_is_source_code("# RESOURCE_EXHAUSTED"));
        assert!(line_is_source_code("fn handle_rate_limit() {"));
        assert!(line_is_source_code(
            r#"        assert!(has_rate_limit(&parser.parse("rate_limit_error")));"#
        ));
        assert!(line_is_source_code("```Error: rate_limit_error"));
        assert!(line_is_source_code(
            "- `rate_limit_error` is the error code"
        ));
        assert!(line_is_source_code("| pattern | rate_limit_error | desc |"));

        // Real errors must NOT be classified as source code
        assert!(!line_is_source_code("Error: rate_limit_error"));
        assert!(!line_is_source_code("overloaded_error: service busy"));
        assert!(!line_is_source_code("RateLimitError: exceeded quota"));
        assert!(!line_is_source_code("RESOURCE_EXHAUSTED"));
        assert!(!line_is_source_code("HTTP/1.1 429 Too Many Requests"));
        assert!(!line_is_source_code("Retry-After: 60"));
    }

    // --- API error detection tests ---

    fn has_api_error(events: &[ParsedEvent]) -> bool {
        events
            .iter()
            .any(|e| matches!(e, ParsedEvent::ApiError { .. }))
    }

    fn get_api_error(events: &[ParsedEvent]) -> Option<(&str, &str, &str)> {
        events.iter().find_map(|e| match e {
            ParsedEvent::ApiError {
                pattern_name,
                matched_text,
                error_kind,
            } => Some((
                pattern_name.as_str(),
                matched_text.as_str(),
                error_kind.as_str(),
            )),
            _ => None,
        })
    }

    #[test]
    fn test_api_error_claude_500() {
        let mut parser = OutputParser::new();
        let input = r#"API Error: 500 {"type":"error","error":{"type":"api_error","message":"Internal server error"},"request_id":"req_011CYV92oEFMbcz45mjVYssM"}"#;
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Claude API error");
        assert_eq!(name, "claude-api-error");
        assert_eq!(kind, "server");
    }

    #[test]
    fn test_api_error_claude_529_overloaded() {
        let mut parser = OutputParser::new();
        // 529 overloaded should be caught by rate limit (overloaded_error), not api-error
        // But the api_error JSON type should NOT match overloaded_error
        let input = r#"API Error: 529 {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#;
        // overloaded_error is a rate limit pattern, not api-error
        assert!(!has_api_error(&parser.parse(input)));
        assert!(has_rate_limit(&parser.parse(input)));
    }

    #[test]
    fn test_api_error_claude_auth() {
        let mut parser = OutputParser::new();
        let input = r#"API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}"#;
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Claude auth error");
        assert_eq!(name, "claude-auth-error");
        assert_eq!(kind, "auth");
    }

    #[test]
    fn test_api_error_gemini_unavailable() {
        let mut parser = OutputParser::new();
        let input = r#"API Error: got status: UNAVAILABLE. {"error":{"code":503,"message":"The model is overloaded."}}"#;
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Gemini UNAVAILABLE");
        assert_eq!(name, "gemini-server-error");
        assert_eq!(kind, "server");
    }

    #[test]
    fn test_api_error_gemini_internal() {
        let mut parser = OutputParser::new();
        let input = r#"API Error: got status: INTERNAL. {"error":{"code":500,"message":"An internal error has occurred."}}"#;
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Gemini INTERNAL");
        assert_eq!(name, "gemini-server-error");
        assert_eq!(kind, "server");
    }

    #[test]
    fn test_api_error_aider_server() {
        let mut parser = OutputParser::new();
        let input = r#"litellm.InternalServerError: AnthropicException - {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#;
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Aider server error");
        assert_eq!(name, "aider-server-error");
        assert_eq!(kind, "server");
    }

    #[test]
    fn test_api_error_aider_auth() {
        let mut parser = OutputParser::new();
        let input = "litellm.AuthenticationError: AnthropicException - invalid x-api-key";
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Aider auth error");
        assert_eq!(name, "aider-auth-error");
        assert_eq!(kind, "auth");
    }

    #[test]
    fn test_api_error_aider_translated_server() {
        let mut parser = OutputParser::new();
        let input = "The API provider's servers are down or overloaded.";
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Aider translated msg");
        assert_eq!(name, "aider-server-msg");
        assert_eq!(kind, "server");
    }

    #[test]
    fn test_api_error_aider_translated_auth() {
        let mut parser = OutputParser::new();
        let input = "The API provider is not able to authenticate you. Check your API key.";
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Aider auth msg");
        assert_eq!(name, "aider-auth-msg");
        assert_eq!(kind, "auth");
    }

    #[test]
    fn test_api_error_codex_stream_error() {
        let mut parser = OutputParser::new();
        let input = "⚠  stream error: exceeded retry limit, last status: 401 Unauthorized; retrying 5/5 in 3.087s…";
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Codex stream error");
        assert_eq!(name, "codex-stream-error");
        assert_eq!(kind, "server");
    }

    #[test]
    fn test_api_error_codex_500() {
        let mut parser = OutputParser::new();
        let input = "stream error: exceeded retry limit, last status: 500 Internal Server Error";
        let events = parser.parse(input);
        assert!(has_api_error(&events));
    }

    #[test]
    fn test_api_error_copilot_token() {
        let mut parser = OutputParser::new();
        let input = "Failed to get copilot token";
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Copilot token error");
        assert_eq!(name, "copilot-auth-error");
        assert_eq!(kind, "auth");
    }

    #[test]
    fn test_no_api_error_generic_request_failed() {
        // "request failed unexpectedly" is too generic — should NOT trigger api error
        // (was causing false positives on Claude Code output, see log scan 2026-03-01)
        let mut parser = OutputParser::new();
        let input = "request failed unexpectedly";
        let events = parser.parse(input);
        assert!(!has_api_error(&events));
    }

    // --- Provider-level API error tests ---

    #[test]
    fn test_api_error_openai_server_error() {
        let mut parser = OutputParser::new();
        let input = r#"{"error":{"message":"The server had an error","type":"server_error","param":null,"code":null}}"#;
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect OpenAI server_error");
        assert_eq!(name, "openai-server-error");
        assert_eq!(kind, "server");
    }

    #[test]
    fn test_api_error_google_internal() {
        let mut parser = OutputParser::new();
        let input = r#"{"error":{"code":500,"message":"An internal error has occurred.","status":"INTERNAL"}}"#;
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Google INTERNAL");
        assert_eq!(name, "google-api-server");
        assert_eq!(kind, "server");
    }

    #[test]
    fn test_api_error_google_unavailable() {
        let mut parser = OutputParser::new();
        let input = r#"{"error":{"code":503,"message":"The service is currently unavailable.","status":"UNAVAILABLE"}}"#;
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Google UNAVAILABLE");
        assert_eq!(name, "google-api-server");
        assert_eq!(kind, "server");
    }

    #[test]
    fn test_api_error_google_auth() {
        let mut parser = OutputParser::new();
        let input = r#"{"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}"#;
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect Google UNAUTHENTICATED");
        assert_eq!(name, "google-api-auth");
        assert_eq!(kind, "auth");
    }

    #[test]
    fn test_api_error_openrouter() {
        let mut parser = OutputParser::new();
        let input = r#"{"error":{"code":502,"message":"Your chosen model is down","metadata":{"provider_name":"Anthropic"}}}"#;
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect OpenRouter error");
        assert_eq!(name, "openrouter-server");
        assert_eq!(kind, "server");
    }

    #[test]
    fn test_api_error_minimax() {
        let mut parser = OutputParser::new();
        let input = r#"{"id":"abc","base_resp":{"status_code":1013,"status_msg":"internal service error"}}"#;
        let events = parser.parse(input);
        let (name, _, kind) = get_api_error(&events).expect("should detect MiniMax error");
        assert_eq!(name, "minimax-server");
        assert_eq!(kind, "server");
    }

    #[test]
    fn test_no_api_error_normal_output() {
        let mut parser = OutputParser::new();
        assert!(!has_api_error(&parser.parse("Building project... done")));
        assert!(!has_api_error(&parser.parse("ls -la\ntotal 42")));
        assert!(!has_api_error(
            &parser.parse("Hello world, everything is fine")
        ));
    }

    #[test]
    fn test_no_api_error_false_positive_source_code() {
        let mut parser = OutputParser::new();
        // Agent reading this very source file should not trigger
        assert!(!has_api_error(&parser.parse(
            "        ae(\"claude-api-error\", \"type\":\"api_error\", \"server\"),"
        )));
        assert!(!has_api_error(
            &parser.parse("// detect \"type\":\"api_error\" in JSON")
        ));
        assert!(!has_api_error(
            &parser.parse("# Handle authentication_error from Claude")
        ));
    }

    #[test]
    fn test_api_error_dedup_same_text() {
        let mut parser = OutputParser::new();
        let input = r#"API Error: 500 {"type":"error","error":{"type":"api_error","message":"Internal server error"}}"#;
        // First parse should detect the error
        assert!(has_api_error(&parser.parse(input)));
        // Same text again (e.g. prompt redraw) should be suppressed
        assert!(!has_api_error(&parser.parse(input)));
    }

    #[test]
    fn test_api_error_dedup_resets_when_cleared() {
        let mut parser = OutputParser::new();
        let error_input = r#"API Error: 500 {"type":"error","error":{"type":"api_error","message":"Internal server error"}}"#;
        // First detection
        assert!(has_api_error(&parser.parse(error_input)));
        // Suppressed on repeat
        assert!(!has_api_error(&parser.parse(error_input)));
        // Manually clear dedup (simulates user-input reset in production)
        parser.last_api_error_match = None;
        // Same error text should fire again (new agent cycle)
        assert!(has_api_error(&parser.parse(error_input)));
    }

    #[test]
    fn test_api_error_dedup_different_error_fires() {
        let mut parser = OutputParser::new();
        let error1 = r#"API Error: 500 {"type":"error","error":{"type":"api_error","message":"Internal server error"}}"#;
        let error2 =
            r#"{"error":{"message":"The server had an error","type":"server_error","param":null}}"#;
        assert!(has_api_error(&parser.parse(error1)));
        // Different error text should fire even without user-input reset
        assert!(has_api_error(&parser.parse(error2)));
    }

    // --- Diff output false-positive prevention tests ---

    fn has_question(events: &[ParsedEvent]) -> bool {
        events
            .iter()
            .any(|e| matches!(e, ParsedEvent::Question { .. }))
    }

    #[test]
    fn test_no_question_diff_line_with_menu_pattern() {
        let mut parser = OutputParser::new();
        // Diff line from output_parser.rs containing ") 1." pattern — NOT a real menu
        assert!(!has_question(&parser.parse(
            "462 -        // Numbered menu choices: ❯ 1. or ) 1. followed by option text"
        )));
    }

    #[test]
    fn test_no_question_diff_line_with_yn_pattern() {
        let mut parser = OutputParser::new();
        // Diff line from docs containing [Y/n] pattern — NOT a real Y/N prompt
        assert!(!has_question(
            &parser.parse("465 //GenericY/Nprompts:[Y/n],[y/N],(yes/no)")
        ));
    }

    #[test]
    fn test_no_question_diff_line_with_hardcoded_prompts() {
        let mut parser = OutputParser::new();
        // Markdown doc line in diff containing question patterns — NOT a real question
        assert!(!has_question(&parser.parse(
            r#"75 +- **Hardcoded prompts**: "Would you like to proceed?", "Do you want to...?", "Is this plan/a"#
        )));
    }

    #[test]
    fn test_no_question_diff_line_with_yn_doc() {
        let mut parser = OutputParser::new();
        // Markdown doc line in diff listing Y/N patterns — NOT a real prompt
        assert!(!has_question(
            &parser.parse("77 +- **Y/N prompts**: `[Y/n]`, `[y/N]`, `(yes/no)`")
        ));
    }

    #[test]
    fn test_no_question_diff_hunk_with_code_changes() {
        let mut parser = OutputParser::new();
        // Claude Code diff summary block — NOT a real question
        assert!(!has_question(&parser.parse(
            "⏺⎿ Added16lines,removed2lines     459          // Claude Code: \"Would you like to proceed?\" / \"Do you want to...\""
        )));
    }

    #[test]
    fn test_no_rate_limit_in_diff_output() {
        let mut parser = OutputParser::new();
        // Diff lines showing test assertions that mention RESOURCE_EXHAUSTED — NOT real errors
        assert!(!has_rate_limit(&parser.parse(
            "+        assert!(has_rate_limit(&parser.parse(\"RESOURCE_EXHAUSTED\")));"
        )));
        assert!(!has_rate_limit(&parser.parse(
            "-        assert!(has_rate_limit(&parser.parse(\"RESOURCE_EXHAUSTED\")));"
        )));
        // Diff line with line number prefix
        assert!(!has_rate_limit(&parser.parse(
            "1226         assert!(has_rate_limit(&parser.parse(\"RESOURCE_EXHAUSTED\")));"
        )));
    }

    #[test]
    fn test_no_question_unified_diff_plus_minus_lines() {
        let mut parser = OutputParser::new();
        // Unified diff lines with + or - prefix containing question patterns
        assert!(!has_question(
            &parser.parse("+        if QUESTION_RE.is_match(trimmed) {")
        ));
        assert!(!has_question(&parser.parse(
            "-        // Numbered menu choices: ❯ 1. or ) 1. followed by option text"
        )));
        assert!(!has_question(
            &parser.parse("+- **Y/N prompts**: `[Y/n]`, `[y/N]`, `(yes/no)`")
        ));
    }

    // --- Intent detection tests ---

    fn get_intent(events: &[ParsedEvent]) -> Option<String> {
        events.iter().find_map(|e| match e {
            ParsedEvent::Intent { text, .. } => Some(text.clone()),
            _ => None,
        })
    }

    fn get_intent_title(events: &[ParsedEvent]) -> Option<String> {
        events.iter().find_map(|e| match e {
            ParsedEvent::Intent { title, .. } => title.clone(),
            _ => None,
        })
    }

    #[test]
    fn test_no_intent_normal_output() {
        let mut parser = OutputParser::new();
        assert!(get_intent(&parser.parse("Building project... done")).is_none());
        assert!(get_intent(&parser.parse("The intent is to refactor")).is_none());
    }

    // --- Plain-prefix intent tests ---

    #[test]
    fn test_intent_plain_prefix_with_ink_bullet() {
        // Claude Code (and other Ink-hosted agents) prepend every assistant
        // output line with `● ` (U+25CF). The plain-prefix parser must still
        // recognise the token even when the bullet is present.
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{25CF} intent: wiring the parser (Parser Fix)");
        assert_eq!(get_intent(&events), Some("wiring the parser".to_string()));
        assert_eq!(get_intent_title(&events), Some("Parser Fix".to_string()));
    }

    #[test]
    fn test_intent_plain_prefix_with_record_bullet() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{23FA} intent: fixing the layout bug");
        assert_eq!(
            get_intent(&events),
            Some("fixing the layout bug".to_string())
        );
    }

    #[test]
    fn test_suggest_plain_prefix_with_ink_bullet() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{25CF} suggest: [ Rebuild | Retry | Abort ]");
        let items = events.iter().find_map(|e| match e {
            ParsedEvent::Suggest { items } => Some(items.clone()),
            _ => None,
        });
        assert_eq!(
            items,
            Some(vec![
                "Rebuild".to_string(),
                "Retry".to_string(),
                "Abort".to_string()
            ])
        );
    }

    #[test]
    fn test_intent_plain_prefix_basic() {
        let mut parser = OutputParser::new();
        let events = parser.parse("intent: reading the config file");
        assert_eq!(
            get_intent(&events),
            Some("reading the config file".to_string())
        );
    }

    #[test]
    fn test_intent_plain_prefix_with_title() {
        let mut parser = OutputParser::new();
        let events = parser.parse("intent: analyzing code (Analysis)");
        assert_eq!(get_intent(&events), Some("analyzing code".to_string()));
        assert_eq!(get_intent_title(&events), Some("Analysis".to_string()));
    }

    #[test]
    fn test_intent_plain_prefix_indented_matches() {
        // Indented continuation lines produced by Ink-hosted agents must
        // still be detected: Claude Code indents every line after the
        // first by the bullet width, so plain-prefix intents emitted mid-
        // message arrive as `  intent: ...` rather than at column 0.
        let mut parser = OutputParser::new();
        assert_eq!(
            get_intent(&parser.parse("  intent: indented continuation")),
            Some("indented continuation".to_string()),
        );
    }

    #[test]
    fn test_intent_plain_prefix_midline_no_match() {
        let mut parser = OutputParser::new();
        // Mid-line intent: should NOT match
        assert!(get_intent(&parser.parse("The intent: of this code is clear")).is_none());
    }

    #[test]
    fn test_intent_plain_prefix_in_multiline() {
        // Use \r\n to simulate real PTY output (LF without CR leaves cursor at same column)
        let mut parser = OutputParser::new();
        let events = parser.parse("some output\r\nintent: debugging login flow\r\nmore output");
        assert_eq!(
            get_intent(&events),
            Some("debugging login flow".to_string())
        );
    }

    #[test]
    fn test_intent_plain_prefix_too_short_filtered() {
        let mut parser = OutputParser::new();
        assert!(get_intent(&parser.parse("intent: ab")).is_none());
    }

    #[test]
    fn test_intent_plain_prefix_ellipsis_filtered() {
        let mut parser = OutputParser::new();
        assert!(get_intent(&parser.parse("intent: ...")).is_none());
    }

    #[test]
    fn test_intent_plain_prefix_no_space_after_colon_no_match() {
        let mut parser = OutputParser::new();
        // Requires space after colon per `^intent:\s+`
        assert!(get_intent(&parser.parse("intent:nospace")).is_none());
    }

    // ---- False positive regression tests ----

    fn has_status_line(events: &[ParsedEvent]) -> bool {
        events
            .iter()
            .any(|e| matches!(e, ParsedEvent::StatusLine { .. }))
    }

    #[test]
    fn test_no_status_line_in_diff_output() {
        let mut parser = OutputParser::new();
        // Diff line containing * and ... in JSON — should not trigger status line
        assert!(!has_status_line(&parser.parse(
            "484 + *   - {\"type\":\"output\",\"data\":\"...\"} for raw PTY output"
        )));
    }

    #[test]
    fn test_no_status_line_in_css_comment() {
        let mut parser = OutputParser::new();
        // CSS block comment with * prefix — should not trigger status line
        assert!(!has_status_line(&parser.parse("/* Last prompt sub-row */")));
        assert!(!has_status_line(
            &parser.parse(" * Strip ANSI escape codes from text */")
        ));
    }

    #[test]
    fn test_no_status_line_in_code_listing() {
        let mut parser = OutputParser::new();
        // Code listing with line numbers and * in a comment
        assert!(!has_status_line(&parser.parse(
            "156  /* Last prompt sub-row */                                                                                                                    "
        )));
    }

    #[test]
    fn test_no_status_line_from_markdown_bullet() {
        let mut parser = OutputParser::new();
        // Markdown bullet list — should NOT trigger Codex bullet pattern
        assert!(!has_status_line(
            &parser.parse("• This is a bullet point in a list")
        ));
        assert!(!has_status_line(
            &parser.parse("  • Another nested bullet item")
        ));
    }

    #[test]
    fn test_no_rate_limit_story_429() {
        let mut parser = OutputParser::new();
        // Conversational text mentioning "story 429" — not an HTTP 429
        assert!(!has_rate_limit(
            &parser.parse("che sembrano provenire da altre sessioni (story 429")
        ));
    }

    #[test]
    fn test_no_rate_limit_ansi_bridged_429() {
        let mut parser = OutputParser::new();
        // Raw ANSI output where \S* bridges http/ to 429 through escape codes
        assert!(!has_rate_limit(&parser.parse(
            "http/\x1b[1C\x1b[39me\x1b[1C\x1b[38;2;177;185;249mstate.rs\x1b[1Cche\x1b[1Csembrano\x1b[1Cprovenire\x1b[1Cda\x1b[1Caltre\x1b[1Csessioni\x1b[1C(story\x1b[1C429"
        )));
    }

    // --- Suggest detection tests ---

    fn get_suggest(events: &[ParsedEvent]) -> Option<Vec<String>> {
        events.iter().find_map(|e| match e {
            ParsedEvent::Suggest { items } => Some(items.clone()),
            _ => None,
        })
    }

    #[test]
    fn test_no_suggest_normal_text() {
        let mut parser = OutputParser::new();
        assert!(get_suggest(&parser.parse("I suggest we refactor")).is_none());
        assert!(get_suggest(&parser.parse("Building project...")).is_none());
    }

    // --- Plain-prefix suggest tests ---

    #[test]
    fn test_suggest_plain_prefix_basic() {
        let mut parser = OutputParser::new();
        let events = parser.parse("suggest: [ Run tests | Check logs | Push changes ]");
        let items = get_suggest(&events).expect("should parse plain-prefix suggest");
        assert_eq!(items, vec!["Run tests", "Check logs", "Push changes"]);
    }

    #[test]
    fn test_suggest_brackets_bound_stray_pipes() {
        // Regression: a `suggest:` followed by mermaid / markdown pipe content
        // (||--o{, table rows) was greedily collected into 20+ garbage chips.
        // The `[ … ]` bracket bounds the token — the closing `]` terminates the
        // walk, so pipes in the surrounding output are ignored entirely.
        let mut parser = OutputParser::new();
        let events = parser.parse(
            "suggest: [ A | B | C ]\n\
             software_product ||--o{ software_signature | product_id\n\
             file_artifact ||--o{ software_instance | sha256\n",
        );
        assert_eq!(
            get_suggest(&events),
            Some(vec!["A".to_string(), "B".to_string(), "C".to_string()]),
            "only the bracketed items count; trailing pipe content is ignored"
        );
    }

    #[test]
    fn test_suggest_plain_prefix_single_item_rejected() {
        // Single item (no `|` separator) must NOT match — prevents false
        // positives on prose that happens to be bracketed.
        let mut parser = OutputParser::new();
        let events = parser.parse("suggest: [ Single option ]");
        assert!(
            get_suggest(&events).is_none(),
            "single item without | must not parse as suggest"
        );
    }

    #[test]
    fn test_suggest_plain_prefix_indented_matches() {
        // See test_intent_plain_prefix_indented_matches.
        let mut parser = OutputParser::new();
        let items = get_suggest(&parser.parse("  suggest: [ A | B ]"))
            .expect("indented suggest must match");
        assert_eq!(items, vec!["A", "B"]);
    }

    #[test]
    fn test_suggest_plain_prefix_midline_no_match() {
        let mut parser = OutputParser::new();
        assert!(get_suggest(&parser.parse("I suggest: [ A | B ]")).is_none());
    }

    #[test]
    fn test_suggest_prose_at_column_zero_no_match() {
        // A column-0 `suggest:` WITHOUT the bracketed list must NOT parse — prose
        // like "suggest: we should investigate ..." has no `[ … ]` token.
        let mut parser = OutputParser::new();
        assert!(
            get_suggest(&parser.parse("suggest: we should investigate the streaming issue"))
                .is_none(),
            "prose at column 0 without a bracketed list must not match"
        );
    }

    #[test]
    fn test_suggest_plain_prefix_trims_whitespace() {
        let mut parser = OutputParser::new();
        let events = parser.parse("suggest: [   Fix test  |  Refactor  |  Add docs   ]");
        let items = get_suggest(&events).expect("should parse");
        assert_eq!(items, vec!["Fix test", "Refactor", "Add docs"]);
    }

    #[test]
    fn test_suggest_plain_prefix_in_multiline() {
        let mut parser = OutputParser::new();
        let events = parser.parse("some output\r\nsuggest: [ Option A | Option B ]\r\nmore output");
        let items = get_suggest(&events).expect("should parse in multiline");
        assert_eq!(items, vec!["Option A", "Option B"]);
    }

    #[test]
    fn test_suggest_dewraps_multiline_content() {
        // A token wider than the terminal wraps its `[ … ]` across physical
        // rows. `dewrap_suggest_brackets` rejoins the newline (plus the wrap's
        // leading whitespace) so the four items parse. The closing `]` still
        // bounds the token, so it cannot run into surrounding output.
        let input = "suggest: [ 1) Screenshot overview panel | 2) Fix scroll flicker | 3) Fix\n\
                      Cmd+Shift+M collision | 4) Manual test ]";
        let items = match parse_suggest(input, true) {
            Some(ParsedEvent::Suggest { items }) => items,
            _ => panic!("wrapped suggest content must dewrap and parse"),
        };
        assert_eq!(
            items,
            vec![
                "1) Screenshot overview panel",
                "2) Fix scroll flicker",
                "3) Fix Cmd+Shift+M collision",
                "4) Manual test"
            ]
        );
    }

    #[test]
    fn test_suggest_dewraps_realistic_wrap() {
        // The exact shape that failed in practice: a ~78-col token on a ~76-col
        // terminal, wrapping between (here) the second and third item.
        let input =
            "suggest: [ Crea v1.3.1-nightly | Lascia solo\nrolling | Indaga il gap versionato ]";
        let items = match parse_suggest(input, true) {
            Some(ParsedEvent::Suggest { items }) => items,
            _ => panic!("realistic wrapped suggest must parse"),
        };
        assert_eq!(
            items,
            vec![
                "Crea v1.3.1-nightly",
                "Lascia solo rolling",
                "Indaga il gap versionato"
            ]
        );
    }

    #[test]
    fn test_suggest_wrapped_without_closing_bracket_no_match() {
        // An opening `suggest: [` whose `]` never arrives (truncated/streaming)
        // must NOT parse — the dewrap bails without a closing bracket, so it
        // cannot absorb the unbounded output that follows.
        let input = "suggest: [ A | B\nthis is just more terminal output, not a list";
        assert!(
            parse_suggest(input, true).is_none(),
            "wrapped suggest with no closing `]` must not parse"
        );
    }

    #[test]
    fn test_suggest_wrapped_nested_bracket_no_match() {
        // A nested `[` before the closing `]` invalidates the match even when
        // wrapped, so a mermaid label split across rows can never be captured.
        let input = "suggest: [ A | EP[\"node\"\n] | C ]";
        assert!(
            parse_suggest(input, true).is_none(),
            "a nested bracket must invalidate the wrapped suggest match"
        );
    }

    #[test]
    fn test_suggest_rejects_nested_bracket() {
        // Condition 2: a `[` inside the content invalidates the match, so a
        // mermaid node label like `EP["…"]` sharing the suggest line can never
        // be captured as items.
        let input = "suggest: [ A | EP[\"node\"] | C ]";
        assert!(
            parse_suggest(input, true).is_none(),
            "a nested bracket must invalidate the suggest match"
        );
    }

    #[test]
    fn test_suggest_wrap_stops_at_new_token() {
        // The `]` terminator bounds the token — content after it (here a new
        // `intent:` token) is never absorbed into the item list.
        let input = "suggest: [ A | B | C ]\nintent: doing something";
        let items = parse_suggest(input, true);
        let items = match items {
            Some(ParsedEvent::Suggest { items }) => items,
            _ => panic!("should parse"),
        };
        assert_eq!(items, vec!["A", "B", "C"]);
    }

    #[test]
    fn test_suggest_keyword_split_mid_word() {
        // Extremely narrow terminal splits the `suggest:` keyword itself:
        // `sugges` on one row, `t:` on the next. The dewrap pass must
        // rejoin these before the outer regex tries to match.
        for split in [
            "s\nuggest:",
            "su\nggest:",
            "sug\ngest:",
            "sugg\nest:",
            "sugge\nst:",
            "sugges\nt:",
            "suggest\n:",
        ] {
            let input = format!("{split} [ A | B | C ]");
            let items = parse_suggest(&input, true);
            let items = match items {
                Some(ParsedEvent::Suggest { items }) => items,
                _ => panic!("should dewrap and parse split={split:?}"),
            };
            assert_eq!(items, vec!["A", "B", "C"], "split={split:?}");
        }
    }

    #[test]
    fn test_suggest_keyword_split_not_at_column_zero_ignored() {
        // Only dewrap when the prefix is at column 0 (after optional
        // whitespace / ● marker). Mid-line `suggest` splits are prose.
        let input = "I will sugges\nt: we should refactor | no";
        assert!(
            parse_suggest(input, true).is_none(),
            "mid-line split must NOT be dewrapped"
        );
    }

    #[test]
    fn test_dewrap_stays_borrowed_when_no_keyword_match() {
        // Hot path: most PTY chunks have newlines but no broken `suggest:`
        // keyword. `dewrap_suggest_keyword` must return `Cow::Borrowed`
        // without any heap allocation in that case (story 1275-c45e).
        let inputs = [
            "",
            "no newlines here",
            "line one\nline two\nline three",
            "prose with the word suggest inside\nbut not a split keyword",
            "  ● some bullet\nnothing to dewrap",
        ];
        for input in inputs {
            let result = super::dewrap_suggest_keyword(input);
            assert!(
                matches!(result, std::borrow::Cow::Borrowed(_)),
                "must stay borrowed for input={input:?}"
            );
        }
    }

    #[test]
    fn test_dewrap_returns_owned_only_when_it_actually_rewrites() {
        // Sanity check: the happy path (real keyword split at column 0) does
        // allocate. Complements the no-alloc test above.
        let result = super::dewrap_suggest_keyword("sugges\nt: A | B");
        assert!(
            matches!(result, std::borrow::Cow::Owned(_)),
            "must allocate when dewrap actually rewrites"
        );
        assert_eq!(result.as_ref(), "suggest: A | B");
    }

    #[test]
    fn test_suggest_short_two_items_does_not_grab_tail() {
        // The closing `]` ends the token — unrelated prose on the next row is
        // never grabbed as a continuation.
        let input = "suggest: [ A | B ]\nsome unrelated prose here";
        let items = parse_suggest(input, true);
        let items = match items {
            Some(ParsedEvent::Suggest { items }) => items,
            _ => panic!("should still parse the suggest"),
        };
        assert_eq!(items, vec!["A", "B"]);
    }

    #[test]
    fn test_suggest_canonical_roundtrips() {
        // The returned canonical line must re-parse to the same items, so a
        // re-render of the same suggest row dedups instead of re-firing.
        let input = "suggest: [ A | B | C ]";
        let (evt, canonical) = parse_suggest_with_line(input, true).expect("should parse");
        let items = match evt {
            ParsedEvent::Suggest { items } => items,
            _ => panic!("expected Suggest event"),
        };
        assert_eq!(items, vec!["A", "B", "C"]);
        let roundtrip = parse_suggest(&canonical, true).expect("canonical must re-parse");
        assert!(
            matches!(roundtrip, ParsedEvent::Suggest { items } if items == vec!["A", "B", "C"]),
            "canonical must roundtrip to the same items"
        );
    }

    #[test]
    fn test_suggest_keyword_alone_on_narrow_row() {
        // Terminal so narrow (≤9 cols) that `suggest: ` fills the row entirely;
        // item content lands on the next physical row.
        let input = "suggest: \n[ A | B | C ]";
        let items = parse_suggest(input, true);
        let items = match items {
            Some(ParsedEvent::Suggest { items }) => items,
            _ => panic!("should parse when suggest: fills its row"),
        };
        assert_eq!(items, vec!["A", "B", "C"]);
    }

    #[test]
    fn test_suggest_bullet_keyword_alone_on_narrow_row() {
        // With bullet prefix (⏺): `⏺ suggest: ` takes ~12 cols; content on next row.
        let input = "⏺ suggest: \n[ A | B | C ]";
        let items = parse_suggest(input, true);
        let items = match items {
            Some(ParsedEvent::Suggest { items }) => items,
            _ => panic!("should parse with bullet when suggest: fills row"),
        };
        assert_eq!(items, vec!["A", "B", "C"]);
    }

    // --- Agent-gating tests for parse_intent / parse_suggest ---
    // Tokens must only be parsed when agent_active=true.

    #[test]
    fn test_intent_not_parsed_without_agent() {
        assert!(
            parse_intent("intent: reading the config file", false).is_none(),
            "intent should not parse when agent_active=false"
        );
    }

    #[test]
    fn test_intent_parsed_with_agent() {
        assert!(
            parse_intent("intent: reading the config file", true).is_some(),
            "intent should parse when agent_active=true"
        );
    }

    #[test]
    fn test_suggest_not_parsed_without_agent() {
        assert!(
            parse_suggest("suggest: [ Run tests | Check logs ]", false).is_none(),
            "suggest should not parse when agent_active=false"
        );
    }

    #[test]
    fn test_suggest_parsed_with_agent() {
        assert!(
            parse_suggest("suggest: [ Run tests | Check logs ]", true).is_some(),
            "suggest should parse when agent_active=true"
        );
    }

    #[test]
    fn test_parse_clean_lines_gated_by_agent() {
        use crate::state::ChangedRow;
        let mut parser = OutputParser::new();
        let rows = vec![ChangedRow {
            row_index: 0,
            text: "suggest: [ A | B | C ]".into(),
        }];

        // Without agent: should NOT be parsed
        let events = parser.parse_clean_lines(&rows, false);
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Suggest { .. })),
            "suggest should be gated when agent_active=false"
        );

        // With agent: should be parsed
        let events = parser.parse_clean_lines(&rows, true);
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Suggest { .. })),
            "suggest should parse when agent_active=true"
        );
    }

    // --- parse_clean_lines tests ---

    fn row(i: usize, text: &str) -> crate::state::ChangedRow {
        crate::state::ChangedRow {
            row_index: i,
            text: text.to_string(),
        }
    }

    #[test]
    fn test_parse_clean_lines_status_line() {
        let mut parser = OutputParser::new();
        let rows = vec![row(0, "* Reading files...")];
        let events = parser.parse_clean_lines(&rows, true);
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::StatusLine { .. })),
            "expected StatusLine, got: {:?}",
            events
        );
    }

    #[test]
    fn test_parse_clean_lines_intent_with_title() {
        let mut parser = OutputParser::new();
        let rows = vec![row(0, "intent: Implementing feature (My title)")];
        let events = parser.parse_clean_lines(&rows, true);
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Intent { title: Some(_), .. })),
            "expected Intent with title, got: {:?}",
            events
        );
    }

    #[test]
    fn test_parse_clean_lines_suggest() {
        let mut parser = OutputParser::new();
        let rows = vec![row(0, "suggest: [ Run tests | Review diff | Deploy ]")];
        let events = parser.parse_clean_lines(&rows, true);
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Suggest { items } if items.len() == 3)),
            "expected Suggest with 3 items, got: {:?}",
            events
        );
    }

    #[test]
    fn test_parse_clean_lines_suggest_dedups_on_rerender() {
        // The same suggest row re-rendered in a later frame must NOT re-fire —
        // dedup against the last emitted items.
        let mut parser = OutputParser::new();
        let rows = vec![row(0, "suggest: [ A | B | C ]")];

        let first = parser.parse_clean_lines(&rows, true);
        assert!(
            first
                .iter()
                .any(|e| matches!(e, ParsedEvent::Suggest { items } if items.len() == 3)),
            "first render emits the suggest"
        );

        let second = parser.parse_clean_lines(&rows, true);
        assert!(
            !second
                .iter()
                .any(|e| matches!(e, ParsedEvent::Suggest { .. })),
            "re-render of the identical suggest must be deduped"
        );
    }

    #[test]
    fn test_parse_clean_lines_suggest_filters_empty_items() {
        let mut parser = OutputParser::new();
        let rows = vec![row(0, "suggest: [ Run tests |  | Deploy ]")];
        let events = parser.parse_clean_lines(&rows, true);
        let items = events.iter().find_map(|e| match e {
            ParsedEvent::Suggest { items } => Some(items.clone()),
            _ => None,
        });
        assert_eq!(
            items.as_deref(),
            Some(&["Run tests".to_string(), "Deploy".to_string()][..]),
            "empty items should be filtered; got: {:?}",
            items
        );
    }

    #[test]
    fn test_parse_clean_lines_no_instant_question() {
        let mut parser = OutputParser::new();
        let rows = vec![row(0, "Would you like to proceed?")];
        let events = parser.parse_clean_lines(&rows, true);
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::Question { .. })),
            "no instant question detection — silence-based only"
        );
    }

    #[test]
    fn test_parse_clean_lines_usage_limit() {
        let mut parser = OutputParser::new();
        let rows = vec![row(0, "You've used 78% of your weekly limit")];
        let events = parser.parse_clean_lines(&rows, true);
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::UsageLimit { .. })),
            "expected UsageLimit, got: {:?}",
            events
        );
    }

    #[test]
    fn test_parse_clean_lines_intent_with_bullet_prefix() {
        // Claude Code prepends a bullet glyph (● U+25CF) at column 0 before the token.
        let mut parser = OutputParser::new();
        let rows = vec![row(0, "\u{25CF} intent: Implementing feature (My title)")];
        let events = parser.parse_clean_lines(&rows, true);
        assert!(
            events.iter().any(
                |e| matches!(e, ParsedEvent::Intent { title: Some(t), .. } if t == "My title")
            ),
            "expected Intent with title='My title', got: {:?}",
            events
        );
    }

    #[test]
    fn test_parse_clean_lines_rate_limit() {
        let mut parser = OutputParser::new();
        let rows = vec![row(0, "Error: rate_limit_error - please try again")];
        let events = parser.parse_clean_lines(&rows, true);
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::RateLimit { .. })),
            "expected RateLimit, got: {:?}",
            events
        );
    }

    #[test]
    fn test_parse_clean_lines_plan_file() {
        let mut parser = OutputParser::new();
        let rows = vec![row(0, "Reading plans/vt100-clean-parsing.md")];
        let events = parser.parse_clean_lines(&rows, true);
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::PlanFile { .. })),
            "expected PlanFile, got: {:?}",
            events
        );
    }

    #[test]
    fn test_parse_clean_lines_plan_file_backtick_stripped() {
        let mut parser = OutputParser::new();
        // Backticks around the path should be stripped by parse_clean_lines
        let rows = vec![row(0, "Piano scritto in `plans/document-organizer.md`.")];
        let events = parser.parse_clean_lines(&rows, true);
        let path = events.iter().find_map(|e| match e {
            ParsedEvent::PlanFile { path } => Some(path.clone()),
            _ => None,
        });
        assert_eq!(
            path,
            Some("plans/document-organizer.md".to_string()),
            "backtick-wrapped plan path should be detected via clean_lines strip; got: {:?}",
            events
        );
    }

    #[test]
    fn test_parse_clean_lines_pr_url() {
        let mut parser = OutputParser::new();
        let rows = vec![row(
            0,
            "Pull request: https://github.com/owner/repo/pull/42",
        )];
        let events = parser.parse_clean_lines(&rows, true);
        assert!(
            events
                .iter()
                .any(|e| matches!(e, ParsedEvent::PrUrl { .. })),
            "expected PrUrl, got: {:?}",
            events
        );
    }

    #[test]
    fn test_parse_clean_lines_multiple_events() {
        let mut parser = OutputParser::new();
        let rows = vec![
            row(0, "intent: Working on feature (Test)"),
            row(1, "* Reading files..."),
        ];
        let events = parser.parse_clean_lines(&rows, true);
        let has_intent = events
            .iter()
            .any(|e| matches!(e, ParsedEvent::Intent { .. }));
        let has_status = events
            .iter()
            .any(|e| matches!(e, ParsedEvent::StatusLine { .. }));
        assert!(has_intent, "expected Intent event, got: {:?}", events);
        assert!(has_status, "expected StatusLine event, got: {:?}", events);
    }

    // --- parse_slash_menu tests ---

    fn make_screen(rows: &[&str], total: usize) -> Vec<String> {
        let mut screen: Vec<String> = vec![String::new(); total.saturating_sub(rows.len())];
        screen.extend(rows.iter().map(|r| r.to_string()));
        screen
    }

    #[test]
    fn test_slash_menu_claude_code_basic() {
        let screen = make_screen(
            &[
                "   /help      Get help with using Claude Code",
                " ❯ /review    Review your code",
                "   /clear     Clear conversation history",
            ],
            24,
        );
        let evt = parse_slash_menu(&screen).expect("should detect slash menu");
        match evt {
            ParsedEvent::SlashMenu { items } => {
                assert_eq!(items.len(), 3);
                assert_eq!(items[0].command, "/help");
                assert_eq!(items[0].description, "Get help with using Claude Code");
                assert!(!items[0].highlighted);
                assert_eq!(items[1].command, "/review");
                assert!(items[1].highlighted);
                assert_eq!(items[2].command, "/clear");
                assert!(!items[2].highlighted);
            }
            _ => panic!("Expected SlashMenu event"),
        }
    }

    #[test]
    fn test_slash_menu_no_highlight_fallback_first() {
        let screen = make_screen(
            &[
                "   /help      Get help with using Claude Code",
                "   /review    Review your code",
            ],
            24,
        );
        let evt = parse_slash_menu(&screen).expect("should detect slash menu");
        match evt {
            ParsedEvent::SlashMenu { items } => {
                assert_eq!(items.len(), 2);
                assert!(
                    items[0].highlighted,
                    "first item should be highlighted as fallback"
                );
                assert!(!items[1].highlighted);
            }
            _ => panic!("Expected SlashMenu event"),
        }
    }

    #[test]
    fn test_slash_menu_single_row_not_detected() {
        let screen = make_screen(&["   /help      Get help with using Claude Code"], 24);
        assert!(
            parse_slash_menu(&screen).is_none(),
            "single row should not trigger menu"
        );
    }

    #[test]
    fn test_slash_menu_no_match() {
        let screen = make_screen(&["some random output", "another line"], 24);
        assert!(parse_slash_menu(&screen).is_none());
    }

    #[test]
    fn test_slash_menu_trailing_empty_rows() {
        let screen = make_screen(
            &[
                "   /help      Get help",
                " ❯ /review    Review code",
                "   /clear     Clear history",
                "",
                "",
            ],
            24,
        );
        let evt = parse_slash_menu(&screen).expect("should skip trailing empty rows");
        match evt {
            ParsedEvent::SlashMenu { items } => {
                assert_eq!(items.len(), 3);
            }
            _ => panic!("Expected SlashMenu event"),
        }
    }

    #[test]
    fn test_slash_menu_mixed_content_below() {
        // Menu rows are at bottom, non-menu content above
        let mut screen: Vec<String> = vec![String::new(); 20];
        screen.push("$ claude-code".to_string()); // non-menu line
        screen.push("   /help      Get help".to_string());
        screen.push(" ❯ /review    Review code".to_string());
        screen.push("   /clear     Clear history".to_string());
        let evt = parse_slash_menu(&screen).expect("should detect menu below non-menu content");
        match evt {
            ParsedEvent::SlashMenu { items } => {
                assert_eq!(items.len(), 3);
            }
            _ => panic!("Expected SlashMenu event"),
        }
    }

    #[test]
    fn test_slash_menu_codex_style_no_arrow() {
        // Codex-style: no ❯, just indented /commands
        let screen = make_screen(
            &[
                "  /help        Show help information",
                "  /edit        Edit a file",
                "  /run         Run a command",
            ],
            24,
        );
        let evt = parse_slash_menu(&screen).expect("should detect codex-style menu");
        match evt {
            ParsedEvent::SlashMenu { items } => {
                assert_eq!(items.len(), 3);
                assert!(items[0].highlighted, "first should be highlighted fallback");
                assert!(!items[1].highlighted);
                assert!(!items[2].highlighted);
            }
            _ => panic!("Expected SlashMenu event"),
        }
    }

    #[test]
    fn test_slash_menu_description_with_spaces() {
        let screen = make_screen(
            &[
                "   /compact   Compact the conversation to reduce context window usage",
                "   /review    Review a pull request with detailed feedback",
            ],
            24,
        );
        let evt = parse_slash_menu(&screen).expect("should parse long descriptions");
        match evt {
            ParsedEvent::SlashMenu { items } => {
                assert_eq!(items[0].command, "/compact");
                assert_eq!(
                    items[0].description,
                    "Compact the conversation to reduce context window usage"
                );
            }
            _ => panic!("Expected SlashMenu event"),
        }
    }

    #[test]
    fn test_slash_menu_empty_screen() {
        let screen: Vec<String> = vec![String::new(); 24];
        assert!(parse_slash_menu(&screen).is_none());
    }

    #[test]
    fn test_slash_menu_above_prompt_chrome_needs_trim() {
        // Real Claude Code screen: menu renders above the prompt chrome.
        // parse_slash_menu receives pre-trimmed rows (chrome stripped by caller).
        let mut screen: Vec<String> = vec![String::new(); 18];
        screen.push("some output".to_string());
        screen.push(String::new());
        // Menu items (these are what remains after chrome trimming)
        screen.push(
            "  /wiz:plan                   (wiz) Transform feature descriptions into well"
                .to_string(),
        );
        screen.push(
            "  /wiz:stories                (wiz) Persistent file-based task tracking with"
                .to_string(),
        );
        screen.push(
            "  /wiz:review                 (wiz) Exhaustive multi-agent code review for P"
                .to_string(),
        );
        screen.push(
            "  /wiz:work                   (wiz) Execute work plans efficiently while mai"
                .to_string(),
        );
        let evt = parse_slash_menu(&screen).expect("should detect menu from pre-trimmed screen");
        match evt {
            ParsedEvent::SlashMenu { items } => {
                assert_eq!(items.len(), 4);
                assert_eq!(items[0].command, "/wiz:plan");
                assert_eq!(items[3].command, "/wiz:work");
            }
            _ => panic!("Expected SlashMenu event"),
        }
    }

    #[test]
    fn test_slash_menu_fails_with_chrome_below() {
        // When chrome is at the very bottom (below menu), the parser can't
        // reach the menu items because it breaks on the status bar row.
        let mut screen: Vec<String> = vec![String::new(); 18];
        screen.push("  /help      Get help with using Claude Code".to_string());
        screen.push("  /review    Review your code".to_string());
        // Chrome below the menu (untrimmed)
        screen.push("────────────────────────────────────────".to_string());
        screen.push("❯ /".to_string());
        screen.push("────────────────────────────────────────".to_string());
        screen.push("  [Opus 4.6 | Max] project git:(main)".to_string());
        assert!(
            parse_slash_menu(&screen).is_none(),
            "untrimmed chrome should prevent detection"
        );
    }

    #[test]
    fn test_slash_menu_below_chrome_cc_v2() {
        // Claude Code v2.1+ renders the autocomplete menu BELOW the prompt
        // chrome. The parser receives the full (untrimmed) screen and must
        // find the menu items by scanning bottom-up past trailing empties.
        let mut screen: Vec<String> = vec![String::new(); 4]; // banner rows
        screen[0] = "➜  tuicommander git:(main) ✗ c".into();
        screen[1] = " ▐▛███▜▌   Claude Code v2.1.104".into();
        screen[2] = "▝▜█████▛▘  Opus 4.6".into();
        screen[3] = "  ▘▘ ▝▝    ~/Gits/personal/tuicommander".into();
        screen.push(String::new()); // empty
        screen.push("─────────────────────────────────────────────────".into());
        screen.push("❯ /".into());
        screen.push("─────────────────────────────────────────────────".into());
        screen.push("/wiz:plan                             (wiz) Transform feature descriptions into well-structured project plans".into());
        screen.push("/wiz:work                             (wiz) Execute work plans efficiently while maintaining quality".into());
        screen.push("/wiz:review                           (wiz) Exhaustive multi-agent code review for PRs".into());
        screen.push(
            "/wiz:stories                          (wiz) Persistent file-based task tracking"
                .into(),
        );
        screen.push("/add-dir                              Add a new working directory".into());
        // Trailing empty rows (terminal padding)
        for _ in 0..20 {
            screen.push(String::new());
        }
        let evt = parse_slash_menu(&screen).expect("should detect menu below chrome");
        match evt {
            ParsedEvent::SlashMenu { items } => {
                assert_eq!(items.len(), 5);
                assert_eq!(items[0].command, "/wiz:plan");
                assert_eq!(items[4].command, "/add-dir");
            }
            _ => panic!("Expected SlashMenu event"),
        }
    }

    #[test]
    fn test_slash_menu_wrapped_descriptions() {
        // Real-world scenario: descriptions wrap when terminal is narrower than
        // the description text. The parser must skip continuation lines.
        let mut screen: Vec<String> = vec![String::new(); 4];
        screen.push("──────────────────────────────────────────────────────────────────────────────────────────────────────────────────".into());
        screen.push("❯\u{a0}/".into());
        screen.push("──────────────────────────────────────────────────────────────────────────────────────────────────────────────────".into());
        screen.push("/wiz:stories                  (wiz) Persistent file-based task tracking with merge-safe IDs, dependency".into());
        screen.push("                              awareness, and unattended auto-continue across sessions. Use when managing stor…".into());
        screen.push("/handoff                      (wiz) Use at session end to write handoff + capture session memory to mdkb".into());
        screen.push("/wiz:work                     (wiz) Execute work plans efficiently while maintaining quality with systematic".into());
        screen.push("                              task tracking".into());
        for _ in 0..20 {
            screen.push(String::new());
        }
        let evt = parse_slash_menu(&screen).expect("should detect menu with wrapped descriptions");
        match evt {
            ParsedEvent::SlashMenu { items } => {
                assert_eq!(items.len(), 3);
                assert_eq!(items[0].command, "/wiz:stories");
                assert_eq!(items[1].command, "/handoff");
                assert_eq!(items[2].command, "/wiz:work");
            }
            _ => panic!("Expected SlashMenu event"),
        }
    }

    #[test]
    fn test_slash_menu_continuation_only_after_item() {
        // A continuation-like row before any menu item should break the scan
        let screen = make_screen(
            &[
                "                              some random indented text",
                "/help      Get help",
                "/clear     Clear history",
            ],
            24,
        );
        let evt = parse_slash_menu(&screen).expect("should still detect menu items");
        match evt {
            ParsedEvent::SlashMenu { items } => {
                assert_eq!(items.len(), 2);
            }
            _ => panic!("Expected SlashMenu event"),
        }
    }

    #[test]
    fn test_slash_menu_chrome_not_skipped_as_continuation() {
        // Chrome lines (2-space indent) must NOT be treated as continuation.
        // If they were, the parser would skip past chrome and find stale
        // menu items from a previous render → false positive.
        let mut screen: Vec<String> = vec![String::new(); 20];
        screen.push("/help      Get help".to_string());
        screen.push("/clear     Clear history".to_string());
        screen.push("──────────────────────────────────────".to_string());
        screen.push("❯".to_string());
        screen.push("──────────────────────────────────────".to_string());
        screen.push("  [Opus 4.6 | Team] ██████░░░░ 64%".to_string());
        screen.push("  ⏵⏵ bypass permissions on (shift+tab to cycle)".to_string());
        assert!(
            parse_slash_menu(&screen).is_none(),
            "chrome lines should stop the scan"
        );
    }

    // ── ActiveSubtasks tests ──────────────────────────────────────────

    #[test]
    fn test_active_subtasks_local_agents() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{203A}\u{203A} bypass permissions on \u{00B7} 2 local agents");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 2);
                assert_eq!(task_type, "local agents");
            }
            _ => panic!("Expected ActiveSubtasks event, got: {:?}", events),
        }
    }

    #[test]
    fn test_active_subtasks_single_bash() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{203A}\u{203A} reading config files \u{00B7} 1 bash");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 1);
                assert_eq!(task_type, "bash");
            }
            _ => panic!("Expected ActiveSubtasks event, got: {:?}", events),
        }
    }

    #[test]
    fn test_active_subtasks_background_tasks() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{203A}\u{203A} fixing tests \u{00B7} 3 background tasks");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 3);
                assert_eq!(task_type, "background tasks");
            }
            _ => panic!("Expected ActiveSubtasks event, got: {:?}", events),
        }
    }

    #[test]
    fn test_active_subtasks_single_local_agent() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{203A}\u{203A} writing code \u{00B7} 1 local agent");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 1);
                assert_eq!(task_type, "local agent");
            }
            _ => panic!("Expected ActiveSubtasks event, got: {:?}", events),
        }
    }

    #[test]
    fn test_active_subtasks_bare_mode_line_resets_to_zero() {
        let mut parser = OutputParser::new();
        // ›› line without · N type → count=0
        let events = parser.parse("\u{203A}\u{203A} bypass permissions on");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, .. }) => {
                assert_eq!(*count, 0);
            }
            _ => panic!("Expected ActiveSubtasks with count=0, got: {:?}", events),
        }
    }

    #[test]
    fn test_active_subtasks_explicit_zero_count() {
        let mut parser = OutputParser::new();
        // ›› mode · 0 bash → count=0 (sub-tasks finished, via count-regex branch)
        let events = parser.parse("\u{203A}\u{203A} finishing \u{00B7} 0 bash");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, .. }) => {
                assert_eq!(*count, 0);
            }
            _ => panic!("Expected ActiveSubtasks with count=0, got: {:?}", events),
        }
    }

    #[test]
    fn test_active_subtasks_embedded_in_multiline_output() {
        let mut parser = OutputParser::new();
        let input =
            "some other output\n\u{203A}\u{203A} working \u{00B7} 2 bash\nmore output after";
        let events = parser.parse(input);
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 2);
                assert_eq!(task_type, "bash");
            }
            _ => panic!(
                "Expected ActiveSubtasks in multiline input, got: {:?}",
                events
            ),
        }
    }

    #[test]
    fn test_active_subtasks_not_triggered_by_regular_text() {
        let mut parser = OutputParser::new();
        let events = parser.parse("some regular output with \u{203A} single guillemet");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. })),
            "Should not match single guillemet: {:?}",
            events
        );
    }

    // ── ActiveSubtasks tests with ⏵⏵ (U+23F5) prefix ──────────────────

    #[test]
    fn test_active_subtasks_triangle_local_agents() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{23F5}\u{23F5} bypass permissions on \u{00B7} 2 local agents");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 2);
                assert_eq!(task_type, "local agents");
            }
            _ => panic!(
                "Expected ActiveSubtasks event with ⏵⏵ prefix, got: {:?}",
                events
            ),
        }
    }

    #[test]
    fn test_active_subtasks_triangle_bare_resets_to_zero() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{23F5}\u{23F5} bypass permissions on");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, .. }) => {
                assert_eq!(*count, 0);
            }
            _ => panic!(
                "Expected ActiveSubtasks with count=0 for ⏵⏵ prefix, got: {:?}",
                events
            ),
        }
    }

    #[test]
    fn test_active_subtasks_triangle_single_bash() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{23F5}\u{23F5} reading config files \u{00B7} 1 bash");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 1);
                assert_eq!(task_type, "bash");
            }
            _ => panic!(
                "Expected ActiveSubtasks event with ⏵⏵ prefix, got: {:?}",
                events
            ),
        }
    }

    #[test]
    fn test_active_subtasks_not_triggered_by_single_triangle() {
        let mut parser = OutputParser::new();
        let events = parser.parse("some output with \u{23F5} single triangle");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. })),
            "Should not match single ⏵: {:?}",
            events
        );
    }

    #[test]
    fn test_active_subtasks_triangle_explicit_zero_count() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{23F5}\u{23F5} finishing \u{00B7} 0 bash");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, .. }) => assert_eq!(*count, 0),
            _ => panic!("Expected ActiveSubtasks with count=0, got: {:?}", events),
        }
    }

    #[test]
    fn test_active_subtasks_triangle_embedded_in_multiline() {
        let mut parser = OutputParser::new();
        let input =
            "some other output\n\u{23F5}\u{23F5} working \u{00B7} 2 bash\nmore output after";
        let events = parser.parse(input);
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 2);
                assert_eq!(task_type, "bash");
            }
            _ => panic!(
                "Expected ActiveSubtasks in multiline input, got: {:?}",
                events
            ),
        }
    }

    #[test]
    fn test_active_subtasks_triangle_background_tasks() {
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{23F5}\u{23F5} fixing tests \u{00B7} 3 background tasks");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 3);
                assert_eq!(task_type, "background tasks");
            }
            _ => panic!(
                "Expected ActiveSubtasks event with ⏵⏵ prefix, got: {:?}",
                events
            ),
        }
    }

    // --- ActiveSubtasks: new format (count LEFT of markers) — captured from live sessions ---

    #[test]
    fn test_active_subtasks_new_format_count_left() {
        // Real: "1 shell · ⏵⏵ bypass permissions on" (CC v2.1.81, 2026-03-21)
        let mut parser = OutputParser::new();
        let events = parser.parse("1 shell \u{00B7} \u{23F5}\u{23F5} bypass permissions on");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 1);
                assert_eq!(task_type, "shell");
            }
            _ => panic!(
                "Expected ActiveSubtasks with count-left format, got: {:?}",
                events
            ),
        }
    }

    #[test]
    fn test_active_subtasks_new_format_plural() {
        let mut parser = OutputParser::new();
        let events = parser.parse("2 local agents \u{00B7} \u{23F5}\u{23F5} bypass permissions on");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 2);
                assert_eq!(task_type, "local agents");
            }
            _ => panic!(
                "Expected ActiveSubtasks with count-left plural, got: {:?}",
                events
            ),
        }
    }

    #[test]
    fn test_active_subtasks_new_format_with_hint() {
        // Mode line with both subprocess count and shift+tab hint
        let mut parser = OutputParser::new();
        let events = parser
            .parse("1 shell \u{00B7} \u{23F5}\u{23F5} bypass permissions on (shift+tab to cycle)");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 1);
                assert_eq!(task_type, "shell");
            }
            _ => panic!(
                "Expected ActiveSubtasks with hint suffix, got: {:?}",
                events
            ),
        }
    }

    #[test]
    fn test_active_subtasks_mode_line_with_hint_no_count() {
        // "⏵⏵ bypass permissions on (shift+tab to cycle)" — no subprocess count
        let mut parser = OutputParser::new();
        let events = parser.parse("\u{23F5}\u{23F5} bypass permissions on (shift+tab to cycle)");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, .. }) => {
                assert_eq!(*count, 0);
            }
            _ => panic!("Expected ActiveSubtasks with count=0, got: {:?}", events),
        }
    }

    #[test]
    fn test_active_subtasks_bare_count_no_mode_marker() {
        // "  1 shell" without ⏵⏵ — new CC format where subprocess count
        // appears alone without mode markers
        let mut parser = OutputParser::new();
        let events = parser.parse("  1 shell");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 1);
                assert_eq!(task_type, "shell");
            }
            _ => panic!("Expected ActiveSubtasks with count=1, got: {:?}", events),
        }
    }

    #[test]
    fn test_active_subtasks_bare_count_plural() {
        let mut parser = OutputParser::new();
        let events = parser.parse("  2 local agents");
        match events
            .iter()
            .find(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. }))
        {
            Some(ParsedEvent::ActiveSubtasks { count, task_type }) => {
                assert_eq!(*count, 2);
                assert_eq!(task_type, "local agents");
            }
            _ => panic!("Expected ActiveSubtasks with count=2, got: {:?}", events),
        }
    }

    #[test]
    fn test_active_subtasks_bare_count_not_triggered_by_random_number() {
        // "  3 files changed" should NOT trigger subtask detection
        let mut parser = OutputParser::new();
        let events = parser.parse("  3 files changed");
        assert!(
            !events
                .iter()
                .any(|e| matches!(e, ParsedEvent::ActiveSubtasks { .. })),
            "random numbered line should not trigger subtasks: {:?}",
            events
        );
    }

    // --- Ink footer question detection ---

    #[test]
    fn test_ink_footer_detected_as_question() {
        let input = "Enter to select · ↑/↓ to navigate · Esc to cancel";
        let evt = parse_question(input);
        assert!(
            evt.is_some(),
            "Ink footer should trigger question detection"
        );
        match evt.unwrap() {
            ParsedEvent::Question { confident, .. } => {
                assert!(confident, "Ink footer should be confident");
            }
            other => panic!("Expected Question, got: {:?}", other),
        }
    }

    #[test]
    fn test_ink_footer_in_diff_context_ignored() {
        // "Enter to select" inside a diff hunk should NOT trigger
        let input = "+  Enter to select an item";
        let evt = parse_question(input);
        assert!(
            evt.is_none(),
            "Ink footer in diff context should be ignored"
        );
    }

    #[test]
    fn test_regular_question_not_detected_by_parser() {
        // Regular questions should NOT be detected by parse_question — they use
        // the silence-based detector in pty.rs instead.
        let input = "Do you want to proceed?";
        let evt = parse_question(input);
        assert!(
            evt.is_none(),
            "Regular questions use silence detector, not parse_question"
        );
    }

    #[test]
    fn test_cliclack_prompt_detected_as_question() {
        // Goose uses cliclack prompts: "◆  Do you allow this tool call?"
        let input = "\u{25C6}  Goose would like to call the above tool, do you allow?";
        let evt = parse_question(input);
        assert!(evt.is_some(), "cliclack prompt should be detected");
        match evt.unwrap() {
            ParsedEvent::Question {
                prompt_text,
                confident,
            } => {
                assert!(prompt_text.contains("do you allow"));
                assert!(confident);
            }
            _ => panic!("Expected Question"),
        }
    }

    #[test]
    fn test_cliclack_compact_confirmation() {
        let input = "  \u{25C6}  Are you sure you want to compact this conversation?";
        let evt = parse_question(input);
        assert!(evt.is_some(), "cliclack confirm should be detected");
    }

    #[test]
    fn test_cliclack_short_text_ignored() {
        // Very short text after ◆ should be ignored (likely not a real prompt)
        let input = "\u{25C6}  hi";
        let evt = parse_question(input);
        assert!(evt.is_none(), "Short cliclack text should be ignored");
    }

    // --- parse_choice_prompt fixture-driven golden tests ---

    // Fixture corpus lives at src-tauri/src/fixtures/choice_prompts/. Each
    // positive fixture is a pair <name>.txt (raw screen rows) + <name>.json
    // (expected ChoicePrompt). Negatives are under negative/ and must parse
    // to None. To add a new fixture: drop the files in the folder — the test
    // auto-discovers. When an agent layout drifts, add a new .txt/.json pair
    // and loosen the parser only as needed.

    fn fixtures_dir() -> std::path::PathBuf {
        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/fixtures/choice_prompts")
    }

    fn load_rows(path: &std::path::Path) -> Vec<String> {
        let text = std::fs::read_to_string(path)
            .unwrap_or_else(|e| panic!("read fixture {}: {}", path.display(), e));
        text.lines().map(|l| l.to_string()).collect()
    }

    fn assert_matches_expected(
        fixture_name: &str,
        actual: &ParsedEvent,
        expected: &serde_json::Value,
    ) {
        let ParsedEvent::ChoicePrompt {
            title,
            options,
            dismiss_key,
            amend_key,
        } = actual
        else {
            panic!(
                "fixture {}: expected ChoicePrompt, got {:?}",
                fixture_name, actual
            );
        };
        assert_eq!(
            title,
            expected["title"].as_str().unwrap(),
            "fixture {} title mismatch",
            fixture_name
        );
        let exp_opts = expected["options"].as_array().unwrap();
        assert_eq!(
            options.len(),
            exp_opts.len(),
            "fixture {} option count mismatch",
            fixture_name
        );
        for (i, (got, want)) in options.iter().zip(exp_opts.iter()).enumerate() {
            assert_eq!(
                got.key,
                want["key"].as_str().unwrap(),
                "fixture {} opt[{}] key",
                fixture_name,
                i
            );
            assert_eq!(
                got.label,
                want["label"].as_str().unwrap(),
                "fixture {} opt[{}] label",
                fixture_name,
                i
            );
            assert_eq!(
                got.highlighted,
                want["highlighted"].as_bool().unwrap(),
                "fixture {} opt[{}] highlighted",
                fixture_name,
                i
            );
            assert_eq!(
                got.destructive,
                want["destructive"].as_bool().unwrap(),
                "fixture {} opt[{}] destructive",
                fixture_name,
                i
            );
            let want_hint = want.get("hint").and_then(|v| v.as_str()).map(String::from);
            assert_eq!(
                got.hint, want_hint,
                "fixture {} opt[{}] hint",
                fixture_name, i
            );
        }
        let want_dismiss = expected
            .get("dismiss_key")
            .and_then(|v| v.as_str())
            .map(String::from);
        let want_amend = expected
            .get("amend_key")
            .and_then(|v| v.as_str())
            .map(String::from);
        assert_eq!(
            dismiss_key, &want_dismiss,
            "fixture {} dismiss_key",
            fixture_name
        );
        assert_eq!(amend_key, &want_amend, "fixture {} amend_key", fixture_name);
    }

    #[test]
    fn test_choice_prompt_positive_fixtures() {
        let dir = fixtures_dir();
        let mut count = 0;
        for entry in
            std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("read_dir {}: {}", dir.display(), e))
        {
            let path = entry.unwrap().path();
            if path.extension().and_then(|s| s.to_str()) != Some("txt") {
                continue;
            }
            let name = path.file_stem().unwrap().to_string_lossy().into_owned();
            let json_path = path.with_extension("json");
            let expected: serde_json::Value = serde_json::from_str(
                &std::fs::read_to_string(&json_path)
                    .unwrap_or_else(|_| panic!("missing expected JSON for {}", name)),
            )
            .unwrap_or_else(|e| panic!("parse {}.json: {}", name, e));

            let rows = load_rows(&path);
            let actual = parse_choice_prompt(&rows)
                .unwrap_or_else(|| panic!("fixture {} did not parse — parser regression?", name));
            assert_matches_expected(&name, &actual, &expected);
            count += 1;
        }
        assert!(
            count >= 1,
            "no positive fixtures found in {}",
            dir.display()
        );
    }

    #[test]
    fn test_choice_prompt_negative_fixtures() {
        let dir = fixtures_dir().join("negative");
        let mut count = 0;
        for entry in
            std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("read_dir {}: {}", dir.display(), e))
        {
            let path = entry.unwrap().path();
            if path.extension().and_then(|s| s.to_str()) != Some("txt") {
                continue;
            }
            let rows = load_rows(&path);
            let result = parse_choice_prompt(&rows);
            assert!(
                result.is_none(),
                "negative fixture {} should NOT parse, got {:?}",
                path.file_stem().unwrap().to_string_lossy(),
                result,
            );
            count += 1;
        }
        assert!(
            count >= 1,
            "no negative fixtures found in {}",
            dir.display()
        );
    }

    #[test]
    fn test_choice_prompt_no_footer() {
        // Dialog without "Esc to cancel" footer — still valid.
        let rows: Vec<String> = ["Proceed with deletion?", "❯ 1. Yes", "  2. No"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let evt = parse_choice_prompt(&rows).expect("should parse without footer");
        let ParsedEvent::ChoicePrompt {
            dismiss_key,
            amend_key,
            ..
        } = evt
        else {
            panic!("wrong variant");
        };
        assert!(dismiss_key.is_none());
        assert!(amend_key.is_none());
    }

    #[test]
    fn test_choice_prompt_single_option_rejected() {
        let rows: Vec<String> = ["Continue?", "❯ 1. Yes"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        assert!(
            parse_choice_prompt(&rows).is_none(),
            "single option should not match"
        );
    }

    #[test]
    fn test_choice_prompt_wire_deserializes_into_payload() {
        // Contract test: state.rs dispatches by deserializing the serialized
        // ParsedEvent::ChoicePrompt JSON directly into ChoicePromptPayload.
        // If the wire shape drifts from the payload struct (field rename, type
        // change), this test fails loudly before the runtime dispatcher does.
        let evt = ParsedEvent::ChoicePrompt {
            title: "Do you want to proceed?".into(),
            options: vec![
                ChoiceOption {
                    key: "1".into(),
                    label: "Yes".into(),
                    highlighted: true,
                    destructive: false,
                    hint: None,
                },
                ChoiceOption {
                    key: "2".into(),
                    label: "No".into(),
                    highlighted: false,
                    destructive: true,
                    hint: None,
                },
            ],
            dismiss_key: Some("cancel".into()),
            amend_key: Some("amend".into()),
        };
        let json = serde_json::to_value(&evt).expect("serialize");
        assert_eq!(json["type"], "choice-prompt");
        let payload: ChoicePromptPayload = serde_json::from_value(json)
            .expect("state.rs-style round-trip through ChoicePromptPayload");
        assert_eq!(payload.title, "Do you want to proceed?");
        assert_eq!(payload.options.len(), 2);
        assert_eq!(payload.dismiss_key.as_deref(), Some("cancel"));
        assert_eq!(payload.amend_key.as_deref(), Some("amend"));
    }

    #[test]
    fn test_choice_prompt_variant_glyphs_and_numbering() {
        // Chaos table: agents drift glyph/numbering/padding. The parser must
        // tolerate the common combinations that appear in the wild without
        // requiring a new fixture for each permutation.
        let cases: &[(&str, &str)] = &[
            // (highlight_glyph, number_suffix)
            ("❯", "."),
            ("›", "."),
            (">", "."),
            ("❯", ")"),
            ("›", ")"),
            (">", ")"),
        ];
        for (glyph, suffix) in cases {
            let rows: Vec<String> = [
                "Do you want to proceed?".to_string(),
                format!("{} 1{} Yes", glyph, suffix),
                format!("  2{} No", suffix),
            ]
            .to_vec();
            let evt = parse_choice_prompt(&rows).unwrap_or_else(|| {
                panic!("variant glyph={:?} suffix={:?} should parse", glyph, suffix);
            });
            let ParsedEvent::ChoicePrompt { options, .. } = evt else {
                panic!("wrong variant");
            };
            assert_eq!(
                options.len(),
                2,
                "variant glyph={:?} suffix={:?}: expected 2 options",
                glyph,
                suffix
            );
            assert_eq!(options[0].key, "1");
            assert!(
                options[0].highlighted,
                "variant glyph={:?} suffix={:?}: option 1 must be highlighted",
                glyph, suffix
            );
            assert_eq!(options[1].key, "2");
            assert!(!options[1].highlighted);
        }
    }

    #[test]
    fn test_choice_prompt_extra_blank_padding() {
        // Trailing blank rows (common after repaint) must not defeat bottom-up scan.
        let rows: Vec<String> = ["Do you want to proceed?", "❯ 1. Yes", "  2. No", "", "", ""]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let evt = parse_choice_prompt(&rows).expect("trailing blanks must be skipped");
        let ParsedEvent::ChoicePrompt { options, .. } = evt else {
            panic!("wrong variant");
        };
        assert_eq!(options.len(), 2);
    }

    #[test]
    fn test_choice_prompt_title_must_be_question_or_verb() {
        // Plain numbered list with a non-question, non-verb preceding line.
        let rows: Vec<String> = ["Results:", "  1. apple", "  2. banana", "  3. cherry"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        assert!(
            parse_choice_prompt(&rows).is_none(),
            "numbered list without question/verb title must not match"
        );
    }
}
