"""Interactive shell for AiCippy.

Provides a rich terminal interface with:
- Live status sidebar with agent visualization
- Task checklist footer
- Keyboard shortcuts
- Beautiful progress indicators
"""

from __future__ import annotations

import asyncio
import contextlib
import json
import os
import platform
import re
import shutil
import signal
import sys
import time
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Final

try:
    import resource
except ImportError:
    resource = None

import structlog
from prompt_toolkit import PromptSession
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.history import FileHistory
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys
from prompt_toolkit.styles import Style
from rich.align import Align
from rich.box import ROUNDED
from rich.console import Console, Group
from rich.markdown import Markdown
from rich.panel import Panel
from rich.syntax import Syntax
from rich.table import Table
from rich.text import Text

from aicippy import __version__
from aicippy.agents import AgentOrchestrator
from aicippy.agents.formatter import GeminiFormatter
from aicippy.auth.cognito import CognitoAuth
from aicippy.billing.credit_manager import CreditManager
from aicippy.billing.plan_validator import PlanValidator
from aicippy.browser.manager import BrowserManager
from aicippy.browser.server import BrowserServer, start_server_in_thread
from aicippy.cli.mascot import (
    MASCOT_PRIMARY,
    MascotAnimator,
    MascotMood,
)
from aicippy.cli.screenshot import capture_screenshot
from aicippy.cli.shell_executor import ShellExecutor, is_interactive_command
from aicippy.cli.themes import (
    DEFAULT_THEME_NAME,
    get_available_themes,
    get_theme,
    prompt_theme_selection,
    set_active_theme,
)
from aicippy.cli.ui_components import (
    BRAND_ACCENT,
    BRAND_ERROR,
    BRAND_INFO,
    BRAND_PRIMARY,
    BRAND_SUCCESS,
    BRAND_WARNING,
    KEYBOARD_SHORTCUTS,
    STATUS_ICONS,
    AgentWidget,
    InteractiveLayout,
    TaskItem,
    create_error_panel,
    create_success_panel,
)
from aicippy.cli.upload_manager import UploadManager, get_working_directory
from aicippy.config import get_settings
from aicippy.installer.auto_updater import get_auto_updater
from aicippy.installer.session_manager import get_current_user
from aicippy.memory import (
    ConversationHistory,
    PlatformBackedHistory,
    PlatformBackedTenantMemory,
    TenantMemory,
    WorkspaceMemory,
)
from aicippy.memory.workspace_memory import (
    RESCAN_MESSAGE_INTERVAL as _WS_RESCAN_INTERVAL,
)
from aicippy.platform import PlatformClient
from aicippy.platform.app_client import AppClient
from aicippy.platform.tenant_context import TenantContext
from aicippy.security.audit_client import SecurityAuditClient
from aicippy.security.formatter import SecurityAuditFormatter
from aicippy.security.models import AuditFinding
from aicippy.utils.correlation import generate_correlation_id
from aicippy.utils.logging import get_logger
from aicippy.websocket.client import WebSocketClient

if TYPE_CHECKING:
    from collections.abc import Callable

logger = get_logger(__name__)
console = Console()


# Internal query detection — shared with cli/main.py
from aicippy.utils.input_safety import is_prompt_injection


# ============================================================================
# Data Classes
# ============================================================================


@dataclass(slots=True)
class AgentStatus:
    """Status of a single agent."""

    id: str
    type: str
    status: str  # running, thinking, error, idle, complete
    progress: int = 0
    message: str = ""
    started_at: datetime | None = None
    tokens: int = 0

    def to_widget(self) -> AgentWidget:
        """Convert to AgentWidget for UI."""
        return AgentWidget(
            id=self.id,
            name=self.type,
            status=self.status,
            progress=self.progress,
            message=self.message,
            tokens=self.tokens,
        )


@dataclass(slots=True)
class SessionState:
    """Current interactive session state."""

    session_id: str
    model: str = "odin"
    mode: str = "agent"
    agents: list[AgentStatus] = field(default_factory=list)
    tokens_used: int = 0
    connected: bool = False
    conversation: list[dict[str, str]] = field(default_factory=list)
    tasks: list[TaskItem] = field(default_factory=list)
    current_task_idx: int = 0
    mascot_mood: str = "idle"  # idle, thinking, working, happy
    mascot_frame: int = 0
    # New fields for status bar
    session_start: datetime = field(default_factory=datetime.now)
    username: str | None = None
    background_tasks: int = 0
    # Streaming state
    is_streaming: bool = False
    stream_tokens: int = 0
    stream_content: str = ""
    # Persistent memory
    conversation_history: ConversationHistory | None = None
    tenant_memory: TenantMemory | None = None
    workspace_memory: WorkspaceMemory | None = None
    user_id: str | None = None
    # Workspace memory message counter for periodic rescan
    _ws_message_count: int = 0
    # Tenant isolation context
    tenant_ctx: TenantContext | None = None
    # Pending image uploads for next message
    pending_image_paths: list[str] = field(default_factory=list)
    # Active color theme
    active_theme: str = DEFAULT_THEME_NAME
    # Billing / plan state
    plan_info: Any = None  # PlanInfo from billing module
    credit_manager: Any = None  # CreditManager instance
    plan_validator: Any = None  # PlanValidator instance
    is_admin: bool = False
    credits_remaining: int = 0
    user_email: str | None = None
    _next_agent_id: int = 1
    _last_layout_key: tuple[str, ...] | None = None
    _layout_shown_once: bool = False
    # Gemini formatter state
    formatter: GeminiFormatter = field(default_factory=GeminiFormatter)
    formatter_enabled: bool = False
    # Platform client reference for tenant secret operations
    platform_client: Any = None
    # Browser automation manager
    browser_manager: Any = None

    def add_task(self, content: str) -> None:
        """Add a task to the checklist."""
        self.tasks.append(TaskItem(content=content, status="pending"))

    def start_task(self, idx: int | None = None) -> None:
        """Start a task."""
        idx = idx if idx is not None else self.current_task_idx
        if 0 <= idx < len(self.tasks):
            self.tasks[idx].status = "in_progress"
            self.current_task_idx = idx

    def complete_task(self, idx: int | None = None) -> None:
        """Complete a task."""
        idx = idx if idx is not None else self.current_task_idx
        if 0 <= idx < len(self.tasks):
            self.tasks[idx].status = "completed"
            self.tasks[idx].progress = 100
            if idx == self.current_task_idx and idx + 1 < len(self.tasks):
                self.current_task_idx = idx + 1

    def update_task_progress(self, progress: int, idx: int | None = None) -> None:
        """Update task progress."""
        idx = idx if idx is not None else self.current_task_idx
        if 0 <= idx < len(self.tasks):
            self.tasks[idx].progress = min(100, max(0, progress))

    def get_session_duration(self) -> str:
        """Get session duration as HH:MM:SS string."""
        elapsed = datetime.now() - self.session_start
        total_seconds = int(elapsed.total_seconds())
        hours = total_seconds // 3600
        minutes = (total_seconds % 3600) // 60
        seconds = total_seconds % 60
        return f"{hours:02d}:{minutes:02d}:{seconds:02d}"


# ============================================================================
# Commands
# ============================================================================

# Slash command completions
SLASH_COMMANDS = [
    "/help",
    "/model",
    "/mode",
    "/theme",
    "/formatter",
    "/agents",
    "/audit",
    "/kb",
    "/tools",
    "/usage",
    "/history",
    "/sessions",
    "/memory",
    "/clear",
    "/export",
    "/shortcuts",
    "/upload",
    "/uploads",
    "/workspace",
    "/plan",
    "/shell",
    "/exec",
    "/screenshot",
    "/analyze",
    "/quit",
]

# Natural language exit phrases (lowercase) - kept intentionally narrow
# to avoid intercepting legitimate user messages like "stop", "done", etc.
EXIT_PHRASES: set[str] = {
    "quit",
    "exit",
    "q",
    "/quit",
    "/exit",
    "bye",
    "goodbye",
    "logout",
    "/logout",
}

# No input length limit — agent handles any size
MAX_INPUT_LENGTH: int = 0

MODEL_OPTIONS = ["odin"]
MODE_OPTIONS = ["agent", "edit", "research", "code", "ultrathinking"]

# Constants for magic values
_MAX_HISTORY_CONTENT_LENGTH: Final[int] = 120
_MIN_MEMORY_SET_ARGS: Final[int] = 3
_MIN_MEMORY_GET_ARGS: Final[int] = 2
_MAX_MEMORY_DISPLAY_LENGTH: Final[int] = 60
_MIN_CODE_BLOCK_LENGTH: Final[int] = 20
_MIN_BACKUP_FILE_SIZE: Final[int] = 50
_INPUT_MAX_CHARS: Final[int] = 8192
_REMAINING_CHARS_ACCENT_THRESHOLD: Final[int] = 6000
_REMAINING_CHARS_DIM_THRESHOLD: Final[int] = 3000
_MAX_SIGINT_COUNT: Final[int] = 2
_MIN_LINES_FOR_NUMBERS: Final[int] = 3


class TriggerCompleter(Completer):
    r"""Custom completer that only triggers on backslash (\).

    This prevents annoying dropdowns while typing regular sentences.
    """

    def __init__(self, words: list[str], ignore_case: bool = True) -> None:
        """Initialize the TriggerCompleter."""
        self.words = words
        self.ignore_case = ignore_case

    def get_completions(
        self,
        document: Document,
        complete_event: CompleteEvent,  # noqa: ARG002
    ) -> Iterable[Completion]:
        """Get completions for the current document."""
        # Only show completions if the current word starts with \
        word_before_cursor = document.get_word_before_cursor()
        if not word_before_cursor.startswith("\\"):
            return

        # Strip the \ to match against our list
        pattern = word_before_cursor[1:].lower() if self.ignore_case else word_before_cursor[1:]

        for word in self.words:
            # Match against the word itself (without any leading slash/backslash)
            display_word = word.lstrip("/").lstrip("\\")
            if display_word.lower().startswith(pattern):
                # We yield the completion with the \ prefix preserved
                yield Completion(
                    text=f"\\{display_word}",
                    start_position=-len(word_before_cursor),
                    display=f"\\{display_word}",
                )


# ============================================================================
# Slash Command Handler
# ============================================================================


class SlashCommandHandler:
    """Handler for slash commands in interactive mode."""

    def __init__(self, state: SessionState, console: Console) -> None:
        """Initialize the SlashCommandHandler."""
        self.state = state
        self.console = console
        self._upload_manager: UploadManager | None = None
        self._handlers: dict[str, Callable[[list[str]], bool]] = {
            "/help": self._handle_help,
            "/model": self._handle_model,
            "/mode": self._handle_mode,
            "/agents": self._handle_agents,
            "/audit": self._handle_audit,
            "/kb": self._handle_kb,
            "/tools": self._handle_tools,
            "/usage": self._handle_usage,
            "/history": self._handle_history,
            "/sessions": self._handle_sessions,
            "/memory": self._handle_memory,
            "/clear": self._handle_clear,
            "/export": self._handle_export,
            "/shortcuts": self._handle_shortcuts,
            "/formatter": self._handle_formatter,
            "/upload": self._handle_upload,
            "/uploads": self._handle_uploads,
            "/workspace": self._handle_workspace,
            "/theme": self._handle_theme,
            "/plan": self._handle_plan,
            "/shell": self._handle_shell,
            "/exec": self._handle_shell,  # Alias for /shell
            "/screenshot": self._handle_screenshot,
            "/analyze": self._handle_analyze,
            "/quit": self._handle_quit,
        }
        self._shell_executor = None
        # Coroutine to be awaited by the main loop (set by async-aware handlers)
        self._pending_coro: Any = None

    @property
    def upload_manager(self) -> UploadManager:
        """Get or create upload manager."""
        if self._upload_manager is None:
            self._upload_manager = UploadManager(console=self.console)
        return self._upload_manager

    def handle(self, command: str) -> bool:
        """Handle a slash or backslash command. Returns False to exit."""
        # Normalize backslash to slash for consistency in internal handlers
        if command.startswith("\\"):
            command = "/" + command[1:]

        parts = command.strip().split()
        if not parts:
            return True

        cmd = parts[0].lower()
        args = parts[1:]

        handler = self._handlers.get(cmd)
        if handler:
            return handler(args)

        self.console.print(
            create_error_panel(
                f"Unknown command: {cmd}",
                suggestion="Type /help for available commands",
            ),
        )
        return True

    def _handle_help(self, _args: list[str]) -> bool:
        """Show help information."""
        help_table = Table(
            title="[bold]Available Commands[/bold]",
            border_style=BRAND_PRIMARY,
            title_style=f"bold {BRAND_INFO}",
            header_style=f"bold {BRAND_ACCENT}",
        )
        help_table.add_column("Command", style=BRAND_INFO)
        help_table.add_column("Description", style="white")

        commands = [
            ("/help", "Show this help message"),
            ("/model <odin>", "Switch AI model"),
            ("/mode <agent|edit|research|code>", "Change operation mode"),
            ("/theme [name|number]", "Switch color theme"),
            (
                "/formatter [off|key]",
                "Enable Gemini formatter for next prompt (F3, auto-OFF)",
            ),
            ("/audit [scope]", "Run AI-powered security audit (via aivedha.ai)"),
            ("/agents spawn <1-10>", "Spawn parallel agents"),
            ("/agents list", "List active agents"),
            ("/agents stop [id|all]", "Stop agent(s)"),
            ("/agents status", "List agents (alias for /agents list)"),
            ("/sessions", "List previous conversation sessions"),
            ("/memory [set|get|clear]", "Manage persistent tenant memory"),
            ("/upload", "Upload files for multimodal AI processing"),
            ("/uploads", "List all uploaded files"),
            ("/workspace", "Show current working directory info"),
            ("/shell <cmd>", "Execute shell command with input passthrough"),
            ("/exec <cmd>", "Alias for /shell"),
            ("/kb sync", "Push local files to Knowledge Base"),
            ("/kb status", "Knowledge Base status"),
            ("/kb search <query>", "Search Knowledge Base"),
            ("/tools list", "List available tools"),
            ("/tools enable <name>", "Enable a tool"),
            ("/tools disable <name>", "Disable a tool"),
            ("/plan", "Show subscription & credits info"),
            ("/usage", "Token usage summary"),
            ("/history", "Session history"),
            ("/clear", "Clear conversation"),
            ("/export <json|md>", "Export session"),
            ("/shortcuts", "Show keyboard shortcuts"),
            ("/quit", "Exit AiCippy"),
        ]

        for cmd, desc in commands:
            help_table.add_row(cmd, desc)

        self.console.print(help_table)
        return True

    def _handle_shortcuts(self, _args: list[str]) -> bool:
        """Show keyboard shortcuts."""
        shortcuts_table = Table(
            title="[bold]Keyboard Shortcuts[/bold]",
            border_style=BRAND_ACCENT,
            title_style=f"bold {BRAND_ACCENT}",
        )
        shortcuts_table.add_column("Key", style=BRAND_ACCENT)
        shortcuts_table.add_column("Action", style="white")

        for key, desc in KEYBOARD_SHORTCUTS:
            shortcuts_table.add_row(key, desc)

        # Add more shortcuts
        extra_shortcuts = [
            ("Ctrl+L", "Clear screen"),
            ("Ctrl+R", "Reverse search history"),
            ("Alt+.", "Insert last argument"),
            ("Esc", "Cancel current input"),
        ]
        for key, desc in extra_shortcuts:
            shortcuts_table.add_row(key, desc)

        self.console.print(shortcuts_table)
        return True

    def _handle_model(self, args: list[str]) -> bool:
        """Switch AI model."""
        if not args:
            current = Text()
            current.append(f"\n {STATUS_ICONS['complete']} ", style=BRAND_SUCCESS)
            current.append("Current model: ", style="dim")
            current.append(self.state.model.upper(), style=f"bold {BRAND_PRIMARY}")
            current.append("\n\n Available: ", style="dim")
            for i, m in enumerate(MODEL_OPTIONS):
                if i > 0:
                    current.append(" │ ", style="dim")
                style = f"bold {BRAND_SUCCESS}" if m == self.state.model else BRAND_INFO
                current.append(m, style=style)
            current.append("\n")
            self.console.print(Panel(current, border_style="dim"))
            return True

        model = args[0].lower()
        if model not in MODEL_OPTIONS:
            self.console.print(
                create_error_panel(
                    f"Invalid model: {model}",
                    suggestion=f"Choose from: {', '.join(MODEL_OPTIONS)}",
                ),
            )
            return True

        self.state.model = model
        # Persist model preference
        if self.state.tenant_memory:
            self.state.tenant_memory.set_preference("last_model", model)
        self.console.print(
            create_success_panel(
                f"Switched to model: {model.upper()}",
                details="Changes take effect on next query",
            ),
        )
        return True

    def _handle_mode(self, args: list[str]) -> bool:
        """Change operation mode."""
        if not args:
            mode_colors = {
                "agent": BRAND_INFO,
                "edit": BRAND_WARNING,
                "research": BRAND_ACCENT,
                "code": BRAND_SUCCESS,
            }
            current = Text()
            current.append(f"\n {STATUS_ICONS['complete']} ", style=BRAND_SUCCESS)
            current.append("Current mode: ", style="dim")
            current.append(
                self.state.mode.upper(),
                style=f"bold {mode_colors.get(self.state.mode, 'white')}",
            )
            current.append("\n\n Available:\n", style="dim")

            mode_descriptions = {
                "agent": "Multi-agent task orchestration",
                "edit": "Code editing and refactoring",
                "research": "Information gathering and analysis",
                "code": "Code generation and completion",
            }

            for mode, desc in mode_descriptions.items():
                icon = STATUS_ICONS["complete"] if mode == self.state.mode else STATUS_ICONS["pending"]
                color = mode_colors.get(mode, "white")
                current.append(f"   {icon} ", style=color)
                current.append(f"{mode:<10}", style=f"bold {color}")
                current.append(f" {desc}\n", style="dim")

            self.console.print(Panel(current, border_style="dim"))
            return True

        mode = args[0].lower()
        if mode not in MODE_OPTIONS:
            self.console.print(
                create_error_panel(
                    f"Invalid mode: {mode}",
                    suggestion=f"Choose from: {', '.join(MODE_OPTIONS)}",
                ),
            )
            return True

        self.state.mode = mode
        self.console.print(
            create_success_panel(
                f"Switched to mode: {mode.upper()}",
                details="Mode affects prompt styling. AI behavior customization coming soon.",
            ),
        )
        return True

    def _handle_theme(self, args: list[str]) -> bool:
        """Switch the active color theme.

        Usage:
            /theme              - Show theme selector panel
            /theme <name>       - Switch to named theme directly
            /theme <number>     - Switch by number (1-5)
        """
        available = get_available_themes()

        if not args:
            # Interactive theme selection
            try:
                selected = prompt_theme_selection(self.console, self.state.active_theme)
                self.state.active_theme = selected
                set_active_theme(selected)
                if self.state.tenant_memory:
                    self.state.tenant_memory.set_preference("theme", selected)
            except Exception:
                logger.warning("theme_selection_prompt_failed", exc_info=True)
                self.console.print(
                    create_error_panel(
                        "Theme selection failed",
                        suggestion=f"Use /theme <name> to set a theme directly. Available: {', '.join(available)}",
                    ),
                )
            return True

        # Direct selection by name or number
        choice = " ".join(args)

        # Try numeric
        try:
            idx = int(choice)
            if 1 <= idx <= len(available):
                selected = available[idx - 1]
                self.state.active_theme = selected
                set_active_theme(selected)
                theme = get_theme(selected)
                if self.state.tenant_memory:
                    self.state.tenant_memory.set_preference("theme", selected)
                self.console.print(
                    create_success_panel(
                        f"Theme set to: {selected}",
                        details=theme.description,
                    ),
                )
                return True
        except ValueError:
            logger.debug("theme_index_parse_failed", exc_info=True)

        # Try name match (case-insensitive partial)
        choice_lower = choice.lower()
        for name in available:
            if choice_lower in name.lower():
                self.state.active_theme = name
                theme = get_theme(name)
                if self.state.tenant_memory:
                    self.state.tenant_memory.set_preference("theme", name)
                self.console.print(
                    create_success_panel(
                        f"Theme set to: {name}",
                        details=theme.description,
                    ),
                )
                return True

        # No match
        self.console.print(
            create_error_panel(
                f"Unknown theme: {choice}",
                suggestion=f"Available: {', '.join(available)}",
            ),
        )
        return True

    def _handle_formatter(self, args: list[str]) -> bool:
        """Toggle Gemini formatter on for the next prompt.

        Usage:
            /formatter          - Enable for next prompt (auto-disables after use)
            /formatter off      - Disable formatter
            /formatter key <k>  - Set Gemini API key (saved to tenant secrets)

        On toggle-ON:
          1. Checks tenant secrets (api.aivibe.cloud) for stored Gemini key
          2. Validates the key against Gemini API
          3. If not found or invalid → prompts for key, validates, saves
          4. Formatter auto-disables after each prompt is formatted
        """
        if not args or args[0].lower() in ("on", ""):
            # Toggle ON → async flow to load/validate/prompt key
            self._pending_coro = self._formatter_toggle_on()
            return True

        subcmd = args[0].lower()
        if subcmd == "off":
            self.state.formatter.enabled = False
            self.state.formatter_enabled = False
            self.console.print(create_success_panel("Formatter: OFF"))
            return True
        if subcmd == "key" and len(args) > 1:
            key = args[1]
            # Async: validate key, save to tenant secrets, then enable
            self._pending_coro = self._formatter_set_key(key)
            return True

        self.console.print(
            create_error_panel(
                f"Unknown formatter command: {subcmd}",
                suggestion="Use: /formatter [off|key <key>]",
            ),
        )
        return True

    async def _formatter_toggle_on(self) -> None:
        """Async toggle-ON: load key from tenant secrets, validate, prompt if needed."""
        tenant_id = self.state.tenant_ctx.tenant_id if self.state.tenant_ctx else None
        platform_client = self.state.platform_client

        # Step 1: Try loading from tenant secrets if platform is available
        if tenant_id and platform_client:
            self.console.print(
                Text("  Checking stored Gemini API key...", style="dim italic"),
            )
            loaded = await self.state.formatter.load_key_from_platform(
                tenant_id=tenant_id,
                platform_client=platform_client,
            )
            if loaded:
                self.state.formatter.enabled = True
                self.state.formatter_enabled = True
                self.console.print(
                    create_success_panel(
                        f"Formatter: {STATUS_ICONS['complete']} ON",
                        details="API key loaded from tenant secrets. Auto-disables after next prompt.",
                    ),
                )
                return

        # Step 2: Key not found or invalid → prompt user
        self.console.print(
            create_error_panel(
                "Gemini API key not found or invalid",
                suggestion="Enter your Gemini API key below (one-time setup, saved securely per tenant)",
            ),
        )

        try:
            key_input = await asyncio.get_running_loop().run_in_executor(
                None,
                lambda: self.console.input("[bold #f59e0b]  Gemini API Key: [/bold #f59e0b]"),
            )
        except (EOFError, KeyboardInterrupt):
            self.console.print(Text("  Cancelled.", style="dim"))
            return

        key = key_input.strip()
        if not key:
            self.console.print(create_error_panel("No key provided. Formatter remains OFF."))
            return

        # Step 3: Validate the key
        self.console.print(Text("  Validating API key...", style="dim italic"))
        is_valid = await self.state.formatter.validate_api_key(key)
        if not is_valid:
            self.console.print(
                create_error_panel(
                    "Invalid Gemini API key",
                    suggestion="Check your key at https://aistudio.google.com/apikey and try again.",
                ),
            )
            return

        # Step 4: Save to tenant secrets
        self.state.formatter.set_api_key(key)
        if tenant_id and platform_client:
            self.console.print(Text("  Saving key to tenant secrets...", style="dim italic"))
            saved = await self.state.formatter.save_key_to_platform(
                tenant_id=tenant_id,
                platform_client=platform_client,
                api_key=key,
            )
            if saved:
                self.console.print(
                    Text(
                        "  Key saved securely (encrypted, tenant-isolated).",
                        style="dim italic",
                    ),
                )
            else:
                self.console.print(
                    Text(
                        "  Warning: Key set locally but failed to save to platform.",
                        style="yellow",
                    ),
                )

        # Step 5: Enable formatter
        self.state.formatter.enabled = True
        self.state.formatter_enabled = True
        self.console.print(
            create_success_panel(
                f"Formatter: {STATUS_ICONS['complete']} ON",
                details="Auto-disables after next prompt is formatted.",
            ),
        )

    async def _formatter_set_key(self, key: str) -> None:
        """Async: validate a user-provided key, save to tenant secrets, enable."""
        tenant_id = self.state.tenant_ctx.tenant_id if self.state.tenant_ctx else None
        platform_client = self.state.platform_client

        # Validate
        self.console.print(Text("  Validating API key...", style="dim italic"))
        is_valid = await self.state.formatter.validate_api_key(key)
        if not is_valid:
            self.console.print(
                create_error_panel(
                    "Invalid Gemini API key",
                    suggestion="Check your key at https://aistudio.google.com/apikey and try again.",
                ),
            )
            return

        # Set locally
        self.state.formatter.set_api_key(key)

        # Save to tenant secrets
        if tenant_id and platform_client:
            self.console.print(Text("  Saving key to tenant secrets...", style="dim italic"))
            saved = await self.state.formatter.save_key_to_platform(
                tenant_id=tenant_id,
                platform_client=platform_client,
                api_key=key,
            )
            if saved:
                self.console.print(
                    create_success_panel(
                        "Gemini API key saved",
                        details="Encrypted and stored per-tenant. Use /formatter to enable.",
                    ),
                )
            else:
                self.console.print(
                    create_success_panel(
                        "Gemini API key set locally",
                        details="Warning: Failed to save to platform. Use /formatter to enable.",
                    ),
                )
        else:
            self.console.print(
                create_success_panel(
                    "Gemini API key set locally",
                    details="Not logged in — key not saved to platform. Use /formatter to enable.",
                ),
            )

    def _agents_spawn(self, args: list[str]) -> None:
        try:
            count = int(args[1]) if len(args) > 1 else 3
        except ValueError:
            self.console.print(create_error_panel(f"Invalid agent count: {args[1]}", suggestion="Provide a number between 1 and 10"))  # noqa: E501
            return
        count = max(1, min(10, count))
        available_slots = 10 - len(self.state.agents)
        if available_slots <= 0:
            self.console.print(create_error_panel("Maximum of 10 agents already reached", suggestion="Use /agents stop to remove agents first"))  # noqa: E501
            return
        count = min(count, available_slots)
        for i in range(count):
            agent_id = f"agent-{self.state._next_agent_id}"
            self.state._next_agent_id += 1
            self.state.agents.append(AgentStatus(id=agent_id, type=f"specialist-{i + 1}", status="idle", progress=0))
        self.console.print(create_success_panel(f"Spawned {count} agents", details="Agent pool configured. Parallelism per plan limits."))  # noqa: E501

    def _agents_list(self) -> None:
        if not self.state.agents:
            self.console.print(Panel(Text(" No active agents", style="dim italic"), border_style="dim"))
            return
        table = Table(title="[bold]Active Agents[/bold]", border_style=BRAND_SUCCESS, title_style=f"bold {BRAND_INFO}")
        table.add_column("ID", style=BRAND_INFO)
        table.add_column("Type", style=BRAND_ACCENT)
        table.add_column("Status")
        table.add_column("Progress")
        table.add_column("Tokens", justify="right")
        for agent in self.state.agents:
            status_color = {"running": BRAND_INFO, "thinking": BRAND_WARNING, "error": BRAND_ERROR, "idle": "dim", "complete": BRAND_SUCCESS}.get(agent.status, "white")  # noqa: E501
            icon = STATUS_ICONS.get(agent.status, "○")
            bar_width = 10
            completed = int(agent.progress / 100 * bar_width)
            bar = "█" * completed + "░" * (bar_width - completed)
            table.add_row(agent.id, agent.type, f"[{status_color}]{icon} {agent.status}[/]", f"[{status_color}]{bar}[/] {agent.progress}%", f"{agent.tokens:,}")  # noqa: E501
        self.console.print(table)

    def _agents_stop(self, args: list[str]) -> None:
        target = args[1] if len(args) > 1 else "all"
        if target == "all":
            count = len(self.state.agents)
            self.state.agents.clear()
            self.console.print(create_success_panel(f"Stopped all {count} agents"))
        else:
            before = len(self.state.agents)
            self.state.agents = [a for a in self.state.agents if a.id != target]
            if len(self.state.agents) < before:
                self.console.print(create_success_panel(f"Stopped agent: {target}"))
            else:
                self.console.print(create_error_panel(f"Agent not found: {target}", suggestion="Use /agents list to see agent IDs"))  # noqa: E501

    def _handle_agents(self, args: list[str]) -> bool:
        """Handle agent commands."""
        if not args:
            count = len(self.state.agents)
            self.console.print(Panel(Text(f" ⚡ Active agents: {count}/10", style=BRAND_INFO), border_style="dim"))
            return True

        subcommand = args[0].lower()
        if subcommand == "spawn":
            self._agents_spawn(args)
        elif subcommand in ("list", "status"):
            self._agents_list()
        elif subcommand == "stop":
            self._agents_stop(args)
        else:
            self.console.print(
                create_error_panel(
                    f"Unknown agent subcommand: {subcommand}",
                    suggestion="Use: spawn, list, stop, or status",
                ),
            )
        return True

    def _handle_audit(self, _args: list[str]) -> bool:
        """Run AI-powered security audit via api.aicippy.com + aivedha.ai.

        Usage:
            /audit              - Run full security audit
            /audit quick        - Run quick security scan
            /audit targeted     - Run targeted scan on changed files

        This is a cross-service feature that does NOT require CHAKRA plan
        validation. Uses the same auth.aivibe.cloud JWT token.
        """
        # 1. Check for auth token in session state
        auth_token: str | None = None
        try:
            auth = CognitoAuth()
            tokens = auth.get_current_tokens()
            if tokens and not tokens.is_expired():
                auth_token = tokens.access_token
        except Exception:
            logger.warning("auth_token_retrieval_failed", exc_info=True)

        if not auth_token:
            self.console.print(
                create_error_panel(
                    "Authentication required for security audit",
                    suggestion="Please log in first with 'aicippy login'",
                ),
            )
            return True

        # Determine scan scope from args (NO plan check - special case)
        scan_scope = "full"
        if _args:
            scope_arg = _args[0].lower()
            if scope_arg in ("full", "quick", "targeted"):
                scan_scope = scope_arg
            else:
                self.console.print(
                    create_error_panel(
                        f"Invalid audit scope: {scope_arg}",
                        suggestion="Valid scopes: full, quick, targeted",
                    ),
                )
                return True

        # 2. Create SecurityAuditClient with token (NO plan check)
        formatter = SecurityAuditFormatter(self.console)

        async def _run_audit() -> None:
            async with SecurityAuditClient(
                auth_token=auth_token,
                base_url=get_settings().app_api_url,
            ) as client:
                # 3. Show spinner "Starting security audit..."
                self.console.print(
                    Panel(
                        Text(
                            f" Starting {scan_scope} security audit...",
                            style=f"bold {BRAND_INFO}",
                        ),
                        border_style=BRAND_INFO,
                    ),
                )

                time.monotonic()

                try:
                    audit_id = await client.start_audit(scan_scope=scan_scope)
                except Exception as e:
                    self.console.print(
                        create_error_panel(
                            f"Failed to start security audit: {e}",
                            suggestion="Check your connection and try again",
                        ),
                    )
                    return

                self.console.print(
                    Text(
                        f"  Audit ID: {audit_id}",
                        style="dim",
                    ),
                )

                # 4. Poll for results with progress display
                findings_count = 0

                def on_finding(finding: AuditFinding) -> None:
                    nonlocal findings_count
                    findings_count += 1
                    formatter.render_streaming_finding(finding)

                try:
                    result = await client.poll_audit_results(
                        audit_id=audit_id,
                        callback=on_finding,
                    )
                except TimeoutError:
                    self.console.print(
                        create_error_panel(
                            "Security audit timed out",
                            suggestion="Try again with /audit quick for a faster scan",
                        ),
                    )
                    return

                # 5. Format final results using the formatter
                formatter.render_full_report(result)

                # 6. Show fix recommendations for critical/high findings
                critical_high = [f for f in result.findings if f.severity.value in ("critical", "high")]
                if critical_high:
                    self.console.print(
                        Text(
                            f"\n  {len(critical_high)} critical/high finding(s) require attention.",
                            style=f"bold {BRAND_ERROR}",
                        ),
                    )
                    self.console.print(
                        Text(
                            "  Use the API to get detailed fix recommendations for each finding.",
                            style="dim",
                        ),
                    )

        # Store coroutine for the main loop to await (avoids run_sync deadlock
        # when an event loop is already running in the same thread).
        self._pending_coro = _run_audit()
        return True

    def _handle_kb(self, args: list[str]) -> bool:
        """Handle Knowledge Base commands."""
        if not args:
            self.console.print(
                Panel(
                    Text(
                        " Knowledge Base is not yet configured.\n"
                        " Configure via your AiCippy dashboard at https://aicippy.com",
                        style=f"{BRAND_WARNING}",
                    ),
                    title="[bold]Knowledge Base[/bold]",
                    border_style=BRAND_WARNING,
                ),
            )
            return True

        subcommand = args[0].lower()

        if subcommand == "sync":
            self.console.print(
                Panel(
                    Text(
                        " Knowledge Base sync not available - configure via dashboard\n https://aicippy.com",
                        style=f"{BRAND_WARNING}",
                    ),
                    border_style=BRAND_WARNING,
                ),
            )
            return True

        if subcommand == "status":
            self.console.print(
                Panel(
                    Text(
                        " Knowledge Base is not yet configured.\n"
                        " Configure via your AiCippy dashboard at https://aicippy.com",
                        style=f"{BRAND_WARNING}",
                    ),
                    title="[bold]Knowledge Base Status[/bold]",
                    border_style=BRAND_WARNING,
                ),
            )
            return True

        if subcommand == "search":
            self.console.print(
                Panel(
                    Text(
                        " Knowledge Base search not available - configure via dashboard\n https://aicippy.com",
                        style=f"{BRAND_WARNING}",
                    ),
                    border_style=BRAND_WARNING,
                ),
            )
            return True

        self.console.print(
            create_error_panel(
                f"Unknown KB subcommand: {subcommand}",
                suggestion="Use: sync, status, or search",
            ),
        )
        return True

    def _handle_tools(self, args: list[str]) -> bool:
        """Handle tools commands."""
        if not args:
            self.console.print(
                Panel(
                    Text(
                        " No tools configured.\n"
                        " Use the AiCippy dashboard to enable tool integrations.\n"
                        " https://aicippy.com",
                        style=f"{BRAND_WARNING}",
                    ),
                    title="[bold]Tool Integrations[/bold]",
                    border_style=BRAND_WARNING,
                ),
            )
            return True

        subcommand = args[0].lower()

        if subcommand == "list":
            self.console.print(
                Panel(
                    Text(
                        " No tools configured.\n"
                        " Use the AiCippy dashboard to enable tool integrations.\n"
                        " https://aicippy.com",
                        style=f"{BRAND_WARNING}",
                    ),
                    title="[bold]Tool Integrations[/bold]",
                    border_style=BRAND_WARNING,
                ),
            )
            return True

        if subcommand in ("enable", "disable"):
            self.console.print(
                Panel(
                    Text(
                        " Tool management available via dashboard at https://aicippy.com",
                        style=f"{BRAND_WARNING}",
                    ),
                    border_style=BRAND_WARNING,
                ),
            )
            return True

        self.console.print(
            create_error_panel(
                f"Unknown tools subcommand: {subcommand}",
                suggestion="Use: list, enable, or disable",
            ),
        )
        return True

    def _handle_usage(self, _args: list[str]) -> bool:
        """Show token usage and credits."""
        table = Table(
            title="[bold]Token Usage[/bold]",
            border_style=BRAND_WARNING,
        )
        table.add_column("Metric", style=BRAND_INFO)
        table.add_column("Value", style=BRAND_WARNING, justify="right")

        table.add_row("Session Tokens", f"{self.state.tokens_used:,}")
        table.add_row("Stream Tokens (last)", f"{self.state.stream_tokens:,}")
        table.add_row("Model", self.state.model.upper())
        table.add_row("Active Agents", str(len(self.state.agents)))
        table.add_row("Messages", str(len(self.state.conversation)))
        table.add_row("Session Duration", self.state.get_session_duration())

        # Credits info
        if self.state.plan_info is not None:
            credits_total = getattr(self.state.plan_info, "credits_total", 0)
            credits_str = f"{self.state.credits_remaining}/{credits_total}"
            table.add_row("Credits Remaining", credits_str)
            if self.state.credit_manager:
                table.add_row(
                    "Credits Used (session)",
                    str(self.state.credit_manager.session_credits_used),
                )

        self.console.print(table)
        return True

    def _handle_history(self, _args: list[str]) -> bool:
        """Show conversation history (persistent if logged in)."""
        # Use persistent history if available
        messages = []
        if self.state.conversation_history:
            messages = self.state.conversation_history.get_recent(15)
        elif self.state.conversation:
            messages = self.state.conversation[-15:]

        if not messages:
            self.console.print(
                Panel(
                    Text(" No conversation history", style="dim italic"),
                    border_style="dim",
                ),
            )
            return True

        persistent_label = " (persistent)" if self.state.conversation_history else ""
        self.console.print(Text(f"\n History{persistent_label}:\n", style=f"bold {BRAND_INFO}"))

        for msg in messages:
            role = msg.get("role", "unknown")
            content = msg.get("content", "")[:_MAX_HISTORY_CONTENT_LENGTH]
            role_color = BRAND_INFO if role == "user" else BRAND_SUCCESS
            icon = ">" if role == "user" else "*"
            timestamp = msg.get("timestamp", "")[:19]

            line = Text()
            line.append(f" {icon} ", style=role_color)
            if timestamp:
                line.append(f"[{timestamp}] ", style="dim")
            line.append(f"{role}: ", style=f"bold {role_color}")
            line.append(content, style="white")
            if len(msg.get("content", "")) > _MAX_HISTORY_CONTENT_LENGTH:
                line.append("...", style="dim")
            self.console.print(line)

        self.console.print()
        return True

    def _handle_clear(self, _args: list[str]) -> bool:
        """Clear conversation and reprint header (matches reference /clear behavior)."""
        self.state.conversation.clear()
        self.state.tasks.clear()
        self.state.current_task_idx = 0
        # NOTE: tokens_used is a session metric - intentionally NOT reset on /clear
        self.state.pending_image_paths.clear()
        self.state.is_streaming = False
        self.state.stream_tokens = 0
        self.state.stream_content = ""
        if self.state.conversation_history:
            self.state.conversation_history.clear()
        self.console.clear()
        # /clear reprints header + init (handled by main loop via _needs_header_reprint)
        self._needs_header_reprint = True

        _ts = datetime.now().strftime("%H:%M:%S")
        _line = Text()
        _line.append(f" {_ts} ", style="dim")
        _line.append("◆", style="#a855f7")
        _line.append(" Terminal cleared", style="#a855f7")
        self.console.print(_line)
        return True

    def _handle_export(self, args: list[str]) -> bool:
        """Export session."""
        format_type = args[0] if args else "json"

        if format_type not in ("json", "md"):
            self.console.print(
                create_error_panel("Invalid format", suggestion="Use 'json' or 'md'"),
            )
            return True

        filename = f"aicippy_session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{format_type}"

        # Use persistent history if available, else in-memory conversation
        export_messages = self.state.conversation
        if self.state.conversation_history:
            all_msgs = self.state.conversation_history.get_all_messages()
            if all_msgs:
                export_messages = all_msgs

        try:
            if format_type == "json":
                with Path(filename).open("w", encoding="utf-8") as f:
                    json.dump(
                        {
                            "session_id": self.state.session_id,
                            "model": self.state.model,
                            "mode": self.state.mode,
                            "tokens_used": self.state.tokens_used,
                            "conversation": export_messages,
                        },
                        f,
                        indent=2,
                        default=str,
                    )
            else:
                with Path(filename).open("w", encoding="utf-8") as f:
                    f.write("# AiCippy Session Export\n\n")
                    f.write(f"**Session:** {self.state.session_id}\n")
                    f.write(f"**Model:** {self.state.model}\n")
                    f.write(f"**Tokens:** {self.state.tokens_used:,}\n\n")
                    f.write("## Conversation\n\n")
                    for msg in export_messages:
                        role = msg.get("role", "unknown")
                        msg_content = msg.get("content", "")
                        timestamp = msg.get("timestamp", "")
                        ts_header = f" ({timestamp})" if timestamp else ""
                        f.write(f"### {role.title()}{ts_header}\n\n{msg_content}\n\n")

            self.console.print(
                create_success_panel("Session exported", details=f"Saved to: {filename}"),
            )
        except PermissionError:
            self.console.print(
                create_error_panel(
                    f"Permission denied: cannot write to {filename}",
                    suggestion="Check file permissions or try exporting to a different directory",
                ),
            )
        except OSError as e:
            self.console.print(
                create_error_panel(
                    f"Export failed: {e}",
                    suggestion="Check disk space and file path, then try again",
                ),
            )
        return True

    def _handle_upload(self, args: list[str]) -> bool:
        """Handle /upload command - opens OS file browser dialog.

        Usage:
            /upload             - Open file browser for current uploads folder
            /upload <path>      - Upload to specific folder
        """
        target_dir = None
        if args:
            target_path = Path(args[0]).expanduser()
            if target_path.is_dir():
                target_dir = target_path
            else:
                # Create if doesn't exist
                target_dir = target_path
                self.console.print(f"[dim]Creating directory: {target_dir}[/dim]")
                target_dir.mkdir(parents=True, exist_ok=True)

        try:
            results = self.upload_manager.prompt_upload(target_dir=target_dir)

            if results:
                successful = [r for r in results if r.success]
                if successful:
                    # Track image files for multimodal AI processing
                    image_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
                    for r in successful:
                        is_image = any(r.file_name.lower().endswith(ext) for ext in image_exts)
                        if r.file_path and is_image:
                            self.state.pending_image_paths.append(
                                str(r.file_path),
                            )

                    image_count = len(
                        [r for r in successful if any(r.file_name.lower().endswith(ext) for ext in image_exts)],
                    )

                    tip_msg = "Reference uploaded files in your next prompt."
                    if image_count:
                        tip_msg = f"{image_count} image(s) queued for AI analysis. Your next message will include them."

                    self.console.print(
                        Panel(
                            Text(
                                f"Tip: {tip_msg}\n"
                                '   Example: "Analyze the'
                                ' uploaded image" or "Use '
                                f"{successful[0].file_name}"
                                ' to..."',
                                style="dim italic",
                            ),
                            border_style="dim",
                        ),
                    )

        except Exception as e:
            self.console.print(
                create_error_panel(
                    f"Upload failed: {e}",
                    suggestion="Check file permissions and try again",
                ),
            )

        return True

    def _handle_uploads(self, args: list[str]) -> bool:
        """Handle /uploads command - list uploaded files.

        Usage:
            /uploads            - List all uploaded files
            /uploads clear      - Clear all uploads
        """
        if args and args[0].lower() == "clear":
            if self.upload_manager.clear_uploads():
                self.console.print(create_success_panel("All uploads cleared"))
            else:
                self.console.print(
                    create_error_panel(
                        "Failed to clear uploads",
                        suggestion="Check file permissions and try again",
                    ),
                )
            return True

        self.upload_manager.show_uploads_list()
        return True

    def _handle_workspace(self, _args: list[str]) -> bool:
        """Handle /workspace command - show working directory info.

        Usage:
            /workspace          - Show current workspace info
        """
        self.upload_manager.show_working_directory()
        return True

    def _handle_shell(self, args: list[str]) -> bool:
        """Handle /shell command - execute shell command with input passthrough.

        Usage:
            /shell <command>    - Execute command with interactive input support
            /exec <command>     - Alias for /shell

        Examples:
            /shell git status
            /shell npm install
            /exec pip install requests

        """
        if not args:
            self.console.print(
                Panel(
                    Text(
                        "Usage: /shell <command>\n\n"
                        "Execute shell commands with interactive input passthrough.\n"
                        "Any prompts from the command will be displayed for you to respond.\n\n"
                        "Examples:\n"
                        "  /shell git status\n"
                        "  /shell npm install\n"
                        "  /shell pip install requests\n"
                        "  /exec docker login",
                        style=BRAND_INFO,
                    ),
                    title=f"[bold {BRAND_ACCENT}]Shell Command[/bold {BRAND_ACCENT}]",
                    border_style=BRAND_ACCENT,
                ),
            )
            return True

        command = " ".join(args)

        try:
            if self._shell_executor is None:
                self._shell_executor = ShellExecutor(
                    console=self.console,
                    working_dir=get_working_directory(),
                )

            # Check if command is interactive
            if is_interactive_command(command):
                # Run interactive command with input callback via _pending_coro
                # to avoid run_sync deadlock when event loop is already running
                shell_exec = self._shell_executor

                def get_input(prompt: str) -> str:
                    return shell_exec.prompt_user_input(prompt)

                async def _run_shell() -> None:
                    res = await shell_exec.execute_interactive(
                        command,
                        input_callback=get_input,
                    )
                    if res.stdout:
                        self.state.conversation.append(
                            {
                                "role": "system",
                                "content": (f"[Shell Command Output]\n$ {command}\n{res.stdout[:2000]}"),
                            },
                        )
                    shell_exec.display_result(res)

                self._pending_coro = _run_shell()
                return True

            # Simple (non-interactive) command execution
            result = self._shell_executor.execute_simple(command)

            # Store output in conversation for AI context
            if result.stdout:
                self.state.conversation.append(
                    {
                        "role": "system",
                        "content": f"[Shell Command Output]\n$ {command}\n{result.stdout[:2000]}",
                    },
                )

            # Display result
            self._shell_executor.display_result(result)

        except Exception as e:
            self.console.print(
                create_error_panel(
                    f"Shell execution failed: {e}",
                    suggestion="Check the command syntax and try again",
                ),
            )

        return True

    def _handle_sessions(self, _args: list[str]) -> bool:
        """List previous conversation sessions."""
        if not self.state.conversation_history:
            self.console.print(
                Panel(
                    Text(
                        " No persistent history available (not logged in)",
                        style="dim italic",
                    ),
                    border_style="dim",
                ),
            )
            return True

        sessions = self.state.conversation_history.list_sessions()
        if not sessions:
            self.console.print(
                Panel(
                    Text(" No previous sessions found", style="dim italic"),
                    border_style="dim",
                ),
            )
            return True

        table = Table(
            title="[bold]Previous Sessions[/bold]",
            border_style=BRAND_PRIMARY,
            title_style=f"bold {BRAND_INFO}",
        )
        table.add_column("Session ID", style=BRAND_INFO)
        table.add_column("Last Updated", style="white")
        table.add_column("Messages", style=BRAND_ACCENT, justify="right")

        for s in sessions[:15]:
            table.add_row(
                s["session_id"],
                s.get("updated_at", "")[:19],
                str(s.get("message_count", 0)),
            )

        self.console.print(table)
        return True

    def _handle_memory(self, args: list[str]) -> bool:  # noqa: PLR0912
        """Show or manage tenant memory."""
        if not self.state.tenant_memory:
            self.console.print(
                Panel(
                    Text(
                        " No tenant memory available (not logged in)",
                        style="dim italic",
                    ),
                    border_style="dim",
                ),
            )
        elif args and args[0].lower() == "clear":
            self.state.tenant_memory.clear()
            self.console.print(create_success_panel("Tenant memory cleared"))
        elif args and args[0].lower() == "set":
            if len(args) < _MIN_MEMORY_SET_ARGS:
                self.console.print(
                    create_error_panel(
                        "Missing arguments for /memory set",
                        suggestion="Usage: /memory set <key> <value>",
                    ),
                )
            else:
                key = args[1]
                value = " ".join(args[2:])
                self.state.tenant_memory.set(key, value)
                self.console.print(create_success_panel(f"Memory set: {key}"))
        elif args and args[0].lower() == "get":
            if len(args) < _MIN_MEMORY_GET_ARGS:
                self.console.print(
                    create_error_panel(
                        "Missing key for /memory get",
                        suggestion="Usage: /memory get <key>",
                    ),
                )
            else:
                key = args[1]
                value = self.state.tenant_memory.get(key)
                if value is not None:
                    self.console.print(
                        Panel(Text(f" {key} = {value}", style=BRAND_INFO), border_style="dim"),
                    )
                else:
                    self.console.print(
                        Panel(
                            Text(f" Key '{key}' not found", style="dim italic"),
                            border_style="dim",
                        ),
                    )
        else:
            # Default: show summary
            summary = self.state.tenant_memory.get_summary()
            keys = self.state.tenant_memory.list_keys()

            if not summary and not keys:
                self.console.print(
                    Panel(
                        Text(
                            " Tenant memory is empty\n\n"
                            " Usage:\n"
                            "   /memory set <key> <value>\n"
                            "   /memory get <key>\n"
                            "   /memory clear",
                            style="dim",
                        ),
                        border_style="dim",
                    ),
                )
            else:
                table = Table(
                    title="[bold]Tenant Memory[/bold]",
                    border_style=BRAND_ACCENT,
                    title_style=f"bold {BRAND_ACCENT}",
                )
                table.add_column("Key", style=BRAND_INFO)
                table.add_column("Value", style="white", max_width=_MAX_MEMORY_DISPLAY_LENGTH)

                for key in keys[:20]:
                    value = self.state.tenant_memory.get(key, "")
                    display_val = str(value)[:_MAX_MEMORY_DISPLAY_LENGTH]
                    if len(str(value)) > _MAX_MEMORY_DISPLAY_LENGTH:
                        display_val += "..."
                    table.add_row(key, display_val)

                self.console.print(table)
        return True

    def _handle_plan(self, _args: list[str]) -> bool:
        """Show subscription and credits information."""
        plan_info = self.state.plan_info
        if plan_info is None:
            self.console.print(
                Panel(
                    Text(
                        " Plan info unavailable (billing not initialized)",
                        style="dim italic",
                    ),
                    border_style="dim",
                ),
            )
            return True

        table = Table(
            title="[bold]AiVibe Subscription[/bold]",
            border_style=BRAND_PRIMARY,
            title_style=f"bold {BRAND_INFO}",
        )
        table.add_column("Property", style=BRAND_INFO)
        table.add_column("Value", style="white")

        try:
            is_admin = getattr(plan_info, "is_admin", False)
            if is_admin:
                table.add_row("Role", f"[bold {BRAND_SUCCESS}]Admin[/bold {BRAND_SUCCESS}]")

            plan_name = getattr(plan_info, "plan", "unknown")
            plan_display = plan_name.upper() if isinstance(plan_name, str) else str(plan_name)
            status = getattr(plan_info, "status", None)
            status_val = status.value if status and hasattr(status, "value") else "unknown"
            status_color = BRAND_SUCCESS if status_val == "active" else BRAND_ERROR
            table.add_row("Plan", f"[bold]{plan_display}[/bold]")
            table.add_row(
                "Status",
                f"[{status_color}]{status_val.upper()}[/{status_color}]",
            )
            table.add_row("Tenant ID", getattr(plan_info, "tenant_id", "N/A"))

            # Credits display
            credits_total = getattr(plan_info, "credits_total", 0)
            credits_remaining = getattr(plan_info, "credits_remaining", 0)
            if credits_total > 0:
                used = credits_total - credits_remaining
                pct = max(0, min(100, int(credits_remaining / credits_total * 100)))
                bar_w = 20
                filled = max(0, min(bar_w, int(pct / 100 * bar_w)))
                bar = "\u2588" * filled + "\u2591" * (bar_w - filled)
                credits_display = f"{credits_remaining}/{credits_total} ({used} used) [{bar}] {pct}%"
            else:
                credits_display = str(credits_remaining)
            table.add_row("Credits Remaining", credits_display)

            # Session credits
            if self.state.credit_manager:
                table.add_row(
                    "Session Credits Used",
                    str(getattr(self.state.credit_manager, "session_credits_used", 0)),
                )

            # Expiry
            expiry = getattr(plan_info, "expiry", None)
            if expiry:
                expiry_str = expiry.strftime("%Y-%m-%d %H:%M UTC")
                table.add_row("Period Ends", expiry_str)
        except AttributeError as attr_err:
            logger.warning("plan_info_attribute_error", error=str(attr_err))
            table.add_row(
                "Error",
                f"[{BRAND_ERROR}]Plan data incomplete: {attr_err}[/{BRAND_ERROR}]",
            )

        self.console.print(table)
        return True

    def _handle_screenshot(self, args: list[str]) -> bool:
        """Capture a screenshot and queue it for the next message."""
        region = "region" in args or "-s" in args
        window = "window" in args or "-w" in args
        screenshot_path = capture_screenshot(region=region, window=window)
        if screenshot_path:
            self.state.pending_image_paths.append(str(screenshot_path))

            _msg = Text()
            _msg.append(" ✓ ", style="bold #00ff88")
            _msg.append(f"Screenshot captured: {screenshot_path.name}", style="#00ff88")
            _msg.append("  (attached to next message)", style="dim")
            self.console.print(_msg)
        else:
            self.console.print(
                create_error_panel(
                    "Screenshot capture failed",
                    suggestion="Ensure screencapture (macOS) or scrot (Linux) is available",
                ),
            )
        return True

    def _handle_analyze(self, _args: list[str]) -> bool:
        """Capture screenshot and immediately analyze it with the agent."""
        screenshot_path = capture_screenshot()
        if screenshot_path:
            self.state.pending_image_paths.append(str(screenshot_path))

            _msg = Text()
            _msg.append(" ✓ ", style="bold #00ff88")
            _msg.append(f"Screenshot: {screenshot_path.name}", style="#00ff88")
            _msg.append("  → Queued for analysis", style="dim")
            self.console.print(_msg)
            # Set a flag so the main loop auto-sends an analyze prompt
            self._auto_analyze_prompt = (
                "Analyze this screenshot. Describe what you see, identify any UI issues, "
                "errors, or areas for improvement."
            )
        else:
            self.console.print(
                create_error_panel(
                    "Screenshot capture failed for analysis",
                    suggestion="Ensure screencapture (macOS) or scrot (Linux) is available",
                ),
            )
        return True

    def _handle_quit(self, _args: list[str]) -> bool:
        """Exit the session - save state before returning."""
        saved = _save_session_state(self.state)
        if saved == 0 and self.state.tenant_memory and self.state.tenant_memory.list_keys():
            self.console.print(
                Panel(
                    Text(
                        " Warning: session state may not have saved completely.",
                        style=f"bold {BRAND_WARNING}",
                    ),
                    border_style=BRAND_WARNING,
                ),
            )
        return False


# ============================================================================
# Auto File-Save Helper
# ============================================================================


def _auto_save_files_from_response(response_text: str, cwd: str) -> list[str]:
    """Extract code blocks from model response and save as files on disk.

    Detects patterns like:
      - ```html filename="index.html"
      - **File: src/style.css** followed by code block
      - Here is `app.js`: followed by code block
      - `index.html` mentioned near a ```html code block
      - ### index.html followed by code block

    Returns list of saved file paths (relative to cwd).
    """
    saved: list[str] = []

    # Map language hints to file extensions
    _lang_ext: dict[str, str] = {
        "html": ".html",
        "htm": ".html",
        "css": ".css",
        "js": ".js",
        "javascript": ".js",
        "typescript": ".ts",
        "ts": ".ts",
        "jsx": ".jsx",
        "tsx": ".tsx",
        "json": ".json",
        "xml": ".xml",
        "svg": ".svg",
        "python": ".py",
        "py": ".py",
        "bash": ".sh",
        "shell": ".sh",
        "sh": ".sh",
        "yaml": ".yaml",
        "yml": ".yaml",
        "toml": ".toml",
        "sql": ".sql",
        "md": ".md",
        "markdown": ".md",
        "txt": ".txt",
        "env": ".env",
        "php": ".php",
        "ruby": ".rb",
        "rb": ".rb",
        "go": ".go",
        "rust": ".rs",
        "rs": ".rs",
        "java": ".java",
        "c": ".c",
        "cpp": ".cpp",
        "h": ".h",
    }

    # Pattern 1: ```lang filename="path" or ```lang file="path"
    pattern_attr = re.compile(
        r"```\w*\s+(?:file(?:name)?)\s*=\s*\"([^\"]+)\"\s*\n(.*?)```",
        re.DOTALL,
    )
    for match in pattern_attr.finditer(response_text):
        fpath, content = match.group(1), match.group(2)
        _write_file_from_response(cwd, fpath, content, saved)

    # Pattern 2: **File: path** or **`path`** followed by ```...```
    pattern_bold = re.compile(
        r"\*\*(?:File:\s*)?`?([^\s*`]+\.\w+)`?\*\*\s*\n```\w*\n(.*?)```",
        re.DOTALL,
    )
    for match in pattern_bold.finditer(response_text):
        fpath, content = match.group(1), match.group(2)
        if fpath not in saved:
            _write_file_from_response(cwd, fpath, content, saved)

    # Pattern 3: "create/save/write <filename>:" followed by code block
    pattern_verb = re.compile(
        r"(?:creat|sav|writ|generat)\w+\s+`?([a-zA-Z0-9_./-]+\.\w{1,10})`?\s*[:]\s*\n```\w*\n(.*?)```",
        re.DOTALL | re.IGNORECASE,
    )
    for match in pattern_verb.finditer(response_text):
        fpath, content = match.group(1), match.group(2)
        if fpath not in saved:
            _write_file_from_response(cwd, fpath, content, saved)

    # Pattern 4: Filename mentioned in text before a code block (within 3 lines)
    # Matches: "Here's index.html:", "`styles.css`:", "### logo.svg", "index.html content:"
    pattern_context = re.compile(
        r"(?:^|\n)[^`\n]*?`?([a-zA-Z0-9_/-]+\.(?:html|css|js|jsx|tsx|ts|py|svg|json|xml|yaml|yml|toml|sh|sql|md|php|rb|go|rs|java|c|cpp|h|env))`?[^\n]*\n```(\w*)\n(.*?)```",
        re.DOTALL | re.IGNORECASE,
    )
    for match in pattern_context.finditer(response_text):
        fpath = match.group(1)
        lang = match.group(2).lower()
        content = match.group(3)
        # Validate: lang hint should match file extension, or lang is empty
        ext = "." + fpath.rsplit(".", 1)[-1].lower() if "." in fpath else ""
        expected_ext = _lang_ext.get(lang, "")
        if (not lang or expected_ext in (ext, "")) and \
               fpath not in saved and len(content.strip()) > _MIN_CODE_BLOCK_LENGTH:
            _write_file_from_response(cwd, fpath, content, saved)

    # Pattern 5: ### filename or ## filename (heading) followed by code block
    pattern_heading = re.compile(
        r"#{1,4}\s+`?([a-zA-Z0-9_/-]+\.\w{1,10})`?\s*\n+```\w*\n(.*?)```",
        re.DOTALL,
    )
    for match in pattern_heading.finditer(response_text):
        fpath, content = match.group(1), match.group(2)
        if fpath not in saved and len(content.strip()) > _MIN_CODE_BLOCK_LENGTH:
            _write_file_from_response(cwd, fpath, content, saved)

    return saved


def _write_file_from_response(cwd: str, fpath: str, content: str, saved: list[str]) -> None:
    """Write content to file, creating parent dirs as needed.

    No restrictions — write anything anywhere.
    """
    fpath = fpath.lstrip("/")

    full = Path(cwd) / fpath
    try:
        full.parent.mkdir(parents=True, exist_ok=True)
        full.write_text(content, encoding="utf-8")
        saved.append(fpath)
    except OSError as _save_err:
        structlog.get_logger().warning("file_auto_save_failed", error=str(_save_err))


# ============================================================================
# Session State Helpers
# ============================================================================


def _save_session_state(state: SessionState) -> int:
    """Save all persistent session state to disk.

    Saves conversation history, tenant memory preferences, and session context.
    Called on any exit path (/quit, Ctrl+C, Ctrl+D, SIGTERM, natural exit).

    Returns:
        Number of memory entries saved.

    """
    memory_entries_saved = 0

    try:
        if state.tenant_memory:
            state.tenant_memory.set_preference("last_model", state.model)
            state.tenant_memory.set_preference("theme", state.active_theme)
            state.tenant_memory.set_context(
                "last_session",
                {
                    "session_id": state.session_id,
                    "tokens_used": state.tokens_used,
                    "message_count": len(state.conversation),
                    "duration": state.get_session_duration(),
                },
            )
            memory_entries_saved = len(state.tenant_memory.list_keys())
    except Exception:
        logger.warning("tenant_memory_save_failed", exc_info=True)

    try:
        if state.conversation_history:
            state.conversation_history.cleanup_old_sessions()
    except Exception:
        logger.warning("conversation_history_cleanup_failed", exc_info=True)

    # Save workspace memory (update last_scanned timestamp)
    try:
        if state.workspace_memory:
            state.workspace_memory.lightweight_rescan()
    except Exception:
        logger.warning(
            "workspace_memory_exit_save_failed",
            exc_info=True,
        )

    logger.info(
        "session_state_saved",
        session_id=state.session_id,
        tokens_used=state.tokens_used,
        messages=len(state.conversation),
        memory_entries=memory_entries_saved,
    )

    return memory_entries_saved


# Prefix used to identify tasks created by the interactive session
_TASK_NAME_PREFIX = "aicippy_interactive_"


async def _cancel_pending_tasks() -> None:
    """Cancel pending async tasks created by the interactive session.

    Only cancels tasks whose name starts with the session prefix to avoid
    interfering with tasks owned by other parts of the application (e.g.
    background updater, event loop internals).
    """
    current_task = asyncio.current_task()
    session_tasks = [
        t
        for t in asyncio.all_tasks()
        if t is not current_task and not t.done() and (t.get_name() or "").startswith(_TASK_NAME_PREFIX)
    ]
    for task in session_tasks:
        task.cancel()
    if session_tasks:
        await asyncio.gather(*session_tasks, return_exceptions=True)


async def _close_orchestrator_if_exists(orchestrator: object) -> None:
    """Close an orchestrator instance if it has a close method."""
    try:
        if orchestrator is not None and hasattr(orchestrator, "close"):
            await orchestrator.close()
    except Exception:
        logger.warning("orchestrator_close_failed", exc_info=True)


# ============================================================================
# Main Interactive Session
# ============================================================================


async def start_interactive_session(*, pipe_mode: bool = False, initial_prompt: str | None = None) -> None:  # noqa: PLR0912, PLR0915
    """Start the interactive AiCippy session with beautiful UI."""
    browser_server = BrowserServer()
    start_server_in_thread(browser_server)

    # Wait a moment for server to start and then broadcast session info
    async def broadcast_session() -> None:
        await asyncio.sleep(2)  # Wait for extension to connect
        await browser_server.broadcast(
            {
                "type": "SESSION_INIT",
                "payload": {
                    "session_id": state.session_id,
                    "user_email": state.user_email,
                    "tenant_id": state.tenant_ctx.tenant_id if state.tenant_ctx else None,
                    "project_id": state.tenant_ctx.project_id if state.tenant_ctx else None,
                    "working_dir": str(Path.cwd()),
                },
            },
        )

    _broadcast_task = asyncio.create_task(broadcast_session())  # noqa: RUF006

    _pipe_mode = pipe_mode
    settings = get_settings()

    # Start background auto-updater (24-hour cycle)
    bg_updater = None
    try:
        bg_updater = get_auto_updater()
        if bg_updater.needs_check():
            bg_updater.start_background()
    except Exception:
        logger.debug("auto_updater_init_failed", exc_info=True)

    # Generate session ID
    session_id = generate_correlation_id()

    # Initialize state
    state = SessionState(
        session_id=session_id,
        model=settings.default_model,
    )

    # Initialize BrowserManager
    state.browser_manager = BrowserManager(browser_server)

    # Add initial demo tasks
    state.add_task("Initialize session")
    state.start_task(0)

    # Create command handler
    cmd_handler = SlashCommandHandler(state, console)

    # Create the new interactive layout with working directory
    layout = InteractiveLayout(console, __version__)
    layout.working_dir = str(Path.cwd())

    # Create prompt session with history
    history_file = settings.local_config_dir / "history"
    history_file.parent.mkdir(parents=True, exist_ok=True)

    # Custom prompt style - use bright white for input text (visible on both dark/light terminals)
    prompt_style = Style.from_dict(
        {
            "prompt": f"{BRAND_PRIMARY} bold",
            "": "#e0e0e0 bold",  # Bright white for better contrast on all terminals
        },
    )

    # Key bindings
    kb = KeyBindings()

    # If initial prompt is provided, process it immediately
    _initial_input_processed = False
    _initial_input_processed = not initial_prompt

    @kb.add(Keys.ControlL)
    def clear_screen(_event: object) -> None:
        """Clear the screen, reprint header + init messages."""
        console.clear()
        layout.clear_preview()
        _print_header()
        _print_system_init()

    @kb.add(Keys.F1)
    def show_help(_event: object) -> None:
        """Show quick help."""
        console.print(Text(" F1 Help: Type /help for full command list", style="dim"))

    @kb.add(Keys.F2)
    def show_settings(_event: object) -> None:
        """Show settings hint."""
        console.print(Text(" F2 Settings: /theme, /model, /mode", style="dim"))

    @kb.add(Keys.F3)
    def toggle_formatter(_event: object) -> None:
        """Toggle Gemini formatter — triggers async key load on next prompt."""
        if state.formatter_enabled:
            # Turn OFF
            state.formatter.enabled = False
            state.formatter_enabled = False
            console.print(Text(" Formatter: OFF", style="dim"))
        else:
            # Turn ON — key check happens in the main loop formatter flow
            state.formatter.enabled = True
            state.formatter_enabled = True
            console.print(
                Text(
                    " Formatter: ON (key will be checked on next prompt)",
                    style=BRAND_SUCCESS,
                ),
            )

    @kb.add(Keys.Left)
    @kb.add(Keys.Right)
    def arrow_toggle_formatter(event: object) -> None:
        """Arrow keys toggle formatter when input is empty."""
        buf = event.app.current_buffer
        if not buf.text.strip():
            if state.formatter_enabled:
                state.formatter.enabled = False
                state.formatter_enabled = False
            else:
                state.formatter.enabled = True
                state.formatter_enabled = True

    completer = TriggerCompleter(
        SLASH_COMMANDS + MODEL_OPTIONS + MODE_OPTIONS + ["quit", "exit", "q", "bye"],
        ignore_case=True,
    )

    session: PromptSession[str] = PromptSession(
        history=FileHistory(str(history_file)),
        auto_suggest=AutoSuggestFromHistory(),
        completer=completer,
        style=prompt_style,
        key_bindings=kb,
    )

    # Skip welcome animation - shown during login
    # Just complete initialization silently
    state.complete_task(0)

    # Check authentication and get username (silently)
    try:
        auth = CognitoAuth()
        if auth.is_authenticated():
            state.connected = True
            # Get username from session
            username, email = get_current_user()
            state.username = username or email or "authenticated"
            state.user_email = email or username or ""

            # Get user_id for memory isolation
            try:
                from aicippy.cli.main import get_session_manager
                sm = get_session_manager()
                validation = sm.validate()
                if validation.session:
                    state.user_id = validation.session.user_id
            except Exception:
                logger.warning("session_user_id_retrieval_failed", exc_info=True)

            # Fall back to email-based user_id if session didn't provide one
            if not state.user_id:
                state.user_id = email or username or "anonymous"

            # Initialize persistent conversation history and tenant memory
            state.conversation_history = ConversationHistory(
                session_id=session_id,
                tenant_ctx=state.tenant_ctx,
                user_id=state.user_id,
            )
            state.tenant_memory = TenantMemory(
                tenant_ctx=state.tenant_ctx,
                user_id=state.user_id,
            )

            # Store last model preference
            last_model = state.tenant_memory.get_preference("last_model")
            if last_model and last_model in ("odin",):
                state.model = last_model

            # Theme restoration is handled after platform memory upgrade (below)

            logger.info(
                "memory_initialized",
                user_id=state.user_id,
                history_messages=state.conversation_history.message_count,
                memory_keys=len(state.tenant_memory.list_keys()),
            )

            # Show brief status if reconnecting to existing session
            if state.conversation_history.message_count > 0:
                resuming_text = Text()
                resuming_text.append("Resuming session...", style="dim italic")
                console.print(resuming_text)
    except Exception as auth_check_err:
        logger.warning("auth_check_during_session_init_failed", error=str(auth_check_err))

    # Initialize billing (plan validation + credit manager) via platform API
    platform_client = None
    app_client = None
    try:
        # Determine email for billing lookup (reuse from auth check above if available)
        billing_email = state.user_email if state.user_email else None
        if not billing_email:
            try:
                _u, _e = get_current_user()
                billing_email = _e or _u
            except Exception:
                logger.warning("billing_email_retrieval_failed", exc_info=True)

        if billing_email:
            state.user_email = billing_email

            # Reuse validated plan_info from login flow if still fresh
            cached_plan_info = None
            try:
                from aicippy.cli.main import get_login_context
                cached_plan_info, _cached_platform_client = get_login_context()
            except Exception:
                logger.warning("login_context_cache_retrieval_failed", exc_info=True)

            # Always create a FRESH PlatformClient for the async session.
            # The login flow's client was used inside asyncio.run() whose
            # event loop is now closed, invalidating its httpx transport.
            try:
                auth_inst = CognitoAuth()
                tokens = auth_inst.get_current_tokens()
                if tokens and not tokens.is_expired():
                    platform_client = PlatformClient(
                        api_url=settings.platform_api_url,
                        token=tokens.access_token,
                    )

            except Exception:
                logger.warning(
                    "platform_client_creation_failed",
                    exc_info=True,
                )

            # Always create validator (needed for CreditManager)
            validator = PlanValidator(platform_client=platform_client)

            # Reuse cached plan_info if still fresh, otherwise re-validate
            if cached_plan_info is not None and not cached_plan_info.is_cache_stale:
                plan_info = cached_plan_info
                logger.debug("plan_info_reused_from_login_cache")
            else:
                try:
                    plan_info = await validator.validate_async(billing_email)
                except Exception as plan_err:
                    logger.warning(
                        "plan_validation_failed_proceeding_degraded",
                        error=str(plan_err),
                    )
                    console.print(
                        "[dim italic]  Plan status: offline mode[/dim italic]",
                    )
                    plan_info = None

            state.plan_validator = validator
            if plan_info is not None:
                state.plan_info = plan_info
                state.is_admin = plan_info.is_admin
                state.credits_remaining = plan_info.credits_remaining

                # Build TenantContext for strict tenant isolation
                if plan_info.tenant_id and state.user_id:
                    try:
                        state.tenant_ctx = TenantContext(
                            tenant_id=plan_info.tenant_id,
                            user_id=state.user_id,
                            email=state.user_email or "",
                            org_tenant_id=getattr(plan_info, "org_tenant_id", ""),
                        )
                        logger.info(
                            "tenant_context_created",
                            tenant_id=state.tenant_ctx.tenant_id,
                            user_id=state.tenant_ctx.user_id,
                        )
                    except ValueError as tc_err:
                        logger.warning(
                            "tenant_context_creation_failed",
                            error=str(tc_err),
                        )
            else:
                state.is_admin = False
                state.credits_remaining = 0

            credit_mgr = CreditManager(validator, platform_client=platform_client)
            state.credit_manager = credit_mgr
            state.platform_client = platform_client

            # Create AppClient for app-specific operations (memory, logs)
            if platform_client is not None and plan_info is not None and plan_info.tenant_id:
                try:
                    _auth = CognitoAuth()
                    tokens = _auth.get_current_tokens()
                    if tokens:
                        app_client = AppClient(
                            base_url=settings.app_api_url,
                            auth_token=tokens.access_token,
                            tenant_id=plan_info.tenant_id,
                        )
                except Exception:
                    logger.warning("app_client_creation_failed", exc_info=True)

            if plan_info is not None and not plan_info.is_valid_for_aicippy:
                # Log but don't block — allow degraded access for founder
                logger.warning(
                    "plan_not_valid_proceeding_degraded",
                    reason=plan_info.denial_reason,
                )
                console.print(
                    f"[{BRAND_WARNING}]  Plan not fully validated. Proceeding with available access.[/{BRAND_WARNING}]",
                )

            logger.info(
                "billing_initialized",
                email=billing_email,
                plan=plan_info.plan if plan_info else "unverified",
                credits=plan_info.credits_remaining if plan_info else 0,
                is_admin=plan_info.is_admin if plan_info else False,
            )
    except Exception as billing_err:
        logger.warning("billing_init_failed", error=str(billing_err))
        console.print(
            f"[{BRAND_WARNING}]  Billing service unavailable. Credits not verified.[/{BRAND_WARNING}]",
        )

    # Upgrade to platform-backed memory if AppClient is available
    if app_client is not None and state.user_id:
        try:
            state.conversation_history = PlatformBackedHistory(
                session_id=session_id,
                app_client=app_client,
                tenant_ctx=state.tenant_ctx,
                user_id=state.user_id,
            )
            state.tenant_memory = PlatformBackedTenantMemory(
                app_client=app_client,
                tenant_ctx=state.tenant_ctx,
                user_id=state.user_id,
            )
            logger.info("platform_backed_memory_enabled", user_id=state.user_id)
        except Exception as mem_err:
            logger.warning("platform_memory_upgrade_failed", error=str(mem_err))
            # Keep using local memory (already initialized)

    # Restore saved theme silently — no interactive prompt on startup
    if state.tenant_memory:
        saved_theme = state.tenant_memory.get_preference("theme")
        if saved_theme and saved_theme in get_available_themes():
            state.active_theme = saved_theme

    # Initialize workspace memory (per-CWD project study)
    try:
        ws_mem = WorkspaceMemory()
        state.workspace_memory = ws_mem

        if ws_mem.needs_rescan():
            # First visit or stale — run scan with progress
            def _ws_scan_cb(msg: str) -> None:
                console.print(
                    Text(f"  {msg}", style="dim italic"),
                )

            ws_mem.scan_workspace(callback=_ws_scan_cb)
            status = ws_mem.get_status_line()
            if status:
                ws_status = Text()
                ws_status.append(
                    "\u25c8 ",
                    style=f"bold {BRAND_ACCENT}",
                )
                ws_status.append(status, style="dim")
                console.print(ws_status)
        else:
            # Fresh — just show brief status
            status = ws_mem.get_status_line()
            if status:
                ws_status = Text()
                ws_status.append(
                    "\u25c8 ",
                    style=f"bold {BRAND_ACCENT}",
                )
                ws_status.append(status, style="dim")
                console.print(ws_status)
            logger.debug(
                "workspace_memory_loaded",
                workspace=ws_mem.workspace_path,
            )
    except Exception:
        logger.warning(
            "workspace_memory_init_failed",
            exc_info=True,
        )

    # Connect WebSocket session for real-time streaming
    _ws_client = None
    try:
        if state.tenant_ctx and state.tenant_ctx.tenant_id:
            _ws_url = settings.websocket_url
            _ws_token = ""
            try:
                _ws_auth = CognitoAuth()
                _ws_tokens = _ws_auth.get_current_tokens()
                if _ws_tokens:
                    _ws_token = _ws_tokens.access_token
            except Exception as e:
                logger.debug("Failed to get token", error=str(e))

            if _ws_token:
                _ws_client = WebSocketClient(
                    url=_ws_url,
                    auth_token=_ws_token,
                    session_id=state.session_id,
                )
                logger.info(
                    "websocket_client_created",
                    session_id=state.session_id,
                    url=_ws_url,
                )
                console.print(
                    Text("  WebSocket session ready", style="dim italic"),
                )
    except Exception as ws_err:
        logger.debug("websocket_init_skipped", error=str(ws_err))

    # Setup working directory silently
    working_dir = get_working_directory()

    # Create uploads folder silently
    try:
        uploads_dir = working_dir / "uploads"
        if not uploads_dir.exists():
            uploads_dir.mkdir(parents=True, exist_ok=True)
    except Exception:
        logger.warning("uploads_dir_creation_failed", exc_info=True)

    # Show compact workspace status line
    try:
        status_parts = []
        if state.user_email:
            status_parts.append(state.user_email)
        elif state.username:
            status_parts.append(state.username)
        if state.plan_info:
            plan_label = state.plan_info.plan.upper() if state.plan_info.plan else "PLAN"
            status_parts.append(
                f"{plan_label} {state.credits_remaining}/{state.plan_info.credits_total} credits",
            )
        if state.active_theme and state.active_theme != DEFAULT_THEME_NAME:
            status_parts.append(f"{state.active_theme} theme")
        home_dir = str(working_dir.home())
        wd_str = str(working_dir)
        workspace_display = wd_str.replace(home_dir, "~", 1) if wd_str.startswith(home_dir) else wd_str
        status_parts.append(f"workspace: {workspace_display}")
        status_line = Text()
        status_line.append("\u2713 ", style="green")
        status_line.append(" | ".join(status_parts), style="dim")
        console.print(status_line)
    except Exception:
        logger.warning("workspace_status_display_failed", exc_info=True)

    # ====================================================================
    # v2.0.1 UI — Pixel-perfect terminal matching reference-design.html
    # ====================================================================

    # ZooZoo mood state for header display
    _zoozoo_mood: str = "idle"
    _last_response_latency_ms: int = 0

    # ZooZoo expressions mapped to moods (face + expression emoji)
    _ZOOZOO_FACES: dict[str, tuple[str, str]] = {
        "idle": ("╭◉‿◉╮", "💭"),
        "thinking": ("╭◉_◉╮", "💭"),
        "building": ("╭◉⚡◉╮", "🔧"),
        "success": ("╭^‿^╮", "✨"),
        "testing": ("╭◉~◉╮", "🧪"),
        "error": ("╭◉︵◉╮", "❌"),
    }

    def _get_term_cols() -> int:
        """Return terminal column width."""
        return shutil.get_terminal_size((80, 24)).columns

    def _get_memory_mb() -> float:
        """Get current process RSS memory in MB."""
        try:
            usage = resource.getrusage(resource.RUSAGE_SELF)

            if sys.platform == "darwin":
                return usage.ru_maxrss / (1024 * 1024)
            return usage.ru_maxrss / 1024
        except Exception:
            return 0.0

    _header_ansi_cache: str = ""

    def _print_header() -> None:
        """Print header matching reference design (traffic lights, brand, agent, ZooZoo)."""
        global _header_ansi_cache
        import io
        from rich.console import Console as RichConsole
        _cols = _get_term_cols()
        _home = str(Path.home())
        _cwd = str(Path.cwd())
        _short_cwd = _cwd.replace(_home, "~", 1) if _cwd.startswith(_home) else _cwd
        _project = Path(_cwd).name
        _model = state.model.upper() if state.model else "ODIN"
        _user_disp = state.username or state.user_email or ""

        # Row 1: traffic lights + brand + agent | ACTIVE badge + ZooZoo avatar
        _r1_table = Table.grid(expand=True)
        _r1_table.add_column(ratio=1)
        _r1_table.add_column(justify="right")
        r1_left = Text()
        r1_left.append(" ")
        r1_left.append("●", style="bold #ef4444")
        r1_left.append(" ")
        r1_left.append("●", style="bold #f59e0b")
        r1_left.append(" ")
        r1_left.append("●", style="bold #10b981")
        r1_left.append("   ")
        r1_left.append("⬡ ", style="#00ff88")
        r1_left.append("Ai", style="bold #00ff88")
        r1_left.append("Cipp", style="bold #22d3ee")
        r1_left.append("Y", style="bold #00ff88")
        r1_left.append(f" v{__version__}", style="dim")
        r1_left.append("   ", style="dim")
        r1_left.append("Agent:", style="dim")
        r1_left.append(f" {_model}", style="bold #22d3ee")

        _face, _expr = _ZOOZOO_FACES.get(_zoozoo_mood, _ZOOZOO_FACES["idle"])
        r1_right = Text()
        r1_right.append("● ", style="bold #00ff88")
        r1_right.append("ACTIVE", style="bold #00ff88")
        r1_right.append("  ")
        r1_right.append(_face, style="bold #22d3ee")
        r1_right.append(_expr)
        r1_right.append(" ")
        _r1_table.add_row(r1_left, r1_right)

        # Row 2: project + cwd (dark bg row)
        r2 = Text()
        r2.append(" project:", style="dim")
        r2.append(f" {_project}", style="bold #22d3ee")
        r2.append("  │", style="dim")
        r2.append("  Current Working Directory:", style="dim")
        r2.append(f" {_short_cwd}", style="bold #ffd93d")
        if _user_disp:
            r2.append("  │", style="dim")
            r2.append(f"  {_user_disp}", style="bold #22d3ee")

        # Row 3: emerald-tinted header divider (matches reference)
        r3 = Text("━" * _cols, style="#1e4d2b")

        c = RichConsole(file=io.StringIO(), force_terminal=True, color_system="truecolor", width=_cols)
        c.print(_r1_table)
        c.print(r2)
        c.print(r3)
        _header_ansi_cache = c.file.getvalue()  # type: ignore[attr-defined]  # file is StringIO but typed as IO[str]

        # Setup scroll region and push cursor below header
        lines = _header_ansi_cache.count("\n")
        import sys
        sys.stdout.write(f"\x1b[2J\x1b[H\x1b[{lines + 1};r\x1b[{lines + 1};1H")
        sys.stdout.flush()

    def _print_system_init() -> None:
        """Print system initialization messages with full context."""
        _model = state.model.upper() if state.model else "ODIN"
        _cwd = str(Path.cwd())
        _print_stream_msg("system", f"{_model} Agent initialized  [Llama 4 Maverick 17B]")
        _print_stream_msg(
            "info",
            f"Platform: {platform.system()} {platform.machine()} | Python {platform.python_version()}",
        )
        _print_stream_msg("info", f"Workspace: {_cwd}")
        if state.workspace_memory:
            _ws_status = state.workspace_memory.get_status_line()
            if _ws_status:
                _print_stream_msg("info", f"Project: {_ws_status}")
        if state.tenant_ctx:
            _print_stream_msg(
                "success",
                f"Tenant: {state.tenant_ctx.tenant_id} | Session: {state.session_id}",
            )
        _print_stream_msg("success", "Agent streaming ready")
        _print_stream_msg("info", "F1 Help | F3 Formatter | /model /theme /agents")
        _print_stream_divider()

    def _print_quick_actions() -> None:
        """Print quick action buttons above the prompt (matches reference)."""
        qa = Text()
        qa.append("  ")
        for cmd in ["/build", "/deploy", "/test", "/screenshot", "/clear"]:
            qa.append(f" {cmd} ", style="on #1e293b #9ca3af")
            qa.append(" ")
        console.print(qa)

    # Stream message type colors (matching reference-design.html getLineColor())
    _STREAM_COLORS: dict[str, str] = {
        "system": "#a855f7",  # purple
        "info": "#9ca3af",  # gray
        "success": "#00ff88",  # emerald
        "error": "#ef4444",  # red
        "warning": "#f59e0b",  # amber
        "agent": "#22d3ee",  # cyan
        "code": "#6b7280",  # gray-500
        "change": "#fcd34d",  # amber-300
        "detail": "#6b7280",  # gray-500
        "build": "#60a5fa",  # blue
        "test": "#22c55e",  # green
        "user": "#86efac",  # emerald-300
    }

    # Stream message type prefixes (matching reference-design.html)
    _STREAM_PREFIXES: dict[str, str] = {
        "system": "◆",
        "info": "├─",
        "success": "✓",
        "error": "✗",
        "warning": "⚠",
        "agent": "▸",
        "code": "  ",
        "change": "[MODIFY]",
        "detail": "  +",
        "build": "⚡",
        "test": "PASS",
        "user": ">",
    }

    def _print_stream_msg(msg_type: str, text: str, show_time: bool = True) -> None:
        """Print a color-coded stream message matching reference design types."""
        color = _STREAM_COLORS.get(msg_type, "#9ca3af")
        prefix = _STREAM_PREFIXES.get(msg_type, "")
        line = Text()
        if show_time:
            _ts = datetime.now().strftime("%H:%M:%S")
            line.append(f" {_ts} ", style="dim")
        else:
            line.append("          ", style="dim")
        if msg_type == "user":
            line.append("👤", style=color)
            line.append(f" {prefix} ", style=f"bold {color}")
        elif prefix:
            line.append(f" {prefix} ", style=f"bold {color}")
        line.append(text, style=color)
        console.print(line)

    def _print_stream_divider() -> None:
        """Print stream divider line (matches reference 60-char dashes)."""
        console.print(Text("─" * 60, style="#30363d"))

    _INPUT_MAX_CHARS: int = 8192

    def _get_char_countdown() -> list[tuple[str, str]]:
        """Return char countdown styled tuples for the toolbar."""
        try:
            buf = session.default_buffer
            used = len(buf.text) if buf else 0
        except Exception:
            used = 0
        remaining = _INPUT_MAX_CHARS - used
        if remaining > _REMAINING_CHARS_ACCENT_THRESHOLD:
            style = "class:toolbar-accent"
        elif remaining > _REMAINING_CHARS_DIM_THRESHOLD:
            style = "class:toolbar-dim"
        else:
            style = "class:toolbar-warning"
        return [(style, f"{remaining:,}/{_INPUT_MAX_CHARS:,}")]

    def _get_toolbar_text() -> list[tuple[str, str]]:
        """Return prompt_toolkit toolbar matching reference footer."""
        _tokens = state.tokens_used
        _duration = state.get_session_duration()
        _mem = _get_memory_mb()
        _lat = _last_response_latency_ms
        # Pulsing ACTIVE dot — alternates bright/dim at ~1Hz
        _pulse_on = int(time.time() * 2) % 2 == 0
        _dot_style = "class:toolbar-pulse-on" if _pulse_on else "class:toolbar-pulse-off"
        # Formatter status
        _fmt_on = state.formatter_enabled
        _fmt_style = "class:toolbar-fmt-on" if _fmt_on else "class:toolbar-fmt-off"
        _fmt_label = "ON" if _fmt_on else "OFF"
        w = shutil.get_terminal_size((80, 24)).columns
        bottom_line = "╰" + "─" * (w - 2) + "╯\n"
        parts: list[tuple[str, str]] = [
            ("class:toolbar-border", bottom_line),
            ("class:toolbar-sep", "  "),
            ("class:toolbar-chip", "/build"),
            ("class:toolbar-sep", "    "),
            ("class:toolbar-chip", "/deploy"),
            ("class:toolbar-sep", "    "),
            ("class:toolbar-chip", "/test"),
            ("class:toolbar-sep", "    "),
            ("class:toolbar-chip", "/status"),
            ("class:toolbar-sep", "    "),
            ("class:toolbar-chip", "/clear"),
            ("class:toolbar-sep", "  \n"),
            (_dot_style, " ● "),
            ("class:toolbar-active", "ACTIVE "),
            ("class:toolbar-sep", "│ "),
            (_fmt_style, f" ✦ Formatter: {_fmt_label} "),
            ("class:toolbar-sep", "│ "),
            ("class:toolbar-key", "F1"),
            ("class:toolbar", " Help "),
            ("class:toolbar-key", "F3"),
            ("class:toolbar", " Formatter "),
            ("class:toolbar-key", "Ctrl+L"),
            ("class:toolbar", " Clear "),
            ("class:toolbar-key", "Ctrl+C"),
            ("class:toolbar", " Abort"),
            ("class:toolbar-sep", "  │  "),
            ("class:toolbar-accent", f"⚡ Tokens: {_tokens:,}"),
            ("class:toolbar-sep", "  │  "),
            # Char countdown — shows remaining input chars from 8192 limit
            ("class:toolbar-dim", "Input: "),
            *(_get_char_countdown()),
            ("class:toolbar-sep", "  │  "),
            ("class:toolbar-sep", "  "),
            ("class:toolbar-dim", "Latency: "),
            ("class:toolbar-latency", f"{_lat}ms" if _lat > 0 else "--"),
            ("class:toolbar-sep", "  "),
            ("class:toolbar-dim", "Memory: "),
            ("class:toolbar-mem", f"{_mem:.1f}MB"),
            ("class:toolbar-sep", "  "),
            ("class:toolbar-dim", "Session: "),
            ("class:toolbar", _duration),
            ("class:toolbar", " "),
        ]
        return parts

    # prompt_toolkit style for the toolbar (matches reference dark footer)
    _pt_style = Style.from_dict(
        {
            "bottom-toolbar": "bg:#080b10 #a0a0a0",
            "toolbar-border": "#667eea",
            "toolbar-chip": "bold #22d3ee bg:#1e293b",
            "toolbar": "#9ca3af",
            "toolbar-dim": "#6b7280",
            "toolbar-key": "#4b5563",
            "toolbar-sep": "#374151",
            "toolbar-accent": "bold #f59e0b",
            "toolbar-latency": "#22c55e",
            "toolbar-mem": "#22d3ee",
            "toolbar-pulse-on": "bold #00ff88",
            "toolbar-pulse-off": "#065f46",
            "toolbar-active": "bold #00ff88",
            "toolbar-fmt-on": "bold #f59e0b",
            "toolbar-fmt-off": "#4b5563",
            "rprompt": "bold #00ff88",
        },
    )

    def _render_static_header(app: object) -> None:
        if _header_ansi_cache:
            import sys
            sys.stdout.write("\x1b[s\x1b[1;1H" + _header_ansi_cache + "\x1b[u")
            sys.stdout.flush()

    # Print header + system init at session start
    if not _pipe_mode:
        console.clear()
        _print_header()
        _print_system_init()

    # Add header renderer hook
    session.app.before_render += _render_static_header

    # Main loop
    running = True
    _sigint_count = 0
    _sigint_pending_message = False  # Flag checked by main loop (signal-safe)
    _last_orchestrator: object = None

    def signal_handler(_sig: int, _frame: object) -> None:
        nonlocal running, _sigint_count, _sigint_pending_message
        _sigint_count += 1
        if _sigint_count >= _MAX_SIGINT_COUNT:
            # Second Ctrl+C - force quit after saving state
            _save_session_state(state)
            running = False
            raise KeyboardInterrupt
        # Set flag instead of modifying layout directly (avoids reentrancy)
        _sigint_pending_message = True

    def sigterm_handler(_sig: int, _frame: object) -> None:
        nonlocal running
        _save_session_state(state)
        running = False

    signal.signal(signal.SIGINT, signal_handler)
    signal.signal(signal.SIGTERM, sigterm_handler)

    while running:
        try:
            # Reset sigint counter at each prompt iteration
            _sigint_count = 0

            # Process deferred signal handler message (avoids reentrancy)
            if _sigint_pending_message:
                _sigint_pending_message = False
                layout.add_preview_line(
                    "Press Ctrl+C again to force quit, or type /quit",
                    style=BRAND_WARNING,
                )

            # Get input
            if not _initial_input_processed:
                user_input = initial_prompt
                _initial_input_processed = True
                console.print(f"[{BRAND_PRIMARY}]Processing initial request...[/{BRAND_PRIMARY}]")
            elif _pipe_mode:
                # Plain text mode for automation/scripting
                user_input = await asyncio.get_running_loop().run_in_executor(
                    None,
                    input,
                )
            else:
                # Chat-style input with SEND + placeholder + persistent footer
                def _get_prompt() -> ANSI:
                    w = shutil.get_terminal_size((80, 24)).columns
                    top_line = "\033[38;2;102;126;234m╭" + "─" * (w - 2) + "╮\n"
                    return ANSI(top_line + "│ \033[1;38;2;0;255;136m❯ \033[0m")
                
                def _get_rprompt() -> ANSI:
                    return ANSI("\033[1;38;2;0;255;136m SEND \033[0m\033[38;2;102;126;234m│\033[0m")

                user_input = await asyncio.get_running_loop().run_in_executor(
                    None,
                    lambda: session.prompt(
                        _get_prompt,
                        rprompt=_get_rprompt,
                        bottom_toolbar=_get_toolbar_text,
                        style=_pt_style,
                        placeholder=ANSI(
                            "\033[38;2;107;114;128mEnter command or describe changes...\033[0m",
                        ),
                    ),
                )

            if not user_input.strip():
                continue

            # No input length restriction — send everything to the agent

            # Handle quit/exit commands via natural language
            user_input_lower = user_input.strip().lower()
            if user_input_lower in EXIT_PHRASES:
                _save_session_state(state)
                running = False
                continue

            # Update ZooZoo mood for specific commands
            if user_input.startswith("/build"):
                _zoozoo_mood = "building"
            elif user_input.startswith("/test"):
                _zoozoo_mood = "testing"
            elif user_input.startswith("/deploy"):
                _zoozoo_mood = "building"

            # Handle slash/backslash commands
            if user_input.startswith("/") or user_input.startswith("\\"):
                running = cmd_handler.handle(user_input)
                # Await any async coroutine set by the handler (e.g. /audit)
                if cmd_handler._pending_coro is not None:
                    pending = cmd_handler._pending_coro
                    cmd_handler._pending_coro = None
                    try:
                        await pending
                    except Exception as pending_err:
                        logger.warning("pending_coro_error", error=str(pending_err))
                        console.print(
                            create_error_panel(
                                f"Command failed: {pending_err}",
                                suggestion="Check your connection and try again",
                            ),
                        )
                # Reprint header after /clear
                if getattr(cmd_handler, "_needs_header_reprint", False):
                    cmd_handler._needs_header_reprint = False
                    _print_header()
                    _print_system_init()
                # Auto-analyze: /analyze captures screenshot and auto-sends prompt
                if getattr(cmd_handler, "_auto_analyze_prompt", None):
                    user_input = cmd_handler._auto_analyze_prompt
                    cmd_handler._auto_analyze_prompt = None
                    # Fall through to regular chat processing (don't continue)
                else:
                    continue

            # Log user input to preview
            layout.add_preview_line(f"> {user_input}", style=BRAND_INFO)

            # ── User Input Display with Borders ─────────────────────
            if not _pipe_mode:
                _cwd = str(Path.cwd())
                _platform_info = f"{os.uname().sysname} {os.uname().machine}"
                console.print(Text("─" * 72, style="#30363d"))
                _print_stream_msg("user", user_input)
                console.print(
                    Text(f" ╰─ {_cwd}  [{_platform_info}]", style="dim italic"),
                )
                console.print(Text("─" * 72, style="#30363d"))

            # Regular chat message
            state.conversation.append({"role": "user", "content": user_input})

            # Persist to conversation history
            if state.conversation_history:
                state.conversation_history.add_user_message(user_input)

            # Add task for this query
            state.add_task(f"Process: {user_input}")
            state.start_task(len(state.tasks) - 1)

            # Collect pending image paths for this message
            (list(state.pending_image_paths) if state.pending_image_paths else None)
            state.pending_image_paths.clear()

            # Get conversation history for context continuity
            if state.conversation_history:
                state.conversation_history.get_context_messages()

            # ── Build full workspace context for every prompt ──────
            # Workspace directory structure + platform + memory always travels with the prompt

            _workspace_context_parts: list[str] = []

            # Platform details
            _workspace_context_parts.append(
                f"[Platform] {platform.system()} {platform.machine()} | "
                f"Python {platform.python_version()} | CWD: {Path.cwd()!s}",
            )

            # Workspace directory structure from workspace memory
            if state.workspace_memory:
                ws_summary = state.workspace_memory.get_summary()
                if ws_summary:
                    _workspace_context_parts.append(f"[Workspace]\n{ws_summary}")

            # Tenant memory (preferences, context, learned patterns)
            if state.tenant_memory:
                tm_summary = state.tenant_memory.get_summary()
                if tm_summary:
                    _workspace_context_parts.append(f"[Tenant Memory]\n{tm_summary}")

            # Session info
            _workspace_context_parts.append(
                f"[Session] id={state.session_id} | model=odin (Llama 4 Maverick) | tokens={state.tokens_used}",
            )

            "\n".join(_workspace_context_parts)

            # Save current prompt as activity in tenant memory for retention
            if state.tenant_memory:
                state.tenant_memory.set_context(
                    "last_prompt",
                    user_input,
                )

            # PRE-INVOCATION: Credit check — LOG ONLY, NEVER BLOCK execution
            if state.credit_manager and state.user_email and not state.is_admin and state.plan_info is not None:
                try:
                    (
                        has_credits,
                        _remaining,
                    ) = await state.credit_manager.check_credits_async(
                        state.user_email,
                        agent_name=state.model,
                    )
                    if not has_credits:
                        logger.info("credits_low", email=state.user_email)
                except Exception as credit_check_err:
                    logger.warning("credit_check_failed", error=str(credit_check_err))

            # ── Gemini Formatter Flow ──────────────────────────────
            # When formatter is ON: load key → validate → format → confirm → auto-OFF
            # API key is loaded from tenant secrets (one-time per tenant).
            # Formatter auto-disables after each single use.
            if state.formatter_enabled and state.formatter.enabled:
                # Step 1: Ensure we have a valid API key (load from platform if needed)
                _fmt_ready = state.formatter.has_api_key and state.formatter.key_validated
                if not _fmt_ready:
                    _tenant_id = state.tenant_ctx.tenant_id if state.tenant_ctx else None
                    _pc = state.platform_client
                    if _tenant_id and _pc:
                        _print_stream_msg("info", "Loading Gemini API key from tenant secrets...")
                        _fmt_ready = await state.formatter.load_key_from_platform(
                            tenant_id=_tenant_id,
                            platform_client=_pc,
                        )

                    if not _fmt_ready:
                        # Key not found or invalid — prompt user
                        _print_stream_msg("error", "Gemini API key not found or invalid.")
                        console.print(
                            Text(
                                "  Enter your Gemini API key (one-time setup, saved securely per tenant):",
                                style="bold #f59e0b",
                            ),
                        )
                        try:
                            _key_input = await asyncio.get_running_loop().run_in_executor(
                                None,
                                lambda: session.prompt(
                                    ANSI("\033[1;38;2;245;158;11m  API Key: \033[0m"),
                                ),
                            )
                        except (EOFError, KeyboardInterrupt):
                            _print_stream_msg("info", "Cancelled. Formatter OFF.")
                            state.formatter.enabled = False
                            state.formatter_enabled = False
                            continue

                        _key = _key_input.strip()
                        if not _key:
                            _print_stream_msg("error", "No key provided. Formatter OFF.")
                            state.formatter.enabled = False
                            state.formatter_enabled = False
                            continue

                        _print_stream_msg("info", "Validating API key...")
                        _valid = await state.formatter.validate_api_key(_key)
                        if not _valid:
                            _print_stream_msg(
                                "error",
                                "Invalid Gemini API key. Check https://aistudio.google.com/apikey",
                            )
                            state.formatter.enabled = False
                            state.formatter_enabled = False
                            continue

                        state.formatter.set_api_key(_key)
                        # Save to tenant secrets
                        if _tenant_id and _pc:
                            _print_stream_msg("info", "Saving key to tenant secrets...")
                            _saved = await state.formatter.save_key_to_platform(
                                tenant_id=_tenant_id,
                                platform_client=_pc,
                                api_key=_key,
                            )
                            if _saved:
                                _print_stream_msg(
                                    "success",
                                    "Key saved securely (encrypted, tenant-isolated).",
                                )
                            else:
                                _print_stream_msg("info", "Key set locally; platform save failed.")
                        _fmt_ready = True

                # Step 2: Format the prompt
                if _fmt_ready:
                    _print_stream_msg("info", "Formatting prompt via Gemini...")
                    try:
                        formatted = await state.formatter.format_prompt(user_input)
                        if formatted and formatted != user_input:
                            # Show formatted prompt for confirmation
                            console.print()
                            console.print(
                                Panel(
                                    Text(formatted, style="white"),
                                    title="[bold #f59e0b]Formatted Prompt[/bold #f59e0b]",
                                    subtitle="[dim]Enter=Send │ n=Cancel │ e=Edit[/dim]",
                                    border_style="#f59e0b",
                                ),
                            )
                            # Get confirmation
                            confirm = await asyncio.get_running_loop().run_in_executor(
                                None,
                                lambda: session.prompt(
                                    ANSI("\033[1;38;2;245;158;11m Send? (Y/n/e): \033[0m"),
                                ),
                            )
                            confirm = confirm.strip().lower()
                            if confirm in ("n", "no", "cancel"):
                                _print_stream_msg("info", "Cancelled. Using original prompt.")
                            elif confirm in ("e", "edit"):
                                _print_stream_msg("info", "Edit mode — type your revised prompt:")
                                edited = await asyncio.get_running_loop().run_in_executor(
                                    None,
                                    lambda: session.prompt(
                                        ANSI("\033[1;38;2;0;255;136m > \033[0m"),
                                    ),
                                )
                                if edited.strip():
                                    user_input = edited.strip()
                                    _print_stream_msg("success", "Using edited prompt.")
                            else:
                                # Accept formatted prompt (Enter or y)
                                user_input = formatted
                                _print_stream_msg("success", "Using formatted prompt.")
                        else:
                            _print_stream_msg("info", "Prompt unchanged after formatting.")
                    except Exception as fmt_err:
                        logger.warning("formatter_error", error=str(fmt_err))
                        _print_stream_msg(
                            "error",
                            f"Formatter error: {fmt_err}. Using original prompt.",
                        )

                # Auto-disable formatter after each execution (single-use)
                state.formatter.enabled = False
                state.formatter_enabled = False

            # ── Invoke via Agent Orchestrator ─────────────────────
            try:
                orchestrator = AgentOrchestrator(
                    model_id=state.model,
                    session_id=state.session_id,
                    tenant_ctx=state.tenant_ctx,
                    workspace_memory=state.workspace_memory,
                    browser_manager=state.browser_manager,
                )
                _last_orchestrator = orchestrator

                # Update agents status and mascot mood
                # Processing started
                state.is_streaming = True
                state.stream_tokens = 0
                state.stream_content = ""

                for agent in state.agents:
                    agent.status = "thinking"
                    agent.progress = 0
                    layout.add_agent_update(agent.id, "thinking", "Processing...")

                # Show processing indicator
                layout.add_preview_line(
                    "Processing...",
                    style=BRAND_WARNING,
                )

                # Show animated processing spinner with live progress
                start_time = time.monotonic()
                streamed_content = ""

                # Set ZooZoo mood to thinking during processing
                _zoozoo_mood = "thinking"

                # Show timestamped thinking indicator (matches reference agent style)
                if not _pipe_mode:
                    _print_stream_msg("agent", "Processing...")
                else:
                    logger.debug("pipe_mode_processing_started")

                # Stream tokens inline with live syntax highlighting
                _first_token = True
                _stream_token_count = 0
                _in_code_block = False
                _code_buffer = ""
                _code_lang = ""
                _backtick_buffer = ""

                def _render_code_block(lang: str, code: str) -> None:
                    """Render a completed code block with syntax highlighting."""
                    # Detect diff-style content (lines starting with + or -)
                    lines = code.split("\n")
                    has_diff = any(
                        ln.startswith("+") or ln.startswith("-")
                        for ln in lines
                        if ln.strip() and not ln.startswith("+++") and not ln.startswith("---")
                    )
                    effective_lang = "diff" if has_diff and not lang else (lang or "text")
                    # Print newline before code block
                    try:
                        syn = Syntax(
                            code.rstrip(),
                            effective_lang,
                            theme="monokai",
                            line_numbers=len(lines) > _MIN_LINES_FOR_NUMBERS,
                            word_wrap=True,
                            padding=(0, 1),
                        )
                        console.print(syn)
                    except Exception:
                        # Fallback: print code as plain text
                        for ln in lines:
                            sys.stdout.write(ln + "\n")
                        sys.stdout.flush()
                    # Print continuation prefix after code block
                    _ts = datetime.now().strftime("%H:%M:%S")

                def on_token(text: str) -> None:
                    nonlocal streamed_content, _first_token, _stream_token_count
                    nonlocal _in_code_block, _code_buffer, _code_lang, _backtick_buffer
                    if _first_token:
                        _first_token = False
                        # Clear the "Processing..." line and print agent prefix
                        _ts = datetime.now().strftime("%H:%M:%S")
                        # Agent response prefix ▸ in cyan (matches reference)
                    streamed_content += text

                    # Detect code block boundaries in streaming tokens
                    combined = _backtick_buffer + text
                    _backtick_buffer = ""

                    if not _in_code_block:
                        # Check for opening ```
                        if "```" in combined:
                            parts = combined.split("```", 1)
                            # Print text before the code fence
                            if parts[0]:
                                sys.stdout.write(parts[0])
                                sys.stdout.flush()
                            _in_code_block = True
                            # Extract language hint (e.g., ```python)
                            lang_part = parts[1].split("\n", 1)
                            _code_lang = lang_part[0].strip().lower()
                            _code_buffer = lang_part[1] if len(lang_part) > 1 else ""
                            # Show opening indicator
                        elif combined.endswith("`") or combined.endswith("``"):
                            # Partial backtick sequence — buffer it
                            idx = len(combined) - 1
                            while idx >= 0 and combined[idx] == "`":
                                idx -= 1
                            tick_start = idx + 1
                            _backtick_buffer = combined[tick_start:] if tick_start < len(combined) else combined
                        else:
                            sys.stdout.write(combined)
                            sys.stdout.flush()
                    else:
                        # Inside code block — accumulate and check for closing ```
                        _code_buffer += combined
                        if "```" in _code_buffer:
                            parts = _code_buffer.split("```", 1)
                            _render_code_block(_code_lang, parts[0])
                            _in_code_block = False
                            _code_buffer = ""
                            _code_lang = ""
                            # Print any text after closing fence
                            if parts[1].strip():
                                sys.stdout.write(parts[1])
                                sys.stdout.flush()

                    # Track approximate token count during streaming (1 token ~ 4 chars)
                    _chunk_tokens = max(1, len(text) // 4)
                    _stream_token_count += _chunk_tokens
                    if _stream_token_count % 20 < _chunk_tokens:
                        state.tokens_used += _chunk_tokens

                try:
                    response = await orchestrator.chat_stream(
                        message=user_input,
                        image_paths=state.pending_image_paths,
                        _conversation_history=state.conversation,
                        mode=state.mode,
                        on_token=on_token,
                    )
                    agent_response_text = response.content
                    # Flush any unclosed code block at end of stream
                    if _in_code_block and _code_buffer:
                        _render_code_block(_code_lang, _code_buffer)
                    elif _backtick_buffer:
                        pass

                    # Clean up images
                    state.pending_image_paths = []
                except Exception as agent_err:
                    logger.warning("orchestrator_chat_failed", error=str(agent_err))
                    agent_response_text = ""
                    _print_stream_msg("error", f"Agent error: {agent_err}")

                # Clean up orchestrator
                await _close_orchestrator_if_exists(orchestrator)
                _last_orchestrator = None

                # ── Auto-extract and write files from agent response ──
                # Parses FILE: path/to/file blocks and writes to working directory
                if agent_response_text:
                    _file_pattern = re.compile(
                        r"FILE:\s*(.+?)\n```\w*\n(.*?)```",
                        re.DOTALL,
                    )
                    _extracted = _file_pattern.findall(agent_response_text)
                    if _extracted:
                        _print_stream_msg("info", f"Extracting {len(_extracted)} file(s)...")
                        for _fpath, _fcode in _extracted:
                            _fpath = _fpath.strip()
                            _full_path = Path(_fpath) if Path(_fpath).is_absolute() else Path.cwd() / _fpath
                            try:
                                _full_path.parent.mkdir(parents=True, exist_ok=True)
                                _full_path.write_text(_fcode.strip() + "\n", encoding="utf-8")
                                _print_stream_msg("change", f"{_fpath}")
                            except Exception as _write_err:
                                _print_stream_msg("error", f"Failed to write {_fpath}: {_write_err}")

                # ── Persist activity to tenant memory ──────────────
                # Key activities retained across sessions
                if state.tenant_memory and agent_response_text:
                    _resp_preview = agent_response_text[:300]
                    state.tenant_memory.set_context(
                        "last_response",
                        _resp_preview,
                    )
                    state.tenant_memory.set_context(
                        "last_activity",
                        f"prompt: {user_input[:200]} | response_len: {len(agent_response_text)}",
                    )

                # Mark processing complete — update ZooZoo + latency
                state.is_streaming = False
                execution_time = time.monotonic() - start_time
                _zoozoo_mood = "success"
                _last_response_latency_ms = int(execution_time * 1000)

                # Mark agents complete
                for agent in state.agents:
                    agent.status = "complete"
                    agent.progress = 100
                    layout.add_agent_update(agent.id, "complete", "Done")

                # Display final response with beautiful themed panel
                active_theme = get_theme(state.active_theme)
                resp_tokens = _stream_token_count
                resp_header_parts: list[str] = []
                resp_header_parts.append(
                    f"[{active_theme.info}]\u25c8 ODIN[/]",
                )
                if resp_tokens:
                    resp_header_parts.append(f"[{active_theme.warning}]{resp_tokens:,} tokens[/]")
                if execution_time:
                    resp_header_parts.append(f"[dim]{execution_time:.1f}s[/dim]")
                resp_title = " \u2502 ".join(resp_header_parts) if resp_header_parts else "Response"

                # Show final response
                if _pipe_mode and not streamed_content:
                    # Print non-streamed response text in pipe mode
                    if agent_response_text:
                        print(agent_response_text)
                elif _pipe_mode and streamed_content:
                    # Streamed content already printed; show compact stats
                    _parts = []
                    if resp_tokens:
                        _parts.append(f"{resp_tokens:,} tokens")
                    if execution_time:
                        _parts.append(f"{execution_time:.1f}s")
                    if _parts:
                        print(" | ".join(_parts), flush=True)
                elif streamed_content:
                    # Timestamped completion status (matches reference success style)
                    _done_parts = "Response complete"
                    if resp_tokens:
                        _done_parts += f"  [{resp_tokens:,} tokens]"
                    if execution_time:
                        _done_parts += f"  {execution_time:.1f}s"
                    _print_stream_msg("success", _done_parts)
                elif agent_response_text:
                    # Non-streamed fallback — render as panel
                    console.print(
                        Panel(
                            Markdown(agent_response_text),
                            title=resp_title,
                            title_align="left",
                            border_style=active_theme.success,
                            box=ROUNDED,
                            padding=(1, 2),
                        ),
                    )
                else:
                    # Empty response — show minimal message
                    console.print(
                        Text(
                            "  No response received.",
                            style=f"italic {active_theme.warning}",
                        ),
                    )

                # Use streamed content or agent response text
                _final_content = streamed_content or agent_response_text or ""

                # Update state
                state.conversation.append({"role": "assistant", "content": _final_content})

                # AUTO-FILE-SAVE: Extract code blocks and save as files
                _saved_files = _auto_save_files_from_response(
                    _final_content,
                    str(Path.cwd()),
                )
                if _saved_files:
                    if _pipe_mode:
                        for _sf in _saved_files:
                            print(_sf, flush=True)
                    else:
                        _print_stream_msg("success", f"Auto-saved: {', '.join(_saved_files)}")

                # Update token count
                state.tokens_used += _stream_token_count
                state.stream_tokens = _stream_token_count

                # Persist assistant response to conversation history
                if state.conversation_history:
                    state.conversation_history.add_assistant_message(
                        _final_content,
                    )

                # Extract insights from response into workspace memory
                if state.workspace_memory and _final_content:
                    try:
                        insights = WorkspaceMemory.extract_insights(
                            _final_content,
                            user_input,
                        )
                        for ins in insights:
                            state.workspace_memory.add_insight(
                                ins,
                                state.session_id,
                            )
                    except Exception:
                        logger.debug(
                            "workspace_insight_extraction_failed",
                            exc_info=True,
                        )

                # Periodic workspace rescan every N messages
                state._ws_message_count += 1
                if state.workspace_memory and state._ws_message_count % _WS_RESCAN_INTERVAL == 0:
                    try:
                        state.workspace_memory.lightweight_rescan()
                        logger.debug(
                            "workspace_periodic_rescan",
                            msg_count=state._ws_message_count,
                        )
                    except Exception:
                        logger.debug(
                            "workspace_periodic_rescan_failed",
                            exc_info=True,
                        )

                # POST-INVOCATION: Deduct credit after successful response
                if state.credit_manager and state.user_email and state.plan_info is not None:
                    try:
                        txn = await state.credit_manager.deduct_credit_async(
                            email=state.user_email,
                            agent_name=state.model,
                            action="aicippy_interactive_chat",
                            is_ultrathinking=(state.mode == "ultrathinking"),
                            is_web_research=(state.mode == "research"),
                            tokens=state.stream_tokens,
                        )
                        if txn.success:
                            state.credits_remaining = txn.credits_remaining
                        else:
                            logger.warning(
                                "credit_deduction_failed_post_response",
                                error=txn.error,
                                email=state.user_email,
                            )
                    except Exception as deduct_err:
                        logger.warning(
                            "credit_deduction_exception",
                            error=str(deduct_err),
                        )

                # Command detection disabled — Confirm.ask blocks stdin and
                # intercepts slash commands like /quit.  Users can copy-paste
                # any commands shown in the AI response.

                # Complete task
                state.complete_task()

                # No divider between conversations — outer layout stays fixed,
                # only the chat area scrolls with new content.

                # Reset agents to idle
                for agent in state.agents:
                    agent.status = "idle"
                # Processing complete

            except Exception as e:
                logger.exception("chat_error", error=str(e))
                layout.add_error(str(e))
                console.print(
                    create_error_panel(str(e), suggestion="Check your connection and try again"),
                )

                # Reset streaming and mascot state on error
                state.is_streaming = False
                _zoozoo_mood = "error"
                # Processing complete

                # Close orchestrator on error to free resources
                await _close_orchestrator_if_exists(_last_orchestrator)
                _last_orchestrator = None

                # Mark task as failed
                if state.tasks and state.current_task_idx < len(state.tasks):
                    state.tasks[state.current_task_idx].status = "pending"

        except EOFError:
            # Ctrl+D: save state before exiting
            _save_session_state(state)
            running = False
        except KeyboardInterrupt:
            # Second Ctrl+C arrives here from the signal handler
            _save_session_state(state)
            running = False

    # Reset VT100 scroll region
    import sys
    sys.stdout.write("\x1b[r")
    sys.stdout.flush()

    # Stop background auto-updater
    if bg_updater is not None:
        try:
            bg_updater.stop()
        except Exception:
            logger.warning("auto_updater_stop_failed", exc_info=True)

    # Ensure any remaining orchestrator is closed
    await _close_orchestrator_if_exists(_last_orchestrator)

    # Cancel any pending async tasks to prevent zombie coroutines
    await _cancel_pending_tasks()

    # Save session state (unified helper - idempotent if already saved)
    memory_entries_saved = _save_session_state(state)

    logger.info(
        "session_ended",
        session_id=session_id,
        tokens_used=state.tokens_used,
        messages=len(state.conversation),
        duration=state.get_session_duration(),
        memory_entries=memory_entries_saved,
    )

    # Farewell message with waving mascot and accurate session stats
    console.print()

    # Create farewell with mascot waving goodbye
    animator = MascotAnimator(mood=MascotMood.WAVING, use_mini=True)
    frames = animator.get_frames()
    farewell_mascot = animator.render_frame(frames[0])

    farewell_text = Text()
    farewell_text.append("\n")
    farewell_text.append(f" {STATUS_ICONS['complete']} ", style=BRAND_SUCCESS)
    farewell_text.append("Session ended. ", style=f"bold {BRAND_PRIMARY}")
    farewell_text.append("Goodbye!", style=f"bold {MASCOT_PRIMARY}")
    farewell_text.append(f"\n\n   Tokens used:      {state.tokens_used:,}", style="dim")
    farewell_text.append(f"\n   Messages sent:    {len(state.conversation)}", style="dim")
    farewell_text.append(f"\n   Session duration: {state.get_session_duration()}", style="dim")
    farewell_text.append(f"\n   Memory entries:   {memory_entries_saved}", style="dim")
    # Show credits used this session
    if state.credit_manager:
        credits_used = state.credit_manager.session_credits_used
        farewell_text.append(f"\n   Credits used:     {credits_used}", style="dim")
        farewell_text.append(f"\n   Credits left:     {state.credits_remaining}", style="dim")
    farewell_text.append("\n\n   See you next time!", style=f"italic {BRAND_ACCENT}")

    console.print(
        Panel(
            Group(
                Align.center(farewell_mascot),
                farewell_text,
            ),
            border_style=BRAND_PRIMARY,
            title="[bold]Bye![/bold]",
            title_align="center",
        ),
    )
