# -*- coding: utf-8 -*-
"""
Abyss CLI 主入口
类似 Claude Code，一个命令启动，一切在工具内完成。
补全菜单使用自定义 ANSI 渲染（ansi_menu），绕过 prompt_toolkit 灰色背景问题。
"""
import click
import json
import os
import sys
import re
import threading
import time
import itertools
import traceback
from pathlib import Path
from openai import APIConnectionError
from .config import Config, init_user_dirs
from .api_client import DeepSeekClient, _extract_cause_summary
from .session import Session
from .prompts import load_system_prompt
from .prompts.methodologies import (
    LAZY_CODING, TDD_DISCIPLINE, DEBUG_DISCIPLINE,
    REVIEW_DISCIPLINE, AUDIT_DISCIPLINE, ARCHITECTURE_DISCIPLINE,
    PROTOTYPE_DISCIPLINE, DOMAIN_MODELING_DISCIPLINE,
)
from .tools.registry import (
    create_default_registry, get_tc_field, merge_tool_calls, format_tool_result,
)
from .tools.todo_write import get_active_todos, clear_todos
from .tools.spawn_subagents import SpawnSubagentsTool
from .tools.knowledge_fetch import KnowledgeFetchTool
from .tools.knowledge_save import KnowledgeSaveTool
from .tools.memory_save import MemorySaveTool
from .tools.memory_search import MemorySearchTool
from .memory import MemoryStore, LLMReranker
from .runtime import LifecycleHost
from .ansi_menu import (
    ansi_prompt, register_custom_commands, ansi_gray_line,
    _h_yel_bold, _h_grn_bold, _h_grn, _h_grn_dim, _h_cyn_bold, _h_mag_bold,
    _h_red_bold, _h_grn_bg, _h_yel_bg, _h_red_bg, _h_dbg,
)
from .commands.slash import create_default_registry as create_slash_registry
from .commands.knowledge import (
    format_knowledge_list_grouped,
    format_knowledge_stats,
    handle_knowledge_pin,
    handle_knowledge_archive,
    handle_knowledge_interactive,
)
from .interrupt import EscInterrupt
from . import logger
from . import __version__
from .ui import (
    get_theme_renderer, get_current_renderer, HackerRenderer,
)
from ._globals import ctx
from .context_engine import create_engine
from .curator import (
    CuratorDaemon,
    bump_use as curator_bump_use,
    get_report as curator_report,
    pin_skill,
    unpin_skill,
    restore_skill,
    list_archived,
)
from .agent_loop import (
    execute_tool as _agent_execute_tool,
    execute_tool_batch as _agent_execute_tool_batch,
    process_tool_calls as _agent_process_tool_calls,
    SelfCorrectionLoop,
    TaskInterrupted,
)

# 工具注册表：6 个内置工具。run_with_pt() 会额外注册 spawn_subagents。
# 用 ToolRegistry 替代原硬编码 TOOLS dict，支持动态注册。
REGISTRY = create_default_registry()
# 用户自定义 slash 命令注册表（从 ~/.abyss/commands/*.md 加载）
SLASH_REGISTRY = create_slash_registry()
register_custom_commands(SLASH_REGISTRY.list_names())

# LifecycleHost 实例（run_with_pt 初始化，execute_tool 读取埋点）。
# 模块级让 execute_tool 能访问 host 而无需改签名传递。
_HOST: LifecycleHost = None

# Esc 中断信号源（run_with_pt 启动监听，_consume_stream/execute_tool 检查）。
# 模块级让 _consume_stream/execute_tool 能读取取消标志而无需改签名传递。
_ESC: EscInterrupt = None

# 上下文压缩引擎（run_with_pt 初始化，每次 LLM 响应后更新使用量）。
_ENGINE = None

# 跨会话长期记忆存储（run_with_pt 初始化，/memory 命令读写）
_MEMORY_STORE = None

# 定时任务服务（run_with_pt 初始化，/cron 命令管理）
_cron_service = None


# Esc 中断信号源（run_with_pt 启动监听，_consume_stream/execute_tool 检查）。
# 模块级让 _consume_stream/execute_tool 能读取取消标志而无需改签名传递。
_ESC: EscInterrupt = None

# 上下文压缩引擎（run_with_pt 初始化，每次 LLM 响应后更新使用量）。
_ENGINE = None

# 跨会话长期记忆存储（run_with_pt 初始化，/memory 命令读写）
_MEMORY_STORE = None

# 上一轮对话上下文度量（用于 ThinkingBudgetCalculator 自适应思考预算）
# 在每次 _consume_stream 后更新，由 _compute_adaptive_effort 读取
_LAST_METRICS = {"tool_calls": 0, "output_tokens": 0, "turn_count": 1}

# 工具调用往返轮数安全网（仅防 AI 死循环，正常任务不会达到）
MAX_TOOL_ROUNDS = 100


def _update_last_metrics(usage_info, tool_calls_count: int, session=None) -> None:
    """从本轮 LLM 响应中提取度量，更新 _LAST_METRICS 供下一轮自适应思考预算使用。

    Args:
        usage_info: OpenAI usage 对象（可能为 None）
        tool_calls_count: 本轮 LLM 返回的 tool_calls 数量
        session: Session 对象，用于推算 turn_count（基于 messages 长度）
    """
    # output_tokens：优先用 usage.completion_tokens，兜底用 0
    output_tokens = 0
    if usage_info is not None:
        # 兼容 dict / pydantic 对象
        ct = (
            usage_info.get("completion_tokens")
            if isinstance(usage_info, dict)
            else getattr(usage_info, "completion_tokens", None)
        )
        if isinstance(ct, int) and ct > 0:
            output_tokens = ct

    # turn_count：基于 session.messages 角色为 user 的轮数推算
    turn_count = 1
    if session is not None:
        msgs = getattr(session, "messages", None) or []
        # 统计 user 消息数（系统/assistant/tool 不计入对话轮次深度）
        user_turns = sum(1 for m in msgs if isinstance(m, dict) and m.get("role") == "user")
        turn_count = max(1, user_turns)

    _LAST_METRICS["tool_calls"] = max(0, tool_calls_count)
    _LAST_METRICS["output_tokens"] = output_tokens
    _LAST_METRICS["turn_count"] = turn_count


def _compute_adaptive_effort(cfg, session=None) -> str:
    """根据上一轮上下文度量计算本轮 reasoning_effort。

    若 cfg.adaptive_thinking_budget=False 或当前 effort=max（用户显式最高档），
    返回原值；否则用 ThinkingBudgetCalculator 三因子动态计算。

    Args:
        cfg: Config 对象
        session: Session 对象，用于推算 turn_count

    Returns:
        effort 字符串（"low" / "medium" / "high" / "max"）
    """
    current_effort = cfg.get("reasoning_effort") or "high"
    if not cfg.get("adaptive_thinking_budget"):
        return current_effort

    from .llm.thinking_budget import (
        ContextMetrics,
        compute_adaptive_effort,
    )

    # 实时刷新 turn_count（其他两个度量由 _update_last_metrics 维护）
    turn_count = _LAST_METRICS.get("turn_count", 1)
    if session is not None:
        msgs = getattr(session, "messages", None) or []
        user_turns = sum(1 for m in msgs if isinstance(m, dict) and m.get("role") == "user")
        turn_count = max(1, user_turns)

    metrics = ContextMetrics(
        tool_calls=_LAST_METRICS.get("tool_calls", 0),
        output_tokens=_LAST_METRICS.get("output_tokens", 0),
        turn_count=turn_count,
    )
    return compute_adaptive_effort(metrics, current_effort, enabled=True)

_SPINNER_CHARS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"


class TaskInterrupted(Exception):
    """用户按 Esc 中断当前任务时抛出。定义在 agent_loop 中，此处为兼容性重导出。"""
    pass


# /help 输出文本
_HELP_TEXT = """
╭─────────────────────────────────────────────╮
│          Abyss CLI · 使用帮助                 │
╰─────────────────────────────────────────────╯

基础命令：
  /help                        显示本帮助
  /clear                       清空当前会话上下文
  /exit, /quit                 退出 CLI
  /session                     会话管理（保存/加载/删除）

会话与上下文：
  /retry                       重试上一条 user 消息（重新生成回复）
  /compact [指令]              手动压缩上下文（可附自定义压缩指令）
  /undo                        撤销最近一轮 AI 文件编辑（批量回退）
  /redo                        恢复最近一次被 /undo 撤销的修改
  /stats                       显示会话统计（消息数 / token / 工具调用）
  /logs [选项]                 查看日志（-n/-l/-c/-t/--since/--trace/--stats）

Git 集成：
  /diff [staged]               显示 Git 工作区或暂存区差异
  /git blame <file> [起-止行]  行级作者归因
  /git log [N]                 最近 N 条提交（默认 10）
  /git stats                   按作者统计提交数
  /git show <hash>             显示指定 commit 摘要

定时任务：
  /cron list                   列出所有定时任务
  /cron create <秒> <命令>     创建定时任务
  /cron delete <id>            删除指定任务
  /cron cleanup                清理过期任务

AI 分析命令：
  /bug-hunter <描述>           启动 bug 猎手分析模式
  /pr-review                   审查最近一次 git commit
  /security-scan <路径>        安全扫描指定路径
  /explain <路径>              解释指定文件
  /advisor <问题>              顾问模式（咨询架构/方案）

扩展系统：
  /skills                       列出已加载的 Skill
  /mcp                          列出 MCP server 和工具
  /hooks                        列出已配置的 Hook
  /curator                      Skill 生命周期管理
  /knowledge                    交互式菜单（列出/搜索/查看/固定/归档/统计）
  /knowledge search <q>         关键词搜索（直接执行）
  /knowledge show <name>        显示完整内容（直接执行）
  /knowledge stats              知识库统计（直接执行）
  /knowledge pin <name>         固定知识（直接执行）
  /knowledge archive <name>     手动归档（直接执行）

长期记忆（跨会话）：
  /memory                       列出最近 50 条记忆
  /memory search <q>            关键词搜索记忆
  /memory show <id>             显示某条记忆完整内容
  /memory save <标题> | <内容>  手动保存记忆
  /memory delete <id>           删除某条记忆
  /memory stats                 统计分类与存储路径

工程方法论命令：
  /diagnose <问题描述>            启动调试纪律流程（6 阶段）
  /tdd <功能描述>                 启动 TDD 红-绿-重构流程
  /review                        审查当前 diff 的过度工程
  /audit                         全仓库过度工程审计
  /architecture                  扫描架构深化机会
  /prototype <问题>               构建原型回答设计问题
  /domain                        领域建模，提炼术语和决策

配置与模式（低频）：
  /model                       交互式切换模型（上下键选 + 回车）
  /thinking                    查看或开关思考模式（on/off）
  /show-reasoning              开关思考过程在屏幕上的显示
  /config                      查看当前配置
  /config set api-key <key>    设置 API Key
  /config set model <name>     设置模型（deepseek-v4-pro / deepseek-v4-flash）
  /config set thinking <on|off> 开关思考模式
  /config set max-tokens <n>   设置单次响应最大 token 数
  /config unset <key>          删除某项配置（恢复默认值）
  /version                     显示版本号

文件引用语法（@）：
  @file.py                      引用整个文件（受 500 行截断保护）
  @file.py:L10                  引用第 10 行
  @file.py:L10-L20              引用第 10-20 行
  @src/main.py:L1395-L1420      支持相对路径 + 行范围

小贴士：
  • 输入 /       会弹出补全菜单（上下键选 + Tab 补全）
  • 输入 @file   引用文件到上下文（支持 :L10-L20 行范围）
  • 按 ↑ / ↓     翻历史消息
  • 按 Esc       取消当前补全菜单或中断任务
  • 配置文件位置：~/.abyss/
"""

# 7 条方法论命令分发表：(methodology_text, usage_hint, command_name)
# usage_hint 为空表示无参数命令（使用预置描述）；非空表示需要用户传描述
_METHODOLOGY_COMMANDS = {
    "/diagnose":     (DEBUG_DISCIPLINE,         "用法: /diagnose <问题描述>", "diagnose"),
    "/tdd":          (TDD_DISCIPLINE,           "用法: /tdd <要开发的功能描述>", "tdd"),
    "/review":       (REVIEW_DISCIPLINE,        "", "review"),
    "/audit":        (AUDIT_DISCIPLINE,         "", "audit"),
    "/architecture": (ARCHITECTURE_DISCIPLINE,  "", "architecture"),
    "/prototype":    (PROTOTYPE_DISCIPLINE,     "用法: /prototype <要验证的设计问题>", "prototype"),
    "/domain":       (DOMAIN_MODELING_DISCIPLINE, "", "domain"),
}

# 无参数方法论命令的预置描述（替代原 inline 字符串）
_METHODOLOGY_DEFAULT_DESC = {
    "/review":       "审查当前代码变更中的过度工程。运行 git diff 查看当前改动，然后用下面的标签审查。",
    "/audit":        "对整个代码仓库进行过度工程审计。扫描所有源代码文件，用下面的标签列出发现。",
    "/architecture": "扫描代码库寻找架构深化机会。用子代理探索代码，找出浅模块和紧耦合，提出深化建议。",
    "/domain":       "开始领域建模。读取现有 CONTEXT.md（如有），挑战当前术语，提出精确化建议，记录架构决策。",
}


class Spinner:
    """终端动画旋转指示器，类 Claude Code 风格。"""

    def __init__(self, message="思考中"):
        self._message = message
        self._running = False
        self._thread = None
        self._lock = threading.Lock()

    def start(self):
        logger.spinner_start(self._message)
        self._running = True
        self._thread = threading.Thread(target=self._spin, daemon=True)
        self._thread.start()

    def stop(self):
        with self._lock:
            if not self._running:
                return
            self._running = False
        if self._thread:
            self._thread.join(timeout=0.5)
        sys.stdout.write("\r\x1b[K")
        sys.stdout.flush()
        logger.spinner_stop(self._message)

    def _spin(self):
        chars = itertools.cycle(_SPINNER_CHARS)
        while True:
            with self._lock:
                if not self._running:
                    break
                sys.stdout.write(f"\r  \033[2m{next(chars)} {self._message}\033[0m")
                sys.stdout.flush()
            time.sleep(0.08)


def _extract_missing_param(exc: TypeError, func) -> str:
    """从 TypeError 消息中提取缺失的参数名。已移至 agent_loop。"""
    from .agent_loop import _extract_missing_param as _fn
    return _fn(exc, func)


def execute_tool(tool_call, batch_index: int = None, batch_total: int = None) -> str:
    """执行单个工具调用（带 UI 显示），委托 agent_loop.execute_tool 处理核心逻辑。"""
    R = get_current_renderer()
    _is_hacker = isinstance(R, HackerRenderer)
    func = get_tc_field(tool_call, "function", {})
    name = get_tc_field(func, "name", "")
    args_str = get_tc_field(func, "arguments", "")
    try:
        arguments = json.loads(args_str) if args_str else {}
    except json.JSONDecodeError:
        err = {"success": False, "error": f"参数解析失败: {args_str}"}
        if not _is_hacker:
            _show_tool_result(name, {}, err)
        return format_tool_result(name, err)

    # PreToolUse 钩子可阻断（在 agent_loop 内部也会检查，这里提前显示 UI）
    if _HOST is not None:
        guard = _HOST.pre_tool(name, arguments)
        if guard["block"]:
            err = {"success": False, "error": f"被钩子阻断: {guard.get('reason', '')}"}
            if _is_hacker:
                _accumulated = getattr(execute_tool, "_hack_status", None)
            else:
                _show_tool_result(name, arguments, err)
            return format_tool_result(name, err)

    # 文件操作先不打动作行——专属 spinner 用 \r 占用同一行动画
    # Hacker：统一跳过 pre-action 与 spinner，等 result 出来一次性打一行
    _is_file_op = name in ("file_read", "file_write", "file_edit")
    if not _is_hacker and not _is_file_op:
        _show_tool_action(name, arguments, batch_index, batch_total)
    # 注：tool_start/tool_end 日志由 agent_loop.execute_tool 核心层统一记录，避免重复

    # 文件操作启动专属 spinner（Hacker 不启动）
    file_spinner = None
    if not _is_hacker:
        file_spinner = _start_file_spinner(name, arguments)

    _t0 = time.perf_counter()
    try:
        result_str = _agent_execute_tool(
            tool_call, REGISTRY, host=None, esc=_ESC  # hook 已在上面处理
        )
        result = json.loads(result_str)
    except TaskInterrupted:
        if file_spinner is not None:
            file_spinner.stop()
        raise
    finally:
        if file_spinner is not None:
            file_spinner.stop()
            if not _is_hacker:
                _show_tool_action(name, arguments, batch_index, batch_total)
                sys.stdout.flush()

    _dur_ms = int((time.perf_counter() - _t0) * 1000)
    if _is_hacker:
        # Hacker：操作行 + 结果合并成一行树形格式
        # 累积到 batch footer（跨函数，用 execute_tool._hack_status 暂存）
        success = bool(result.get("success", False))
        status_token = "ok" if success else "fail"
        if hasattr(execute_tool, "_hack_status"):
            execute_tool._hack_status.append({
                "i": batch_index, "n": batch_total,
                "name": name, "args": arguments,
                "success": success, "ms": _dur_ms,
            })
        click.echo(R.tool_result_tree(
            i=batch_index, n=batch_total, name=name, args=arguments,
            status=status_token, duration_ms=_dur_ms,
            error_msg=None if success else str(result.get("error", ""))[:80],
        ), nl=False)
    else:
        _show_tool_result(name, arguments, result)

    # PostToolUse 钩子
    if _HOST is not None:
        _HOST.post_tool(name, arguments, result)

    return result_str


def _show_tool_action(name: str, args: dict, batch_index: int = None, batch_total: int = None):
    """显示工具调用信息。batch_index/batch_total 非空时输出 [i/total] 编号前缀"""
    R = get_current_renderer()
    if isinstance(R, HackerRenderer):
        # Hacker 不打前置 action，由 execute_tool 在 result 时统一打一行
        return
    prefix = ""
    if batch_index is not None and batch_total is not None:
        prefix = f"\033[2m[{batch_index}/{batch_total}]\033[0m "
    if name == "shell_exec":
        click.echo(f"  {prefix}\033[33m$ {args.get('command', '')}\033[0m", nl=False)
    elif name == "file_write":
        click.echo(f"  {prefix}\033[36m写入文件: {args.get('path', '')}\033[0m", nl=False)
    elif name == "file_read":
        click.echo(f"  {prefix}\033[36m读取文件: {args.get('path', '')}\033[0m", nl=False)
    elif name == "file_edit":
        click.echo(f"  {prefix}\033[36m编辑文件: {args.get('path', '')}\033[0m", nl=False)
    elif name == "web_search":
        click.echo(f"  {prefix}\033[35m搜索: {args.get('query', '')}\033[0m", nl=False)
        sys.stdout.flush()
    elif name == "todo_write":
        click.echo(f"  {prefix}\033[36m更新任务清单\033[0m", nl=False)
    else:
        click.echo(f"  {prefix}[tool] {name}", nl=False)


def _show_batch_header(round_num: int, total: int, max_rounds: int = MAX_TOOL_ROUNDS):
    """打印工具调用批次头。Hacker: 直接打 batch header；Default: 旋转动画 → ● 落定。

    !! BUG 修复关键代码 - 请勿修改「光标新行」逻辑 !!
    - Default 主题：先 \\n 再 \\r 动画（否则 tool_spinner 清行后文件路径被覆盖）。
    - Hacker 主题：紧凑，不强制多打 \\n（上游 _consume_stream 已按需换行，避免双重空行）。
    """
    R = get_current_renderer()
    is_hacker = isinstance(R, HackerRenderer)
    # Hacker：紧凑换行（仅光标不在行首时补）
    if is_hacker:
        sys.stdout.write("\n")
        sys.stdout.flush()
        click.echo(R.tool_batch_header(round_num, max_rounds, total), nl=False)
        return
    # 注：batch_header 日志由 agent_loop.execute_tool_batch 统一记录，避免重复
    sys.stdout.write('\n')
    sys.stdout.flush()
    header = f"工具调用 (第 {round_num} 轮 / {max_rounds}) — 共 {total} 个"
    frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
    for i, c in enumerate(frames):
        if i > 0:
            time.sleep(0.06)
        sys.stdout.write(f"\r\x1b[K  \033[2m{c} {header}\033[0m")
        sys.stdout.flush()
    sys.stdout.write(f"\r\x1b[K  \033[1m●\033[0m \033[2m{header}\033[0m\n")
    sys.stdout.flush()


def _start_thinking_spinner(message="推理中"):
    """启动动画旋转指示器，返回 Spinner 实例供调用方在首段内容到达时停止"""
    R = get_current_renderer()
    if isinstance(R, HackerRenderer):
        # Hacker: 思考阶段不用 spinner（已输出 [A.I] THINK header + 暗绿流）
        # 但仍返回一个未启动的占位 Spinner，保证调用方 .stop() 不崩
        s = Spinner(message)
        return s
    spinner = Spinner(message)
    spinner.start()
    return spinner


def _start_file_spinner(name: str, args: dict):
    """为文件操作启动专属 spinner，返回 Spinner 实例；非文件操作返回 None。

    文件操作可能处理大文件耗时长，专属文案让用户明确知道在做什么，
    区别于「思考中」「推理中」的旋转动画。
    """
    R = get_current_renderer()
    if isinstance(R, HackerRenderer):
        return None
    messages = {
        "file_read":  f"读取中 {args.get('path', '')}",
        "file_write": f"写入中 {args.get('path', '')}",
        "file_edit":  f"编辑中 {args.get('path', '')}",
    }
    msg = messages.get(name)
    if msg is None:
        return None
    spinner = Spinner(msg)
    spinner.start()
    return spinner


def _show_tool_result(name: str, args: dict, result: dict):
    """在终端显示工具执行结果（Default 主题专用；Hacker 走 execute_tool 内分支）"""
    R = get_current_renderer()
    if isinstance(R, HackerRenderer):
        return
    success = result.get("success", False)

    if name == "shell_exec":
        if success:
            stdout = result.get("stdout", "").strip()
            stderr = result.get("stderr", "").strip()
            has_output = bool(stdout or stderr)
            if has_output:
                click.echo()
                if stdout:
                    click.echo(stdout)
                if stderr:
                    click.echo(f"\033[33m{stderr}\033[0m")
                click.echo(f"  \033[32m完成\033[0m")
            else:
                click.echo()
                click.echo(f"  \033[32m完成 (无输出)\033[0m")
        else:
            click.echo()
            err = result.get("error", result.get("stderr", ""))
            click.echo(f"  \033[31m失败: {err}\033[0m")

    elif name in ("file_write", "file_read", "file_edit"):
        click.echo()
        if success:
            click.echo(f"  \033[32m完成\033[0m")
        else:
            click.echo(f"  \033[31m失败: {result.get('error', '')}\033[0m")

    elif name == "web_search":
        if success:
            count = len(result.get("results", []))
            click.echo(f"  \033[32m搜索完成 ({count}条)\033[0m")
        else:
            click.echo()
            click.echo(f"  \033[31m搜索失败: {result.get('error', '')}\033[0m")

    else:
        click.echo()
        if success:
            click.echo(f"  \033[32m完成\033[0m")
        else:
            click.echo(f"  \033[31m失败\033[0m")


def _execute_tool_batch(round_num: int, tool_calls: list) -> list:
    """执行一批工具调用（带 UI 显示），委托 agent_loop 处理核心逻辑。"""
    R = get_current_renderer()
    is_hacker = isinstance(R, HackerRenderer)
    _show_batch_header(round_num, len(tool_calls))
    # Hacker：准备累积器（每次 batch 重置）
    if is_hacker:
        execute_tool._hack_status = []
    results = []
    total = len(tool_calls)
    for i, tc in enumerate(tool_calls, 1):
        result = execute_tool(tc, batch_index=i, batch_total=total)
        results.append(result)
    # Hacker：打 batch footer（汇总 ok/fail/warn/count 与总时长）
    if is_hacker:
        aggregated = getattr(execute_tool, "_hack_status", [])
        try:
            delattr(execute_tool, "_hack_status")
        except AttributeError:
            pass
        click.echo(R.tool_batch_footer(aggregated), nl=False)
    return results


def _make_413_callback(engine, session):
    """构造 413 两阶段恢复回调。

    当 LLM API 返回 HTTP 413 时，依次以 phase=1/2 调用本回调：
    - Phase 1: 激进压缩（减少保护条数，删除更多中间消息）
    - Phase 2: 紧急压缩（仅保留 system + 最近 1 轮对话）
    每次压缩后写回 session.messages 并守卫 system 首条不丢失。
    返回 True 表示压缩成功可重试，False 表示压缩失败。
    """
    def on_413(phase: int) -> bool:
        try:
            if phase == 1:
                compressed = engine.compress(
                    session.get_messages_for_api(), aggressive=True
                )
            elif phase == 2:
                compressed = engine.compress(
                    session.get_messages_for_api(), emergency=True
                )
            else:
                return False
            # 守卫：保证 system 首条不丢失
            sys_content = (
                session.messages[0].get("content", "")
                if session.messages and session.messages[0].get("role") == "system"
                else ""
            )
            session.messages = Session._ensure_system_preserved(compressed, sys_content)
            return True
        except Exception as e:
            logger.error(f"413 压缩失败: {e}")
            return False
    return on_413


def _consume_stream(chunks, spinner, show_reasoning):
    """消费流式 chunk 迭代器，返回 (content, reasoning, tool_calls, usage)。

    职责：chunk 三段分发（reasoning/content/tool_calls）+ 样式切换 + first_token/api_response 日志。
    支持双主题：默认主题（原青色+灰线）与 Hacker 主题（[TAG] 前缀 + 主绿单色）。

    光标管理约定（Bug 修复核心）：
    - 所有 spinner.stop() 用 \\r\\x1b[K 清空自身行，光标停在行首（列0）
    - 切换到 content/reasoning 时，先换行再输出，避免 \\r 覆盖上一行
    - 禁止使用 \\033[F（光标上移）：上移后 \\r 会覆盖上一行内容（用户报告的截断bug）
    - 流结束后：有 content 则光标在 content 末尾（无换行，调用方负责加换行）；
      有 tool_calls 则光标在清空的 spinner 行行首（后续 _show_batch_header 会 \\n 下移）
    """
    R = get_current_renderer()
    is_hacker = isinstance(R, HackerRenderer)

    content_buffer = ""
    reasoning_buffer = ""
    new_tool_calls = []
    usage_info = None
    api_start = time.time()
    think_start_t = None          # hacker 主题：reasoning 首 chunk 时间
    think_header_emitted = False  # hacker 主题：reasoning 结束后汇总行是否已打
    first_token_logged = False
    tool_spinner = None
    outer_spinner_stopped = False
    just_stopped_tool_spinner = False

    def _stop_tool_spinner_for_text():
        """停止 tool_spinner 并换行到新行，光标停在新行首列0。

        !! BUG 修复关键代码 - 请勿修改 !!
        Bug1: 不换行导致后续文件路径被覆盖
        stop() 的 \\r\\x1b[K 清行后光标在列0，必须再写 \\n 下移到新行，
        否则后续输出(文件路径/reasoning/content)会写在该清空行上被下一轮 \\r 覆盖。
        """
        nonlocal tool_spinner, just_stopped_tool_spinner
        if tool_spinner is not None:
            tool_spinner.stop()
            sys.stdout.write("\n")
            sys.stdout.flush()
            tool_spinner = None
            just_stopped_tool_spinner = True

    def _stop_outer_spinner():
        """停止外部推理spinner并标记，返回时已清行、光标在行首。

        !! BUG 修复关键代码 - 请勿修改 !!
        Bug2: spinner线程未join导致 \\r 覆盖 content 首字符(你好只显示好)
        spinner.stop() 内部 join 线程后写 \\r\\x1b[K，确保线程彻底退出。
        """
        nonlocal outer_spinner_stopped
        if not outer_spinner_stopped:
            spinner.stop()
            outer_spinner_stopped = True

    def _emit_hacker_think_summary_if_any():
        """reasoning 阶段结束（切换到 content/tool 或 全部结束）时，
        打一次 [SYS] THINK depth=1 tokens=N t=Xs 汇总行。
        调用方保证光标在行首新行，避免 THINK 行与 reasoning 末尾并排。"""
        nonlocal think_header_emitted
        if think_header_emitted:
            return
        if not reasoning_buffer:
            think_header_emitted = True
            return
        # 估算 tokens（粗略：每 3.5 字符 ~ 1 token，英中混合）
        est_tokens = max(1, int(len(reasoning_buffer) / 3.5))
        dur = max(0.1, time.time() - (think_start_t or api_start))
        sys.stdout.write(R.ai_turn_header(est_tokens, dur))
        sys.stdout.flush()
        think_header_emitted = True

    try:
        for chunk in chunks:
            if _ESC is not None and _ESC.is_cancelled():
                raise TaskInterrupted()

            has_visible = False

            if not first_token_logged and (chunk.get("reasoning_content") or chunk.get("content") or chunk.get("tool_calls")):
                logger.api_first_token(int((time.time() - api_start) * 1000))
                first_token_logged = True
            if chunk.get("reasoning_content"):
                _stop_tool_spinner_for_text()
                if think_start_t is None:
                    think_start_t = time.time()
                if show_reasoning and not reasoning_buffer:
                    _stop_outer_spinner()
                    if not just_stopped_tool_spinner:
                        click.echo()
                    if is_hacker:
                        # hacker：首 reasoning 前不打"思考:"，直接暗绿色开始
                        # （汇总 THINK header 等 reasoning 结束后再打）
                        sys.stdout.write(R.ai_think_prefix())
                        sys.stdout.flush()
                    else:
                        click.echo("  \033[2;90m思考: ", nl=False)
                    logger.stream_reasoning_start()
                reasoning_buffer += chunk["reasoning_content"]
                if show_reasoning:
                    click.echo(chunk["reasoning_content"], nl=False)
                    sys.stdout.flush()
                    # !! BUG 修复关键代码 - 请勿修改 !!
                    # has_visible 只在真正输出可见文本时才置True，
                    # 否则 show_reasoning=False 时 tool_spinner 不创建、outer_spinner 不停止，
                    # 导致后续 \\r 覆盖 content (Bug1+Bug2)
                    has_visible = True
            if chunk.get("content"):
                _stop_tool_spinner_for_text()
                # Phase4 Root Fix (b)：空 content chunk（len=0 / 纯空白）不应触发 [A.I] prefix，
                # 否则会出现「空 [A.I] 标签行 + >> 后面没内容」后紧跟 TOOL BATCH 的空块。
                chunk_text = chunk["content"]
                content_is_still_empty = (not content_buffer) and (not chunk_text or not chunk_text.strip())
                # 第一次真正有内容的 content chunk 才打 prefix
                first_real_content = (not content_buffer) and (bool(chunk_text) and chunk_text.strip())
                if first_real_content:
                    if is_hacker and reasoning_buffer:
                        sys.stdout.write("\n")
                        sys.stdout.flush()
                    if is_hacker:
                        _emit_hacker_think_summary_if_any()
                    if show_reasoning and reasoning_buffer:
                        click.echo("\033[0m", nl=False)
                        if not just_stopped_tool_spinner:
                            click.echo()
                    # !! BUG 修复关键代码 - 请勿修改 !!
                    # content 输出前必须停止 outer_spinner，否则 spinner 线程的 \\r
                    # 会回到行首覆盖第一个字符 (Bug2: 你好只显示好)
                    _stop_outer_spinner()
                    if not (show_reasoning and reasoning_buffer) and not just_stopped_tool_spinner:
                        if not is_hacker:
                            click.echo()
                    logger.stream_content_start()
                    if is_hacker:
                        # hacker：[A.I] 单独一行 + 下一行「  >> 内容...」
                        sys.stdout.write(R.ai_content_prefix())
                        sys.stdout.flush()
                    else:
                        # 默认主题：前分隔线 + 青色开始（视觉边界：区分用户消息与系统回复）
                        click.echo(ansi_gray_line())
                        click.echo("\x1b[36m", nl=False)
                if not content_is_still_empty:
                    content_buffer += chunk_text
                click.echo(chunk_text, nl=False)
                if is_hacker:
                    # 每 chunk 后重置颜色防溢出
                    sys.stdout.write(R.ai_content_reset())
                sys.stdout.flush()
                if not content_is_still_empty:
                    has_visible = True
            if chunk.get("tool_calls"):
                new_tool_calls.extend(chunk["tool_calls"])
                if tool_spinner is None:
                    # ---- Bug Fix：无论 has_visible 与否，tool_calls 首次出现时：
                    # 1) 保证 reasoning 末尾已经换行，否则 THINK/TOOL 与 reasoning 并排
                    # 2) THINK header 没打的话补打（has_visible=True 时此前只保证 content 前打了，
                    #    但若 tool_calls 在 content 后再出现，THINK 肯定已打，跳过即可）
                    if is_hacker and reasoning_buffer and not think_header_emitted:
                        sys.stdout.write("\n")
                        sys.stdout.flush()
                    if is_hacker:
                        _emit_hacker_think_summary_if_any()
                    # ---- 停 spinner 与换行管理：
                    if show_reasoning and reasoning_buffer and not content_buffer:
                        click.echo("\033[0m", nl=False)
                    else:
                        _stop_outer_spinner()
                    # Default 主题：保持原 "生成中" spinner
                    if not is_hacker:
                        # 仅 has_visible=False 时补换行（否则 content 末字符后会被 \r 覆盖）
                        if not has_visible:
                            click.echo()
                        if tool_spinner is None:
                            tool_spinner = Spinner("生成中")
                            tool_spinner.start()
                    # Hacker 主题：has_visible（有 content）后 tool_calls 出现，
                    # 光标在 content 最后一行最末字符 → 先确保光标在行首新行，
                    # 否则 _show_batch_header 的 '\n' 只能换 1 行（不会产生巨大空白但视觉紧凑）
                    # → 这里不打多余空行，让 _show_batch_header 统一控制换行节奏
            just_stopped_tool_spinner = False
            if chunk.get("usage"):
                usage_info = chunk["usage"]
    finally:
        # !! BUG 修复关键代码 - 请勿修改 !!
        # finally 必须停止两个 spinner，否则后台线程持续用 \\r 覆盖后续输出。
        # Bug1: tool_spinner 未停止 → 文件路径被覆盖
        # Bug2: outer_spinner 未停止 → content 首字符被覆盖
        _stop_outer_spinner()
        if tool_spinner is not None:
            tool_spinner.stop()
            tool_spinner = None
        # hacker 主题：若有 reasoning 但从头到尾没 content/tool（纯 reasoning 后中断），
        # 最后补打一次 THINK 汇总（便于日志回看）。
        if is_hacker and reasoning_buffer:
            _emit_hacker_think_summary_if_any()
            sys.stdout.write(R.ai_content_reset())
            sys.stdout.flush()

    logger.api_response(api_ms=int((time.time() - api_start) * 1000),
                        content_len=len(content_buffer),
                        reasoning_len=len(reasoning_buffer),
                        tool_calls=len(new_tool_calls))
    return content_buffer, reasoning_buffer, new_tool_calls, usage_info


def process_tool_calls(session, client, tools_schemas, tool_calls, cfg, show_reasoning=False):
    """执行工具调用并获取后续回复（带 UI），委托 agent_loop.process_tool_calls 处理核心逻辑。"""

    def _stream_handler(_client, _msgs, _schema, _cfg, _sr):
        spinner = _start_thinking_spinner()
        try:
            content, reasoning, tc, usage = _consume_stream(
                _client.chat(
                    messages=_inject_todos_into_messages(_msgs),
                    tools=_schema, stream=True,
                    model=_cfg.get("model"), thinking=_cfg.get("thinking"),
                    reasoning_effort=_compute_adaptive_effort(_cfg, session),
                    max_tokens=_cfg.get("max_tokens"), user_id=_cfg.get("user_id"),
                    stream_idle_timeout=_cfg.get("stream_idle_timeout"),
                    on_413_callback=_make_413_callback(_ENGINE, session) if _ENGINE else None,
                ),
                spinner, _sr,
            )
            # 自适应思考预算：记录本轮度量供下一轮计算
            _update_last_metrics(usage, len(tc) if tc else 0, session)
        except TaskInterrupted:
            spinner.stop()
            R = get_current_renderer()
            if isinstance(R, HackerRenderer):
                click.echo(R.interrupted(), nl=False)
            else:
                click.echo()
                click.echo(f"  \033[33m(已中断当前任务)\033[0m")
            raise
        except APIConnectionError as e:
            spinner.stop()
            logger.api_error(str(e))
            R = get_current_renderer()
            msg = _format_api_connection_error_message(e, _cfg.get('model', '?'))
            if isinstance(R, HackerRenderer):
                click.echo(R.api_error(msg), nl=False)
            else:
                click.echo()
                click.echo(f"  \033[31m{msg}\033[0m")
            return "", "", [], None
        except Exception as e:
            spinner.stop()
            logger.api_error(str(e))
            R = get_current_renderer()
            if isinstance(R, HackerRenderer):
                click.echo(R.api_error(f"API call failed: {e}"), nl=False)
            else:
                click.echo()
                click.echo(f"  \033[31mAPI 调用失败: {e}\033[0m")
            return "", "", [], None
        spinner.stop()
        return content, reasoning, tc, usage

    # 自纠错循环：工具失败后注入结构化修正提示，让 LLM 修正参数重试
    _self_correction = SelfCorrectionLoop(
        max_attempts=cfg.get("self_correction_max_attempts") or 3
    )

    # Phase 8 Tier 2 CG：开启文件历史事务，供 /undo 命令批量回退
    # agent_loop.execute_tool 中针对 file_edit/file_write 自动调
    # record_in_current_transaction 记录原内容快照。
    _tx_id = None
    try:
        from .history import (
            get_global_service,
            set_current_transaction,
        )
        _hist_svc = get_global_service()
        if _hist_svc is not None:
            _session_id = getattr(session, "session_id", "") or "default"
            _msg_id = str(int(time.time() * 1000))
            try:
                _tx_id = _hist_svc.begin_transaction(_session_id, _msg_id)
                set_current_transaction(_tx_id)
            except Exception:
                _tx_id = None
    except ImportError:
        pass

    try:
        return _agent_process_tool_calls(
            session=session,
            client=client,
            tools_schemas=tools_schemas,
            tool_calls=tool_calls,
            cfg=cfg,
            registry=REGISTRY,
            host=_HOST,
            esc=_ESC,
            engine=_ENGINE,
            stream_handler=_stream_handler,
            show_reasoning=show_reasoning,
            execute_tool_batch_fn=_execute_tool_batch,
            self_correction=_self_correction,
        )
    finally:
        # 提交事务（即使异常也提交，保留已记录的快照供 /undo）
        if _tx_id is not None:
            try:
                from .history import set_current_transaction as _clear
                _clear(None)
                _hist_svc = get_global_service()
                if _hist_svc is not None:
                    try:
                        _hist_svc.commit_transaction(_tx_id)
                    except KeyError:
                        pass  # 已 commit
            except Exception:
                pass


def handle_slash_command(input_str: str, session, client, cfg, sr: list):
    """
    处理 / 命令。返回 True=已处理, "exit"=退出, False=非命令
    """
    stripped = input_str.strip()
    parts = stripped.split()
    if not parts:
        return False
    cmd = parts[0].lower()

    if cmd == "/clear":
        session.clear()
        clear_todos()  # 同步清空 todo 状态，避免跨会话残留
        if _h_echo(stripped):
            click.echo(_h_ok(_h_grn(f"ctx cleared (msgs: {len(session.messages) if session.messages else 1:} → 1 · todos 已清空)")))
        else:
            click.echo("[OK] 上下文已清空")
        return True

    if cmd in ("/exit", "/quit"):
        return "exit"

    if cmd == "/help":
        R = get_current_renderer()
        if isinstance(R, HackerRenderer):
            _h_echo(stripped)  # /help 的 CMD TAG
            hl = R.help_list()
            if hl:
                click.echo(hl, nl=False)
                return True
        click.echo(_HELP_TEXT)
        return True

    # 7 条方法论命令走分发表，差异全在数据里（/diagnose /tdd /review /audit /architecture /prototype /domain）
    if cmd in _METHODOLOGY_COMMANDS:
        methodology, usage, name = _METHODOLOGY_COMMANDS[cmd]
        user_desc = stripped[len(cmd):].strip()
        if usage and not user_desc:
            click.echo(usage)
            return True
        if not user_desc:
            user_desc = _METHODOLOGY_DEFAULT_DESC.get(cmd, "")
        return _inject_methodology(session, methodology, user_desc, name)

    # /config
    if cmd == "/config":
        if len(parts) == 1:
            hacker = _h_echo(stripped)
            if hacker:
                items: list[tuple] = []
                for k, v in cfg.to_dict().items():
                    if k == "api_key" and v:
                        v = v[:8] + "****" + v[-4:]
                    style = "grn"
                    if k == "model":
                        style = "cyn"
                    elif k == "thinking":
                        style = "grn_bg" if bool(v) else "red_bg"
                        v = "on" if bool(v) else "off"
                    elif k == "log_level":
                        style = "yel_bg" if str(v).upper() not in ("INFO","DEBUG") else "grn"
                    elif k == "show_reasoning":
                        style = "grn_bg" if bool(v) else "red_bg"
                        v = "on" if bool(v) else "off"
                    elif k == "ui.theme":
                        style = "yel"
                    items.append((k, v, style))
                click.echo(_h_kv_list(items, "key", "value"))
                click.echo(_h_dbg("  saved at ~/.abyss/config.toml · 运行时生效 · 重启保留  ") + _h_grn_bg("[OK]"))
            else:
                click.echo("当前配置:")
                for k, v in cfg.to_dict().items():
                    if k == "api_key" and v:
                        v = v[:8] + "****" + v[-4:]
                    click.echo(f"  {k}: {v}")
            return True

        # /config get <key>
        if parts[1] == "get":
            if len(parts) < 3:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_dbg("用法：/config get ") + _h_yel_bold("<key>"), "ERR·参数缺失"))
                    click.echo(_h_dbg(f"  可用键: {', '.join(cfg.to_dict().keys())}"))
                else:
                    click.echo(f"用法: /config get <key>")
                    click.echo(f"可用键: {', '.join(cfg.to_dict().keys())}")
                return True
            key_in = parts[2]
            # 支持 kebab-case 映射（api-key → api_key / max-tokens → max_tokens / log-level → log_level）
            real_key = {
                "api-key": "api_key",
                "max-tokens": "max_tokens",
                "log-level": "log_level",
                "show-reasoning": "show_reasoning",
                "ui-theme": "ui.theme",
            }.get(key_in, key_in)
            value = cfg.get(real_key)
            if _h_echo(stripped):
                if value is None:
                    click.echo(_h_err(_h_grn(f"cfg {key_in} = ") + _h_dbg("<未设置>")))
                else:
                    disp = value
                    if real_key == "api_key" and value:
                        disp = value[:8] + "****" + value[-4:]
                    elif real_key in ("thinking", "show_reasoning"):
                        disp = "on" if bool(value) else "off"
                    click.echo(_h_ok(_h_grn(f"cfg {key_in} = ") + _h_grn_bold(str(disp))))
            else:
                if value is None:
                    click.echo(f"[INFO] {key_in}: <未设置>")
                else:
                    disp = value
                    if real_key == "api_key" and value:
                        disp = value[:8] + "****" + value[-4:]
                    click.echo(f"{key_in}: {disp}")
            return True

        # /config set <key> [value]
        if parts[1] == "set":
            if len(parts) < 3:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_dbg("用法：/config set ") + _h_yel_bold("<key> [value]"), "ERR·参数缺失"))
                    click.echo(_h_dbg("  常用键：api-key, model, thinking, max-tokens, log-level, show-reasoning, ui-theme"))
                else:
                    click.echo("用法: /config set <key> [value]")
                    click.echo("常用键：api-key, model, thinking, max-tokens, log-level")
                return True
            key = parts[2]
            has_value = len(parts) >= 4
            val_str = parts[3] if has_value else None

            if key == "api-key":
                if has_value:
                    val = " ".join(parts[3:])
                    cfg.set("api_key", val)
                    client.api_key = val
                    client.client.api_key = val
                    if _h_echo(stripped):
                        click.echo(_h_ok(_h_grn("cfg api_key = ") + _h_grn_bold(f"(none) → sk-****{val[-4:]}") if len(val) > 4 else _h_grn_bold("→ SET")))
                    else:
                        click.echo(f"[OK] API Key 已保存")
                else:
                    if _h_echo(stripped):
                        click.echo(_h_err(_h_dbg("用法：/config set api-key ") + _h_yel_bold("你的Key"), "ERR·参数缺失"))
                    else:
                        click.echo(f"用法: /config set api-key <你的Key>")
                return True
            if key == "model":
                if has_value:
                    old = cfg.get("model", "(none)")
                    cfg.set("model", val_str)
                    if _h_echo(stripped):
                        click.echo(_h_ok(_h_grn("cfg model = ") + _h_grn_bold(f"{old} → {val_str}")))
                    else:
                        click.echo(f"[OK] 模型 → {val_str}")
                else:
                    if _h_echo(stripped):
                        click.echo(_h_ok(_h_grn("cfg model = ") + _h_grn_bold(cfg.get("model"))))
                        click.echo(_h_dbg("  用法：/config set model <模型名>"))
                    else:
                        click.echo(f"当前模型: {cfg.get('model')}")
                        click.echo(f"用法: /config set model <模型名>")
                return True
            if key == "thinking":
                old = cfg.get("thinking")
                if has_value:
                    val = val_str.lower() in ("on", "true", "1", "yes")
                    cfg.set("thinking", val)
                else:
                    val = not bool(old)
                    cfg.set("thinking", val)
                if _h_echo(stripped):
                    click.echo(_h_ok(_h_grn("cfg thinking = ") + _h_grn_bold(f"{'on' if bool(old) else 'off'} → {'on' if val else 'off'}")))
                else:
                    click.echo(f"[OK] 思考模式: {'开' if val else '关'}")
                return True
            if key == "max-tokens":
                if has_value:
                    try:
                        old = cfg.get("max_tokens")
                        cfg.set("max_tokens", int(val_str))
                        if _h_echo(stripped):
                            click.echo(_h_ok(_h_grn("cfg max_tokens = ") + _h_grn_bold(f"{old} → {val_str}")))
                        else:
                            click.echo(f"[OK] max_tokens → {val_str}")
                    except ValueError:
                        if _h_echo(stripped):
                            click.echo(_h_err(_h_grn(f"请输入有效数字，当前 = {cfg.get('max_tokens')}")))
                        else:
                            click.echo(f"请输入有效数字，当前: {cfg.get('max_tokens')}")
                else:
                    if _h_echo(stripped):
                        click.echo(_h_ok(_h_grn("cfg max_tokens = ") + _h_grn_bold(str(cfg.get("max_tokens")))))
                        click.echo(_h_dbg("  用法：/config set max-tokens <数字>"))
                    else:
                        click.echo(f"当前 max_tokens: {cfg.get('max_tokens')}")
                        click.echo(f"用法: /config set max-tokens <数字>")
                return True
            if key == "log-level":
                if has_value:
                    level = val_str.lower()
                    if level in ("debug", "info", "warn", "error"):
                        old = cfg.get("log_level")
                        cfg.set("log_level", level.upper())
                        logger.set_level(level.upper())
                        if _h_echo(stripped):
                            click.echo(_h_ok(_h_grn("cfg log_level = ") + _h_grn_bold(f"{old or 'INFO'} → {level.upper()}")))
                        else:
                            click.echo(f"[OK] 日志级别 → {level.upper()}")
                    else:
                        if _h_echo(stripped):
                            click.echo(_h_err(_h_grn(f"无效级别: {val_str}  · 可用: debug, info, warn, error")))
                        else:
                            click.echo(f"无效级别: {val_str}，可用: debug, info, warn, error")
                else:
                    if _h_echo(stripped):
                        click.echo(_h_ok(_h_grn("cfg log_level = ") + _h_grn_bold(str(logger.get_logger().get_level()))))
                        click.echo(_h_dbg("  用法：/config set log-level <debug|info|warn|error>"))
                    else:
                        click.echo(f"当前日志级别: {logger.get_logger().get_level()}")
                        click.echo(f"用法: /config set log-level <debug|info|warn|error>")
                return True
            if key == "show-reasoning":
                old = cfg.get("show_reasoning")
                if has_value:
                    val = val_str.lower() in ("on", "true", "1", "yes")
                else:
                    val = not bool(old)
                cfg.set("show_reasoning", val)
                if _h_echo(stripped):
                    click.echo(_h_ok(_h_grn("cfg show_reasoning = ") + _h_grn_bold(f"{'on' if bool(old) else 'off'} → {'on' if val else 'off'}")))
                else:
                    click.echo(f"[OK] 显示思考过程: {'开' if val else '关'}")
                return True

            # 通用 key：无特殊语义 → 直接保存为字符串（支持"任意 key"设置）
            real_key = {"show-reasoning": "show_reasoning", "ui-theme": "ui.theme"}.get(key, key)
            if has_value:
                old = cfg.get(real_key, "<未设置>")
                # 主题 UI：只保留 hacker（强制锁死为复古 CRT 风格；传任何值都归一化到 hacker）
                if real_key == "ui.theme":
                    v_raw = str(val_str).strip().lower()
                    if v_raw != "hacker":
                        if _h_echo(stripped):
                            click.echo(_h_warn(
                                _h_dbg("ui.theme 已锁定为唯一值：") + _h_yel_bold("hacker")
                                + _h_grn_dim(f"  （输入 {val_str} → 已归一化为 hacker）"),
                                "WARN·主题锁定",
                            ))
                        else:
                            click.echo(f"[WARN] ui.theme 唯一有效值：hacker（已归一化 {val_str} → hacker）")
                    v_norm = "hacker"
                    if _h_echo(stripped):
                        old_disp = old if old not in (None, "<未设置>") else "<默认>"
                        click.echo(_h_ok(_h_grn("cfg ui.theme = ")
                                         + f"{_h_grn_bold(str(old_disp))} {_h_grn_dim('→')} {_h_grn_bold(v_norm)}"
                                         + f"  {_h_dbg('(复古 CRT · abyss 专属风格)')}"))
                    else:
                        click.echo(f"[OK] ui.theme = hacker")
                    cfg.set(real_key, v_norm)
                    return True
                cfg.set(real_key, val_str)
                if _h_echo(stripped):
                    click.echo(_h_ok(_h_grn(f"cfg {key} = ") + _h_grn_bold(f"{old} → {val_str}")))
                else:
                    click.echo(f"[OK] {key} → {val_str}")
            else:
                # 只 set key 不带 value → 显示当前值
                cur = cfg.get(real_key)
                if _h_echo(stripped):
                    if cur is None:
                        click.echo(_h_err(_h_grn(f"cfg {key} = ") + _h_dbg("<未设置> · 用法：/config set ") + _h_yel_bold(f"{key} <value>")))
                    else:
                        click.echo(_h_ok(_h_grn(f"cfg {key} = ") + _h_grn_bold(str(cur))))
                        if real_key == "ui.theme":
                            click.echo(_h_dbg("  风格：复古 CRT（abyss 唯一主题）"))
                        else:
                            click.echo(_h_dbg(f"  用法：/config set {key} ") + _h_yel_bold("<value>"))
                else:
                    click.echo(f"当前 {key}: {cur if cur is not None else '<未设置>'}")
                    if real_key != "ui.theme":
                        click.echo(f"用法: /config set {key} <value>")
            return True

        # /config unset <key>
        if parts[1] == "unset":
            if len(parts) < 3:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_dbg("用法：/config unset ") + _h_yel_bold("<key>"), "ERR·参数缺失"))
                    click.echo(_h_dbg(f"  已保存的键：{', '.join(cfg.to_dict().keys())}"))
                else:
                    click.echo("用法: /config unset <key>")
                    click.echo(f"已保存的键: {', '.join(cfg.to_dict().keys())}")
                return True
            key_in = parts[2]
            real_key = {
                "api-key": "api_key",
                "max-tokens": "max_tokens",
                "log-level": "log_level",
                "show-reasoning": "show_reasoning",
                "ui-theme": "ui.theme",
            }.get(key_in, key_in)
            deleted = cfg.delete(real_key)
            if _h_echo(stripped):
                if deleted:
                    msg = _h_grn(f"cfg {key_in} ") + _h_dbg("removed") + _h_grn(" (fallback 默认值)")
                    click.echo(_h_ok(msg))
                else:
                    click.echo(_h_dbg(f"  cfg {key_in} 本就不存在（已忽略）"))
            else:
                if deleted:
                    click.echo(f"[OK] 已删除配置项: {key_in}")
                else:
                    click.echo(f"[INFO] 配置项 {key_in} 本就不存在（已忽略）")
            return True

        # 未知子命令
        if _h_echo(stripped):
            click.echo(_h_err(_h_grn(f"未知子命令: {parts[1]}  · 可用: get, set, unset")))
        else:
            click.echo(f"未知子命令: {parts[1]}")
            click.echo("可用: /config [get|set|unset] <key> ...")
        return True

    # /model
    if cmd == "/model":
        if len(parts) == 1:
            # 无参数：先回显命令，再弹出交互式选择器
            _hacker = _h_echo(stripped)
            from .ansi_menu import ansi_select
            options = ["deepseek-v4-pro", "deepseek-v4-flash"]
            current = cfg.get("model")
            try:
                default_idx = options.index(current)
            except ValueError:
                default_idx = 0
            selected = ansi_select("选择模型", options, default_idx=default_idx)
            if selected:
                old = cfg.get("model", "(none)")
                cfg.set("model", selected)
                if _hacker:
                    echo_line = f"/model {selected}"
                    _h_echo(echo_line)
                    click.echo(_h_ok(_h_grn("cfg model = ") + _h_grn_bold(f"{old} → {selected}")))
                else:
                    click.echo(f"[OK] 已切换: {selected}")
            return True
        m = parts[1]
        if m in ("deepseek-v4-pro", "deepseek-v4-flash"):
            old = cfg.get("model", "(none)")
            cfg.set("model", m)
            if _h_echo(stripped):
                click.echo(_h_ok(_h_grn("cfg model = ") + _h_grn_bold(f"{old} → {m}")))
            else:
                click.echo(f"[OK] 已切换: {m}")
        else:
            if _h_echo(stripped):
                click.echo(_h_err(_h_grn(f"未知模型: {m}  · 可用: deepseek-v4-pro, deepseek-v4-flash")))
            else:
                click.echo(f"未知模型: {m}，可用: deepseek-v4-pro, deepseek-v4-flash")
        return True

    # /thinking
    if cmd == "/thinking":
        old = cfg.get("thinking")
        if len(parts) == 1:
            if _h_echo(stripped):
                click.echo(_h_ok(_h_grn("cfg thinking = ") + _h_grn_bold("on" if bool(old) else "off")))
            else:
                click.echo(f"思考模式: {'开' if cfg.get('thinking') else '关'}")
            return True
        val = parts[1].lower() in ("on", "true", "1", "yes")
        cfg.set("thinking", val)
        if _h_echo(stripped):
            click.echo(_h_ok(_h_grn("cfg thinking = ") + _h_grn_bold(f"{'on' if bool(old) else 'off'} → {'on' if val else 'off'}")))
        else:
            click.echo(f"[OK] 思考模式: {'开' if val else '关'}")
        return True

    # /show-reasoning
    if cmd == "/show-reasoning":
        sr[0] = not sr[0]
        # 同步写盘，重启后不丢失（用户反馈：UI 切到 true 磁盘仍是 false）
        cfg.set("show_reasoning", sr[0])
        if _h_echo(stripped):
            click.echo(_h_ok(_h_grn("cfg show_reasoning = ") + _h_grn_bold(f"{'off' if not sr[0] else 'off → on'} · ") + _h_dbg("（已保存到磁盘）")))
        else:
            click.echo(f"[OK] 思考过程显示: {'开' if sr[0] else '关'} (已保存)")
        return True

    # /skills — Skill 列表/交互选择/直接注入
    if cmd == "/skills":
        if _HOST is None:
            if _h_echo(stripped):
                click.echo(_h_dbg("  扩展系统未加载"))
            else:
                click.echo("[INFO] 扩展系统未加载")
            return True
        names = _HOST._skills.list_names()
        if not names:
            if _h_echo(stripped):
                click.echo(_h_dbg("  未加载任何 skill（放入 ~/.abyss/skills/<name>/SKILL.md）"))
            else:
                click.echo("[INFO] 未加载任何 skill（放入 ~/.abyss/skills/<name>/SKILL.md）")
            return True
        if len(parts) >= 2:
            target = parts[1]
            if target in names:
                _HOST.inject_skill_by_name(session, target)
                curator_bump_use(target)  # 记录 skill 使用
                if _h_echo(stripped):
                    click.echo(_h_ok(_h_grn("skill ") + _h_cyn_bold(target) + _h_grn(" injected  · used = ") + _h_grn_bold("+1")))
                else:
                    click.echo(f"[OK] 已注入 skill: {target}")
            else:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_grn(f"未找到 skill: {target}  · 可用: {', '.join(names)}")))
                else:
                    click.echo(f"[ERR] 未找到 skill: {target}（可用: {', '.join(names)}）")
            return True
        # 无参数：先回显命令，再弹交互式选择器
        _hacker = _h_echo(stripped)
        from .ansi_menu import ansi_select
        default_idx = 0
        selected = ansi_select("选择 Skill 注入上下文 (Esc 取消)", names, default_idx=default_idx)
        if selected:
            _HOST.inject_skill_by_name(session, selected)
            curator_bump_use(selected)  # 记录 skill 使用
            if _hacker:
                echo_line = f"/skills {selected}"
                _h_echo(echo_line)
                click.echo(_h_ok(_h_grn("skill ") + _h_cyn_bold(selected) + _h_grn(" injected  · used = ") + _h_grn_bold("+1")))
            else:
                click.echo(f"[OK] 已注入 skill: {selected}")
        return True

    # /knowledge — 只读知识库命令（新增/更新走 knowledge_save 工具）
    if cmd == "/knowledge":
        if _HOST is None or _HOST._knowledge is None:
            if _h_echo(stripped):
                click.echo(_h_dbg("  知识库未加载"))
            else:
                click.echo("[INFO] 知识库未加载")
            return True
        loader = _HOST._knowledge
        all_k = loader.list_all()
        curator = _HOST._knowledge_curator
        # /knowledge search <q>
        if len(parts) >= 2 and parts[1] == "search":
            if len(parts) < 3:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_dbg("用法：/knowledge search ") + _h_yel_bold("<关键词>")))
                else:
                    click.echo("用法: /knowledge search <关键词>")
                return True
            query = " ".join(parts[2:])
            hacker = _h_echo(stripped)
            matched = loader.match_by_keyword(query, limit=20)
            if not matched:
                if hacker:
                    click.echo(_h_dbg(f"  未找到匹配 '{query}' 的知识"))
                else:
                    click.echo(f"[INFO] 未找到匹配 '{query}' 的知识")
                return True
            if hacker:
                click.echo(_h_grn(f"匹配 ") + _h_grn_bold(str(len(matched))) + _h_grn(" 条："))
                for k in matched:
                    p_color = _h_cyn_bold if k.level == "global" else _h_mag_bold
                    p = "[G]" if k.level == "global" else "[P]"
                    click.echo(f"  {p_color(p)} {_h_grn_bold(k.name)}: {_h_grn(k.description)}")
            else:
                for k in matched:
                    prefix = "G" if k.level == "global" else "P"
                    click.echo(f"[{prefix}] {k.name}: {k.description}")
            return True
        # /knowledge show <name>
        if len(parts) >= 2 and parts[1] == "show":
            if len(parts) < 3:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_dbg("用法：/knowledge show ") + _h_yel_bold("<名称>")))
                else:
                    click.echo("用法: /knowledge show <名称>")
                return True
            name = parts[2]
            k = loader.get(name)
            if k is None:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_grn(f"未找到知识: {name}")))
                else:
                    click.echo(f"[ERR] 未找到知识: {name}")
                return True
            hacker = _h_echo(stripped)
            if hacker:
                scope = _h_cyn_bold("[G]lobal") if k.level == "global" else _h_mag_bold("[P]roject")
                tags_line = " ".join(_h_yel_bg(t) for t in (k.tags or [])) if k.tags else _h_dbg("(无 tags)")
                lines = [
                    _h_grn_dim("┌──────────────────────────────────────────────────────────────────────────────┐"),
                    f"{_h_grn_dim('│ NAME  ')}{_h_grn_bold(k.name):<60}{_h_grn_dim(' │ SCOPE ')}{scope:<16}{_h_grn_dim('│')}",
                    f"{_h_grn_dim('│ TAGS  ')}{tags_line:<60}{_h_grn_dim('│')}",
                    f"{_h_grn_dim('│ UPDT  ')}{_h_dbg(str(getattr(k, 'updated_at', '') or '—')):<60}{_h_grn_dim('│')}",
                    _h_grn_dim("├──────────────────────────────────────────────────────────────────────────────┤"),
                    _h_grn((k.content or "").rstrip()),
                    _h_grn_dim("└──────────────────────────────────────────────────────────────────────────────┘"),
                ]
                click.echo("\n".join(lines))
            else:
                prefix = "G" if k.level == "global" else "P"
                click.echo(f"[{prefix}] {k.name}: {k.description}")
                if k.tags:
                    click.echo(f"tags: {', '.join(k.tags)}")
                click.echo("")
                click.echo(k.content)
            return True
        # /knowledge pin <name>
        if len(parts) >= 2 and parts[1] == "pin":
            name = parts[2] if len(parts) >= 3 else ""
            result = handle_knowledge_pin(curator, name)
            if _h_echo(stripped):
                click.echo(_h_ok(_h_grn(result.strip())))
            else:
                click.echo(result)
            return True
        # /knowledge archive <name>
        if len(parts) >= 2 and parts[1] == "archive":
            name = parts[2] if len(parts) >= 3 else ""
            result = handle_knowledge_archive(curator, name)
            if _h_echo(stripped):
                click.echo(_h_ok(_h_grn(result.strip())))
            else:
                click.echo(result)
            return True
        # /knowledge stats
        if len(parts) >= 2 and parts[1] == "stats":
            hacker = _h_echo(stripped)
            lines = list(format_knowledge_stats(loader, curator))
            if hacker:
                items: list[tuple] = []
                for ln in lines:
                    if ": " in ln:
                        k2, _, v2 = ln.partition(": ")
                        items.append((k2.strip(), v2.strip(), "grn_bold"))
                if items:
                    click.echo(_h_kv_list(items, "metric", "value"))
                else:
                    for ln in lines:
                        click.echo(_h_grn(ln))
            else:
                for ln in lines:
                    click.echo(ln)
            return True
        # /knowledge — 分类分组列出
        if not all_k:
            if _h_echo(stripped):
                click.echo(_h_dbg("  知识库为空（全局：~/.abyss/knowledge/，项目：<工作目录>/.knowledge/）"))
            else:
                click.echo("[INFO] 知识库为空（全局：~/.abyss/knowledge/，项目：<工作目录>/.knowledge/）")
            return True
        hacker = _h_echo(stripped)
        lines = list(format_knowledge_list_grouped(loader))
        if hacker:
            for ln in lines:
                if ln.startswith("──") or ln.endswith("──"):
                    click.echo(_h_yel_bold(ln))
                elif ln.startswith("[G]"):
                    rest = ln[3:]
                    click.echo(_h_cyn_bold("[G]") + " " + rest)
                elif ln.startswith("[P]"):
                    rest = ln[3:]
                    click.echo(_h_mag_bold("[P]") + " " + rest)
                else:
                    click.echo(_h_grn(ln))
        else:
            for ln in lines:
                click.echo(ln)
        return True

    # /memory — 跨会话长期记忆管理（list/search/show/save/delete）
    if cmd == "/memory":
        if _MEMORY_STORE is None:
            if _h_echo(stripped):
                click.echo(_h_dbg("  记忆库未加载"))
            else:
                click.echo("[INFO] 记忆库未加载")
            return True
        # /memory list
        if len(parts) == 1 or (len(parts) == 2 and parts[1] == "list"):
            all_mem = _MEMORY_STORE.list_all()
            hacker = _h_echo(stripped)
            if not all_mem:
                if hacker:
                    click.echo(_h_dbg("  记忆库为空（用 memory_save 工具或 /memory save 添加）"))
                else:
                    click.echo("[INFO] 记忆库为空（用 memory_save 工具或 /memory save 添加）")
                return True
            if hacker:
                click.echo(_h_grn("共 ") + _h_grn_bold(str(len(all_mem))) + _h_grn(" 条记忆 · 最近 50："))
                for m in all_mem[-50:]:
                    cat = f"[{m.category}]" if m.category != "session" else "[sess]"
                    cat_color = _h_yel_bold
                    click.echo(f"  {_h_dbg(m.id):<16}{cat_color(cat)}  {_h_grn(m.title)}")
            else:
                click.echo(f"共 {len(all_mem)} 条记忆:")
                for m in all_mem[-50:]:
                    cat_tag = f"[{m.category}]" if m.category != "session" else ""
                    click.echo(f"  {m.id} {cat_tag} {m.title}")
            return True
        # /memory search <query>
        if parts[1] == "search":
            if len(parts) < 3:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_dbg("用法：/memory search ") + _h_yel_bold("<关键词>")))
                else:
                    click.echo("用法: /memory search <关键词>")
                return True
            query = " ".join(parts[2:])
            hacker = _h_echo(stripped)
            hits = _MEMORY_STORE.search(query, top_k=10)
            if not hits:
                if hacker:
                    click.echo(_h_dbg(f"  未找到匹配 '{query}' 的记忆"))
                else:
                    click.echo(f"[INFO] 未找到匹配 '{query}' 的记忆")
                return True
            if hacker:
                click.echo(_h_grn("匹配 ") + _h_grn_bold(str(len(hits))) + _h_grn(" 条："))
                for m in hits:
                    snippet = m.content[:80] + "..." if len(m.content) > 80 else m.content
                    click.echo(f"  {_h_dbg(m.id)}")
                    click.echo(f"    {_h_grn(snippet)}")
            else:
                click.echo(f"匹配 {len(hits)} 条:")
                for m in hits:
                    click.echo(f"  {m.id} {m.title}")
                    snippet = m.content[:80] + "..." if len(m.content) > 80 else m.content
                    click.echo(f"    {snippet}")
            return True
        # /memory show <id>
        if parts[1] == "show":
            if len(parts) < 3:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_dbg("用法：/memory show ") + _h_yel_bold("<id>")))
                else:
                    click.echo("用法: /memory show <id>")
                return True
            m = _MEMORY_STORE.get(parts[2])
            if m is None:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_grn(f"未找到记忆: {parts[2]}")))
                else:
                    click.echo(f"[ERR] 未找到记忆: {parts[2]}")
                return True
            hacker = _h_echo(stripped)
            if hacker:
                tags_line = " ".join(_h_yel_bg(t) for t in (m.tags or [])) if m.tags else _h_dbg("(无 tags)")
                lines = [
                    _h_grn_dim("┌──────────────────────────────────────────────────────────────────────────────┐"),
                    f"{_h_grn_dim('│ ID      ')}{_h_cyn_bold(m.id):<64}{_h_grn_dim('│')}",
                    f"{_h_grn_dim('│ CAT     ')}{_h_yel_bold('[' + m.category + ']'):<16}{_h_grn_dim('│ CREATED ')}{_h_dbg(str(m.created_at)[:16]):<20}{_h_grn_dim('│')}",
                    f"{_h_grn_dim('│ TITLE   ')}{_h_grn_bold(str(m.title)[:72]):<72}{_h_grn_dim('│')}",
                    f"{_h_grn_dim('│ TAGS    ')}{tags_line:<60}{_h_grn_dim('│')}",
                    f"{_h_grn_dim('│ SRC     ')}{_h_dbg(str(m.source_session or '(未知)')):<60}{_h_grn_dim('│')}",
                    _h_grn_dim("├──────────────────────────────────────────────────────────────────────────────┤"),
                    _h_grn((m.content or "").rstrip()),
                    _h_grn_dim("└──────────────────────────────────────────────────────────────────────────────┘"),
                ]
                click.echo("\n".join(lines))
            else:
                click.echo(f"ID: {m.id}")
                click.echo(f"标题: {m.title}")
                click.echo(f"分类: {m.category}")
                click.echo(f"创建: {m.created_at}")
                if m.tags:
                    click.echo(f"标签: {', '.join(m.tags)}")
                click.echo(f"来源会话: {m.source_session or '(未知)'}")
                click.echo("")
                click.echo(m.content)
            return True
        # /memory save <title> | <content>
        if parts[1] == "save":
            rest = stripped[len("/memory save"):].strip()
            if "|" not in rest:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_dbg("用法：/memory save ") + _h_yel_bold("<标题> | <内容>")))
                else:
                    click.echo("用法: /memory save <标题> | <内容>")
                return True
            title, content = rest.split("|", 1)
            from .memory import MemoryEntry
            _MEMORY_STORE.add(MemoryEntry(
                id="", title=title.strip(), content=content.strip(),
                category="session",
            ))
            if _h_echo(stripped):
                click.echo(_h_ok(_h_grn(f"已保存记忆: ") + _h_grn_bold(title.strip())))
            else:
                click.echo(f"[OK] 已保存记忆: {title.strip()}")
            return True
        # /memory delete <id>
        if parts[1] == "delete":
            if len(parts) < 3:
                if _h_echo(stripped):
                    click.echo(_h_err(_h_dbg("用法：/memory delete ") + _h_yel_bold("<id>")))
                else:
                    click.echo("用法: /memory delete <id>")
                return True
            removed = _MEMORY_STORE.remove(parts[2])
            if _h_echo(stripped):
                click.echo(_h_ok(_h_grn(f"已删除记忆: {parts[2]}")) if removed else _h_err(_h_grn(f"未找到记忆: {parts[2]}")))
            else:
                if removed:
                    click.echo(f"[OK] 已删除记忆: {parts[2]}")
                else:
                    click.echo(f"[ERR] 未找到记忆: {parts[2]}")
            return True
        # /memory stats
        if parts[1] == "stats":
            all_mem = _MEMORY_STORE.list_all()
            hacker = _h_echo(stripped)
            from collections import Counter
            cat_counts = Counter(m.category for m in all_mem)
            if hacker:
                items: list[tuple] = [("总记忆数", str(len(all_mem)), "grn_bold")]
                for cat, n in cat_counts.most_common():
                    items.append((f"cat:{cat}", str(n), "yel"))
                items.append(("存储路径", str(_MEMORY_STORE.path), "dbg"))
                click.echo(_h_kv_list(items, "metric", "value"))
            else:
                click.echo(f"总记忆数: {len(all_mem)}")
                for cat, n in cat_counts.most_common():
                    click.echo(f"  {cat}: {n}")
                click.echo(f"存储路径: {_MEMORY_STORE.path}")
            return True
        if _h_echo(stripped):
            click.echo(_h_err(_h_grn("用法：/memory [list|search <q>|show <id>|save|delete <id>|stats]")))
        else:
            click.echo("用法: /memory [list|search <q>|show <id>|save|delete <id>|stats]")
        return True

    # /mcp — MCP server/工具 列表/详情
    if cmd == "/mcp":
        if _HOST is None:
            if _h_echo(stripped):
                click.echo(_h_dbg("  扩展系统未加载"))
            else:
                click.echo("[INFO] 扩展系统未加载")
            return True
        servers = _HOST._mcp.servers
        tools = _HOST._mcp.list_tool_names()
        if len(parts) >= 2:
            target = parts[1]
            hacker = _h_echo(stripped)
            if target in servers:
                srv_tools = [t for t in tools if t.startswith(f"mcp__{target}__")]
                if hacker:
                    click.echo(_h_grn("server ") + _h_grn_bold(target) + _h_dbg(f" ({servers[target].transport.value})") + _h_grn("  · ") + _h_grn_bold(str(len(srv_tools))) + _h_grn(" tools："))
                    schemas = _HOST._mcp.get_openai_schemas()
                    for t in srv_tools:
                        desc = ""
                        for s in schemas:
                            if s.get("function", {}).get("name") == t:
                                desc = s["function"].get("description", "")
                                break
                        click.echo(f"  {_h_grn_bold('•')} {_h_grn_bold(t.replace(f'mcp__{target}__','')):26s}  {_h_dbg(desc[:70])}")
                else:
                    click.echo(f"server: {target} ({servers[target].transport.value}), {len(srv_tools)} 个工具:")
                    schemas = _HOST._mcp.get_openai_schemas()
                    for t in srv_tools:
                        desc = ""
                        for s in schemas:
                            if s.get("function", {}).get("name") == t:
                                desc = s["function"].get("description", "")
                                break
                        click.echo(f"  • {t}  {desc}")
            else:
                if hacker:
                    click.echo(_h_err(_h_grn(f"未找到 server: {target}  · 可用: {', '.join(servers.keys()) or '无'}")))
                else:
                    click.echo(f"[ERR] 未找到 server: {target}（可用: {', '.join(servers.keys()) or '无'}）")
            return True
        if not servers and not tools:
            if _h_echo(stripped):
                click.echo(_h_dbg("  未配置 MCP server（编辑 ~/.abyss/mcp.json）"))
            else:
                click.echo("[INFO] 未配置 MCP server（编辑 ~/.abyss/mcp.json）")
            return True
        from .ansi_menu import ansi_select
        options = [f"server: {n} ({c.transport.value})" for n, c in servers.items()]
        options += [f"tool: {t}" for t in tools]
        selected = ansi_select("MCP server / 工具 (Esc 取消)", options, default_idx=0)
        if selected:
            if isinstance(get_current_renderer(), HackerRenderer):
                echo_line = f"/mcp " + selected[len("server: "):].split(" ")[0] if selected.startswith("server: ") else f"/mcp tool"
                _h_echo(echo_line)
                click.echo(_h_grn(selected))
            else:
                click.echo(f"  {selected}")
            if selected.startswith("server: "):
                sname = selected[8:].split(" ")[0]
                srv_tools = [t for t in tools if t.startswith(f"mcp__{sname}__")]
                for t in srv_tools:
                    if isinstance(get_current_renderer(), HackerRenderer):
                        click.echo(f"    {_h_grn_bold('•')} {_h_grn(t.replace(f'mcp__{sname}__', ''))}")
                    else:
                        click.echo(f"    • {t}")
        return True

    # /hooks — Hook 事件列表/脚本详情
    if cmd == "/hooks":
        if _HOST is None:
            if _h_echo(stripped):
                click.echo(_h_dbg("  扩展系统未加载"))
            else:
                click.echo("[INFO] 扩展系统未加载")
            return True
        events = ["PreToolUse", "PostToolUse", "PreLLMRequest", "PostLLMResponse"]
        event_counts = {ev: len(_HOST._hooks.list_hooks(ev)) for ev in events}
        if len(parts) >= 2:
            target = parts[1]
            hacker = _h_echo(stripped)
            if target in event_counts:
                scripts = _HOST._hooks.list_hooks(target)
                if scripts:
                    if hacker:
                        click.echo(_h_grn_bold(target) + _h_grn(f" (") + _h_grn_bold(str(len(scripts))) + _h_grn(") 个脚本："))
                        for s in scripts:
                            click.echo(f"  {_h_grn_bold('•')} {_h_grn_bold(s.name)}")
                    else:
                        click.echo(f"{target} ({len(scripts)} 个脚本):")
                        for s in scripts:
                            click.echo(f"  • {s.name}")
                else:
                    if hacker:
                        click.echo(_h_dbg(f"  {target} 无脚本"))
                    else:
                        click.echo(f"[INFO] {target} 无脚本")
            else:
                if hacker:
                    click.echo(_h_err(_h_grn(f"未知事件: {target}  · 可用: {', '.join(events)}")))
                else:
                    click.echo(f"[ERR] 未知事件: {target}（可用: {', '.join(events)}）")
            return True
        options = [f"{ev} ({event_counts[ev]})" for ev in events if event_counts[ev] > 0]
        if not options:
            if _h_echo(stripped):
                click.echo(_h_dbg("  未配置 hook（放入 ~/.abyss/hooks/<事件>/<脚本>）"))
            else:
                click.echo("[INFO] 未配置 hook（放入 ~/.abyss/hooks/<事件>/<脚本>）")
            return True
        from .ansi_menu import ansi_select
        selected = ansi_select("Hook 事件 (Esc 取消)", options, default_idx=0)
        if selected:
            ev_name = selected.split(" ")[0]
            scripts = _HOST._hooks.list_hooks(ev_name)
            if isinstance(get_current_renderer(), HackerRenderer):
                echo_line = f"/hooks {ev_name}"
                _h_echo(echo_line)
                click.echo(_h_grn_bold(ev_name) + _h_grn(f":"))
                for s in scripts:
                    click.echo(f"    {_h_grn_bold('•')} {_h_grn_bold(s.name)}")
            else:
                click.echo(f"  {ev_name}:")
                for s in scripts:
                    click.echo(f"    • {s.name}")
        return True

    # /curator — Skill 生命周期管理（交互式菜单）
    if cmd == "/curator":
        # 无参数：先回显命令，再弹主菜单
        _hacker = _h_echo(stripped)
        from .ansi_menu import ansi_select

        report = curator_report()
        # 预计算列表供子菜单使用
        all_names = [r["name"] for r in report if r["state"] != "archived"]
        pinned_names = [r["name"] for r in report if r["pinned"]]
        archived_repo = list_archived()
        archived_names = [a["name"] for a in archived_repo]

        # 主菜单
        main_options = [
            "查看状态",
            "固定 skill...",
            "取消固定...",
            "恢复归档...",
            "查看已归档",
        ]
        choice = ansi_select("Skill 生命周期管理 (Esc 取消)", main_options, default_idx=0)
        if not choice:
            return True

        _hacker_mode = isinstance(get_current_renderer(), HackerRenderer)
        if choice == "查看状态":
            if _hacker_mode:
                _h_echo("/curator 查看状态")
                click.echo(_h_grn(f"  Skill 状态 (") + _h_grn_bold(str(len(report))) + _h_grn(") 个："))
            if not report:
                if _hacker_mode:
                    click.echo(_h_dbg("  未发现 skill"))
                else:
                    click.echo("[INFO] 未发现 skill")
            else:
                for r in report:
                    pin_mark = _h_grn_bold("[P]") if r["pinned"] else _h_dbg("   ")
                    if r["state"] == "active":
                        sm = _h_grn_bg("active")
                    elif r["state"] == "stale":
                        sm = _h_yel_bg("stale ")
                    else:
                        sm = _h_dbg("archived")
                    if _hacker_mode:
                        use_note = f" used={r['use_count']}"
                        if r["state"] == "stale" and isinstance(r.get("last_use_days"), int):
                            use_note += f"  ·  {r['last_use_days']} 天未用"
                        click.echo(f"  {pin_mark} {_h_grn_bold(r['name']):32s} {sm}  {_h_dbg(use_note)}")
                    else:
                        state_mark = {"active": "\033[32mactive\033[0m", "stale": "\033[33mstale\033[0m",
                                      "archived": "\033[2marchived\033[0m"}.get(r["state"], r["state"])
                        pm = "[P]" if r["pinned"] else "   "
                        click.echo(f"  {pm} {r['name']:30s} {state_mark}  used={r['use_count']}")

        elif choice == "固定 skill...":
            if not all_names:
                if _hacker_mode:
                    _h_echo("/curator 固定 skill")
                    click.echo(_h_dbg("  没有可固定的 skill"))
                else:
                    click.echo("[INFO] 没有可固定的 skill")
            else:
                target = ansi_select("选择要固定的 skill (Esc 取消)", all_names, default_idx=0)
                if target:
                    pin_skill(target)
                    if _hacker_mode:
                        _h_echo(f"/curator 固定 {target}")
                        click.echo(_h_ok(_h_grn(f"skill ") + _h_cyn_bold(target) + _h_grn(" pinned")))
                    else:
                        click.echo(f"[OK] 已固定: {target}")

        elif choice == "取消固定...":
            if not pinned_names:
                if _hacker_mode:
                    _h_echo("/curator 取消固定")
                    click.echo(_h_dbg("  没有已固定的 skill"))
                else:
                    click.echo("[INFO] 没有已固定的 skill")
            else:
                target = ansi_select("选择要取消固定的 skill (Esc 取消)", pinned_names, default_idx=0)
                if target:
                    unpin_skill(target)
                    if _hacker_mode:
                        _h_echo(f"/curator 取消固定 {target}")
                        click.echo(_h_ok(_h_grn(f"skill ") + _h_cyn_bold(target) + _h_grn(" unpinned")))
                    else:
                        click.echo(f"[OK] 已取消固定: {target}")

        elif choice == "恢复归档...":
            if not archived_names:
                if _hacker_mode:
                    _h_echo("/curator 恢复归档")
                    click.echo(_h_dbg("  没有可恢复的归档 skill"))
                else:
                    click.echo("[INFO] 没有可恢复的归档 skill")
            else:
                target = ansi_select("选择要恢复的 skill (Esc 取消)", archived_names, default_idx=0)
                if target:
                    ok = restore_skill(target)
                    if _hacker_mode:
                        _h_echo(f"/curator 恢复归档 {target}")
                        click.echo(_h_ok(_h_grn(f"restore: {target}")) if ok else _h_err(_h_grn(f"restore: {target}")))
                    else:
                        click.echo(f"[{'OK' if ok else 'ERR'}] 恢复: {target}")

        elif choice == "查看已归档":
            if not archived_repo:
                if _hacker_mode:
                    _h_echo("/curator 查看已归档")
                    click.echo(_h_dbg("  无已归档 skill"))
                else:
                    click.echo("[INFO] 无已归档 skill")
            else:
                if _hacker_mode:
                    _h_echo("/curator 查看已归档")
                    click.echo(_h_grn("已归档 skill (") + _h_grn_bold(str(len(archived_repo))) + _h_grn(") 个："))
                    for a in archived_repo:
                        d = a.get("archived_at") or ""
                        d_short = str(d)[:10] if d else ""
                        click.echo(f"  {_h_dbg('-')} {_h_grn_bold(a['name']):32s} {_h_dbg(d_short)}")
                else:
                    click.echo(f"已归档 skill ({len(archived_repo)} 个):")
                    for a in archived_repo:
                        click.echo(f"  - {a['name']}  ({a['archived_at'][:10] if a['archived_at'] else ''})")
        return True

    # /session — 极简会话管理，交互式菜单
    if cmd == "/session":
        # 无参数：先回显命令，再弹主菜单
        _hacker_mode = _h_echo(stripped)
        from .config import SESSION_DIR
        from .ansi_menu import ansi_select
        names = Session.list_sessions(SESSION_DIR)
        if _hacker_mode:
            tag_save = _h_yel_bold("[SAVE]  ")
            tag_del  = _h_red_bold("[DEL]   ")
            tag_load = _h_cyn_bold("[LOAD]  ")
        else:
            tag_save = "💾 "
            tag_del  = "🗑 "
            tag_load = "📂 "
        options = [f"{tag_save}保存当前会话", f"{tag_del}删除会话..."]
        for n in names:
            if n != "latest":
                options.append(f"{tag_load}{n}")
        selected = ansi_select("会话管理 (Esc 取消)", options, default_idx=0)
        if not selected:
            return True
        if "保存当前会话" in selected:
            name = session.auto_name()
            session.save(SESSION_DIR / f"{name}.json")
            if _hacker_mode:
                _h_echo(f"/session save {name}")
                click.echo(_h_grn(f"已保存会话: ") + _h_grn_bold(name) + " " + _h_grn_bg("[OK]"))
            else:
                click.echo(f"  \033[2m已保存: {name}\033[0m")
        elif "删除会话" in selected:
            del_options = [n for n in names if n != "latest"]
            if not del_options:
                if _hacker_mode:
                    _h_echo("/session delete")
                    click.echo(_h_dbg("  没有可删除的会话"))
                else:
                    click.echo("[INFO] 没有可删除的会话")
                return True
            target = ansi_select("选择要删除的会话 (Esc 取消)", del_options, default_idx=0)
            if target:
                Session.delete_session(SESSION_DIR, target)
                if _hacker_mode:
                    _h_echo(f"/session delete {target}")
                    click.echo(_h_ok(_h_grn(f"已删除会话: {target}")))
                else:
                    click.echo(f"  \033[2m已删除: {target}\033[0m")
        else:
            # LOAD：去掉 [LOAD] 前缀（兼容两种模式）
            raw = selected
            if _hacker_mode and raw.startswith("[LOAD]"):
                name = raw[len("[LOAD]"):].strip()
            elif raw.startswith("📂 "):
                name = _parse_session_name_from_selection(raw)
            else:
                name = raw.strip()
            # 二次校验：name 必须存在于 names 列表
            if name not in names:
                # 可能带 tag，尝试剥离第一个空格前的内容
                for cand in names:
                    if cand in raw:
                        name = cand
                        break
            loaded = Session.from_file(SESSION_DIR / f"{name}.json", session.messages[0]["content"])
            session.messages = loaded.messages
            session._tool_call_counter = loaded._tool_call_counter
            if _hacker_mode:
                _h_echo(f"/session load {name}")
                count = session.get_message_count()
                click.echo(_h_grn("sess ") + _h_grn_bold(name) + _h_grn(f" loaded · msgs ") + _h_grn_bold(str(count)) + _h_grn(" · loaded → ") + _h_grn_bg("[OK]"))
            else:
                click.echo(f"  \033[2m已加载: {name} ({session.get_message_count()} 条消息)\033[0m\n")
            _print_resumed_messages(session)
        return True

    # /retry — 重试上一条 user 消息（Phase 8 Tier 2 CI）
    if cmd == "/retry":
        from .commands.retry import handle_retry
        result = handle_retry(session)
        hacker = _h_echo(stripped)
        if result == "injected":
            if hacker:
                click.echo(f"  {_h_dbg('[/retry] 已重发上一条消息')}  {_h_grn_bg('injected')}")
            else:
                click.echo(f"  \033[2m[/retry] 已重发上一条消息\033[0m")
        else:
            if hacker:
                click.echo(_h_dbg("  没有可重试的用户消息"))
            else:
                click.echo("[INFO] 没有可重试的用户消息")
        return result

    # /compact [custom_instruction] — 手动压缩上下文（Phase 8 Tier 2 CH）
    if cmd == "/compact":
        from .commands.compact import handle_compact
        custom_instruction = stripped[len("/compact"):].strip() or None
        hacker = _h_echo(stripped)
        result = handle_compact(session, _ENGINE, custom_instruction)
        if hacker:
            # 尝试解析出 msgs in/out 和 token delta 关键数字
            click.echo(_h_grn_bold(result.rstrip()))
        else:
            click.echo(result)
        return True

    # /diff [staged] — Git 差异展示（Phase 8 Tier 2 CJ）
    if cmd == "/diff":
        from .commands.diff import handle_diff
        staged = "staged" in stripped.lower().split() if len(parts) > 1 else False
        hacker = _h_echo(stripped)
        result = handle_diff(os.getcwd(), staged)
        if hacker:
            # 结果按 +/- 行染色（diff 输出常包含这两个前缀）
            for ln in result.splitlines():
                if ln.startswith("+") and not ln.startswith("+++"):
                    click.echo(_h_grn_bold(ln))
                elif ln.startswith("-") and not ln.startswith("---"):
                    click.echo(_h_red_bold(ln))
                elif ln.startswith("@@"):
                    click.echo(_h_grn_bold(ln))
                else:
                    click.echo(_h_grn(ln))
        else:
            click.echo(result)
        return True

    # /git <子命令> — Git 归因信息（Phase 7 Tier 1 BF）
    if cmd == "/git":
        from .commands.git import handle_git
        args = stripped[len("/git"):].strip()
        hacker = _h_echo(stripped)
        result = handle_git(args)
        if hacker:
            click.echo(_h_grn(result.rstrip()))
        else:
            click.echo(result)
        return True

    # /cron <子命令> — 定时任务管理（Phase 8 Tier 2 CL）
    if cmd == "/cron":
        from .commands.cron import handle_cron
        args = stripped[len("/cron"):].strip()
        hacker = _h_echo(stripped)
        result = handle_cron(args, _cron_service)
        if hacker:
            click.echo(_h_grn(result.rstrip()))
        else:
            click.echo(result)
        return True

    # /undo — 撤销最近一轮 AI 文件编辑（Phase 8 Tier 2 CG）
    if cmd == "/undo":
        from .commands.undo import handle_undo
        hacker = _h_echo(stripped)
        result = handle_undo(session)
        if hacker:
            click.echo(_h_grn(result.rstrip()))
        else:
            click.echo(result)
        # 把撤销结果注入上下文，让 AI 知道文件已被回退
        # 否则 AI 下一轮仍以为文件是它改过的版本，给出错误分析
        session.add_user(f"[系统通知] 用户执行了 /undo 命令\n{result}\n\n注意：上述文件已被回退到编辑前的状态，后续操作请基于回退后的内容。")
        return True

    # /redo — 恢复最近一次被 /undo 撤销的修改
    if cmd == "/redo":
        from .commands.undo import handle_redo
        hacker = _h_echo(stripped)
        result = handle_redo(session)
        if hacker:
            click.echo(_h_grn(result.rstrip()))
        else:
            click.echo(result)
        # 把恢复结果注入上下文，让 AI 知道文件已被恢复
        session.add_user(f"[系统通知] 用户执行了 /redo 命令\n{result}\n\n注意：上述文件已恢复到撤销前的状态（即 AI 之前编辑后的版本）。")
        return True

    # AI 分析命令族（Phase 8 Tier 2 CM）
    if cmd in ("/bug-hunter", "/bughunt", "/pr-review", "/review-pr",
               "/security-scan", "/sec-scan", "/explain"):
        from .commands.ai_analysis import handle_ai_analysis, AI_ANALYSIS_COMMANDS
        if cmd not in AI_ANALYSIS_COMMANDS:
            if _h_echo(stripped):
                click.echo(_h_err(_h_grn(f"未知命令: {cmd}")))
            else:
                click.echo(f"[ERR] 未知命令: {cmd}")
            return True
        user_desc = stripped[len(cmd):].strip()
        result = handle_ai_analysis(cmd, user_desc, session, os.getcwd())
        if result == "injected":
            if _h_echo(stripped):
                click.echo(f"  {_h_dbg(f'[{cmd}] 分析指令已注入')}  {_h_grn_bg('injected')}")
            else:
                click.echo(f"  \033[2m[{cmd}] 分析指令已注入\033[0m")
        else:
            click.echo(result)
        return result

    # 信息命令族（Phase 8 Tier 2 CN）
    if cmd == "/stats":
        from .commands.info import handle_stats
        hacker = _h_echo(stripped)
        result = handle_stats(session)
        if hacker:
            click.echo(_h_grn(result.rstrip()))
        else:
            click.echo(result)
        return True
    if cmd == "/advisor":
        from .commands.info import handle_advisor
        user_desc = stripped[len("/advisor"):].strip()
        result = handle_advisor(session, user_desc)
        if result == "injected":
            if _h_echo(stripped):
                click.echo(f"  {_h_dbg('[/advisor] 顾问模式已激活')}  {_h_grn_bg('injected')}")
            else:
                click.echo(f"  \033[2m[/advisor] 顾问模式已激活\033[0m")
        else:
            click.echo(result)
        return result
    if cmd == "/btw":
        from .commands.info import handle_btw
        user_desc = stripped[len("/btw"):].strip()
        result = handle_btw(session, user_desc)
        if result == "injected":
            if _h_echo(stripped):
                click.echo(f"  {_h_dbg('[/btw] 附加问题已注入')}  {_h_grn_bg('injected')}")
            else:
                click.echo(f"  \033[2m[/btw] 顺便提问已注入\033[0m")
        else:
            click.echo(result)
        return result
    if cmd == "/version":
        from .commands.info import handle_version
        hacker = _h_echo(stripped)
        result = handle_version()
        if hacker:
            # 格式化成 KV 表
            lines = result.splitlines()
            items: list[tuple] = []
            for ln in lines:
                if ":" in ln:
                    k, _, v = ln.partition(":")
                    items.append((k.strip(), v.strip(), "grn_bold"))
            if items:
                click.echo(_h_kv_list(items, "key", "value"))
            else:
                click.echo(_h_grn(result.rstrip()))
        else:
            click.echo(result)
        return True

    # /logs — 查看日志（支持过滤和统计）
    if cmd == "/logs":
        from .commands.logs import handle_logs
        args = stripped[len("/logs"):].strip()
        hacker = _h_echo(stripped)
        result = handle_logs(args)
        if hacker:
            click.echo(_h_grn(result.rstrip()))
        else:
            click.echo(result)
        return True

    # 自定义 slash 命令：从 ~/.abyss/commands/*.md 加载，展开模板后注入 session
    custom = SLASH_REGISTRY.get(cmd.lstrip("/"))
    if custom is not None:
        user_desc = stripped[len(cmd):].strip()
        variables = {"USER_INPUT": user_desc}
        expanded = custom.expand(variables)
        session.add_user(expanded)
        if _h_echo(stripped):
            click.echo(f"  {_h_dbg(f'[{cmd}] 自定义命令已注入')}  {_h_grn_bg('injected')}")
        else:
            click.echo(f"  \033[2m[{cmd}] 自定义命令已注入\033[0m")
        return "injected"

    return False


def first_time_setup(cfg):
    """首次启动引导设置 API Key（方案 R = ASCII LOGO 仪式感 + 3 项格式校验 + 保存总览 + ESC 跳过友好）。

    返回 True=已写入 Key；False=用户跳过，稍后通过 /config set api-key 设置。
    """
    # ─── 内部工具：直接复用 main.py 已定义的色板函数（不依赖 get_current_renderer，此时尚未构造） ───
    def _sep(ch: str = "=", w: int = 80):
        try:
            import shutil
            w = shutil.get_terminal_size(fallback=(w, 24)).columns
        except Exception:
            pass
        click.echo(_h_grn_dim(ch * w))

    def _sys(text: str, tag: str = "SYS"):
        t = _h_grn_dim(f"[ {tag} ]")
        click.echo(f"{t} {text}")

    def _chk(prefix_tag: str, ok: bool, extra: str = ""):
        mark = _h_grn_bold(" ✓ ") if ok else _h_red_bold(" ✗ ")
        bit = _h_ok("  OK  ") if ok else _h_err(" FAIL ", code="CHK")
        extra_msg = f"  {_h_dbg(extra)}" if extra else ""
        click.echo(f"{_h_grn_dim(f'[ CHK ]')} {bit} {prefix_tag.ljust(14)}{mark}{extra_msg}")

    # ═══ Screen ① · ASCII FIGLET LOGO + SETUP REQUIRED 徽标 ═══
    #   字形：figlet "abyss" standard（与 HackerRenderer.banner 完全一致）
    #   此时渲染器尚未初始化（2514 行才 get_theme_renderer(cfg)），因此直接硬编码颜色 + __version__
    click.echo()
    _ver = f"v{__version__}"
    # 注意：Python f-string 的 {…} 内不能直接写带反斜杠的字面量，所以先把 6 行字形存成普通字符串列表再上色
    _logo_lines = [
        "       _                           ",
        "  __ _| |__  _   _ ___ ___         ",
        " / _` | '_ \\| | | / __/ __|        ",
        "| (_| | |_) | |_| \\__ \\__ \\        ",
        " \\__,_|_.__/ \\__, |___/___/  ",
        "             |___/               ",
    ]
    click.echo(_h_grn_bold(_logo_lines[0]))
    click.echo(_h_grn_bold(_logo_lines[1]))
    click.echo(_h_grn_bold(_logo_lines[2]))
    click.echo(_h_grn_bold(_logo_lines[3]))
    click.echo(_h_grn_bold(_logo_lines[4]) + _h_grn(_ver) + "   " + _h_dbg("首次启动"))
    click.echo(_h_grn_bold(_logo_lines[5]))
    _sep("=")

    _sys(f"{_h_grn('establishing secure channel …')}           {_h_yel_bg('WAIT')}")
    _sys((f"{_h_grn('status:')} {_h_red_bg('SETUP REQUIRED')}   "
          f"{_h_grn('api_key:')} {_h_red_bold('missing')}"))
    _sys(_h_grn("请申请 DeepSeek API Key："))
    _sys(f"  {_h_cyn_bold('https://platform.deepseek.com/api_keys')}")
    _sys(f"  {_h_dbg('（或直接从邮箱 / 管理后台复制 sk-… 字符串）')}")
    _sys("")
    _sep("─")

    # ═══ Screen ② · 让用户输入（click.prompt：支持 CTRL+C；空字符串视为「跳过」）══════
    #   click.prompt 原生不支持「ESC 取消」，约定：用户输入空行 → 跳过（与原代码完全一致）。
    MAX_RETRY = 3
    key: str = ""
    for attempt in range(1, MAX_RETRY + 1):
        try:
            raw = click.prompt(f"  {_h_yel_bold('API Key:')} ", type=str, default="",
                               show_default=False, prompt_suffix="").strip()
        except (EOFError, KeyboardInterrupt):
            # 用户 CTRL+C / CTRL+D：同样走"跳过"分支，友好不崩
            raw = ""
        if not raw:
            # ─── Screen ③-SKIP · ESC / Enter-空 → 跳过路径 ────────────
            click.echo()
            _sep("=")
            _sys(f"{_h_yel_bg('SKIP')}  跳过首次配置，稍后通过命令补齐。")
            _sep("─")
            _sys(_h_grn("等你准备好后："))
            click.echo(f"{_h_grn_dim('[ CMD ]')} {_h_yel_bold('/config set api-key')} "
                       f"{_h_grn_bold('<你的完整 Key>')}")
            click.echo(f"{_h_grn_dim('[ CMD ]')} {_h_dbg('或再次运行向导（待实现）：')} "
                       f"{_h_yel_bold('/setup')}")
            _sep("─")
            _sys(f"{_h_grn('entering offline mode …')}")
            _sys(_h_dbg("（首次调用模型时会再次提示输入 api_key，不阻断本地 "
                        "/knowledge /memory /git /compact 等功能）"))
            click.echo()
            return False

        # ─── 3 项实时格式校验（方案 R 反馈） ──────────
        stripped = raw.strip()
        prefix_ok = stripped.startswith("sk-")
        length_ok = len(stripped) >= 20
        ws_ok = (" " not in stripped) and ("\n" not in stripped) and ("\t" not in stripped)
        all_ok = prefix_ok and length_ok and ws_ok

        _sys("")
        _chk("前缀 sk-",   prefix_ok)
        _chk("长度 ≥ 20",   length_ok,
             extra=f"当前 {len(stripped)}/20")
        _chk("无空白换行", ws_ok,
             extra=("检测到空格/换行/Tab" if not ws_ok else ""))
        _sys("")

        if all_ok:
            key = stripped
            break

        # 格式不通过，再给一次机会
        if attempt < MAX_RETRY:
            click.echo(
                _h_err(f"格式未通过，请检查后再试（第 {attempt}/{MAX_RETRY - 1} 次，"
                       "直接按 Enter 跳过）", code="KEY")
            )
            _sep("─")
        else:
            click.echo(
                _h_warn("连续 3 次格式异常，已自动跳过（稍后 /config set api-key 粘贴即可）", code="KEY")
            )
            click.echo()
            return False

    # ═══ Screen ③-SAVE · 保存成功 + 配置总览 ════════════════════════════════════
    cfg.set("api_key", key)
    _sep("=")
    _sys(f"{_h_ok(_h_grn('写入 cfg.json (0600)'))}")
    _sep("─")
    # 脱敏显示：显示前后 4 位，中间 ****（len ≥ 8 才有意义；太短就全打码）
    if len(key) >= 8:
        masked = f"{key[:4]}****{key[-4:]}"
    else:
        masked = "****"
    # 总览：与 _h_kv_list 风格一致，但在此直接 echo（避免行号错位）
    overview: list[tuple] = [
        ("api_key",
         f"(none) → {_h_grn_bold(masked)}",
         "grn_bold"),
        ("model",
         f"{cfg.get('model') or 'deepseek-v4-pro'}   "
         f"{_h_dbg('(/model 切换)')}",
         "raw"),
        ("thinking",
         (_h_grn_bold("on") if bool(cfg.get("thinking")) else _h_red_bold("off"))
         + f"   {_h_dbg('(/thinking on 开启)')}",
         "raw"),
        ("快捷命令",
         f"{_h_yel_bold('/config')}{_h_grn_dim(' · ')}"
         f"{_h_yel_bold('/help')}{_h_grn_dim(' · ')}"
         f"{_h_yel_bold('CTRL+H')}",
         "raw"),
        ("UI 主题",
         f"{_h_grn_bold('hacker')}{_h_dbg(' (复古 CRT · abyss 专属风格)')}",
         "raw"),
    ]
    click.echo(_h_kv_list(overview, header="首次配置", value_col="当前值"))
    _sep("─")
    _sys(f"{_h_grn('establishing secure channel …')} {_h_grn_bold('[ONLINE]')}")
    _sys(f"{_h_grn('awaiting input. 输入问题或 ')}{_h_yel_bold('/')}{_h_grn(' 斜杠命令开始！')}")
    click.echo()
    return True


def _h_warn(msg: str, code: str = "WARN") -> str:
    """Hacker 警告 TAG + YEL_BG 辅助（first_time_setup 跳过/格式失败时用，与 _h_err 对称）。"""
    return f"{_h_yel_bg(f'[{code}]')} {msg}"


def _inject_methodology(session, methodology_text: str, user_desc: str, command_name: str) -> str:
    """将方法论纪律文本 + 用户描述组合为一条 user 消息注入 session，返回 'injected'。"""
    combined = f"{methodology_text}\n\n---\n\n# 用户任务\n{user_desc}"
    session.add_user(combined)
    R = get_current_renderer()
    if isinstance(R, HackerRenderer):
        # Hacker 风格：方法论命令按“CMD TAG + injected 徽标”两行格式
        echo_input = f"/{command_name} {user_desc}".strip()
        click.echo(R.cmd_echo(echo_input), nl=False)
        click.echo(f"  {_h_dbg(f'[{command_name}] 方法论已注入')}  {_h_grn_bg('injected')}")
    else:
        click.echo(f"  \033[2m[{command_name}] 方法论已注入\033[0m")
    return "injected"


# ─── Hacker 风格 slash 命令输出辅助函数（仅当 HackerRenderer 时启用） ───
def _h_cmd(tag: str):
    """生成 Hacker CMD TAG，tag=CMD / RESULT / SYS，返回字符串。"""
    from datetime import datetime as _dt
    return _h_grn_dim(f"[{_dt.now().strftime('%H:%M:%S')} {tag}]")


def _h_echo(stripped: str, nl: bool = False):
    """如果当前是 Hacker 主题，打印 CMD TAG + >>> + /slash(YEL_BOLD) args(GRN)
    否则不做任何输出（保留原有 click.echo 内容输出）。
    返回 True=已做 Hacker 输出，调用方不要再打原 echo。"""
    R = get_current_renderer()
    if isinstance(R, HackerRenderer):
        click.echo(R.cmd_echo(stripped), nl=nl)
        return True
    return False


def _h_kv_list(items: list[tuple], header: str = "key", value_col: str = "value") -> str:
    """输出 22ch 对齐的键值表，items=[(key, val_str, style)]  style ∈ {grn/yel/red/cyn/mag/grn_bg/yel_bg/red_bg/grn_bold/raw}"""
    pad = 22
    lines: list[str] = []
    lines.append(_h_grn_dim(f"│ {header.ljust(pad)}│ {value_col.ljust(40)}│"))
    lines.append(_h_grn_dim(f"├─{'─'*pad}┼─{'─'*40}┤"))
    for it in items:
        if len(it) == 2:
            k, v = it
            style = "grn"
        else:
            k, v, style = it
        style_fn = {
            "grn": _h_grn, "grn_bold": _h_grn_bold,
            "yel": _h_yel_bold, "red": _h_red_bold,
            "cyn": _h_cyn_bold, "mag": _h_mag_bold,
            "grn_bg": _h_grn_bg, "yel_bg": _h_yel_bg, "red_bg": _h_red_bg,
            "dbg": _h_dbg, "raw": lambda s: s,
        }.get(style, _h_grn)
        val_s = style_fn(str(v))
        lines.append(_h_grn_dim("│ ") + _h_grn_bold(k.ljust(pad)) + _h_grn_dim("│ ") + f"{val_s:<40}" + _h_grn_dim("│"))
    lines.append(_h_grn_dim(f"└─{'─'*pad}┴─{'─'*40}┘"))
    return "\n".join(lines)


def _h_ok(msg: str) -> str:
    return f"{msg}  {_h_grn_bg('[OK]')}"


def _h_err(msg: str, code: str = "ERR") -> str:
    return f"{msg}  {_h_red_bg(f'[{code}]')}"


def resolve_at_references(user_input: str) -> str:
    """解析 @file[:L10[-L20]] 文件引用，将文件内容（含行范围）添加到消息上下文中。

    委托 ContextAttachmentResolver 处理：
    - 支持 @file.py / @file.py:L10 / @file.py:L10-L20 三种语法
    - 路径安全校验（PathSecurityService 5 重防护）
    - 大文件自动截断（默认 500 行）
    - 失败优雅降级（保留原引用 + 错误说明）
    """
    from .context.attachment_resolver import get_default_resolver

    resolver = get_default_resolver()
    # 同步当前工作目录，让相对路径基于 CWD 解析
    resolver.set_working_dir(os.getcwd())

    expanded = resolver.resolve(user_input)

    # 展开后给用户视觉反馈：列出所有成功展开的引用
    if expanded != user_input:
        refs = resolver.parse_references(user_input)
        for ref in refs:
            # 仅展示成功展开的（不含 [ERR 前缀）
            if ref.raw in user_input and f"[ERR 引用 {ref.raw}" not in expanded:
                click.echo(f"  \033[2m📎 @{ref.path}\033[0m")

    return expanded


def _parse_session_name_from_selection(selected: str) -> str:
    """从菜单选中项解析会话名。

    选中项形如 "📂 {name}"，需去掉前缀 "📂 "。
    Bug 复现：原代码 selected[3:] 假设 emoji 占 2 字符，
    但 Python 中 "📂" 只占 1 个码点 + 1 空格 = 2 字符，
    selected[3:] 会少取一个字符（如 "你好git..." → "好git..."），
    导致文件名找不到，from_file 返回空 session。

    修复：按首个空格分割，取空格后的完整字符串。
    """
    if not selected:
        return ""
    # 按首个空格切分，取剩余部分（会话名可能含空格）
    if " " in selected:
        return selected.split(" ", 1)[1]
    return selected


def _inject_todos_into_messages(messages):
    """在 API 调用前把当前 todo 进度注入到 system 消息末尾。

    方案 B：让 LLM 始终能看到最近一次 todo_write 的任务状态，避免 LLM
    "有头无尾"——展示计划后忘记更新进度。

    设计：
    - 简单任务不调 todo_write → get_active_todos() 返回空 → 不注入 → 零 token 浪费
    - 复杂任务调过 todo_write → 每轮把当前状态作为简短提示追加到 system 末尾
    - 在 messages 副本上操作，不污染 session.messages
    """
    todos = get_active_todos()
    if not todos:
        return messages
    # 仅在存在未完成项时注入；全部完成时也注入一次（让 LLM 看到收尾状态），但下次不再注入
    has_incomplete = any(t.get("status") != "completed" for t in todos)
    if not has_incomplete:
        # 全部完成：清空状态，避免后续轮次继续注入无意义信息
        clear_todos()
        return messages

    # 构造简短提示（每行一项，状态图标 + content）
    icons = {"pending": "○", "in_progress": "◐", "completed": "✓"}
    lines = ["", "## 当前任务进度（请继续推进未完成项，完成后用 merge=true 更新状态）"]
    for t in sorted(todos, key=lambda x: {"in_progress": 0, "pending": 1, "completed": 2}.get(x.get("status", "pending"), 99)):
        icon = icons.get(t.get("status", "pending"), "○")
        lines.append(f"{icon} {t.get('content', '')}")
    injection = "\n".join(lines)

    # 在副本上追加到 system 消息末尾（不修改原 messages）
    result = [dict(m) for m in messages]
    if result and result[0].get("role") == "system":
        result[0] = dict(result[0])
        result[0]["content"] = result[0]["content"] + injection
    else:
        result = [{"role": "system", "content": injection}] + result
    return result


def _format_api_connection_error_message(exc: APIConnectionError, model: str) -> str:
    """格式化 APIConnectionError 的用户友好提示。

    原 message 仅 "Connection error."，无法判断是 DNS / TLS / 服务端 5xx /
    httpx ReadTimeout 等具体原因，用户不知如何处理。

    本函数：
    - 提取底层 cause 摘要（httpx 异常类型 + 消息）
    - 给出可执行的下一步建议（稍后重试 / 用 /model 切换模型）
    """
    cause = _extract_cause_summary(exc)
    lines = [f"API 连接失败 (模型: {model})"]
    if cause:
        lines.append(f"  原因: {cause}")
    lines.append("  建议: 稍后重试，或用 /model 切换到另一个模型 (deepseek-v4-pro / deepseek-v4-flash)")
    return "\n".join(lines)


def _print_resumed_messages(session):
    """打印恢复会话中的历史消息，跳过 system 和 tool 角色。

    带 tool_calls 的 assistant（content 为空）会显示占位标记，
    避免用户视觉上"看不到历史 assistant 消息"。

    视觉规则与实时对话一致：user 用绿色粗体 `>`，assistant content
    前后加灰色分隔线、内容用青色，便于区分消息边界。
    """
    for msg in session.messages:
        role = msg.get("role", "")
        content = msg.get("content", "")
        tool_calls = msg.get("tool_calls")
        if role == "user":
            click.echo(f"\033[1;32m> {content}\033[0m\n")
        elif role == "assistant":
            if content:
                click.echo(ansi_gray_line())
                click.echo(f"\033[36m{content}\033[0m")
                click.echo(f"{ansi_gray_line()}\n")
            elif tool_calls:
                # 显示工具调用占位，让用户知道历史有调用
                names = [tc.get("function", {}).get("name", "?") for tc in tool_calls]
                click.echo(f"\033[2m[工具调用: {', '.join(names)}]\033[0m\n")


def run_with_pt(cfg, show_reasoning=False, resume=None):
    """运行对话循环（使用自定义 ANSI 补全菜单）。resume 为会话名或 None。"""
    global _HOST
    global _ESC
    logger.configure_standard_logging()  # 桥接标准 logging（修复 file_history/undo/cron_service 日志丢失）
    # 从配置加载日志级别
    configured_level = cfg.get("log_level")
    if configured_level:
        logger.set_level(configured_level)
    logger.session_start(model=cfg.get("model"), cwd=os.getcwd())

    if not cfg.is_ready():
        first_time_setup(cfg)

    system_prompt = load_system_prompt()
    from .config import SESSION_DIR
    if resume:
        # ponytail: resume="latest" 或具体会话名
        session_file = SESSION_DIR / f"{resume}.json"
        session = Session.from_file(session_file, system_prompt)
        if session.get_message_count() > 0:
            click.echo(f"  \033[2m已恢复会话: {resume} ({session.get_message_count()} 条消息)\033[0m\n")
            _print_resumed_messages(session)
        # 恢复会话时清空 todo 状态：旧会话的 todo 上下文不再适用
        clear_todos()
    else:
        session = Session(system_prompt)
        clear_todos()  # 新会话：确保 todo 状态干净

    # ------------------------------------------------------------------
    # 初始化主题渲染器 + 会话短 sid（供 banner/prompt 用）
    # ------------------------------------------------------------------
    # 会话短 sid：4 位，取 started_at 末 4 位 hex。与 HackerRenderer cli_prompt 保持一致。
    _SESSION_SID = format(int(session.started_at) & 0xFFFF, "04X")

    def _detect_project_type() -> str:
        """简易项目类型探测：返回 Python / Node / Rust / Go / Shell / Generic 之一。"""
        cwd = Path(os.getcwd())
        marks = [
            ("Python", ("pyproject.toml", "setup.py", "requirements.txt", "Pipfile")),
            ("Node",   ("package.json", "pnpm-lock.yaml", "yarn.lock")),
            ("Rust",   ("Cargo.toml", "Cargo.lock")),
            ("Go",     ("go.mod", "go.sum")),
            ("Shell",  (".bashrc", "Makefile", "Dockerfile")),
        ]
        for name, files in marks:
            if any((cwd / f).exists() for f in files):
                return name
        # fallback：数 .py / .ts / .rs 后缀
        try:
            counts = {"py": 0, "ts": 0, "js": 0, "rs": 0, "go": 0}
            for p in cwd.glob("*"):
                suf = p.suffix.lstrip(".").lower()
                if suf in counts:
                    counts[suf] += 1
            if counts:
                top = max(counts, key=counts.get)
                if counts[top] > 0:
                    return {"py": "Python", "ts": "TS", "js": "Node",
                            "rs": "Rust", "go": "Go"}[top]
        except OSError:
            pass
        return "Generic"

    def _get_git_branch() -> str:
        try:
            # subprocess 轻量调用，失败静默回退 "-"
            import subprocess
            out = subprocess.check_output(
                ["git", "rev-parse", "--abbrev-ref", "HEAD"],
                stderr=subprocess.DEVNULL, timeout=2, cwd=os.getcwd(),
            )
            return out.decode("utf-8", "replace").strip() or "-"
        except Exception:
            return "-"

    def _count_src_files() -> int:
        """粗略数一下项目源码文件，仅用于 banner 展示。"""
        import subprocess
        try:
            out = subprocess.check_output(
                ["git", "ls-files"],
                stderr=subprocess.DEVNULL, timeout=3, cwd=os.getcwd(),
            )
            lines = [l for l in out.decode("utf-8", "replace").splitlines() if l.strip()]
            return len(lines)
        except Exception:
            cwd = Path(os.getcwd())
            try:
                return sum(1 for _ in cwd.rglob("*") if _.is_file()
                           and _.suffix.lower() in (
                    ".py", ".ts", ".tsx", ".js", ".jsx", ".rs",
                    ".go", ".java", ".c", ".cc", ".cpp", ".h",
                    ".md", ".json", ".yaml", ".yml", ".toml",
                ))
            except OSError:
                return 0

    # 首次构造全局渲染器（根据 cfg.ui.theme），之后用 get_current_renderer() 取
    get_theme_renderer(cfg)

    # 同步 PathSecurityService 的工作目录（Phase 6 AP 修复）
    # 必须在 session 创建/恢复之后调用：from_file 会 os.chdir(saved_cwd)
    # 切换到原会话的工作目录，此处同步确保 file_write/file_read 边界正确。
    # 场景：用户在 A 目录启动 abyss，/quit 后在 B 目录重启并 /session 恢复，
    # 若不同步，PathSecurityService 仍用 B 做边界，导致写入 A 目录文件被误判。
    from .security.path_security import get_default_service as _get_path_sec
    _get_path_sec().set_working_dir(os.getcwd())
    logger.set_session_id(getattr(session, "session_id", ""))  # 注入 session_id 到日志上下文
    client = DeepSeekClient(
        api_key=cfg.get("api_key"),
        base_url=cfg.get("base_url")
    )
    # 注册 spawn_subagents 工具（子 Agent 编排），6 个内置工具已在模块级注册
    REGISTRY.register(SpawnSubagentsTool(cfg=cfg, working_dir=os.getcwd()), source="builtin")

    # 初始化 LifecycleHost：启动 Skill/MCP/Hook 三模块，注册 MCP 工具到 REGISTRY
    _HOST = LifecycleHost.create_default(working_dir=os.getcwd())
    _HOST.start()
    # 注册 knowledge_fetch / knowledge_save 工具（依赖 host / working_dir，须在 _HOST 创建后）
    REGISTRY.register(KnowledgeFetchTool(host=_HOST), source="builtin")
    REGISTRY.register(KnowledgeSaveTool(working_dir=os.getcwd()), source="builtin")
    # 注册 memory_save / memory_search 工具（跨会话长期记忆，借鉴 zhikuncode memdir）
    global _MEMORY_STORE
    _memory_store = MemoryStore(Path.home() / ".abyss" / "memory.jsonl")
    _MEMORY_STORE = _memory_store
    _memory_reranker = LLMReranker(client=client, model=cfg.get("model") or "deepseek-v4-flash")
    REGISTRY.register(MemorySaveTool(store=_memory_store), source="builtin")
    REGISTRY.register(MemorySearchTool(store=_memory_store, reranker=_memory_reranker), source="builtin")
    mcp_count = _HOST.attach_mcp_tools(REGISTRY)
    if mcp_count > 0:
        click.echo(f"  \033[2m已加载 {mcp_count} 个 MCP 工具\033[0m")

    tools_schemas = REGISTRY.to_openai_schemas()
    sr = [show_reasoning]

    # 初始化上下文压缩引擎（可插拔，默认 simple 截断引擎）
    global _ENGINE
    _ENGINE = create_engine(name="simple", context_length=128000)
    _ENGINE.on_session_start(session_id="default", model=cfg.get("model"))

    # 启动 Curator 守护线程（定期检查 skill + knowledge 生命周期）
    _curator_daemon = CuratorDaemon(
        check_interval_seconds=3600,
        knowledge_curator=_HOST._knowledge_curator,
    )
    _curator_daemon.start()

    # 初始化 CronTaskService（Phase 8 Tier 2 CL，/cron 命令管理）
    from .tools.cron_service import CronTaskService
    _cron_service = CronTaskService()

    # Phase 8 Tier 2 CG：初始化 FileHistoryService（用于 /undo 命令）
    from .history import FileHistoryService, set_global_service
    from .commands.undo import init_history_service
    _history_service = FileHistoryService(
        storage_dir=str(Path.home() / ".abyss")
    )
    init_history_service(_history_service)
    set_global_service(_history_service)  # 注入全局单例供 agent_loop 集成

    # 初始化懒访问上下文（供测试 mock 和子模块使用）
    ctx.override(
        registry=REGISTRY,
        host=_HOST,
        esc=_ESC,
        engine=_ENGINE,
    )

    # ponytail: 后台线程定时自动保存，daemon=True 随主线程退出
    import threading
    _auto_save_stop = threading.Event()
    interval = cfg.get("auto_save_interval")
    if interval > 0:
        def _auto_save():
            while not _auto_save_stop.is_set():
                _auto_save_stop.wait(interval)
                if not _auto_save_stop.is_set():
                    session.save(SESSION_DIR / "latest.json")
        t = threading.Thread(target=_auto_save, daemon=True)
        t.start()

    # 初始化 Esc 中断信号源：每次任务开始时 start()，结束时 stop()
    _ESC = EscInterrupt()

    if resume and session.get_message_count() > 0:
        R = get_current_renderer()
        click.echo(R.resume_prompt(session.get_message_count()), nl=False)
        if not isinstance(R, HackerRenderer):
            # 默认主题额外补一行（保持原语义），Hacker 主题已含换行
            click.echo()
    else:
        R = get_current_renderer()
        click.echo(R.banner(
            version=__version__,
            model=cfg.get("model") or "default",
            api_ok=cfg.is_ready(),
            project_type=_detect_project_type(),
            git_branch=_get_git_branch(),
            files_n=_count_src_files(),
            session_id=_SESSION_SID,
            thinking_enabled=bool(cfg.get("thinking")),
        ), nl=False)

    try:
        while True:
            try:
                sys.stdout.flush()
                R = get_current_renderer()
                is_hacker = isinstance(R, HackerRenderer)
                prompt_prefix = R.cli_prompt(_SESSION_SID)
                # Hacker 主题 prompt 自带颜色；Default 主题沿用原 prompt_color
                prompt_color = "" if is_hacker else "\x1b[1;32m"

                def _on_help_cb():
                    # Ctrl+H / 空行 Backspace 触发：打印 Hacker 风格帮助列表
                    try:
                        _R = get_current_renderer()
                        if isinstance(_R, HackerRenderer):
                            _txt = _R.help_list()
                            if _txt:
                                click.echo()
                                click.echo(_txt, nl=False)
                        else:
                            click.echo()
                            click.echo(_HELP_TEXT)
                    except Exception:
                        pass

                user_input = ansi_prompt(prompt_prefix, prompt_color=prompt_color,
                                         on_help=_on_help_cb,
                                         completion_style=("hacker-plan-b" if is_hacker else "default")).strip()
                if not user_input:
                    continue

                result = None
                if user_input.startswith("/"):
                    logger.slash_command(user_input)
                    # 回显：Hacker 模式由 handle_slash_command 内的 _h_echo() 统一处理（避免重复输出）
                    if not isinstance(R, HackerRenderer):
                        click.echo(R.cmd_echo(user_input), nl=False)
                    result = handle_slash_command(user_input, session, client, cfg, sr)
                    if result == "exit":
                        logger.session_end("exit_command")
                        break
                    if result == "injected":
                        # 方法论命令已注入 session，跳过 add_user，直接调 LLM
                        logger.new_trace_id()  # 新 trace_id 串联本轮所有事件
                        logger.user_input(user_input)
                    elif result:
                        continue

                if result != "injected":
                    logger.new_trace_id()  # 新 trace_id 串联本轮所有事件
                    logger.user_input(user_input)

                    if not cfg.is_ready():
                        click.echo(get_current_renderer().api_key_hint(), nl=False)
                        continue

                    # PreLLMRequest 埋点：用原始 user_input 匹配 skill 注入 system prompt
                    if _HOST is not None:
                        _HOST.pre_llm(session, user_input)

                    user_input = resolve_at_references(user_input)
                    session.add_user(user_input)

                reasoning_buffer = ""
                content_buffer = ""
                current_tool_calls = []

                spinner = Spinner()
                spinner.start()
                _ESC.start()  # 启动 Esc 监听，任务期间按 Esc 可中断
                # 标记 agent 活跃，curator 不在此期间运行
                _curator_daemon.agent_busy = True

                try:
                    # 上下文压缩检查：在 API 调用前压缩消息列表
                    if _ENGINE is not None and _ENGINE.should_compress():
                        before = len(session.messages)
                        compressed = _ENGINE.compress(session.get_messages_for_api())
                        if len(compressed) < before:
                            # 守卫：保证压缩后 system 首条不丢失
                            session.messages = Session._ensure_system_preserved(
                                compressed, session.messages[0].get("content", "")
                                if session.messages and session.messages[0].get("role") == "system"
                                else ""
                            )
                            click.echo(
                                f"  \033[2m[上下文压缩] {before} → {len(session.messages)} 条消息\033[0m\n"
                            )

                    try:
                        content_buffer, reasoning_buffer, current_tool_calls, usage_info = _consume_stream(
                            client.chat(
                                messages=_inject_todos_into_messages(session.get_messages_for_api()),
                                tools=tools_schemas, stream=True,
                                model=cfg.get("model"), thinking=cfg.get("thinking"),
                                reasoning_effort=_compute_adaptive_effort(cfg, session),
                                max_tokens=cfg.get("max_tokens"), user_id=cfg.get("user_id"),
                                stream_idle_timeout=cfg.get("stream_idle_timeout"),
                                on_413_callback=_make_413_callback(_ENGINE, session) if _ENGINE else None,
                            ),
                            spinner, sr[0],
                        )
                    except TaskInterrupted:
                        spinner.stop()
                        R = get_current_renderer()
                        if isinstance(R, HackerRenderer):
                            click.echo(R.interrupted(), nl=False)
                        else:
                            click.echo()
                            click.echo(f"  \033[33m(已中断当前任务)\033[0m")
                        continue
                    except APIConnectionError as e:
                        spinner.stop()
                        logger.api_error(str(e))
                        R = get_current_renderer()
                        msg = _format_api_connection_error_message(e, cfg.get('model', '?'))
                        if isinstance(R, HackerRenderer):
                            click.echo(R.api_error(msg), nl=False)
                        else:
                            click.echo()
                            click.echo(f"  \033[31m{msg}\033[0m")
                        continue
                    except Exception as e:
                        spinner.stop()
                        logger.api_error(str(e))
                        R = get_current_renderer()
                        if isinstance(R, HackerRenderer):
                            click.echo(R.api_error(f"API call failed: {e}"), nl=False)
                        else:
                            click.echo()
                            click.echo(f"  \033[31mAPI 调用失败: {e}\033[0m")
                        continue

                    spinner.stop()

                    # 更新上下文引擎
                    if _ENGINE is not None and usage_info:
                        if hasattr(usage_info, 'model_dump'):
                            _ENGINE.update_from_response(usage_info.model_dump())
                        elif isinstance(usage_info, dict):
                            _ENGINE.update_from_response(usage_info)

                    # 自适应思考预算：记录本轮度量供下一轮计算
                    _update_last_metrics(
                        usage_info,
                        len(current_tool_calls) if current_tool_calls else 0,
                        session,
                    )

                    # PostLLMResponse 埋点：审计主循环 LLM 响应
                    if _HOST is not None:
                        _HOST.post_llm(content_buffer, current_tool_calls)

                    if not content_buffer and not current_tool_calls:
                        # reasoning 后无 content 也无 tool_calls —— 手动关 dim
                        if sr[0] and reasoning_buffer:
                            click.echo("\033[0m", nl=False)
                        R = get_current_renderer()
                        if isinstance(R, HackerRenderer):
                            from abyss.ui.renderer import GRN_DIM, YEL_BOLD, RST as _RST
                            now_t = time.strftime("%H:%M:%S")
                            click.echo(
                                f"\n{GRN_DIM}[{now_t} SYS]{_RST} "
                                f"{YEL_BOLD}! EMPTY_RESPONSE{_RST} {GRN_DIM}→ retry{_RST}"
                            )
                        else:
                            click.echo()
                            click.echo(f"  \033[33m(模型未返回内容，请重试)\033[0m")
                        continue

                    if current_tool_calls:
                        # reasoning 后直接 tool_calls，没 content 触发复位 —— 手动关 dim
                        if sr[0] and reasoning_buffer and not content_buffer:
                            click.echo("\033[0m", nl=False)
                        # content 后跟 tool_calls：关青色（前分隔线已开），不加分隔线
                        if content_buffer:
                            R = get_current_renderer()
                            if isinstance(R, HackerRenderer):
                                # hacker 前缀颜色 R.ai_content_reset 已逐 chunk 重置
                                sys.stdout.write(R.ai_content_reset())
                                sys.stdout.flush()
                            else:
                                click.echo("\x1b[0m", nl=False)
                        merged = merge_tool_calls(current_tool_calls)
                        _t_round = time.perf_counter()
                        try:
                            process_tool_calls(session, client, tools_schemas,
                                               merged, cfg, sr[0])
                        finally:
                            # DONE 段（若有已完成 todo 项 / AI 文本里明确有 done 内容，则出 [A.I] DONE - [ok]/[fail] 子列表）
                            R = get_current_renderer()
                            if isinstance(R, HackerRenderer):
                                done_ok = []
                                done_fail = []
                                # 从最近 todo 列表收集，按完成数判断标题
                                last_todos = get_active_todos()
                                if last_todos:
                                    for t in last_todos:
                                        st = (t.get("status") or "pending").lower()
                                        content = str(t.get("content", "")).strip()
                                        if not content:
                                            continue
                                        if st in ("completed", "done"):
                                            done_ok.append(content)
                                        elif st in ("fail", "failed", "error", "err"):
                                            done_fail.append(content)
                                # 如果 todo 里有完成项，则组装 heading + [ok] 项
                                if done_ok or done_fail:
                                    total = len(last_todos)
                                    n_done = len(done_ok)
                                    heading = f"round complete ({n_done}/{total} tasks done):"
                                    # 传 heading 作为 ok_items[0]（ai_summary 约定 ok_items[0]=heading）
                                    ok_args = [heading] + done_ok
                                    sum_text = R.ai_summary(ok_args, err_items=done_fail if done_fail else None)
                                    if sum_text:
                                        click.echo(sum_text, nl=False)
                            # round_summary：仅 Hacker 主题追加一行 END 回合摘要
                            R = get_current_renderer()
                            if isinstance(R, HackerRenderer):
                                wall_sec = time.perf_counter() - _t_round
                                line = R.round_summary(
                                    wall_sec=wall_sec,
                                    tool_calls_n=len(merged),
                                    round_i=1, round_n=1,
                                )
                                if line:
                                    click.echo(line, nl=False)
                    else:
                        session.add_assistant(
                            content=content_buffer,
                            reasoning_content=reasoning_buffer if reasoning_buffer else None
                        )
                        # 纯文本回复：关颜色 + 后分隔线（Default 主题）/ 空行（Hacker 主题）
                        R = get_current_renderer()
                        if content_buffer:
                            if isinstance(R, HackerRenderer):
                                click.echo(R.ai_content_reset())
                                # DONE 段（同上）：如果有 completed 的 todo 项就打 [A.I] DONE 段
                                done_ok = []
                                done_fail = []
                                last_todos = get_active_todos()
                                if last_todos:
                                    for t in last_todos:
                                        st = (t.get("status") or "pending").lower()
                                        c = str(t.get("content", "")).strip()
                                        if not c:
                                            continue
                                        if st in ("completed", "done"):
                                            done_ok.append(c)
                                        elif st in ("fail", "failed", "error", "err"):
                                            done_fail.append(c)
                                if done_ok or done_fail:
                                    total = len(last_todos)
                                    n_done = len(done_ok)
                                    heading = f"round complete ({n_done}/{total} tasks done):"
                                    sum_text = R.ai_summary([heading] + done_ok,
                                                            err_items=done_fail if done_fail else None)
                                    if sum_text:
                                        click.echo(sum_text, nl=False)
                            else:
                                click.echo("\x1b[0m")
                                click.echo(ansi_gray_line())

                    # AI 回复结束后确保光标在行首新行，
                    # 否则 ansi_prompt 的菜单会叠在回复文字下面。
                    click.echo()
                finally:
                    # 无论成功、中断还是异常，都停止 Esc 监听
                    _ESC.stop()
                    # 标记 agent 空闲，curator 可以运行
                    _curator_daemon.agent_busy = False

            except KeyboardInterrupt:
                logger.keyboard_interrupt()
                logger.session_end("keyboard_interrupt")
                if _HOST is not None:
                    _HOST.shutdown()
                click.echo("\n再见!")
                break
            except EOFError:
                logger.eof()
                logger.session_end("eof")
                if _HOST is not None:
                    _HOST.shutdown()
                click.echo("\n再见!")
                break
    finally:
        # ponytail: try/finally 统一保存，避免三个退出点分别写
        _auto_save_stop.set()
        session.save(SESSION_DIR / "latest.json")
        _curator_daemon.stop()


@click.command()
@click.option("--model", "-m", help="指定模型 (deepseek-v4-pro / deepseek-v4-flash)")
@click.option("--hide-reasoning", is_flag=True, help="隐藏思考过程（默认显示）")
@click.option("--resume", "-r", "resume_name", is_flag=False, flag_value="latest",
              default=None, help="恢复会话（不带参数恢复上次，或指定会话名）")
@click.option("--version", "-v", is_flag=True, help="显示版本")
def cli(model, hide_reasoning, resume_name, version):
    """abyss - 终端 AI 开发助手"""
    if version:
        from . import __version__
        click.echo(f"abyss v{__version__}")
        return

    cfg = Config()
    init_user_dirs()
    if model:
        cfg.set("model", model)

    # 默认显示思考过程（用户已确认要"看效果"），--hide-reasoning 关闭
    run_with_pt(cfg, show_reasoning=not hide_reasoning, resume=resume_name)


if __name__ == "__main__":
    cli()
