"""Interactive CLI for the /api/v2/agent/ws endpoint."""

from __future__ import annotations

import argparse
import asyncio
import contextlib
import json
import os
import signal
import subprocess
import sys
import time
from importlib import metadata
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Any, Callable, Optional
from uuid import uuid4

from dotenv import load_dotenv
from rich.console import Console
from rich.prompt import Prompt

from cli.file_api_client import LocalAttachment, upload_attachments
from cli.renderer import (
    AskPromptRequest,
    RENDER_PROFILE_IM,
    RENDER_PROFILE_TERMINAL,
    StreamRenderer,
)
from cli.oauth import (
    InvalidTokenError,
    _env_default,
    clear_saved_tokens,
    fetch_lingxing_account,
    fetch_user_id_from_token,
    force_refresh_token,
    get_valid_token,
    load_saved_tokens,
    resolve_frontend_base_url,
    resolve_oauth_base_url,
    resolve_oauth_client_id,
    run_login_flow,
    save_tokens,
    _token_is_still_valid,
    refresh_tokens,
)
from cli.session_store import LuckeeSessionStore
from cli.ws_client import AgentWsV3Client, SessionBindingUpdate, WsClientConfig


DEFAULT_USER_ID = "test_user"
DEFAULT_WHEEL_SERVER_URL = "http://localhost:11113"
PACKAGE_NAME = "luckee_cli"
CLI_RELEASE_TAG = "cli-v20260319104448-167-d2ca8fd"
DEFAULT_SCENARIO = "keyword"
DEFAULT_FREEFORM_ANSWER = "later"
TERMINAL_MESSAGE_TYPES = {"complete", "terminated", "_connection_error"}
METADATA_ONLY_MESSAGE_TYPES = {"metadata", "config", "topic_message"}
ASK_MESSAGE_TYPES = {"ask_message", "ask_user_input", "askuserinput"}
COLLECTION_PLAN_MESSAGE_TYPES = {"collection_plan", "collect_plan", "collect-plan"}
DEFAULT_INTERACTIVE_PLACEHOLDER = "Ask Luckee anything or run /new to open a new session"
PROMPT_SHELL_REPLAY_IDLE_SECONDS = 0.75
PROMPT_SHELL_REPLAY_HARD_IDLE_SECONDS = 3.0
PROGRESS_MESSAGE_TYPES = {
    "thinking_commentary",
    "commentary",
    "child_step_commentary",
    "inter_child_text",
    "agent_thinking",
    "step_discovered",
    "step_started",
    "step_progress",
    "heartbeat",
}
_WINDOWS_UTF8_STREAMS_CONFIGURED = False
_WINDOWS_VT_ENABLED = False


def _normalize_message_type(raw_type: Any) -> str:
    msg_type = str(raw_type or "")
    if msg_type in ASK_MESSAGE_TYPES:
        return "ask_message"
    if msg_type in COLLECTION_PLAN_MESSAGE_TYPES:
        return "collection_plan"
    return msg_type


@dataclass(frozen=True)
class CliErrorInfo:
    """User-facing error summary for startup and connect failures."""

    category: str
    summary: str
    hint: str
    detail: Optional[str] = None


class CliInvocationError(RuntimeError):
    """Raised when the CLI invocation contract is invalid before connect."""

    def __init__(
        self,
        *,
        category: str,
        summary: str,
        hint: str,
        detail: Optional[str] = None,
    ) -> None:
        super().__init__(summary)
        self.category = category
        self.summary = summary
        self.hint = hint
        self.detail = detail


@dataclass
class InteractivePromptState:
    language: Optional[str]
    thread_id: Optional[str]
    pending_attachment_count: int
    hide_session_details: bool = False


@dataclass(frozen=True)
class SlashCommandSpec:
    insert_text: str
    display_text: str
    description: str


@dataclass
class AskPromptQuestionState:
    question_text: str
    labels: list[str]
    selected_index: int = 0
    custom_text: str = ""
    use_custom_text: bool = False
    answered: bool = False


class AskPromptNavigator:
    def __init__(
        self,
        ask_request: AskPromptRequest,
        *,
        default_freeform_answer: str,
    ) -> None:
        self.message_id = ask_request.message_id
        self.default_freeform_answer = default_freeform_answer
        self.questions = [
            AskPromptQuestionState(
                question_text=str(question.get("question") or ""),
                labels=[
                    str(option.get("label"))
                    for option in (question.get("options") or [])
                    if option.get("label")
                ],
            )
            for question in ask_request.questions
        ]
        self.current_index = 0
        self.custom_mode = bool(self.questions and not self.questions[0].labels)

    @property
    def current(self) -> AskPromptQuestionState:
        return self.questions[self.current_index]

    def move_question(self, delta: int) -> None:
        if not self.questions:
            return
        self.current_index = (self.current_index + delta) % len(self.questions)
        self.custom_mode = self.current.use_custom_text or not self.current.labels

    def move_option(self, delta: int) -> None:
        if self.custom_mode or not self.current.labels:
            return
        total = len(self.current.labels)
        self.current.selected_index = (self.current.selected_index + delta) % total

    def toggle_custom_mode(self) -> None:
        if not self.current.labels:
            self.custom_mode = True
            return
        self.custom_mode = not self.custom_mode
        if not self.custom_mode and self.current.use_custom_text:
            self.current.use_custom_text = False

    def append_custom_text(self, value: str) -> None:
        self.custom_mode = True
        self.current.custom_text += value

    def backspace_custom_text(self) -> None:
        if not self.custom_mode:
            return
        self.current.custom_text = self.current.custom_text[:-1]

    def cancel_custom_mode(self) -> None:
        if not self.current.labels:
            return
        self.custom_mode = False

    def _resolved_answer(self, question: AskPromptQuestionState) -> str:
        if question.use_custom_text or not question.labels:
            text = question.custom_text.strip()
            return text or self.default_freeform_answer
        return question.labels[question.selected_index]

    def accept_current(self) -> Optional[dict[str, str]]:
        question = self.current
        if self.custom_mode:
            question.use_custom_text = True
        elif question.labels:
            question.use_custom_text = False

        question.answered = True
        next_unanswered = self._next_unanswered_index(start=self.current_index + 1)
        if next_unanswered is not None:
            self.current_index = next_unanswered
            self.custom_mode = (
                self.current.use_custom_text or not self.current.labels
            )
            return None
        return self.answers()

    def answers(self) -> dict[str, str]:
        return {
            question.question_text: self._resolved_answer(question)
            for question in self.questions
            if question.question_text
        }

    def _next_unanswered_index(self, *, start: int) -> Optional[int]:
        if not self.questions:
            return None
        total = len(self.questions)
        for offset in range(total):
            idx = (start + offset) % total
            if not self.questions[idx].answered:
                return idx
        return None

    def render_fragments(self) -> list[tuple[str, str]]:
        if not self.questions:
            return [("", "No questions.")]

        fragments: list[tuple[str, str]] = []
        fragments.append(("class:ask-title", f"Need input · {self.message_id}\n"))
        fragments.append(("class:ask-hint", "Use ↑↓ to choose • ←→ to switch • Enter to confirm • Tab custom answer\n\n"))

        for idx, question in enumerate(self.questions):
            marker = "✓" if question.answered else ("●" if idx == self.current_index else "○")
            style = "class:ask-tab-current" if idx == self.current_index else "class:ask-tab"
            fragments.append((style, f" {marker} Q{idx + 1} "))
        fragments.append(("", "\n\n"))

        current = self.current
        fragments.append(("class:ask-question", f"{current.question_text}\n"))

        if current.labels:
            for idx, label in enumerate(current.labels):
                is_selected = idx == current.selected_index and not self.custom_mode
                prefix = "❯" if is_selected else " "
                marker = "◉" if is_selected else "○"
                style = "class:ask-option-selected" if is_selected else "class:ask-option"
                fragments.append((style, f" {prefix} {marker} {label}\n"))
        else:
            fragments.append(("class:ask-hint", " Free-form answer required\n"))

        if self.custom_mode:
            custom_text = current.custom_text or ""
            fragments.append(("", "\n"))
            fragments.append(("class:ask-custom-label", "Custom answer: "))
            fragments.append(("class:ask-custom-text", custom_text))
            fragments.append(("class:ask-custom-cursor", "▌"))
            fragments.append(("", "\n"))
        elif current.labels:
            fragments.append(("", "\n"))
            fragments.append(
                ("class:ask-hint", "Tab to type a custom answer for this question\n")
            )

        return fragments


@dataclass
class SessionPickerEntry:
    thread_id: str
    created_at: str
    updated_at: str
    branch: str
    search_text: str


class SessionPickerNavigator:
    def __init__(self, sessions: list[dict[str, Any]]) -> None:
        self.entries = [
            SessionPickerEntry(
                thread_id=str(item.get("thread_id") or "").strip(),
                created_at=_format_relative_time(item.get("created_at")),
                updated_at=_format_relative_time(item.get("updated_at")),
                branch=(
                    str(item.get("branch") or "-")
                    .replace("—", "-")
                    .replace("–", "-")
                    .replace("―", "-")
                    .strip()
                    or "-"
                ),
                search_text=" ".join(
                    value
                    for value in (
                        str(item.get("thread_id") or "").strip(),
                        str(item.get("branch") or "").strip(),
                        str(item.get("conversation") or "").strip(),
                        str(item.get("cwd") or "").strip(),
                    )
                    if value
                ),
            )
            for item in sessions
            if str(item.get("thread_id") or "").strip()
        ]
        self.filter_text = ""
        self.selected_index = 0

    def _filtered_entries(self) -> list[SessionPickerEntry]:
        token = self.filter_text.strip().lower()
        if not token:
            return self.entries
        return [
            entry
            for entry in self.entries
            if token in entry.thread_id.lower()
            or token in entry.branch.lower()
            or token in entry.search_text.lower()
        ]

    def move(self, delta: int) -> None:
        entries = self._filtered_entries()
        if not entries:
            self.selected_index = 0
            return
        self.selected_index = (self.selected_index + delta) % len(entries)

    def append_filter(self, value: str) -> None:
        self.filter_text += value
        self.selected_index = 0

    def backspace_filter(self) -> None:
        self.filter_text = self.filter_text[:-1]
        self.selected_index = 0

    def current_thread_id(self) -> Optional[str]:
        entries = self._filtered_entries()
        if not entries:
            return None
        index = min(self.selected_index, len(entries) - 1)
        return entries[index].thread_id

    def render_fragments(self) -> list[tuple[str, str]]:
        fragments: list[tuple[str, str]] = []
        fragments.append(("class:ask-title", "Resume a previous session\n"))
        fragments.append(("class:ask-hint", "Sort: Updated at\n"))
        fragments.append(("class:ask-hint", "Type to search | ↑↓ select | Enter resume | Esc cancel\n\n"))
        fragments.append(("class:ask-hint", "Thread ID                              Updated at      Created at\n"))

        entries = self._filtered_entries()
        if not entries:
            fragments.append(("class:ask-option", "  No saved sessions match your search\n"))
        else:
            selected = min(self.selected_index, len(entries) - 1)
            self.selected_index = selected
            for index, entry in enumerate(entries):
                is_selected = index == selected
                marker = "▶" if is_selected else " "
                style = "class:ask-option-selected" if is_selected else "class:ask-option"
                row = (
                    f"{marker} {entry.thread_id:<36}  {entry.updated_at:<14}  "
                    f"{entry.created_at}"
                )
                fragments.append((style, row + "\n"))

        fragments.append(("", "\n"))
        fragments.append(("class:ask-custom-label", "Search: "))
        fragments.append(("class:ask-custom-text", self.filter_text))
        fragments.append(("class:ask-custom-cursor", "█"))
        fragments.append(("", "\n"))
        return fragments


def _short_thread_label(thread_id: Optional[str], *, hide_session_details: bool) -> str:
    if hide_session_details:
        return "(hidden)"
    if not thread_id:
        return "(awaiting metadata)"
    if len(thread_id) <= 12:
        return thread_id
    return f"{thread_id[:8]}...{thread_id[-4:]}"


def _build_interactive_placeholder() -> str:
    return "Ask Luckee anything or type / for commands"


def _build_slash_command_specs() -> list[SlashCommandSpec]:
    return [
        SlashCommandSpec("/help", "/help", "show available commands"),
        SlashCommandSpec("/new", "/new", "open a fresh session"),
        SlashCommandSpec("/resume ", "/resume <thread_id>", "resume a specific thread"),
        SlashCommandSpec("/thread", "/thread", "show current thread id"),
        SlashCommandSpec("/attach ", "/attach <path>", "queue a local attachment"),
        SlashCommandSpec("/attachments", "/attachments", "list queued attachments"),
        SlashCommandSpec("/detach ", "/detach <index|all>", "remove queued attachments"),
        SlashCommandSpec("/clear", "/clear", "clear terminal output"),
        SlashCommandSpec("/interrupt", "/interrupt", "interrupt the running turn"),
        SlashCommandSpec("/upgrade", "/upgrade", "upgrade the CLI"),
        SlashCommandSpec("/thinking", "/thinking", "list cached thinking blocks"),
        SlashCommandSpec("/exit", "/exit", "exit the shell"),
    ]


def _build_interactive_toolbar(state: InteractivePromptState) -> str:
    parts = [
        f"language {state.language or '-'}",
        f"thread {_short_thread_label(state.thread_id, hide_session_details=state.hide_session_details)}",
    ]
    if state.pending_attachment_count > 0:
        suffix = "s" if state.pending_attachment_count != 1 else ""
        parts.append(f"attachments {state.pending_attachment_count} queued{suffix}")
    parts.append("type / for commands")
    parts.append("/new fresh session")
    return " | ".join(parts)


def _build_prompt_message() -> list[tuple[str, str]]:
    return [("class:prompt", "> ")]


def _build_prompt_placeholder_formatted() -> list[tuple[str, str]]:
    return [("class:placeholder", _build_interactive_placeholder())]


def _build_prompt_toolbar_formatted(
    state: InteractivePromptState,
) -> list[tuple[str, str]]:
    return [("class:bottom-toolbar", _build_interactive_toolbar(state))]


def _load_prompt_toolkit_components() -> Optional[dict[str, Any]]:
    _configure_prompt_toolkit_runtime()
    try:
        from prompt_toolkit import PromptSession
        from prompt_toolkit.completion import Completer, Completion
        from prompt_toolkit.patch_stdout import patch_stdout
        from prompt_toolkit.styles import Style
    except Exception:
        return None

    style = Style.from_dict(
        {
            "prompt": "bold ansibrightcyan",
            "placeholder": "italic ansibrightblack",
            "bottom-toolbar": "ansibrightblack bg:ansiblack",
        }
    )
    return {
        "PromptSession": PromptSession,
        "Completer": Completer,
        "Completion": Completion,
        "patch_stdout": patch_stdout,
        "style": style,
    }


def _configure_prompt_toolkit_runtime() -> None:
    if _is_truthy_env(os.getenv("LUCKEE_ENABLE_PROMPT_CPR")):
        return
    os.environ.setdefault("PROMPT_TOOLKIT_NO_CPR", "1")


def _load_prompt_toolkit_ask_components() -> Optional[dict[str, Any]]:
    _configure_prompt_toolkit_runtime()
    try:
        from prompt_toolkit.application import Application
        from prompt_toolkit.key_binding import KeyBindings
        from prompt_toolkit.layout import Layout
        from prompt_toolkit.layout.containers import HSplit, Window
        from prompt_toolkit.layout.controls import FormattedTextControl
        from prompt_toolkit.styles import Style
        from prompt_toolkit.widgets import Frame
    except Exception:
        return None

    style = Style.from_dict(
        {
            "ask-title": "bold ansibrightcyan",
            "ask-hint": "ansibrightblack",
            "ask-question": "bold",
            "ask-tab": "ansibrightblack",
            "ask-tab-current": "bold ansibrightcyan",
            "ask-option": "",
            "ask-option-selected": "bold ansibrightgreen",
            "ask-custom-label": "bold ansibrightmagenta",
            "ask-custom-text": "",
            "ask-custom-cursor": "bold ansibrightcyan",
        }
    )
    return {
        "Application": Application,
        "KeyBindings": KeyBindings,
        "Layout": Layout,
        "HSplit": HSplit,
        "Window": Window,
        "FormattedTextControl": FormattedTextControl,
        "Frame": Frame,
        "style": style,
    }


class InteractiveShellInput:
    """TTY-aware prompt that falls back to built-in input() when unavailable."""

    def __init__(self) -> None:
        components = _load_prompt_toolkit_components()
        self._prompt_session = None
        self._patch_stdout = None
        self._style = None
        self._completer = None
        if components is not None:
            try:
                spec_items = _build_slash_command_specs()
                completion_cls = components["Completion"]
                completer_base = components["Completer"]

                class _SlashCommandCompleter(completer_base):
                    def get_completions(self, document, complete_event):
                        text_before_cursor = document.text_before_cursor.lstrip()
                        if not text_before_cursor.startswith("/"):
                            return

                        typed = text_before_cursor.split(maxsplit=1)[0]
                        for spec in spec_items:
                            candidate = spec.insert_text.strip()
                            if typed and not candidate.startswith(typed):
                                continue
                            yield completion_cls(
                                text=spec.insert_text,
                                start_position=-len(typed),
                                display=spec.display_text,
                                display_meta=spec.description,
                            )

                self._prompt_session = components["PromptSession"]()
                self._completer = _SlashCommandCompleter()
                self._patch_stdout = components["patch_stdout"]
                self._style = components["style"]
            except Exception:
                self._prompt_session = None
                self._completer = None
                self._patch_stdout = None
                self._style = None

    @property
    def uses_prompt_toolkit(self) -> bool:
        return self._prompt_session is not None and self._patch_stdout is not None

    def stdout_patch_context(self):
        if not self.uses_prompt_toolkit:
            return contextlib.nullcontext()
        return self._patch_stdout()

    async def read_line(self, *, state: InteractivePromptState) -> str:
        if not self.uses_prompt_toolkit:
            return await asyncio.to_thread(
                _read_raw_shell_input,
                "\n> ",
                _build_interactive_placeholder(),
            )

        with self.stdout_patch_context():
            return await self._prompt_session.prompt_async(
                message=_build_prompt_message(),
                placeholder=_build_prompt_placeholder_formatted(),
                bottom_toolbar=_build_prompt_toolbar_formatted(state),
                completer=self._completer,
                complete_while_typing=True,
                style=self._style,
                refresh_interval=0,
                mouse_support=False,
                reserve_space_for_menu=8,
            )


def _is_truthy_env(value: Optional[str]) -> bool:
    if value is None:
        return False
    return value.strip().lower() in {"1", "true", "yes", "on"}


def _clean_optional_str(value: Any) -> Optional[str]:
    if value is None:
        return None
    text = str(value).strip()
    return text or None


def _is_windows_runtime() -> bool:
    return os.name == "nt"


def _reconfigure_utf8_stream(stream: Any) -> bool:
    reconfigure = getattr(stream, "reconfigure", None)
    if not callable(reconfigure):
        return False
    try:
        reconfigure(encoding="utf-8", errors="replace")
        return True
    except TypeError:
        try:
            reconfigure(encoding="utf-8")
            return True
        except Exception:
            return False
    except Exception:
        return False


def _get_windows_kernel32() -> Any:
    if not _is_windows_runtime():
        return None
    try:
        import ctypes

        return ctypes.windll.kernel32
    except Exception:
        return None


def _enable_windows_vt_mode(kernel32: Any, std_handle: int) -> bool:
    if kernel32 is None:
        return False
    try:
        import ctypes
    except Exception:
        return False

    handle = kernel32.GetStdHandle(std_handle)
    if handle in (None, 0, -1):
        return False

    mode = ctypes.c_uint()
    if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
        return False

    ENABLE_PROCESSED_OUTPUT = 0x0001
    ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
    desired_mode = (
        mode.value | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING
    )
    if desired_mode == mode.value:
        return True
    return bool(kernel32.SetConsoleMode(handle, desired_mode))


def _initialize_console_runtime() -> None:
    global _WINDOWS_UTF8_STREAMS_CONFIGURED, _WINDOWS_VT_ENABLED

    utf8_stream_configured = False
    for stream in (sys.stdin, sys.stdout, sys.stderr):
        utf8_stream_configured = _reconfigure_utf8_stream(stream) or utf8_stream_configured
    _WINDOWS_UTF8_STREAMS_CONFIGURED = utf8_stream_configured

    if not _is_windows_runtime():
        return

    kernel32 = _get_windows_kernel32()
    if kernel32 is None:
        return

    try:
        kernel32.SetConsoleOutputCP(65001)
    except Exception:
        pass
    try:
        kernel32.SetConsoleCP(65001)
    except Exception:
        pass

    if "utf" in (getattr(sys.stdout, "encoding", None) or "").lower():
        _WINDOWS_UTF8_STREAMS_CONFIGURED = True

    _WINDOWS_VT_ENABLED = any(
        _enable_windows_vt_mode(kernel32, std_handle)
        for std_handle in (-11, -12)
    )


def _read_raw_shell_input(
    prompt: str = "\n> ", placeholder: Optional[str] = None
) -> str:
    if placeholder:
        sys.stdout.write(f"\n{placeholder}\n")
    sys.stdout.write(prompt)
    sys.stdout.flush()
    line = sys.stdin.readline()
    if line == "":
        raise EOFError
    return line.rstrip("\r\n")


def _build_console(*, plain_output: bool) -> Console:
    ansi_supported = _stdout_supports_ansi()
    return Console(
        force_terminal=not plain_output and ansi_supported,
        no_color=plain_output or not ansi_supported,
        color_system=None if plain_output or not ansi_supported else "auto",
        soft_wrap=True,
    )


def _print_console_notice(console: Console, text: str, *, style: Optional[str] = None) -> None:
    if getattr(console, "no_color", False):
        console.print(text)
        return
    if style:
        console.print(f"[{style}]{text}[/{style}]")
        return
    console.print(text)


def _config_indicates_openclaw_feishu(config_payload: Any) -> bool:
    if config_payload is None:
        return False

    if isinstance(config_payload, str):
        lowered = config_payload.lower()
        return (
            "openclaw" in lowered
            and ("feishu" in lowered or "lark" in lowered or "channel" in lowered)
        ) or "channel:feishu" in lowered

    if isinstance(config_payload, dict):
        for key, value in config_payload.items():
            key_l = str(key).lower()
            if key_l in {"channel", "source_channel", "platform", "source_platform"}:
                value_l = str(value).lower()
                if value_l in {"feishu", "lark", "openclaw_feishu"}:
                    return True
            if key_l in {"source", "client", "platform_name"} and str(value).lower() == "openclaw":
                return True
            if _config_indicates_openclaw_feishu(value):
                return True
        return False

    if isinstance(config_payload, (list, tuple)):
        return any(_config_indicates_openclaw_feishu(item) for item in config_payload)

    return False


def _should_hide_session_details(args: argparse.Namespace, config_payload: Any) -> bool:
    if args.hide_session_details:
        return True

    # Manual env override keeps behavior explicit when needed.
    if _is_truthy_env(os.getenv("AGENT_CLI_HIDE_SESSION_DETAILS")):
        return True

    # Auto-hide for OpenClaw Feishu/Lark channel if passed via config payload.
    if _config_indicates_openclaw_feishu(config_payload):
        return True

    # Auto-hide in Feishu/Lark environments when related env vars exist.
    known_markers = (
        "RUNNING_IN_FEISHU",
        "RUNNING_IN_LARK",
        "FEISHU_APP_ID",
        "LARK_APP_ID",
        "FEISHU_WEBHOOK",
        "LARK_WEBHOOK",
    )
    if any(os.getenv(key) for key in known_markers):
        return True

    return any(
        key.startswith(("FEISHU_", "LARK_")) and bool(os.getenv(key))
        for key in os.environ
    )


def _stdin_is_interactive() -> bool:
    try:
        return bool(sys.stdin and sys.stdin.isatty())
    except Exception:
        return False


def _stdout_supports_unicode() -> bool:
    if _WINDOWS_UTF8_STREAMS_CONFIGURED:
        return True
    encoding = (getattr(sys.stdout, "encoding", None) or "").lower()
    return "utf" in encoding or "utf-" in encoding


def _stdout_supports_ansi() -> bool:
    try:
        if not bool(sys.stdout and sys.stdout.isatty()):
            return False
    except Exception:
        return False

    if _is_truthy_env(os.getenv("NO_COLOR")):
        return False

    if not _is_windows_runtime():
        return True

    if _WINDOWS_VT_ENABLED:
        return True

    if any(
        os.getenv(key)
        for key in ("WT_SESSION", "ANSICON", "TERM", "ConEmuANSI", "TERM_PROGRAM")
    ):
        return True
    return False


def _should_use_prompt_shell(args: argparse.Namespace) -> bool:
    if _is_truthy_env(os.getenv("LUCKEE_DISABLE_PROMPT_SHELL")):
        return False
    if getattr(args, "prompt_shell", False):
        return True
    if _is_truthy_env(os.getenv("LUCKEE_ENABLE_PROMPT_SHELL")):
        return True
    return (
        _stdin_is_interactive()
        and _stdout_supports_ansi()
        and _stdout_supports_unicode()
        and _load_prompt_toolkit_components() is not None
    )


def _is_one_shot_query(args: argparse.Namespace) -> bool:
    return bool(_clean_optional_str(getattr(args, "query", None)))


def _should_use_non_interactive(args: argparse.Namespace, config_payload: Any) -> bool:
    if args.non_interactive:
        return True
    if _is_one_shot_query(args):
        return True
    if _config_indicates_openclaw_feishu(config_payload):
        return True
    return not _stdin_is_interactive()


def _should_use_plain_output(args: argparse.Namespace, config_payload: Any) -> bool:
    if args.plain_output:
        return True
    if _config_indicates_openclaw_feishu(config_payload):
        return True
    if not _stdout_supports_unicode():
        return True
    try:
        return not bool(sys.stdout and sys.stdout.isatty())
    except Exception:
        return True


def _resolve_idle_finish_seconds(
    args: argparse.Namespace, *, non_interactive: bool
) -> float:
    if args.idle_finish_seconds is not None:
        return max(0.0, float(args.idle_finish_seconds))
    return 0.0


def _message_counts_as_completion_activity(
    msg: dict[str, Any], *, plain_output: bool
) -> bool:
    msg_type = _normalize_message_type(msg.get("type"))
    if msg_type in TERMINAL_MESSAGE_TYPES:
        return False
    if msg_type in METADATA_ONLY_MESSAGE_TYPES:
        return False
    if msg_type in PROGRESS_MESSAGE_TYPES:
        return False
    return True


def _message_is_user_visible_output(msg: dict[str, Any], *, plain_output: bool) -> bool:
    msg_type = _normalize_message_type(msg.get("type"))
    if msg_type in TERMINAL_MESSAGE_TYPES:
        return False
    if msg_type in METADATA_ONLY_MESSAGE_TYPES:
        return False
    if msg_type in PROGRESS_MESSAGE_TYPES:
        return False
    if plain_output and msg_type in {"tool_execution", "tool_progress", "tool_completed"}:
        return False
    if plain_output and msg_type in {"mcp_message", "skill_message", "subagent_message"}:
        status = str(msg.get("status") or "")
        return status in {"failed", "error"}
    if plain_output and msg_type == "ask_message":
        status = str(msg.get("status") or "")
        return status in {"called", "answered", "failed", "error"}
    return True


async def _wait_for_query_completion(
    query_complete_event: asyncio.Event,
    *,
    timeout_seconds: float,
    idle_finish_seconds: float,
    allow_idle_finish: bool,
    saw_completion_activity: Callable[[], bool],
    last_activity_at: Callable[[], float],
) -> str:
    loop = asyncio.get_running_loop()
    deadline = loop.time() + max(0.0, timeout_seconds)

    while True:
        if query_complete_event.is_set():
            return "completed"

        now = loop.time()
        if now >= deadline:
            return "timeout"

        if (
            allow_idle_finish
            and idle_finish_seconds > 0
            and saw_completion_activity()
            and (now - last_activity_at()) >= idle_finish_seconds
        ):
            return "idle_finish"

        await asyncio.sleep(min(0.25, deadline - now))


async def _wait_for_interactive_turn_completion(
    query_complete_event: asyncio.Event, stop_event: asyncio.Event
) -> None:
    while not stop_event.is_set() and not query_complete_event.is_set():
        await asyncio.sleep(0.05)


def _add_run_arguments(parser: argparse.ArgumentParser) -> None:
    """Register all flags shared by the top-level parser and the 'run' subcommand."""
    parser.add_argument("--url", default=None, help="WebSocket endpoint URL.")
    parser.add_argument(
        "--token",
        default=None,
        help="Auth token. If omitted, LUCKEE_AUTH_TOKEN is used.",
    )
    parser.add_argument("--user-id", default=None, help="User ID.")
    parser.add_argument(
        "--thread-id",
        default=None,
        help="Thread ID. If omitted, a new websocket session is created.",
    )
    parser.add_argument(
        "--language",
        default=None,
        help="Language code (e.g. en, zh-CN, ja).",
    )
    parser.add_argument(
        "--config",
        default=None,
        help=(
            "Config object sent in query payload. Accepts JSON like "
            '\'{"lingxing_account":"123"}\' '
            'or PowerShell-friendly object text like "{lingxing_account:123}".'
        ),
    )
    parser.add_argument(
        "--system-prompt",
        default=None,
        help="Optional system_prompt sent in query payload.",
    )
    parser.add_argument(
        "--query",
        default=None,
        help="One-shot mode: send one query and exit after complete.",
    )
    parser.add_argument(
        "--scenario",
        default=None,
        help='Optional scenario sent in query payload (defaults to "keyword").',
    )
    parser.add_argument(
        "--file-api-url",
        default=None,
        help="File API base URL used to upload local attachments.",
    )
    parser.add_argument(
        "--attachment",
        action="append",
        default=None,
        help="Local file path to upload and attach (repeat for multiple files).",
    )
    parser.add_argument(
        "--attachments",
        default=None,
        help="Comma-separated local file paths, e.g. --attachments file1,file2",
    )
    parser.add_argument(
        "--timeout",
        type=int,
        default=1000000,
        help="One-shot mode timeout in seconds (default: 1000000).",
    )
    parser.add_argument(
        "--debug",
        action="store_true",
        help="Print raw websocket messages for both send and receive.",
    )
    parser.add_argument(
        "--hide-session-details",
        action="store_true",
        help="Hide endpoint/user_id/thread_id in the session banner.",
    )
    parser.add_argument(
        "--non-interactive",
        action="store_true",
        help="Auto-answer prompts instead of reading stdin.",
    )
    parser.add_argument(
        "--plain-output",
        action="store_true",
        help="Disable animated/rich-heavy output for subprocess or IM usage.",
    )
    parser.add_argument(
        "--prompt-shell",
        action="store_true",
        help="Enable the experimental prompt_toolkit shell for interactive mode.",
    )
    parser.add_argument(
        "--default-freeform-answer",
        default=None,
        help="Fallback answer used for freeform AskUserQuestion prompts in non-interactive mode.",
    )
    parser.add_argument(
        "--idle-finish-seconds",
        type=float,
        default=None,
        help="Optional idle-finish shortcut for one-shot mode. By default the CLI waits for complete or timeout.",
    )
    parser.add_argument(
        "--wheel-server-url",
        default=None,
        help="Base URL for wheel server used by self-upgrade.",
    )
    parser.add_argument(
        "--upgrade",
        action="store_true",
        help="Upgrade CLI from wheel server and exit.",
    )


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    try:
        installed_version = metadata.version(PACKAGE_NAME)
    except metadata.PackageNotFoundError:
        installed_version = "0.0.0"
    version_text = CLI_RELEASE_TAG or installed_version

    parser = argparse.ArgumentParser(
        description="Agent Loop CLI for websocket streaming messages.",
    )
    parser.add_argument(
        "-v",
        "--version",
        action="version",
        version=f"luckee {version_text}",
    )

    subparsers = parser.add_subparsers(dest="command")

    # --- login subcommand ---
    subparsers.add_parser("login", help="Log in via browser OAuth flow.")

    # --- logout subcommand ---
    subparsers.add_parser("logout", help="Clear saved OAuth tokens and log out.")

    # --- run subcommand (also the default when no subcommand is given) ---
    run_parser = subparsers.add_parser("run", help="Start an agent session (default).")
    _add_run_arguments(run_parser)

    # Allow bare invocation (no subcommand) to behave like 'run'.
    _add_run_arguments(parser)

    args = parser.parse_args(argv)
    if args.command is None:
        args.command = "run"
    return args


def _resolve_render_profile(*, plain_output: bool) -> str:
    return RENDER_PROFILE_IM if plain_output else RENDER_PROFILE_TERMINAL


def _strip_wrapping_quotes(value: str) -> str:
    if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
        return value[1:-1]
    return value


def _split_top_level_pairs(text: str) -> list[str]:
    parts: list[str] = []
    current: list[str] = []
    quote_char: Optional[str] = None

    for char in text:
        if char in {'"', "'"}:
            if quote_char is None:
                quote_char = char
            elif quote_char == char:
                quote_char = None
            current.append(char)
            continue

        if char == "," and quote_char is None:
            segment = "".join(current).strip()
            if segment:
                parts.append(segment)
            current = []
            continue

        current.append(char)

    segment = "".join(current).strip()
    if segment:
        parts.append(segment)
    return parts


def _parse_loose_config_payload(raw_text: str) -> Optional[dict[str, Any]]:
    text = raw_text.strip()
    if not text:
        return None

    if text.startswith("{") and text.endswith("}"):
        text = text[1:-1].strip()

    if not text:
        return {}

    if ":" not in text and "=" not in text:
        return None

    parsed: dict[str, Any] = {}
    for pair in _split_top_level_pairs(text):
        separator = ":" if ":" in pair else "=" if "=" in pair else None
        if separator is None:
            return None

        key_text, value_text = pair.split(separator, 1)
        key = _strip_wrapping_quotes(key_text.strip())
        value = _strip_wrapping_quotes(value_text.strip())
        if not key:
            return None

        lowered = value.lower()
        if lowered == "true":
            parsed[key] = True
        elif lowered == "false":
            parsed[key] = False
        elif lowered in {"null", "none"}:
            parsed[key] = None
        else:
            parsed[key] = value

    return parsed


def _parse_config_payload(raw_config: Optional[str]) -> Optional[dict[str, Any]]:
    raw_text = _clean_optional_str(raw_config)
    if raw_text is None:
        return None

    try:
        payload = json.loads(raw_text)
    except json.JSONDecodeError as exc:
        payload = _parse_loose_config_payload(raw_text)
        if payload is not None:
            return payload
        raise CliInvocationError(
            category="config",
            summary="Invalid --config JSON.",
            hint=(
                "Pass a valid JSON object, for example "
                """--config '{"lingxing_account":"123"}' """
                'or use a PowerShell-friendly object like --config "{lingxing_account:123}".'
            ),
            detail=str(exc),
        ) from exc

    if not isinstance(payload, dict):
        raise CliInvocationError(
            category="config",
            summary="Invalid --config JSON.",
            hint="Pass a JSON object instead of an array, string, or number.",
        )

    return payload


def load_runtime_defaults(args: argparse.Namespace) -> dict[str, Any]:
    load_dotenv()
    oauth_base_url = resolve_oauth_base_url()
    client_id = resolve_oauth_client_id()
    config_payload = _parse_config_payload(args.config)
    url = _clean_optional_str(args.url) or _clean_optional_str(os.getenv("AGENT_CLI_URL")) or _env_default("ws_url")
    token = _clean_optional_str(args.token) or _clean_optional_str(
        os.getenv("LUCKEE_AUTH_TOKEN")
    )
    token_explicit = bool(token)
    file_api_url = (
        args.file_api_url
        or os.getenv("AGENT_CLI_FILE_API_URL")
        or _env_default("file_api_url")
    )
    user_id = _clean_optional_str(args.user_id) or _clean_optional_str(
        os.getenv("AGENT_CLI_USER_ID")
    )

    if not token:
        token = get_valid_token(oauth_base_url, client_id)
    if not token and not user_id:
        login_console = _build_console(plain_output=False)

        def _on_auto_login_url(url: str) -> None:
            login_console.print("[bold]No saved credentials — opening browser for login…[/bold]")
            login_console.print(f"If the browser did not open, copy this URL:\n[link]{url}[/link]")

        result = run_login_flow(
            oauth_base_url=oauth_base_url,
            client_id=client_id,
            on_authorize_url=_on_auto_login_url,
        )
        token = result.tokens.access_token
        login_console.print("[green]Login successful![/green]")

    if token:
        try:
            token_user_id = fetch_user_id_from_token(oauth_base_url, token)
        except InvalidTokenError as orig_exc:
            token_user_id = None
            refreshed = force_refresh_token(oauth_base_url, client_id)
            if refreshed:
                token = refreshed
                try:
                    token_user_id = fetch_user_id_from_token(oauth_base_url, token)
                except InvalidTokenError:
                    refreshed = None

            if not refreshed:
                raise CliInvocationError(
                    category="auth",
                    summary="The provided token is invalid or expired.",
                    hint="Token refresh failed. Run `luckee login` and complete browser authorization.",
                    detail=str(orig_exc),
                ) from orig_exc
        if token_user_id:
            if user_id and user_id != token_user_id:
                raise CliInvocationError(
                    category="auth",
                    summary="Provided user_id does not match the token.",
                    hint=f"Token identifies user '{token_user_id}'. Remove --user-id or pass the correct value.",
                )
            user_id = token_user_id
    if user_id:
        lingxing_account = fetch_lingxing_account(oauth_base_url, user_id, user_token=token)
        if lingxing_account:
            if config_payload is None:
                config_payload = {}
            config_payload["lingxing_account"] = lingxing_account
    language = _clean_optional_str(args.language) or _clean_optional_str(
        os.getenv("AGENT_CLI_LANGUAGE")
    )
    explicit_thread_id = bool(_clean_optional_str(args.thread_id))
    # Generate a thread_id whenever we have a user identity (user_id or token).
    thread_id = _clean_optional_str(args.thread_id) or (str(uuid4()) if (user_id or token) else None)
    scenario = (
        _clean_optional_str(args.scenario)
        or _clean_optional_str(os.getenv("AGENT_CLI_SCENARIO"))
        or DEFAULT_SCENARIO
    )
    wheel_server_url = (
        args.wheel_server_url
        or os.getenv("AGENT_CLI_WHEEL_SERVER_URL")
        or DEFAULT_WHEEL_SERVER_URL
    )

    return {
        "url": url,
        "token": token,
        "file_api_url": file_api_url,
        "user_id": user_id,
        "language": language,
        "thread_id": thread_id,
        "explicit_thread_id": explicit_thread_id,
        "scenario": scenario,
        "config_payload": config_payload,
        "openclaw_mode": _config_indicates_openclaw_feishu(config_payload),
        "wheel_server_url": wheel_server_url.rstrip("/"),
        "oauth_base_url": oauth_base_url,
        "oauth_client_id": client_id,
    }


def _validate_runtime_contract(cfg: dict[str, Any]) -> None:
    if not cfg["url"]:
        hint = "Pass --url or set AGENT_CLI_URL before running the CLI."
        if cfg.get("openclaw_mode"):
            hint = (
                "Set AGENT_CLI_URL in the launcher environment. "
                "OpenClaw-style calls do not fall back to localhost."
            )
        raise CliInvocationError(
            category="config",
            summary="Missing websocket endpoint.",
            hint=hint,
        )

    if not cfg["user_id"] and not cfg["token"]:
        raise CliInvocationError(
            category="config",
            summary="Missing user identity.",
            hint="Pass --user-id or --token, or run the CLI interactively to trigger browser login.",
        )

    if not cfg["token"]:
        raise CliInvocationError(
            category="auth",
            summary="Missing auth token.",
            hint="Pass --token, set LUCKEE_AUTH_TOKEN, or run the CLI interactively to trigger browser login.",
        )


def _looks_like_auth_error(detail: str) -> bool:
    lowered = detail.lower()
    auth_markers = (
        "401",
        "invalidstatus",
        "invalid status",
        "usertoken",
        "token",
        "unauthorized",
        "未登录",
        "已失效",
    )
    return any(marker in lowered for marker in auth_markers)


def _try_refresh_token(cfg: dict[str, Any]) -> Optional[str]:
    """Attempt to transparently refresh the access token.

    Returns the new access_token on success, or None if refresh failed.
    Uses ``force_refresh_token`` so that the refresh endpoint is always
    called — the current token has already been rejected by the server,
    so the local expiry check would just return the same failed token.
    """
    oauth_base_url = cfg.get("oauth_base_url", "")
    client_id = cfg.get("oauth_client_id", "")
    if not oauth_base_url or not client_id:
        return None
    return force_refresh_token(oauth_base_url, client_id)


def _looks_like_network_error(detail: str) -> bool:
    lowered = detail.lower()
    network_markers = (
        "timed out",
        "timeout",
        "connection refused",
        "connection reset",
        "connection closed",
        "network is unreachable",
        "temporary failure in name resolution",
        "name or service not known",
        "getaddrinfo",
        "server rejected websocket connection",
        "ssl",
        "handshake",
        "proxy",
        "10060",
        "10061",
        "11001",
    )
    return any(marker in lowered for marker in network_markers)


def _classify_cli_error(
    exc: Exception,
    *,
    during_connect: bool = False,
) -> CliErrorInfo:
    if isinstance(exc, CliInvocationError):
        return CliErrorInfo(
            category=exc.category,
            summary=exc.summary,
            hint=exc.hint,
            detail=exc.detail,
        )

    detail = str(exc) or exc.__class__.__name__
    if _looks_like_auth_error(detail):
        return CliErrorInfo(
            category="auth",
            summary="Authentication failed.",
            hint="Refresh the user token and retry.",
            detail=detail,
        )

    if during_connect or _looks_like_network_error(detail):
        return CliErrorInfo(
            category="network",
            summary="Failed to reach the websocket endpoint.",
            hint="Check --url or AGENT_CLI_URL and confirm the remote service is reachable.",
            detail=detail,
        )

    return CliErrorInfo(
        category="config",
        summary="CLI invocation failed.",
        hint="Check the CLI arguments and retry.",
        detail=detail,
    )


def _normalize_compare_value(value: Any) -> Optional[str]:
    if value is None:
        return None
    if isinstance(value, (dict, list)):
        return json.dumps(value, ensure_ascii=False, sort_keys=True)
    text = str(value).strip()
    return text or None


def _build_session_binding_notice(binding: SessionBindingUpdate) -> Optional[str]:
    if not binding.explicit_thread_id or not binding.requested_thread_id:
        return None

    if binding.server_overrode_requested_thread:
        return (
            "requested thread "
            f"{binding.requested_thread_id}, server resumed {binding.active_thread_id}"
        )

    if binding.is_new is False:
        return f"reusing thread {binding.active_thread_id}"

    if binding.is_new is True:
        return f"requested thread {binding.active_thread_id} started a new session"

    return None


def _extract_resume_context(config_message: dict[str, Any]) -> dict[str, Any]:
    payload = config_message.get("config")
    if not isinstance(payload, dict):
        return {}

    nested_config = payload.get("config") if isinstance(payload.get("config"), dict) else None
    effective_config = nested_config or payload
    scenario = payload.get("scenario") or effective_config.get("scenario")
    language = (
        payload.get("user_language")
        or payload.get("language")
        or effective_config.get("user_language")
        or effective_config.get("language")
    )

    return {
        "thread_id": payload.get("thread_id") or config_message.get("thread_id"),
        "scenario": scenario,
        "language": language,
        "config_payload": effective_config,
    }


def _build_resume_drift_notice(
    config_message: dict[str, Any],
    *,
    requested_scenario: Optional[str],
    requested_language: Optional[str],
    requested_config: Optional[dict[str, Any]],
) -> Optional[str]:
    resume_context = _extract_resume_context(config_message)
    if not resume_context:
        return None

    differences: list[str] = []
    actual_scenario = resume_context.get("scenario")
    actual_language = resume_context.get("language")
    actual_config = resume_context.get("config_payload")

    if requested_scenario and actual_scenario and actual_scenario != requested_scenario:
        differences.append(f"scenario={actual_scenario} (requested {requested_scenario})")

    if requested_language and actual_language and actual_language != requested_language:
        differences.append(f"language={actual_language} (requested {requested_language})")

    if requested_config is not None:
        if _normalize_compare_value(actual_config) != _normalize_compare_value(
            requested_config
        ):
            differences.append("config differs from the request")

    if not differences:
        return None

    return "resumed context differs: " + ", ".join(differences)


def _build_one_shot_thread_hint(thread_id: str) -> str:
    return (
        f"thread_id={thread_id} | reuse with --thread-id {thread_id} "
        f"or /resume {thread_id}"
    )


def _self_upgrade(wheel_server_url: str, console: Console) -> None:
    cmd = [
        sys.executable,
        "-m",
        "pip",
        "install",
        "--upgrade",
        "--no-cache-dir",
        "--find-links",
        wheel_server_url,
        PACKAGE_NAME,
    ]
    console.print(
        f"[cyan]Upgrading {PACKAGE_NAME} from[/cyan] [bold]{wheel_server_url}[/bold]"
    )
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        stderr = (result.stderr or "").strip()
        stdout = (result.stdout or "").strip()
        detail = stderr or stdout or "pip install failed"
        raise RuntimeError(detail)
    console.print("[bold green]Upgrade complete.[/bold green]")
    console.print("[dim]Restart the CLI to use the newest version.[/dim]")


def _should_use_prompt_toolkit_ask_ui(
    console: Console,
    *,
    non_interactive: bool,
    suppress_output: bool,
) -> bool:
    if non_interactive or suppress_output:
        return False
    if not _stdin_is_interactive():
        return False
    if not _stdout_supports_ansi() or not _stdout_supports_unicode():
        return False
    if getattr(console, "no_color", False):
        return False
    return _load_prompt_toolkit_ask_components() is not None


async def _prompt_and_answer_with_basic_input(
    console: Console,
    ask_request: AskPromptRequest,
    *,
    non_interactive: bool,
    default_freeform_answer: str,
    suppress_output: bool,
) -> dict[str, str]:
    answers: dict[str, str] = {}
    if not suppress_output:
        console.print(
            f"[bold cyan]Agent asked for input[/bold cyan] id={ask_request.message_id}"
        )
    for q in ask_request.questions:
        question_text = str(q.get("question") or "")
        options = q.get("options") or []
        labels = [str(opt.get("label")) for opt in options if opt.get("label")]
        if labels:
            if non_interactive:
                if not suppress_output:
                    console.print(
                        f"[dim]auto-selected[/dim] {question_text}: {labels[0]}"
                    )
                answers[question_text] = labels[0]
                continue

            console.print(f"\n[bold]{question_text}[/bold]")
            for idx, label in enumerate(labels, 1):
                console.print(f"  {idx}. {label}")

            try:
                user_input = Prompt.ask("Your answer", default="1").strip()
            except EOFError:
                user_input = "1"

            if not user_input:
                user_input = "1"

            if user_input.isdigit():
                selected_idx = int(user_input)
                if 1 <= selected_idx <= len(labels):
                    answers[question_text] = labels[selected_idx - 1]
                else:
                    answers[question_text] = user_input
            else:
                answers[question_text] = user_input
        else:
            if non_interactive:
                answers[question_text] = default_freeform_answer
                if not suppress_output:
                    console.print(
                        f"[dim]auto-filled[/dim] {question_text}: {default_freeform_answer}"
                    )
            else:
                try:
                    answers[question_text] = Prompt.ask(question_text)
                except EOFError:
                    answers[question_text] = default_freeform_answer
    return answers


async def _prompt_and_answer_with_prompt_toolkit(
    ask_request: AskPromptRequest,
    *,
    default_freeform_answer: str,
) -> Optional[dict[str, str]]:
    components = _load_prompt_toolkit_ask_components()
    if components is None:
        return None

    navigator = AskPromptNavigator(
        ask_request,
        default_freeform_answer=default_freeform_answer,
    )
    kb = components["KeyBindings"]()

    def _refresh(event) -> None:
        event.app.invalidate()

    @kb.add("left")
    def _go_left(event) -> None:
        navigator.move_question(-1)
        _refresh(event)

    @kb.add("right")
    def _go_right(event) -> None:
        navigator.move_question(1)
        _refresh(event)

    @kb.add("up")
    def _go_up(event) -> None:
        navigator.move_option(-1)
        _refresh(event)

    @kb.add("down")
    def _go_down(event) -> None:
        navigator.move_option(1)
        _refresh(event)

    @kb.add("tab")
    def _toggle_custom(event) -> None:
        navigator.toggle_custom_mode()
        _refresh(event)

    @kb.add("s-tab")
    @kb.add("escape")
    def _leave_custom(event) -> None:
        navigator.cancel_custom_mode()
        _refresh(event)

    @kb.add("backspace")
    def _backspace(event) -> None:
        navigator.backspace_custom_text()
        _refresh(event)

    @kb.add("enter")
    def _accept(event) -> None:
        result = navigator.accept_current()
        if result is not None:
            event.app.exit(result=result)
            return
        _refresh(event)

    @kb.add("c-c")
    def _cancel(event) -> None:
        event.app.exit(result=None)

    @kb.add("<any>")
    def _type_text(event) -> None:
        data = event.data
        if not navigator.custom_mode:
            return
        if not data or not data.isprintable() or data in {"\r", "\n", "\t"}:
            return
        navigator.append_custom_text(data)
        _refresh(event)

    body = components["Window"](
        content=components["FormattedTextControl"](navigator.render_fragments),
        wrap_lines=True,
        always_hide_cursor=True,
    )
    frame = components["Frame"](body=body, title="Agent asked for input")
    layout = components["Layout"](components["HSplit"]([frame]))
    app = components["Application"](
        layout=layout,
        key_bindings=kb,
        style=components["style"],
        full_screen=False,
        mouse_support=False,
    )
    return await app.run_async()


async def prompt_and_answer(
    console: Console,
    ask_request: AskPromptRequest,
    client: AgentWsV3Client,
    *,
    non_interactive: bool = False,
    default_freeform_answer: str = "稍后提供",
    suppress_output: bool = False,
) -> None:
    answers: Optional[dict[str, str]] = None
    if _should_use_prompt_toolkit_ask_ui(
        console,
        non_interactive=non_interactive,
        suppress_output=suppress_output,
    ):
        answers = await _prompt_and_answer_with_prompt_toolkit(
            ask_request,
            default_freeform_answer=default_freeform_answer,
        )

    if answers is None:
        answers = await _prompt_and_answer_with_basic_input(
            console,
            ask_request,
            non_interactive=non_interactive,
            default_freeform_answer=default_freeform_answer,
            suppress_output=suppress_output,
        )

    await client.send_answer(message_id=ask_request.message_id, answers=answers)


def _should_render_shell_banner(
    args: argparse.Namespace, *, non_interactive: bool
) -> bool:
    return not non_interactive and not _is_one_shot_query(args)


def _render_shell_banner(
    renderer: StreamRenderer,
    *,
    show_banner: bool,
    endpoint: str,
    user_id: str,
    thread_id: str,
    language: Optional[str],
    hide_session_details: bool,
) -> None:
    if not show_banner:
        return
    renderer.render_banner(
        endpoint=endpoint,
        user_id=user_id,
        thread_id=thread_id,
        language=language,
        hide_session_details=hide_session_details,
    )


def _split_shell_command(raw: str) -> tuple[str, str]:
    command, _, remainder = raw.partition(" ")
    return command.strip(), remainder.strip()


def _build_new_session_notice(thread_id: Optional[str]) -> str:
    if thread_id:
        return f"started fresh session {thread_id}"
    return "started fresh session"


def _build_resume_request_notice(thread_id: str) -> str:
    return f"resuming thread {thread_id}"


def _detect_git_branch(cwd: str) -> str:
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--abbrev-ref", "HEAD"],
            cwd=cwd,
            capture_output=True,
            text=True,
            timeout=2,
            check=False,
        )
    except Exception:
        return "-"
    if result.returncode != 0:
        return "-"
    branch = str(result.stdout or "").strip()
    return branch or "-"


def _extract_topic_message_title(msg: dict[str, Any]) -> str:
    for key in ("message", "topic", "title", "name"):
        value = str(msg.get(key) or "").strip()
        if value:
            return value
    payload = msg.get("payload")
    if isinstance(payload, dict):
        for key in ("message", "topic", "title", "name"):
            value = str(payload.get(key) or "").strip()
            if value:
                return value
    return ""


def _format_relative_time(value: Any) -> str:
    if not value:
        return "-"
    try:
        timestamp = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
        if timestamp.tzinfo is None:
            timestamp = timestamp.replace(tzinfo=timezone.utc)
    except Exception:
        return str(value)

    delta = datetime.now(timezone.utc) - timestamp.astimezone(timezone.utc)
    seconds = max(0, int(delta.total_seconds()))
    if seconds < 60:
        return "just now"
    minutes = seconds // 60
    if minutes < 60:
        return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
    hours = minutes // 60
    if hours < 24:
        return f"{hours} hour{'s' if hours != 1 else ''} ago"
    days = hours // 24
    if days < 30:
        return f"{days} day{'s' if days != 1 else ''} ago"
    months = days // 30
    if months < 12:
        return f"{months} month{'s' if months != 1 else ''} ago"
    years = months // 12
    return f"{years} year{'s' if years != 1 else ''} ago"


def _build_saved_session_label(entry: dict[str, Any]) -> str:
    created_at = _format_relative_time(entry.get("created_at"))
    updated_at = _format_relative_time(entry.get("updated_at"))
    thread_id = str(entry.get("thread_id") or "").strip() or "-"
    return f"{thread_id:<36}  {updated_at:<14}  {created_at}"


def _load_prompt_toolkit_session_picker_components() -> Optional[dict[str, Any]]:
    _configure_prompt_toolkit_runtime()
    try:
        from prompt_toolkit.application import Application
        from prompt_toolkit.key_binding import KeyBindings
        from prompt_toolkit.layout import Layout
        from prompt_toolkit.layout.containers import HSplit, Window
        from prompt_toolkit.layout.controls import FormattedTextControl
        from prompt_toolkit.widgets import Frame
    except Exception:
        return None
    return {
        "Application": Application,
        "KeyBindings": KeyBindings,
        "Layout": Layout,
        "HSplit": HSplit,
        "Window": Window,
        "FormattedTextControl": FormattedTextControl,
        "Frame": Frame,
    }


async def _pick_saved_session_with_prompt_toolkit(
    sessions: list[dict[str, Any]],
) -> Optional[str]:
    components = _load_prompt_toolkit_session_picker_components()
    if components is None:
        return None
    if not (_stdin_is_interactive() and _stdout_supports_ansi() and _stdout_supports_unicode()):
        return None
    ask_components = _load_prompt_toolkit_ask_components()
    if ask_components is None:
        return None

    navigator = SessionPickerNavigator(sessions)
    if not navigator.entries:
        return None

    kb = components["KeyBindings"]()

    def _refresh(event) -> None:
        event.app.invalidate()

    @kb.add("up")
    def _go_up(event) -> None:
        navigator.move(-1)
        _refresh(event)

    @kb.add("down")
    def _go_down(event) -> None:
        navigator.move(1)
        _refresh(event)

    @kb.add("backspace")
    def _backspace(event) -> None:
        navigator.backspace_filter()
        _refresh(event)

    @kb.add("enter")
    def _accept(event) -> None:
        event.app.exit(result=navigator.current_thread_id())

    @kb.add("escape")
    @kb.add("c-c")
    def _cancel(event) -> None:
        event.app.exit(result=None)

    @kb.add("<any>")
    def _type_text(event) -> None:
        data = event.data
        if not data or not data.isprintable() or data in {"\r", "\n", "\t"}:
            return
        navigator.append_filter(data)
        _refresh(event)

    body = components["Window"](
        content=components["FormattedTextControl"](navigator.render_fragments),
        wrap_lines=True,
        always_hide_cursor=True,
    )
    frame = components["Frame"](body=body, title="Resume")
    layout = components["Layout"](components["HSplit"]([frame]))
    app = components["Application"](
        layout=layout,
        key_bindings=kb,
        style=ask_components["style"],
        full_screen=False,
        mouse_support=False,
        erase_when_done=True,
    )
    return await app.run_async()


async def _pick_saved_session(
    *,
    console: Console,
    session_store: LuckeeSessionStore,
    endpoint: str,
    user_id: str,
) -> Optional[str]:
    sessions = session_store.list_sessions(endpoint=endpoint, user_id=user_id, limit=20)
    if not sessions:
        sessions = session_store.list_sessions(limit=20)
    if not sessions:
        _print_console_notice(console, "No saved sessions found.", style="yellow")
        return None

    selected_thread_id = await _pick_saved_session_with_prompt_toolkit(sessions)
    if selected_thread_id:
        return selected_thread_id

    console.print("[bold cyan]Resume a previous session[/bold cyan]")
    console.print("[dim]Sort: Updated at[/dim]")
    console.print("[dim]Thread ID                              Updated at      Created at[/dim]")
    for index, entry in enumerate(sessions, 1):
        console.print(f"  {index}. {_build_saved_session_label(entry)}")

    try:
        choice = Prompt.ask("Resume session", default="1").strip()
    except EOFError:
        return None
    if not choice:
        return str(sessions[0].get("thread_id") or "").strip() or None
    if choice.isdigit():
        selected_index = int(choice)
        if 1 <= selected_index <= len(sessions):
            return str(sessions[selected_index - 1].get("thread_id") or "").strip() or None
    for entry in sessions:
        thread_id = str(entry.get("thread_id") or "").strip()
        if thread_id == choice:
            return thread_id
    return None


async def _handle_interactive_command(
    raw: str,
    *,
    console: Console,
    renderer: StreamRenderer,
    client: AgentWsV3Client,
    session_store: LuckeeSessionStore,
    cfg: dict[str, Any],
    hide_session_details: bool,
    show_shell_banner: bool,
    pending_attachments: list[LocalAttachment],
    stop_event: asyncio.Event,
    debug: bool,
    before_reconnect: Optional[Callable[[bool], None]] = None,
    rerender_shell_banner_on_reconnect: bool = True,
) -> bool:
    command, argument = _split_shell_command(raw)
    if not command.startswith("/"):
        return False

    if command in {"/quit", "/exit"}:
        stop_event.set()
        return True

    if command == "/help":
        renderer.render_help()
        return True

    if command == "/":
        renderer.render_help()
        return True

    if command == "/upgrade":
        try:
            await asyncio.to_thread(_self_upgrade, cfg["wheel_server_url"], console)
        except Exception as exc:
            renderer.render_invocation_error(
                summary="Upgrade failed.",
                hint="Check the wheel server URL and network connectivity, then retry.",
                detail=str(exc) or exc.__class__.__name__,
                debug=debug,
            )
        return True

    if command == "/thread":
        value = client.thread_id or "(not yet assigned)"
        console.print(f"[dim]thread_id={value}[/dim]")
        return True

    if command == "/thinking":
        if argument:
            renderer.render_thinking_history(argument)
        else:
            renderer.render_thinking_history()
        return True

    if command == "/clear":
        console.clear()
        _render_shell_banner(
            renderer,
            show_banner=show_shell_banner,
            endpoint=cfg["url"],
            user_id=cfg["user_id"],
            thread_id=client.thread_id,
            language=cfg["language"],
            hide_session_details=hide_session_details,
        )
        return True

    if command == "/interrupt":
        await client.send_interrupt()
        return True

    if command == "/new":
        if before_reconnect is not None:
            before_reconnect(expect_history=False)
        await client.reconnect(new_thread=True)
        if show_shell_banner and rerender_shell_banner_on_reconnect:
            console.clear()
            _render_shell_banner(
                renderer,
                show_banner=show_shell_banner,
                endpoint=cfg["url"],
                user_id=cfg["user_id"],
                thread_id=client.thread_id,
                language=cfg["language"],
                hide_session_details=hide_session_details,
            )
        renderer.render_session_notice(_build_new_session_notice(client.thread_id))
        return True

    if command == "/resume":
        if not argument:
            selected_thread_id = await _pick_saved_session(
                console=console,
                session_store=session_store,
                endpoint=cfg["url"],
                user_id=cfg["user_id"],
            )
            if not selected_thread_id:
                return True
            argument = selected_thread_id
        renderer.render_session_notice(_build_resume_request_notice(argument))
        if before_reconnect is not None:
            before_reconnect(expect_history=True)
        await client.reconnect(thread_id=argument)
        if show_shell_banner and rerender_shell_banner_on_reconnect:
            console.clear()
            _render_shell_banner(
                renderer,
                show_banner=show_shell_banner,
                endpoint=cfg["url"],
                user_id=cfg["user_id"],
                thread_id=client.thread_id,
                language=cfg["language"],
                hide_session_details=hide_session_details,
            )
        return True

    if command == "/attach":
        if not argument:
            console.print("[red]Usage: /attach <local_file_path>[/red]")
            return True
        pending_attachments.append(LocalAttachment(path=argument))
        console.print(
            f"[green]queued attachment[/green] #{len(pending_attachments)}: {argument}"
        )
        return True

    if command == "/attachments":
        if not pending_attachments:
            console.print("[dim]no queued attachments[/dim]")
            return True
        console.print("[bold cyan]Queued attachments[/bold cyan]")
        for idx, item in enumerate(pending_attachments, 1):
            console.print(f"  {idx}. {item.path}")
        return True

    if command == "/detach":
        if not argument:
            console.print("[red]Usage: /detach <index|all>[/red]")
            return True
        token = argument.lower()
        if token == "all":
            pending_attachments.clear()
            console.print("[green]cleared queued attachments[/green]")
            return True
        try:
            remove_idx = int(token)
        except ValueError:
            console.print("[red]Usage: /detach <index|all>[/red]")
            return True
        if remove_idx < 1 or remove_idx > len(pending_attachments):
            console.print("[red]attachment index out of range[/red]")
            return True
        removed = pending_attachments.pop(remove_idx - 1)
        console.print(f"[green]removed attachment[/green] {removed.path}")
        return True

    return False


async def _run_cli_legacy_unused(args: argparse.Namespace) -> None:
    cfg = load_runtime_defaults(args)
    non_interactive = _should_use_non_interactive(args, cfg.get("config_payload"))
    plain_output = _should_use_plain_output(args, cfg.get("config_payload"))
    show_shell_banner = _should_render_shell_banner(
        args, non_interactive=non_interactive
    )
    idle_finish_seconds = _resolve_idle_finish_seconds(
        args, non_interactive=non_interactive
    )
    console = Console(
        force_terminal=not plain_output,
        no_color=plain_output,
        color_system=None if plain_output else "auto",
        soft_wrap=True,
    )
    if args.upgrade:
        try:
            _self_upgrade(cfg["wheel_server_url"], console)
        except Exception as exc:
            detail = str(exc) or exc.__class__.__name__
            console.print(f"[red]upgrade failed:[/red] {detail}")
            raise SystemExit(1) from exc
        raise SystemExit(0)

    if not cfg["token"]:
        console.print(
            "[red]Missing auth token.[/red] Provide [bold]--token[/bold] or set [bold]LUCKEE_AUTH_TOKEN[/bold]."
        )
        _print_purchase_credit_hint(console)
        raise SystemExit(1)

    hide_session_details = _should_hide_session_details(args, cfg.get("config_payload"))
    parsed_csv_attachments: list[str] = []
    if args.attachments:
        parsed_csv_attachments = [
            item.strip() for item in args.attachments.split(",") if item.strip()
        ]
    all_attachment_paths = [*(args.attachment or []), *parsed_csv_attachments]
    renderer = StreamRenderer(console=console, plain_output=plain_output)
    stop_event = asyncio.Event()
    query_complete_event = asyncio.Event()
    saw_user_visible_output = False
    saw_completion_activity = False
    last_sigint_ts = 0.0
    loop = asyncio.get_running_loop()
    last_activity_ts = loop.time()
    pending_attachments: list[LocalAttachment] = [
        LocalAttachment(path=p) for p in all_attachment_paths
    ]

    client = AgentWsV3Client(
        WsClientConfig(
            base_url=cfg["url"],
            user_id=cfg["user_id"],
            user_token=cfg["token"],
            thread_id=cfg["thread_id"],
            user_language=cfg["language"],
            debug=args.debug,
        )
    )

    def _handle_sigint() -> None:
        nonlocal last_sigint_ts
        now = time.time()
        if now - last_sigint_ts < 2.0:
            _print_console_notice(
                console,
                "\nExiting (double Ctrl+C)",
                style="yellow",
            )
            asyncio.create_task(client.send_terminate())
            query_complete_event.set()
            stop_event.set()
            loop.call_later(0.15, os._exit, 0)
            return
        last_sigint_ts = now
        _print_console_notice(
            console,
            "\nInterrupt sent. Press Ctrl+C again to exit.",
            style="yellow",
        )
        asyncio.create_task(client.send_interrupt())

    loop = asyncio.get_running_loop()
    for sig in (signal.SIGINT,):
        try:
            loop.add_signal_handler(sig, _handle_sigint)
        except NotImplementedError:  # pragma: no cover - platform dependent
            pass

    if args.debug:
        _token_preview = (cfg["token"] or "")[:8] or "<none>"
        _qs_parts = []
        if cfg["thread_id"]:
            _qs_parts.append(f"thread_id={cfg['thread_id']}")
        if cfg.get("language"):
            _qs_parts.append(f"User_language={cfg['language']}")
        _qs_parts.append(f"userToken={_token_preview}…")
        _url_base = cfg['url'].rstrip('/')
        console.print(
            f"[dim]debug: session_id={cfg['thread_id']}  user_id={cfg['user_id']}\n"
            f"  ws_url: {_url_base}/{cfg['user_id']}?{'&'.join(_qs_parts)}[/dim]"
        )
    try:
        await client.connect()
    except Exception as exc:
        detail = str(exc) or exc.__class__.__name__
        if _looks_like_auth_error(detail):
            new_token = _try_refresh_token(cfg)
            if new_token:
                cfg["token"] = new_token
                client.config.user_token = new_token
                try:
                    await client.connect()
                except Exception:
                    console.print(
                        "[red]Authentication failed: token is missing, invalid, or expired.[/red]"
                    )
                    raise SystemExit(1) from exc
            else:
                console.print(
                    "[red]Authentication failed: token is missing, invalid, or expired.[/red]"
                )
                raise SystemExit(1) from exc
        else:
            console.print(f"[red]Failed to connect:[/red] {detail}")
            raise SystemExit(1) from exc
    if not args.query:
        renderer.render_banner(
            endpoint=cfg["url"],
            user_id=cfg["user_id"],
            thread_id=client.thread_id,
            language=cfg["language"],
            hide_session_details=hide_session_details,
        )

    async def build_query_attachments() -> list[dict[str, Any]]:
        if not pending_attachments:
            return []
        thread_id = await client.wait_for_thread_id(timeout_seconds=10.0)
        console.print(
            f"[dim]uploading {len(pending_attachments)} attachment(s) "
            f"(session_id={thread_id}) via {cfg['file_api_url']}[/dim]"
        )
        uploaded = await upload_attachments(
            base_url=cfg["file_api_url"],
            user_id=cfg["user_id"],
            thread_id=thread_id,
            attachments=pending_attachments,
            token=cfg.get("token"),
            debug=args.debug,
        )
        console.print(
            f"[green]uploaded {len(uploaded)} attachment(s) (session_id={thread_id})[/green]"
        )
        return uploaded

    async def receive_loop() -> None:
        nonlocal saw_user_visible_output, saw_completion_activity, last_activity_ts
        while not stop_event.is_set():
            msg = await client.incoming_queue.get()
            msg_type = msg.get("type")
            last_activity_ts = loop.time()
            if msg_type == "metadata":
                thread_id = str(msg.get("thread_id") or "")
                if thread_id:
                    client.set_thread_id(thread_id)
            elif msg_type not in {
                "thinking_commentary",
                "commentary",
                "child_step_commentary",
                "inter_child_text",
                "agent_thinking",
                "step_discovered",
                "step_started",
                "step_progress",
                "heartbeat",
            }:
                saw_user_visible_output = True
                saw_completion_activity = True

            ask_request = renderer.render_message(msg)
            if ask_request is not None:
                await prompt_and_answer(
                    console,
                    ask_request,
                    client,
                    non_interactive=non_interactive,
                    default_freeform_answer=args.default_freeform_answer or "稍后提供",
                )

            if msg_type in {"complete", "terminated", "_connection_error"}:
                query_complete_event.set()

            if msg_type == "_connection_error":
                detail = str(msg.get("error_message") or "connection error")
                if _looks_like_auth_error(detail):
                    new_token = _try_refresh_token(cfg)
                    if new_token:
                        cfg["token"] = new_token
                        client.config.user_token = new_token
                        try:
                            await client.reconnect()
                            continue
                        except Exception:
                            pass
                    console.print(
                        "[red]Authentication failed: token is missing, invalid, or expired.[/red]"
                    )
                else:
                    console.print(f"[red]Connection closed unexpectedly:[/red] {detail}")
                stop_event.set()
                return

    async def input_loop() -> None:
        while not stop_event.is_set():
            raw = (await asyncio.to_thread(input, "\n> ")).strip()
            if not raw:
                continue

            if raw in {"/quit", "/exit"}:
                stop_event.set()
                break

            if raw == "/help":
                renderer.render_help()
                continue

            if raw == "/upgrade":
                try:
                    await asyncio.to_thread(_self_upgrade, cfg["wheel_server_url"], console)
                except Exception as exc:
                    detail = str(exc) or exc.__class__.__name__
                    console.print(f"[red]upgrade failed:[/red] {detail}")
                continue

            if raw == "/thread":
                value = client.thread_id or "(not yet assigned)"
                console.print(f"[dim]thread_id={value}[/dim]")
                continue

            if raw == "/thinking":
                renderer.render_thinking_history()
                continue

            if raw.startswith("/thinking "):
                thinking_id = raw[len("/thinking ") :].strip()
                if not thinking_id:
                    console.print("[red]Usage: /thinking <message_id|prefix>[/red]")
                    continue
                renderer.render_thinking_history(thinking_id)
                continue

            if raw == "/clear":
                console.clear()
                renderer.render_banner(
                    endpoint=cfg["url"],
                    user_id=cfg["user_id"],
                    thread_id=client.thread_id,
                    language=cfg["language"],
                    hide_session_details=hide_session_details,
                )
                continue

            if raw == "/interrupt":
                await client.send_interrupt()
                continue

            if raw == "/new":
                await client.reconnect(new_thread=True)
                renderer.render_banner(
                    endpoint=cfg["url"],
                    user_id=cfg["user_id"],
                    thread_id=client.thread_id,
                    language=cfg["language"],
                    hide_session_details=hide_session_details,
                )
                continue

            if raw.startswith("/resume "):
                resume_thread_id = raw[len("/resume ") :].strip()
                if not resume_thread_id:
                    console.print("[red]Usage: /resume <thread_id>[/red]")
                    continue
                await client.reconnect(thread_id=resume_thread_id)
                renderer.render_banner(
                    endpoint=cfg["url"],
                    user_id=cfg["user_id"],
                    thread_id=client.thread_id,
                    language=cfg["language"],
                    hide_session_details=hide_session_details,
                )
                continue

            if raw.startswith("/attach "):
                path = raw[len("/attach ") :].strip()
                if not path:
                    console.print("[red]Usage: /attach <local_file_path>[/red]")
                    continue
                pending_attachments.append(LocalAttachment(path=path))
                console.print(
                    f"[green]queued attachment[/green] #{len(pending_attachments)}: {path}"
                )
                continue

            if raw == "/attachments":
                if not pending_attachments:
                    console.print("[dim]no queued attachments[/dim]")
                    continue
                console.print("[bold cyan]Queued attachments[/bold cyan]")
                for idx, item in enumerate(pending_attachments, 1):
                    console.print(f"  {idx}. {item.path}")
                continue

            if raw.startswith("/detach "):
                token = raw[len("/detach ") :].strip().lower()
                if token == "all":
                    pending_attachments.clear()
                    console.print("[green]cleared queued attachments[/green]")
                    continue
                try:
                    remove_idx = int(token)
                except ValueError:
                    console.print("[red]Usage: /detach <index|all>[/red]")
                    continue
                if remove_idx < 1 or remove_idx > len(pending_attachments):
                    console.print("[red]attachment index out of range[/red]")
                    continue
                removed = pending_attachments.pop(remove_idx - 1)
                console.print(f"[green]removed attachment[/green] {removed.path}")
                continue

            query_complete_event.clear()
            try:
                attachments = await build_query_attachments()
            except Exception as exc:
                detail = str(exc) or exc.__class__.__name__
                console.print(f"[red]failed to upload attachments:[/red] {detail}")
                continue
            await client.send_query(
                raw,
                attachments=attachments,
                scenario=cfg["scenario"],
                config=cfg["config_payload"],
                system_prompt=args.system_prompt,
                language=cfg["language"],
            )
            pending_attachments.clear()
            await _wait_for_interactive_turn_completion(
                query_complete_event, stop_event
            )

    recv_task = asyncio.create_task(receive_loop())

    try:
        if args.query:
            query_complete_event.clear()
            try:
                attachments = await build_query_attachments()
            except Exception as exc:
                detail = str(exc) or exc.__class__.__name__
                console.print(f"[red]failed to upload attachments:[/red] {detail}")
                stop_event.set()
                return
            await client.send_query(
                args.query,
                attachments=attachments,
                scenario=cfg["scenario"],
                config=cfg["config_payload"],
                system_prompt=args.system_prompt,
                language=cfg["language"],
            )
            wait_result = await _wait_for_query_completion(
                query_complete_event,
                timeout_seconds=args.timeout,
                idle_finish_seconds=idle_finish_seconds,
                allow_idle_finish=non_interactive,
                saw_completion_activity=lambda: saw_completion_activity,
                last_activity_at=lambda: last_activity_ts,
            )
            if wait_result == "idle_finish":
                if non_interactive and not saw_user_visible_output:
                    renderer.render_invocation_error(
                        summary="No final answer received.",
                        hint="The server stopped after internal activity without a user-visible reply. Retry with --debug to inspect raw events.",
                        detail=None,
                        debug=args.debug,
                    )
                    raise SystemExit(1)
                if not plain_output:
                    console.print(
                        "[dim]No explicit complete received after idle period; returning latest output.[/dim]"
                    )
            elif wait_result == "timeout":
                if non_interactive and saw_user_visible_output:
                    if not plain_output:
                        console.print(
                            "[dim]No explicit complete received; returning latest output.[/dim]"
                        )
                else:
                    console.print(
                        f"[red]Timed out waiting for complete ({args.timeout}s)[/red]"
                    )
            renderer.flush_deferred_output()
            if (
                args.query
                and saw_user_visible_output
                and client.thread_id
                and not args.hide_session_details
            ):
                renderer.render_session_notice(
                    _build_one_shot_thread_hint(client.thread_id)
                )
            stop_event.set()
        else:
            input_task = asyncio.create_task(input_loop())
            await stop_event.wait()
            if not input_task.done():
                input_task.cancel()
                try:
                    await input_task
                except asyncio.CancelledError:
                    pass
    finally:
        if not recv_task.done():
            recv_task.cancel()
            try:
                await recv_task
            except asyncio.CancelledError:
                pass
        await client.close(send_terminate=True)

async def run_cli(args: argparse.Namespace) -> None:
    use_prompt_shell = _should_use_prompt_shell(args)
    bootstrap_plain_output = _should_use_plain_output(args, None)
    console = _build_console(plain_output=bootstrap_plain_output)
    renderer = StreamRenderer(
        console=console,
        plain_output=bootstrap_plain_output,
        render_profile=_resolve_render_profile(plain_output=bootstrap_plain_output),
    )

    try:
        cfg = load_runtime_defaults(args)
    except Exception as exc:
        error = _classify_cli_error(exc)
        renderer.render_invocation_error(
            summary=error.summary,
            hint=error.hint,
            detail=error.detail,
            debug=args.debug,
        )
        raise SystemExit(1) from exc

    non_interactive = _should_use_non_interactive(args, cfg.get("config_payload"))
    plain_output = _should_use_plain_output(args, cfg.get("config_payload"))
    show_shell_banner = _should_render_shell_banner(
        args, non_interactive=non_interactive
    )
    idle_finish_seconds = _resolve_idle_finish_seconds(
        args, non_interactive=non_interactive
    )
    if plain_output != bootstrap_plain_output:
        console = _build_console(plain_output=plain_output)
    renderer = StreamRenderer(
        console=console,
        plain_output=plain_output,
        render_profile=_resolve_render_profile(plain_output=plain_output),
        sticky_collection_plan_live=show_shell_banner and use_prompt_shell,
    )

    if args.upgrade:
        try:
            _self_upgrade(cfg["wheel_server_url"], console)
        except Exception as exc:
            renderer.render_invocation_error(
                summary="Upgrade failed.",
                hint="Check the wheel server URL and network connectivity, then retry.",
                detail=str(exc) or exc.__class__.__name__,
                debug=args.debug,
            )
            raise SystemExit(1) from exc
        raise SystemExit(0)

    try:
        _validate_runtime_contract(cfg)
    except Exception as exc:
        error = _classify_cli_error(exc)
        renderer.render_invocation_error(
            summary=error.summary,
            hint=error.hint,
            detail=error.detail,
            debug=args.debug,
        )
        raise SystemExit(1) from exc

    hide_session_details = _should_hide_session_details(args, cfg.get("config_payload"))
    parsed_csv_attachments: list[str] = []
    if args.attachments:
        parsed_csv_attachments = [
            item.strip() for item in args.attachments.split(",") if item.strip()
        ]
    all_attachment_paths = [*(args.attachment or []), *parsed_csv_attachments]
    stop_event = asyncio.Event()
    query_complete_event = asyncio.Event()
    saw_user_visible_output = False
    saw_completion_activity = False
    emitted_session_notices: set[str] = set()
    last_sigint_ts = 0.0
    loop = asyncio.get_running_loop()
    last_activity_ts = loop.time()
    pending_attachments: list[LocalAttachment] = [
        LocalAttachment(path=p) for p in all_attachment_paths
    ]
    session_store = LuckeeSessionStore()
    workspace_cwd = os.getcwd()
    workspace_branch = _detect_git_branch(workspace_cwd)
    shell_input = (
        InteractiveShellInput()
        if show_shell_banner and use_prompt_shell
        else None
    )
    turn_in_progress = False
    interactive_live_status = _stdout_supports_ansi() and _stdout_supports_unicode()
    base_live_status_enabled = not show_shell_banner or interactive_live_status
    history_replay_active = False
    binding_notice_pending = False
    replay_boundary_pending = False
    replay_user_visible_seen = False
    replay_terminal_boundary_seen = False
    replay_last_message_ts: Optional[float] = None
    replay_idle_release_task: Optional[asyncio.Task[None]] = None
    prompt_ready_event = asyncio.Event()
    if shell_input is None or not cfg["explicit_thread_id"]:
        prompt_ready_event.set()
    else:
        binding_notice_pending = True
        replay_boundary_pending = True
    renderer.set_live_status_enabled(base_live_status_enabled)

    client = AgentWsV3Client(
        WsClientConfig(
            base_url=cfg["url"],
            user_id=cfg["user_id"],
            user_token=cfg["token"],
            thread_id=cfg["thread_id"],
            explicit_thread_id=cfg["explicit_thread_id"],
            user_language=cfg["language"],
            debug=args.debug,
        )
    )

    def _emit_session_notice(message: Optional[str], *, warning: bool = False) -> None:
        if not message or message in emitted_session_notices:
            return
        emitted_session_notices.add(message)
        renderer.render_session_notice(message, warning=warning)

    def _cancel_replay_idle_release() -> None:
        nonlocal replay_idle_release_task
        if replay_idle_release_task is not None and not replay_idle_release_task.done():
            replay_idle_release_task.cancel()
        replay_idle_release_task = None

    def _release_prompt_after_replay() -> None:
        nonlocal history_replay_active, replay_boundary_pending, replay_last_message_ts
        nonlocal replay_user_visible_seen, replay_terminal_boundary_seen
        renderer.flush_pending_historical_asks()
        history_replay_active = False
        replay_boundary_pending = False
        replay_user_visible_seen = False
        replay_terminal_boundary_seen = False
        replay_last_message_ts = None
        prompt_ready_event.set()
        renderer.set_live_status_enabled(base_live_status_enabled)

    def _arm_replay_idle_release() -> None:
        nonlocal replay_last_message_ts, replay_idle_release_task
        if not history_replay_active:
            return
        replay_last_message_ts = loop.time()
        started_at = replay_last_message_ts
        _cancel_replay_idle_release()
        idle_seconds = (
            PROMPT_SHELL_REPLAY_IDLE_SECONDS
            if replay_terminal_boundary_seen or not replay_user_visible_seen
            else PROMPT_SHELL_REPLAY_HARD_IDLE_SECONDS
        )

        async def _release_when_idle() -> None:
            try:
                await asyncio.sleep(idle_seconds)
            except asyncio.CancelledError:
                return

            if (
                history_replay_active
                and replay_last_message_ts is not None
                and replay_last_message_ts == started_at
            ):
                _release_prompt_after_replay()

        replay_idle_release_task = asyncio.create_task(_release_when_idle())

    def _begin_reconnect(*, expect_history: bool) -> None:
        nonlocal binding_notice_pending, replay_boundary_pending, history_replay_active
        nonlocal replay_last_message_ts, replay_user_visible_seen
        nonlocal replay_terminal_boundary_seen
        nonlocal turn_in_progress
        binding_notice_pending = True
        replay_boundary_pending = expect_history
        history_replay_active = False
        replay_user_visible_seen = False
        replay_terminal_boundary_seen = False
        replay_last_message_ts = None
        turn_in_progress = False
        query_complete_event.set()
        prompt_ready_event.clear()
        _cancel_replay_idle_release()
        renderer._stop_thinking_status()
        renderer.begin_historical_replay()
        renderer.set_live_status_enabled(False)

    def _handle_sigint() -> None:
        nonlocal last_sigint_ts
        renderer._stop_thinking_status()
        now = time.time()
        if now - last_sigint_ts < 2.0:
            _print_console_notice(
                console,
                "\nExiting (double Ctrl+C)",
                style="yellow",
            )
            asyncio.create_task(client.send_terminate())
            query_complete_event.set()
            stop_event.set()
            loop.call_later(0.15, os._exit, 0)
            return
        last_sigint_ts = now
        if not turn_in_progress:
            _print_console_notice(
                console,
                "\nNo active turn to interrupt. Press Ctrl+C again to exit.",
                style="yellow",
            )
            return
        _print_console_notice(
            console,
            "\nInterrupt sent. Press Ctrl+C again to exit.",
            style="yellow",
        )
        asyncio.create_task(client.send_interrupt())

    for sig in (signal.SIGINT,):
        try:
            loop.add_signal_handler(sig, _handle_sigint)
        except NotImplementedError:  # pragma: no cover - platform dependent
            pass

    if args.debug:
        _token_preview = (cfg["token"] or "")[:8] or "<none>"
        _qs_parts = []
        if cfg["thread_id"]:
            _qs_parts.append(f"thread_id={cfg['thread_id']}")
        if cfg.get("language"):
            _qs_parts.append(f"User_language={cfg['language']}")
        _qs_parts.append(f"userToken={_token_preview}…")
        _url_base = cfg['url'].rstrip('/')
        console.print(
            f"[dim]debug: session_id={cfg['thread_id']}  user_id={cfg['user_id']}\n"
            f"  ws_url: {_url_base}/{cfg['user_id']}?{'&'.join(_qs_parts)}[/dim]"
        )
    try:
        await client.connect()
    except Exception as exc:
        detail = str(exc) or exc.__class__.__name__
        if _looks_like_auth_error(detail):
            new_token = _try_refresh_token(cfg)
            if new_token:
                cfg["token"] = new_token
                client.config.user_token = new_token
                try:
                    await client.connect()
                except Exception as retry_exc:
                    error = _classify_cli_error(retry_exc, during_connect=True)
                    renderer.render_invocation_error(
                        summary=error.summary,
                        hint=error.hint,
                        detail=error.detail,
                        debug=args.debug,
                    )
                    raise SystemExit(1) from retry_exc
            else:
                error = _classify_cli_error(exc, during_connect=True)
                renderer.render_invocation_error(
                    summary=error.summary,
                    hint=error.hint,
                    detail=error.detail,
                    debug=args.debug,
                )
                raise SystemExit(1) from exc
        else:
            error = _classify_cli_error(exc, during_connect=True)
            renderer.render_invocation_error(
                summary=error.summary,
                hint=error.hint,
                detail=error.detail,
                debug=args.debug,
            )
            raise SystemExit(1) from exc
    _render_shell_banner(
        renderer,
        show_banner=show_shell_banner,
        endpoint=cfg["url"],
        user_id=cfg["user_id"],
        thread_id=client.thread_id,
        language=cfg["language"],
        hide_session_details=hide_session_details,
    )

    async def build_query_attachments() -> list[dict[str, Any]]:
        if not pending_attachments:
            return []
        thread_id = await client.wait_for_thread_id(timeout_seconds=10.0)
        console.print(
            f"[dim]uploading {len(pending_attachments)} attachment(s) via {cfg['file_api_url']}[/dim]"
        )
        uploaded = await upload_attachments(
            base_url=cfg["file_api_url"],
            user_id=cfg["user_id"],
            thread_id=thread_id,
            attachments=pending_attachments,
            token=cfg.get("token"),
            debug=args.debug,
        )
        console.print(f"[green]uploaded {len(uploaded)} attachment(s)[/green]")
        return uploaded

    async def receive_loop() -> None:
        nonlocal saw_user_visible_output, saw_completion_activity, last_activity_ts
        nonlocal turn_in_progress, history_replay_active
        nonlocal binding_notice_pending, replay_boundary_pending
        while not stop_event.is_set():
            msg = await client.incoming_queue.get()
            msg_type = msg.get("type")

            if msg_type == "metadata":
                binding = client.apply_metadata(msg)
                if binding is not None:
                    if binding_notice_pending:
                        notice = _build_session_binding_notice(binding)
                        _emit_session_notice(
                            notice,
                            warning=bool(binding.server_overrode_requested_thread),
                        )
                        binding_notice_pending = False

                    if replay_boundary_pending:
                        if binding.is_new is False:
                            history_replay_active = True
                            replay_user_visible_seen = False
                            replay_terminal_boundary_seen = False
                            prompt_ready_event.clear()
                            renderer.set_live_status_enabled(False)
                        elif history_replay_active:
                            if binding.is_new is True:
                                _cancel_replay_idle_release()
                                _release_prompt_after_replay()
                        else:
                            _cancel_replay_idle_release()
                            _release_prompt_after_replay()
                    elif history_replay_active and binding.is_new is True:
                        _cancel_replay_idle_release()
                        _release_prompt_after_replay()

                    session_store.upsert_session(
                        thread_id=binding.active_thread_id,
                        endpoint=cfg["url"],
                        user_id=cfg["user_id"],
                        language=cfg["language"],
                        cwd=workspace_cwd,
                        branch=workspace_branch,
                    )
                continue

            if msg_type == "config":
                if cfg["explicit_thread_id"]:
                    _emit_session_notice(
                        _build_resume_drift_notice(
                            msg,
                            requested_scenario=cfg["scenario"],
                            requested_language=cfg["language"],
                            requested_config=cfg["config_payload"],
                        ),
                        warning=True,
                    )
                continue

            if msg_type == "topic_message":
                topic_title = _extract_topic_message_title(msg)
                if topic_title and client.thread_id:
                    session_store.update_conversation(
                        thread_id=client.thread_id,
                        conversation=topic_title,
                    )

            historical = history_replay_active and msg_type != "resume_complete"
            if historical:
                if _message_is_user_visible_output(msg, plain_output=plain_output):
                    replay_user_visible_seen = True
                if msg_type in TERMINAL_MESSAGE_TYPES:
                    replay_terminal_boundary_seen = True
                _arm_replay_idle_release()

            if (
                not historical
                and _message_counts_as_completion_activity(msg, plain_output=plain_output)
            ):
                saw_completion_activity = True
                last_activity_ts = loop.time()

            if (
                not historical
                and _message_is_user_visible_output(msg, plain_output=plain_output)
            ):
                saw_user_visible_output = True

            ask_request = renderer.render_message(msg, historical=historical)
            if ask_request is not None and not historical:
                await prompt_and_answer(
                    console,
                    ask_request,
                    client,
                    non_interactive=non_interactive,
                    default_freeform_answer=args.default_freeform_answer
                    or DEFAULT_FREEFORM_ANSWER,
                    suppress_output=non_interactive and plain_output,
                )

            if msg_type == "resume_complete":
                _cancel_replay_idle_release()
                _release_prompt_after_replay()
                binding_notice_pending = False

            if msg_type in TERMINAL_MESSAGE_TYPES:
                turn_in_progress = False
                query_complete_event.set()

            if msg_type == "_connection_error":
                turn_in_progress = False
                error_detail = str(msg.get("error_message") or "connection error")
                if _looks_like_auth_error(error_detail):
                    new_token = _try_refresh_token(cfg)
                    if new_token:
                        cfg["token"] = new_token
                        client.config.user_token = new_token
                        try:
                            _begin_reconnect(expect_history=bool(cfg["explicit_thread_id"]))
                            await client.reconnect()
                            continue
                        except Exception:
                            pass
                error = _classify_cli_error(
                    RuntimeError(error_detail),
                    during_connect=True,
                )
                renderer.render_invocation_error(
                    summary=error.summary,
                    hint=error.hint,
                    detail=error.detail,
                    debug=args.debug,
                )
                stop_event.set()
                return

    async def input_loop() -> None:
        nonlocal turn_in_progress
        while not stop_event.is_set():
            if shell_input is not None and not prompt_ready_event.is_set():
                await prompt_ready_event.wait()
            if stop_event.is_set():
                return
            renderer._stop_thinking_status()
            prompt_state = InteractivePromptState(
                language=cfg["language"],
                thread_id=client.thread_id,
                pending_attachment_count=len(pending_attachments),
                hide_session_details=hide_session_details,
            )
            try:
                raw = (
                    await (
                        shell_input.read_line(state=prompt_state)
                        if shell_input is not None
                        else asyncio.to_thread(
                            _read_raw_shell_input,
                            "\n> ",
                            _build_interactive_placeholder(),
                        )
                    )
                ).strip()
            except EOFError:
                stop_event.set()
                return
            except KeyboardInterrupt:
                _handle_sigint()
                continue
            if not raw:
                continue

            if await _handle_interactive_command(
                raw,
                console=console,
                renderer=renderer,
                client=client,
                session_store=session_store,
                cfg=cfg,
                hide_session_details=hide_session_details,
                show_shell_banner=show_shell_banner,
                pending_attachments=pending_attachments,
                stop_event=stop_event,
                debug=args.debug,
                before_reconnect=_begin_reconnect,
                rerender_shell_banner_on_reconnect=not use_prompt_shell,
            ):
                continue

            query_complete_event.clear()
            try:
                attachments = await build_query_attachments()
            except Exception as exc:
                detail = str(exc) or exc.__class__.__name__
                console.print(f"[red]failed to upload attachments:[/red] {detail}")
                continue
            renderer.reset_turn_state()
            await client.send_query(
                raw,
                attachments=attachments,
                scenario=cfg["scenario"],
                config=cfg["config_payload"],
                system_prompt=args.system_prompt,
                language=cfg["language"],
            )
            turn_in_progress = True
            pending_attachments.clear()
            await _wait_for_interactive_turn_completion(
                query_complete_event, stop_event
            )
            renderer._stop_thinking_status()

    recv_task: Optional[asyncio.Task[None]] = None

    try:
        if args.query:
            recv_task = asyncio.create_task(receive_loop())
            query_complete_event.clear()
            try:
                attachments = await build_query_attachments()
            except Exception as exc:
                detail = str(exc) or exc.__class__.__name__
                console.print(f"[red]failed to upload attachments:[/red] {detail}")
                stop_event.set()
                return
            renderer.reset_turn_state()
            await client.send_query(
                args.query,
                attachments=attachments,
                scenario=cfg["scenario"],
                config=cfg["config_payload"],
                system_prompt=args.system_prompt,
                language=cfg["language"],
            )
            turn_in_progress = True
            wait_result = await _wait_for_query_completion(
                query_complete_event,
                timeout_seconds=args.timeout,
                idle_finish_seconds=idle_finish_seconds,
                allow_idle_finish=non_interactive,
                saw_completion_activity=lambda: saw_completion_activity,
                last_activity_at=lambda: last_activity_ts,
            )
            if wait_result == "idle_finish":
                if non_interactive and not saw_user_visible_output:
                    renderer.render_invocation_error(
                        summary="No final answer received.",
                        hint="The server stopped after internal activity without a user-visible reply. Retry with --debug to inspect raw events.",
                        detail=None,
                        debug=args.debug,
                    )
                    raise SystemExit(1)
                if not plain_output:
                    console.print(
                        "[dim]No explicit complete received after idle period; returning latest output.[/dim]"
                    )
            elif wait_result == "timeout":
                if non_interactive and saw_user_visible_output:
                    if not plain_output:
                        console.print(
                            "[dim]No explicit complete received; returning latest output.[/dim]"
                        )
                else:
                    console.print(
                        f"[red]Timed out waiting for complete ({args.timeout}s)[/red]"
                    )
            renderer.flush_deferred_output()
            if (
                args.query
                and saw_user_visible_output
                and client.thread_id
                and not args.hide_session_details
            ):
                renderer.render_session_notice(
                    _build_one_shot_thread_hint(client.thread_id)
                )
            stop_event.set()
        else:
            recv_task = asyncio.create_task(receive_loop())
            input_task = asyncio.create_task(input_loop())
            await stop_event.wait()
            if not input_task.done():
                input_task.cancel()
                try:
                    await input_task
                except asyncio.CancelledError:
                    pass
    finally:
        turn_in_progress = False
        _cancel_replay_idle_release()
        if recv_task is not None and not recv_task.done():
            recv_task.cancel()
            try:
                await recv_task
            except asyncio.CancelledError:
                pass
        await client.close(send_terminate=True)


def _handle_login_command(*, debug: bool = False) -> None:
    load_dotenv()
    oauth_base_url = resolve_oauth_base_url()
    client_id = resolve_oauth_client_id()
    console = _build_console(plain_output=False)

    def _on_authorize_url(url: str) -> None:
        console.print("[bold]Opening browser for login…[/bold]")
        console.print(f"If the browser did not open, copy this URL:\n[link]{url}[/link]")

    def _on_poll_response(payload: dict) -> None:
        console.print(f"[dim]poll response: {json.dumps(payload, ensure_ascii=False)}[/dim]")

    try:
        result = run_login_flow(
            oauth_base_url=oauth_base_url,
            client_id=client_id,
            on_authorize_url=_on_authorize_url,
            on_poll_response=_on_poll_response if debug else None,
        )
    except RuntimeError as exc:
        console.print(f"[red]Login failed:[/red] {exc}")
        sys.exit(1)
    try:
        user_id = fetch_user_id_from_token(oauth_base_url, result.tokens.access_token)
    except Exception:
        user_id = None
    console.print(f"[green]Login successful![/green]" + (f"  user_id={user_id}" if user_id else ""))


def _handle_logout_command() -> None:
    console = _build_console(plain_output=False)
    removed = clear_saved_tokens()
    if removed:
        console.print("[green]Logged out successfully.[/green] Saved tokens have been removed.")
    else:
        console.print("[dim]No saved tokens found — already logged out.[/dim]")


def main() -> None:
    _initialize_console_runtime()
    args = parse_args()

    if args.command == "login":
        _handle_login_command(debug=getattr(args, "debug", False))
        return

    if args.command == "logout":
        _handle_logout_command()
        return

    try:
        asyncio.run(run_cli(args))
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()
