# -*- coding: utf-8 -*-
"""
自定义 ANSI 补全菜单 — 完全绕过 prompt_toolkit 的灰色背景问题
使用纯 ANSI 转义码渲染，保留 prompt_toolkit 的行编辑能力。

用法：
    from .ansi_menu import ansi_prompt
    user_input = ansi_prompt("> ")
"""
import os
import re
import sys
import msvcrt
import time
from datetime import datetime
from typing import List, Tuple, Optional


# ─── 模糊匹配 ──────────────────────────────────────────────
def _fuzzy_match(search: str, target: str) -> bool:
    if not search:
        return True
    si = 0
    for ch in target:
        if si < len(search) and ch.lower() == search[si].lower():
            si += 1
    return si == len(search)


# ─── 命令列表 ──────────────────────────────────────────────
COMMANDS = [
    # 基础命令（高频）
    ("/help", "显示帮助"),
    ("/clear", "清空当前会话上下文"),
    ("/exit", "退出 CLI"),
    ("/quit", "退出 CLI"),
    ("/session", "会话管理（保存/加载/删除）"),
    # 会话与上下文
    ("/retry", "重试上一条 user 消息"),
    ("/compact", "手动压缩上下文"),
    ("/undo", "撤销最近一轮 AI 文件编辑"),
    ("/redo", "恢复最近一次被 /undo 撤销的修改"),
    ("/stats", "显示会话统计"),
    ("/logs", "查看日志（支持过滤和统计）"),
    # Git 集成
    ("/diff", "显示 Git 工作区或暂存区差异"),
    ("/git", "Git 归因命令族（blame/log/stats/show）"),
    # AI 分析命令
    ("/bug-hunter", "启动 bug 猎手分析模式"),
    ("/pr-review", "审查最近一次 git commit"),
    ("/security-scan", "安全扫描指定路径"),
    ("/explain", "解释指定文件"),
    ("/advisor", "顾问模式（咨询架构/方案）"),
    # 扩展系统
    ("/skills", "列出已加载的 Skill"),
    ("/mcp", "列出 MCP server 和工具"),
    ("/hooks", "列出已配置的 Hook"),
    ("/curator", "Skill 生命周期管理"),
    ("/knowledge", "知识管理"),
    # 长期记忆
    ("/memory", "长期记忆管理"),
    # 工程方法论
    ("/diagnose", "启动调试纪律流程"),
    ("/tdd", "启动 TDD 红-绿-重构流程"),
    ("/review", "审查当前 diff 的过度工程"),
    ("/audit", "全仓库过度工程审计"),
    ("/architecture", "扫描架构深化机会"),
    ("/prototype", "构建原型回答设计问题"),
    ("/domain", "领域建模，提炼术语和决策"),
    # 配置与模式（低频，置底）
    ("/model", "查看/切换模型"),
    ("/thinking", "查看/开关思考模式"),
    ("/show-reasoning", "开关思考过程显示"),
    ("/config", "查看当前配置"),
    ("/version", "显示版本号"),
]

# 用户自定义命令（从 ~/.abyss/commands/*.md 加载，启动时注入）
_CUSTOM_COMMANDS: List[Tuple[str, str]] = []


# ═══════════════════════════════════════════════════════════════
# 层级子命令/参数枚举树（方案 B L2/L3 补全数据源）
#
# 每个一级命令 → (sub_enum, args_enum, value_enum)
#   sub_enum   : L2 二级子命令枚举   [(display, description)]
#                 display 用户补全时会写进 buffer（含父级前缀，如 "/config set"）
#   args_enum  : L3 第 1 段参数（key/name/id/flag）字典
#                 key = 二级命令 display → [(display, description)]
#                 为空 → 这一级没有枚举（用户自由输入）
#   value_enum : L3 第 2 段值枚举字典
#                 key = (二级命令 display, args_display) → [(value, description)]
#                 为空 → 值是自由位（数字/文本/时间，不弹菜单）
#
# 说明：
#   - COMPLETION 菜单（L1）仍从 COMMANDS + _CUSTOM_COMMANDS 模糊匹配（保持原行为）
#   - SUB / ARG 菜单从此 TREE 取枚举，并在 show_menu 里带上面包屑
#   - 自由位（如 max-tokens / git log [N] / memory save <title>|<content>）
#     对应枚举为空，补全层不弹菜单（prompt 只显示 dim 占位）
# ═══════════════════════════════════════════════════════════════
SUB_ENUM: dict = {}
ARG_ENUM: dict = {}
VALUE_ENUM: dict = {}

# ── /config ──
SUB_ENUM["/config"] = [
    ("/config",        "（无参数）查看当前所有配置"),
    ("/config get",    "查看某个具体 key 的当前值"),
    ("/config set",    "修改配置 · 下一级补全 key: 5 options"),
    ("/config unset",  "删除某项配置（恢复默认值）· 同级 keys"),
    ("/config save",   "持久化 → ~/.abyss/config.toml"),
]
_CONFIG_KEYS = [
    ("api-key",    "设置 LLM 接口 Key（加密保存）"),
    ("model",      "切换模型：deepseek-v4-pro / v4-flash"),
    ("thinking",   "开关思考模式 on / off"),
    ("max-tokens", "单次响应最大 token 数（数字，自由输入）"),
    ("log-level",  "日志等级：debug / info / warn / error"),
]
ARG_ENUM["/config set"] = _CONFIG_KEYS
ARG_ENUM["/config unset"] = _CONFIG_KEYS
ARG_ENUM["/config get"] = _CONFIG_KEYS
VALUE_ENUM[("/config set", "model")] = [
    ("deepseek-v4-pro",   "深度思考 · 高质量（推理 / 架构）"),
    ("deepseek-v4-flash", "快速响应 · 低延迟（日常问答 / 改代码）"),
]
VALUE_ENUM[("/config set", "thinking")] = [
    ("on",  "打开深度思考（多 token，质量更高）"),
    ("off", "关闭思考过程（速度更快 / 更省 token）"),
]
VALUE_ENUM[("/config set", "log-level")] = [
    ("debug", "调试级：所有事件 + 完整 trace id"),
    ("info",  "信息级：常规输出（默认）"),
    ("warn",  "警告级：仅异常与性能告警"),
    ("error", "错误级：仅失败与异常"),
]

# ── /git ──
SUB_ENUM["/git"] = [
    ("/git blame", "行级作者归因 · 需要 <file> [起-止行] → @"),
    ("/git log",   "最近 N 条提交 · 默认 10 条 · [N] 自由位"),
    ("/git stats", "按作者统计提交数（无参数）"),
    ("/git show",  "显示指定 commit 摘要 · <hash> 枚举"),
]
# /git show <hash>：取最近 5 个 commit 的缓存（动态，这里写静态占位；
# 实际运行时可在 ansi_prompt 前通过 git log --oneline -5 注入 ARG_ENUM）
ARG_ENUM["/git show"] = [
    ("HEAD",    "HEAD 指针（当前分支最顶）"),
    ("HEAD~1",  "上一个 commit"),
    ("HEAD~2",  "倒数第 2 个 commit"),
    ("HEAD~3",  "倒数第 3 个 commit"),
    ("HEAD~4",  "倒数第 4 个 commit"),
]

# ── /knowledge ──
SUB_ENUM["/knowledge"] = [
    ("/knowledge",        "（无参数）直接列出知识条目"),
    ("/knowledge search", "关键词搜索 · 下一位 <q> 自由输入"),
    ("/knowledge show",   "显示完整内容 · 下一级枚举知识条目名"),
    ("/knowledge stats",  "知识库统计（总条目 / 分组 / 容量）· 无参"),
    ("/knowledge pin",    "固定知识到首页 · 下一级同 show 枚举"),
    ("/knowledge archive","手动归档 · 下一级同 show 枚举"),
    ("/knowledge delete", "删除知识条目 · 下一级按名称枚举"),
]
_KNOWLEDGE_NAMES = [
    ("CODE-STYLE.md",   "代码风格约定（Python / TOML / Shell）"),
    ("CONTEXT.md",      "项目领域语言 · 当前关键术语定义"),
    ("ADR-001.md",      "架构决策 001：Renderer 抽象类双主题"),
    ("ADR-002.md",      "架构决策 002：ansi_menu 弃用 prompt_toolkit"),
    ("PROMPT-TEMPL.md", "方法论系统提示词模板"),
    ("SECURITY.md",     "安全 · Key 存放路径约定"),
]
ARG_ENUM["/knowledge delete"] = _KNOWLEDGE_NAMES
ARG_ENUM["/knowledge show"]   = _KNOWLEDGE_NAMES
ARG_ENUM["/knowledge pin"]    = _KNOWLEDGE_NAMES
ARG_ENUM["/knowledge archive"]= _KNOWLEDGE_NAMES

# ── /memory ──
SUB_ENUM["/memory"] = [
    ("/memory",        "（无参）直接列出最近记忆"),
    ("/memory search", "关键词搜索记忆 · <q> 自由输入"),
    ("/memory show",   "显示某条记忆完整内容 · <id> 枚举"),
    ("/memory save",   "手动保存 · 格式 标题|内容 · 2 自由位"),
    ("/memory delete", "删除某条记忆 · <id> 枚举"),
    ("/memory stats",  "统计分类与存储路径 · 无参"),
]
_MEM_IDS = [
    ("mem-7f2a", "用户偏好：主题 = Hacker · 07-30"),
    ("mem-6e10", "约定：/config 改动要 commit · 07-30"),
    ("mem-5bcc", "项目：abyss 终端 UI v3 完成度 85%"),
    ("mem-4d09", "注意：Windows PowerShell 不支持 HEREDOC"),
    ("mem-3921", "分支：feature/ui-hacker-theme 持续开发"),
]
ARG_ENUM["/memory delete"] = _MEM_IDS
ARG_ENUM["/memory show"]   = _MEM_IDS

# ── /skills（用户点名）──
SUB_ENUM["/skills"] = [
    ("/skills",          "（无参）列出已加载 Skill 及注入状态"),
    ("/skills list",     "列出全部可用 Skill（含未注入）"),
    ("/skills inject",   "注入某 Skill 到当前上下文 · 下一级枚举 name"),
    ("/skills remove",   "移除某已注入 Skill · 下一级枚举 injected"),
    ("/skills reload",   "重新扫描 ~/.trae/skills 并热加载"),
    ("/skills docs",     "显示指定 Skill 的说明文档 · <name> 自由输入"),
]
_SKILL_NAMES = [
    ("brainstorming",         "创意/需求/设计发散前的脑暴流程"),
    ("diagnose",              "调试纪律诊断 · 重现→最小化→假设→修复"),
    ("impeccable",            "前端 UI 打磨：UX/视觉/排版/无障碍"),
    ("ponytail",              "最简方案/最短路径开发模式"),
    ("redis-development",     "Redis 最佳实践与性能优化"),
    ("superdesign",           "前端 UI/UX 设计代理组件生成"),
    ("systematic-debugging",  "4 阶段系统调试：调查→模式→假设→实施"),
    ("tdd",                   "测试驱动开发 · 红-绿-重构循环"),
    ("test-driven-development","测试驱动开发（详细版）"),
    ("web-design-guidelines", "Web UI 规范合规审查 · 可访问性/视觉"),
    ("webapp-testing",        "本地 Web 应用交互测试（Playwright）"),
    ("frontend-design",       "生产级前端组件与页面生成"),
]
ARG_ENUM["/skills inject"] = _SKILL_NAMES
ARG_ENUM["/skills remove"] = _SKILL_NAMES

# ── /mcp（用户点名）──
SUB_ENUM["/mcp"] = [
    ("/mcp",            "（无参）列出全部 MCP server 与工具数"),
    ("/mcp list",       "列出全部 MCP server · 显示连接状态"),
    ("/mcp tools",      "列出指定 server 的所有工具 · <server> 枚举"),
    ("/mcp restart",    "重启某个 MCP server 进程 · <server> 枚举"),
    ("/mcp reconnect",  "断开并重连所有 MCP server（无参）"),
    ("/mcp logs",       "查看 MCP 侧调用日志 · 最近 N 条 自由位"),
]
_MCP_SERVERS = [
    ("integrated_browser", "集成浏览器 · 12 工具（导航/点击/截图/JS）"),
    ("filesystem",         "本地文件系统 · 8 工具（读/写/glob/stat）"),
    ("git-server",         "Git 操作 · 6 工具（diff/blame/log/show）"),
]
ARG_ENUM["/mcp tools"]    = _MCP_SERVERS
ARG_ENUM["/mcp restart"]  = _MCP_SERVERS

# ── /session（用户点名）──
SUB_ENUM["/session"] = [
    ("/session",            "（无参）会话管理主菜单 · 列出最近会话"),
    ("/session list",       "列出最近 20 条会话（含时间/长度）"),
    ("/session save",       "保存当前会话 · <名称> 自由输入"),
    ("/session load",       "加载已有会话 · <id> 枚举"),
    ("/session rename",     "重命名会话 · <id> 枚举 + <name> 自由位"),
    ("/session delete",     "删除会话 · <id> 枚举（多选可 +）"),
    ("/session new",        "新开一个空白会话（保留当前）"),
    ("/session export",     "导出会话为 JSON · 下一级 <id> 枚举"),
]
_SESSION_IDS = [
    ("sess-7f2a", "07-30 10:12 · UI Hacker 方案 B 调试"),
    ("sess-6e10", "07-30 09:45 · /config 边界修复"),
    ("sess-5bcc", "07-30 08:30 · slash 命令重复 echo bug"),
    ("sess-4d09", "07-29 22:14 · curator 知识注入"),
    ("sess-3921", "07-29 20:05 · 初次建库"),
]
ARG_ENUM["/session load"]   = _SESSION_IDS
ARG_ENUM["/session rename"] = _SESSION_IDS
ARG_ENUM["/session delete"] = _SESSION_IDS
ARG_ENUM["/session export"] = _SESSION_IDS

# ── /curator（用户点名）──
SUB_ENUM["/curator"] = [
    ("/curator",            "（无参）知识策展主菜单"),
    ("/curator report",     "展示当前项目策展报告（建议项/状态）"),
    ("/curator refresh",    "重新扫描仓库刷新 CONTEXT.md/ADR/topics"),
    ("/curator topics",     "列出已提炼的领域主题 · 最近 10"),
    ("/curator adr",        "查看某条架构决策 · <NN> 数字自由位"),
    ("/curator inject",     "注入策展报告到下一轮对话上下文 · on/off"),
]
VALUE_ENUM[("/curator inject", "")] = [
    ("on",  "curator 报告自动注入（默认）"),
    ("off", "不自动注入，手动 /curator report 查看"),
]

# ── /hooks ──
SUB_ENUM["/hooks"] = [
    ("/hooks",             "（无参）列出已配置 Pre/Post Hook"),
    ("/hooks list",        "列出全部 Hook（含 enable/disable 状态）"),
    ("/hooks enable",      "启用某条 Hook · <name> 枚举"),
    ("/hooks disable",     "禁用某条 Hook · <name> 枚举"),
    ("/hooks run",         "手动执行一条 PostToolUse Hook · <name> 枚举"),
    ("/hooks logs",        "最近 N 次 Hook 执行日志 · 自由位"),
]
_HOOK_NAMES = [
    ("PreToolUse/01_validate",  "前置：工具调用参数校验"),
    ("PostToolUse/01_audit",    "后置：AI 文件编辑审计"),
    ("PreToolUse/02_safety",    "前置：敏感路径/数据过滤"),
    ("PostToolUse/02_snapshot", "后置：编辑后快照（undo 用）"),
]
ARG_ENUM["/hooks enable"]  = _HOOK_NAMES
ARG_ENUM["/hooks disable"] = _HOOK_NAMES
ARG_ENUM["/hooks run"]     = _HOOK_NAMES

# ── /model ──
SUB_ENUM["/model"] = [
    ("/model",            "（无参）交互式选择模型"),
    ("/model show",       "显示当前模型 / 温度 / token 上限"),
    ("/model set",        "切换模型 · 下一级枚举"),
    ("/model temperature","设置采样温度 · [0.0 ~ 1.2] 自由数字"),
]
_VALUE_ENUM_MODEL = [
    ("deepseek-v4-pro",   "深度思考 · 高质量（推理 / 架构）"),
    ("deepseek-v4-flash", "快速响应 · 低延迟（日常问答 / 改代码）"),
]
ARG_ENUM["/model set"] = _VALUE_ENUM_MODEL

# ── /thinking ──
SUB_ENUM["/thinking"] = [
    ("/thinking",         "（无参）查看当前思考模式 on/off"),
    ("/thinking on",      "打开深度思考（多 token，更高质量）"),
    ("/thinking off",     "关闭深度思考（速度快 / 省 token）"),
    ("/thinking toggle",  "切换 on ↔ off"),
    ("/thinking budget",  "设置思考 token 预算 · 数字自由位 [512~8192]"),
]

# ── /show-reasoning ──
SUB_ENUM["/show-reasoning"] = [
    ("/show-reasoning",      "（无参）查看当前 reasoning 显示状态"),
    ("/show-reasoning on",   "展开显示思考过程（原始 thinking 文本）"),
    ("/show-reasoning off",  "折叠思考过程，只看回答（默认）"),
    ("/show-reasoning toggle","切换 on ↔ off"),
]

# ── /logs（flags 可多选，精简为常见 6 条）──
SUB_ENUM["/logs"] = [
    ("/logs",         "（无参）显示最近日志"),
    ("/logs -n N",    "最后 N 条（数字自由位）"),
    ("/logs -l",      "按等级过滤 · 下一级 4 值"),
    ("/logs -t",      "按工具调用过滤 · 下一级 file_read/shell_exec 等"),
    ("/logs --trace", "显示 trace id 关联链路"),
    ("/logs --stats", "显示日志统计（按级别/按命令）"),
]
VALUE_ENUM[("/logs -l", "")] = [
    ("debug", "只看 debug+ 级日志"),
    ("info",  "只看 info+ 级（默认）"),
    ("warn",  "只看 warn+ 级"),
    ("error", "只看 error 级"),
]
VALUE_ENUM[("/logs -t", "")] = [
    ("file_read",    "file_read 工具调用日志"),
    ("file_write",   "file_write 工具调用日志"),
    ("file_edit",    "file_edit 工具调用日志"),
    ("shell_exec",   "shell_exec / shell 命令日志"),
    ("browser_use",  "browser 自动化日志"),
    ("SearchCodebase", "code search 工具日志"),
    ("TodoWrite",    "任务列表写入日志"),
]

# ── /compact /diff（极简）──
SUB_ENUM["/compact"] = [
    ("/compact",        "（默认）使用默认压缩指令：去冗余 → 提炼摘要"),
    ("[自定义指令]",    "自定义压缩指令 · 例：只保留方法论/架构相关"),
]
SUB_ENUM["/diff"] = [
    ("/diff",        "（默认）显示 git diff 未暂存改动"),
    ("staged",       "显示 git diff --cached（已暂存）"),
]

# ── 方法论命令族 ──
SUB_ENUM["/diagnose"] = [
    ("/diagnose",          "（无参）进入系统调试 4 阶段流程"),
    ("/diagnose bug",      "针对失败用例/异常启动诊断 · <case> 自由位"),
    ("/diagnose perf",     "性能回归诊断 · <模块> 自由位"),
    ("/diagnose reproduce","重现问题并收集第 1 阶段证据"),
    ("/diagnose hypothesis","生成假设并最小化测试（Phase 3）"),
]
SUB_ENUM["/tdd"] = [
    ("/tdd",               "（无参）进入红-绿-重构循环"),
    ("/tdd red",           "红：生成/编写失败测试用例"),
    ("/tdd green",         "绿：最小实现让测试通过"),
    ("/tdd refactor",      "重构：保持测试通过，改进结构"),
    ("/tdd run",           "运行当前测试套件（无菜单）"),
]
SUB_ENUM["/review"] = [
    ("/review",            "（无参）审查当前 diff 是否过度工程"),
    ("/review current",    "审查工作区未提交改动"),
    ("/review HEAD",       "审查最近一次 commit"),
    ("/review <ref>",      "审查指定提交或分支 · <ref> 自由位"),
    ("/review strictness", "审查严格度 · 枚举 lenient/normal/strict"),
]
VALUE_ENUM[("/review strictness", "")] = [
    ("lenient", "宽松：只提示重度反模式"),
    ("normal",  "正常（默认）：含架构/可读性"),
    ("strict",  "严格：连命名/注释风格也审查"),
]
SUB_ENUM["/audit"] = [
    ("/audit",             "（无参）全仓库过度工程审计"),
    ("/audit scope",       "审计范围 · 枚举 full/src/tests/scripts"),
    ("/audit report",      "输出审计报告到终端"),
    ("/audit save",        "保存审计结果到 ~/.abyss/audit/"),
]
VALUE_ENUM[("/audit scope", "")] = [
    ("full",    "全仓库（默认）"),
    ("src",     "仅 src/ 目录"),
    ("tests",   "仅 tests/ 目录"),
    ("scripts", "仅 scripts/ 目录"),
]
SUB_ENUM["/architecture"] = [
    ("/architecture",      "（无参）扫描架构深化机会 + 输出报告"),
    ("/architecture scan", "扫描并列出可深化点（ADR/耦合/循环依赖）"),
    ("/architecture adrs", "列出已有架构决策 ADR-001 …"),
    ("/architecture deps", "展示模块依赖图（文本）"),
]
SUB_ENUM["/prototype"] = [
    ("/prototype",         "（无参）启动原型设计流程"),
    ("/prototype sketch",  "草图：快速写出 3~5 种设计草案"),
    ("/prototype compare", "横向对比多种方案的优劣"),
    ("/prototype pick",    "根据比较结果选择推荐方案"),
]
SUB_ENUM["/domain"] = [
    ("/domain",            "（无参）领域建模全流程"),
    ("/domain terms",      "提炼核心术语与定义"),
    ("/domain decisions",  "列出关键决策与权衡"),
    ("/domain save",       "写入 CONTEXT.md / 知识库"),
]

# ── AI 分析 / 辅助命令族 ──
SUB_ENUM["/bug-hunter"] = [
    ("/bug-hunter",        "（无参）启动 bug 猎手全流程模式"),
    ("/bug-hunter report", "产出结构化 bug 报告（含复现步骤）"),
    ("/bug-hunter triage", "对多个已知问题做严重度分级 · 枚举 critical/high/medium/low"),
]
VALUE_ENUM[("/bug-hunter triage", "")] = [
    ("critical", "致命：阻断主流程"),
    ("high",     "高：影响大功能"),
    ("medium",   "中：局部功能受损"),
    ("low",      "低：视觉/体验细节"),
]
SUB_ENUM["/pr-review"] = [
    ("/pr-review",         "（无参）审查最近一次 commit"),
    ("/pr-review HEAD",    "审查 HEAD 提交"),
    ("/pr-review <ref>",   "审查指定 ref（hash/分支/PR diff）· 自由位"),
    ("/pr-review comment", "生成 PR 评审评论文本"),
]
SUB_ENUM["/security-scan"] = [
    ("/security-scan",     "（无参）扫描当前工作区安全问题"),
    ("/security-scan src", "仅扫描 src 目录"),
    ("/security-scan secrets","扫描 secrets/API Key 泄漏"),
    ("/security-scan paths","扫描越权路径 · 自由位 <dir>"),
]
SUB_ENUM["/explain"] = [
    ("/explain",           "（无参）下一轮对话自动解释工作区关键文件"),
    ("/explain file",      "解释某个文件 · <path> 自由位/@ 补全"),
    ("/explain function",  "解释某个函数 · <name> 自由位"),
    ("/explain diff",      "解释最近一次未提交 diff"),
]
SUB_ENUM["/advisor"] = [
    ("/advisor",           "（无参）顾问模式：下一轮按架构/方案建议回答"),
    ("/advisor arch",      "架构方案咨询"),
    ("/advisor refactor",  "重构方向咨询"),
    ("/advisor plan",      "大型任务拆分计划咨询"),
]

# ── 工具命令 / 状态 / 杂项命令族 ──
SUB_ENUM["/undo"] = [
    ("/undo",              "（无参）撤销最近一轮 AI 文件编辑（默认 1 轮）"),
    ("/undo 1",            "撤销 1 轮 AI 编辑"),
    ("/undo N",            "撤销 N 轮 · <N> 数字自由位"),
    ("/undo dry-run",      "预演：只列将要撤销的文件，不实际还原"),
]
SUB_ENUM["/redo"] = [
    ("/redo",              "（无参）恢复最近一次被 /undo 撤销的修改"),
    ("/redo 1",            "恢复 1 轮"),
    ("/redo N",            "恢复 N 轮 · <N> 数字自由位"),
]
SUB_ENUM["/retry"] = [
    ("/retry",             "（无参）重试上一条 user 消息"),
    ("/retry hard",        "硬重试：清空工具缓存/强制新请求"),
    ("/retry with-hint",   "重试 + 附加错误线索 · <hint> 自由输入"),
]
SUB_ENUM["/stats"] = [
    ("/stats",             "（无参）显示当前会话统计"),
    ("/stats session",     "显示当前会话统计（消息/Token/时长）"),
    ("/stats token",       "按轮次拆解 Token 消耗表"),
    ("/stats tool",        "工具调用频率 Top N"),
    ("/stats cost",        "预估 API 成本（USD）"),
]
SUB_ENUM["/help"] = [
    ("/help",              "（无参）显示 slash 命令帮助（按 Ctrl+H 也可）"),
    ("/help slash",        "详细解释所有 slash 命令"),
    ("/help ansi",         "解释 Hacker 方案 B 视觉规范"),
    ("/help keys",         "列出快捷键：↑↓/Tab/Esc/Backspace/Ctrl+H"),
]
SUB_ENUM["/version"] = [
    ("/version",           "（无参）显示 abyss CLI 版本号"),
    ("/version all",       "显示 abyss + Python + 依赖版本组合"),
    ("/version check",     "检查是否有新版本可升级"),
]

# ── /cron（虽然 COMMANDS 中未列出，但 demo 面板有，加入它）──
SUB_ENUM["/cron"] = [
    ("/cron",              "（无参）列出所有定时任务"),
    ("/cron list",         "列出所有定时任务 · 含状态/下一触发"),
    ("/cron create",       "创建任务 · 格式 秒|<cmd> · 自由位"),
    ("/cron delete",       "删除任务 · <id> 枚举"),
    ("/cron cleanup",      "清理所有过期一次性任务"),
]
_CRON_IDS = [
    ("cron-01", "每 600 秒 · /compact 会话压缩"),
    ("cron-02", "每 1800 秒 · /git stats 快照"),
    ("cron-03", "每 21600 秒 · /memory stats 摘要"),
]
ARG_ENUM["/cron delete"] = _CRON_IDS

# ── 基础命令（极简 SUB：L1 与 L2 动作一致）──
SUB_ENUM["/help"] = SUB_ENUM["/help"]  # 占位保持显式存在

SUB_ENUM["/clear"] = [
    ("/clear",             "（无参）清空当前会话上下文（保留会话元数据）"),
    ("/clear all",         "清空上下文 + 清空显示区域"),
    ("/clear keep-meta",   "保留对话元数据，仅清除内容（默认）"),
]
SUB_ENUM["/exit"] = [
    ("/exit",              "（无参）退出 CLI 程序"),
    ("/exit save",         "保存当前会话 + 退出"),
    ("/exit no-save",      "不保存直接退出"),
]
SUB_ENUM["/quit"] = list(SUB_ENUM["/exit"])  # 与 exit 完全相同语义



# ═══════════════════════════════════════════════════════════════
# 补全菜单标题上下文（get_*_completions 写入，show_menu 读取）
# 避免侵入式改动 completions: List[Tuple[str,str]] 的原有二元结构
# ═══════════════════════════════════════════════════════════════
# 字段：
#   kind       : "L1" | "SUB" | "ARG" | "VALUE" | "FILE"
#   parent     : 父级 slash（如 "/config"），用于 SUB/ARG/VALUE 的 cyan 面包屑
#   sub        : 二级子命令（如 "/config set"），用于 ARG/VALUE
#   arg_key    : 参数名（L3 key，如 "thinking"），VALUE 时显示 magenta
#   total_hint : 附加信息（"(filtered)" / "(8 matches · 模糊 rende)"），显示在标题行末尾
# ═══════════════════════════════════════════════════════════════
_MENU_TITLE_CTX: dict = {
    "kind": "L1",
    "parent": "",
    "sub": "",
    "arg_key": "",
    "total_hint": "",
}


def get_menu_title_ctx() -> dict:
    """给 show_menu 读取当前菜单的标题面包屑上下文。"""
    return dict(_MENU_TITLE_CTX)


def _reset_menu_title_ctx(kind: str = "L1",
                          parent: str = "",
                          sub: str = "",
                          arg_key: str = "",
                          total_hint: str = ""):
    _MENU_TITLE_CTX.clear()
    _MENU_TITLE_CTX.update({
        "kind": kind,
        "parent": parent,
        "sub": sub,
        "arg_key": arg_key,
        "total_hint": total_hint,
    })


def register_custom_commands(names: List[str]) -> None:
    """注册用户自定义 slash 命令名到补全列表（去重，避免与内置命令重复）"""
    builtin = {name for name, _ in COMMANDS}
    _CUSTOM_COMMANDS.clear()
    for n in names:
        full = f"/{n}"
        if full not in builtin:
            _CUSTOM_COMMANDS.append((full, "自定义命令"))


# ─── 输入历史（↑↓ 翻看用）────────────────────────────────
# 模块级变量，跨多次 ansi_prompt 调用共享。
HISTORY: list = []
HISTORY_MAX = 100


# ─── 补全菜单最大可见行数（根据终端高度动态计算）────────────
# 旧版本写死 8，COMMANDS 列表有 14 个 → 后 6 个被截断看不见。
# 现在根据 os.get_terminal_size().lines 算：留 3 行给 prompt / spinner / status。
_MIN_VISIBLE = 3
_RESERVED_LINES = 3


def compute_max_visible() -> int:
    """根据终端高度算补全菜单最大可见行数。
    - 大屏：尽量显示所有项（避免截断）
    - 小屏：至少留 3 行供 prompt 等使用
    - 非 TTY 环境：fallback 到 20（够所有命令 + 文件）
    """
    try:
        term_h = os.get_terminal_size().lines
    except (OSError, AttributeError):
        return 20
    return max(_MIN_VISIBLE, term_h - _RESERVED_LINES)


# ── 补全数据源 ───────────────────────────────────────
def get_file_completions(partial: str) -> List[Tuple[str, str]]:
    cwd = os.getcwd()
    try:
        entries = sorted(os.listdir(cwd))
    except OSError:
        _reset_menu_title_ctx(kind="FILE", total_hint="(cannot list cwd)")
        return []
    results: List[Tuple[str, str]] = []
    search = partial.lower()
    for name in entries:
        full = os.path.join(cwd, name)
        if not os.path.isfile(full):
            continue
        if name.startswith("."):
            continue
        if _fuzzy_match(search, name):
            try:
                size = os.path.getsize(full)
                if size < 1024:
                    meta = f"{size}B"
                elif size < 1024 * 1024:
                    meta = f"{size // 1024}KB"
                else:
                    meta = f"{size // (1024 * 1024)}MB"
            except OSError:
                meta = ""
            results.append((name, meta))
    n = len(results)
    hint = f"({n} files · matched '{partial}')" if partial else f"({n} files · cwd)"
    _reset_menu_title_ctx(kind="FILE", total_hint=hint)
    return results


def get_command_completions(partial: str) -> List[Tuple[str, str]]:
    """按 slash 命令层级取补全。

    决策逻辑（partial 是 prompt 光标前的完整文本）：
      - 不包含空格：L1 一级命令补全（保持原模糊匹配 COMMANDS + _CUSTOM_COMMANDS）
      - 含 1 个空格  : L2 二级子命令 → SUB_ENUM[parent]
      - 含 2 个空格  :
          * parent + " " + arg0 前缀 → 若 ARG_ENUM[L2_cmd] 存在 → L3 key 枚举 ARG
          * 若没有 ARG_ENUM（例如 /git log）→ 返回 []（自由位，不弹）
      - 含 3 个空格  :
          * 若 (L2_cmd, arg_key) ∈ VALUE_ENUM → VALUE 枚举值
          * 否则 → 返回 []（自由位，不弹）
    """
    # ── 去除前面的 /，保留之后的部分按空格切分 ──
    if not partial.startswith("/"):
        _reset_menu_title_ctx(kind="L1", total_hint="(filtered)")
        return []

    tail = partial[1:]               # "/config set thinking " → "config set thinking "
    # 若 tail 以空格结尾，tokens split 会丢空串；这里记录末尾空格数量来判断层级
    # 做法：rstrip 后 split，再补 1 级"准备进入下一段"
    stripped = tail.rstrip(" ")
    trailing_spaces = len(tail) - len(stripped)   # ≥1 时表示用户已经按了 SPACE
    tokens = stripped.split() if stripped else []

    # 层级：0 = L1（没打空格）；1 = L2 前缀（打了 1 空格准备/已写 L2）；2 = L3 key；3 = L3 value
    level = len(tokens)
    if trailing_spaces > 0:
        level += 1

    # 常见 parent 名还原："/" + tokens[0]
    parent = "/" + tokens[0] if tokens else ""
    # L2 完整子命令：比如 "/config set"（显示在 ARG 面包屑上，value 枚举用）
    l2_cmd = ""
    if len(tokens) >= 2:
        l2_cmd = " ".join([parent, tokens[1]]) if parent else ""
    # arg_key：第 3 段（key）/第 4 段（value）
    arg_key = tokens[2] if len(tokens) >= 3 else ""

    # ═══ L1：一级 slash ═══
    if level <= 1:
        results: List[Tuple[str, str]] = []
        for cmd_name, cmd_desc in COMMANDS + _CUSTOM_COMMANDS:
            if _fuzzy_match(partial.strip(), cmd_name):
                results.append((cmd_name, cmd_desc))
        n = len(results)
        hint = f"({n} items, filtered)" if n > 0 else "(filtered)"
        _reset_menu_title_ctx(kind="L1", total_hint=hint)
        return results

    # ═══ L2：parent + 空格 → 二级子命令 ═══
    if level == 2:
        subs = SUB_ENUM.get(parent)
        if not subs:
            # parent 没有子命令：自由位（不弹）
            _reset_menu_title_ctx(kind="L1", parent=parent, total_hint="(自由参数位 · no sub menu)")
            return []
        # partial 末尾已经有 L2 token 前缀的话做一次过滤；否则全显示
        l2_prefix = ""
        if len(tokens) >= 2:
            l2_prefix = tokens[1].lower()
        out: List[Tuple[str, str]] = []
        for sub_name, sub_desc in subs:
            # sub_name 可能是 "/config set" 或 "staged" 或 "[自定义指令]"；
            #   过滤方式：取 sub_name 空格后的最后一段做 l2_prefix 前缀匹配（保留 /config 前缀用于显示/提交）
            sub_tok = sub_name.split()[-1]
            if l2_prefix and not sub_tok.lower().startswith(l2_prefix):
                continue
            out.append((sub_name, sub_desc))
        n = len(out)
        # demo: SUB /config (5 subs) 或 SUB /config (5 subs · Esc 回退一级)
        _reset_menu_title_ctx(kind="SUB", parent=parent,
                              total_hint=f"({n} subs)" if n else "(无匹配)")
        return out

    # ═══ L3-keys：parent sub  + 空格 → 第 3 段 key / name / id / flag / hash
    #         或：/logs -l   + 空格 → 没有 ARG_ENUM，但有 VALUE_ENUM(l2_cmd, "")（直接下一级值）
    # ═══
    if level == 3:
        # 根据 ARG key 的形态（从 l2_cmd 判断语义）决定 total_hint 的分类词：
        #   key / name / id / hash / flag / param（对应 demo 中不同写法）
        def _arg_kind_label(cmd: str) -> str:
            cl = cmd.lower()
            # 优先判断前缀（避免子串 " show" 覆盖 /git show 的 hash 语义）
            if cl.startswith("/git show"):
                return "hash"
            if cl.startswith("/memory") and (" show" in cl or " delete" in cl):
                return "id"
            if cl.startswith("/cron") and " delete" in cl:
                return "id"
            if cl.startswith("/config") or " config" in cl:
                return "key"
            if any(x in cl for x in [" delete", " remove", " show", " pin", " archive"]):
                return "name"
            if any(x in cl for x in ["-l", "-t", "--trace", "--stats", "-n"]):
                return "flag"
            return "param"

        # 精确匹配 l2_cmd（"/config set"）；若没找到，试试只取最后一段 "set" 做宽松匹配
        arg_list = ARG_ENUM.get(l2_cmd)
        if arg_list is not None:
            arg_prefix = tokens[2].lower() if len(tokens) >= 3 else ""
            out: List[Tuple[str, str]] = []
            for k, desc in arg_list:
                if arg_prefix and not str(k).lower().startswith(arg_prefix):
                    continue
                out.append((str(k), desc))
            n = len(out)
            label = _arg_kind_label(l2_cmd)
            # demo: (key · 5 options) / (name · 6 pinned items) / (hash · 最近 5)
            _reset_menu_title_ctx(kind="ARG", parent=parent, sub=l2_cmd,
                                  total_hint=f"({label} · {n} options)" if n else "(无匹配)")
            return out
        # ── 没有 ARG_ENUM[l2_cmd]：回退到 VALUE_ENUM[l2_cmd, ""]（直接有值无 key 的场景，如 /logs -l / -t）──
        value_list = VALUE_ENUM.get((l2_cmd, ""))
        if value_list:
            val_prefix = tokens[2].lower() if len(tokens) >= 3 else ""
            out: List[Tuple[str, str]] = []
            for v, desc in value_list:
                if val_prefix and not str(v).lower().startswith(val_prefix):
                    continue
                out.append((str(v), desc))
            n = len(out)
            # demo: (value · N models) / (value · N options)
            _reset_menu_title_ctx(kind="ARG", parent=parent, sub=l2_cmd, arg_key="",
                                  total_hint=f"(value · {n} options)" if n else "(无匹配)")
            return out
        # 二级没有 args 也没有直接值：自由位（不弹）
        _reset_menu_title_ctx(kind="ARG", parent=parent, sub=l2_cmd,
                              total_hint="(自由参数位 · no key/value menu)")
        return []

    # ═══ L3-values：parent sub key + 空格 → 第 4 段 value ═══
    if level >= 4:
        # 特殊：/logs -t 的 value 和 /logs -l 的 value，key="" 形式（见 VALUE_ENUM 构造）
        # 先按 (l2_cmd, arg_key) 精确取；没有的话再按 (l2_cmd, "") 作为 fallback
        value_list = VALUE_ENUM.get((l2_cmd, arg_key)) or VALUE_ENUM.get((l2_cmd, ""))
        if not value_list:
            _reset_menu_title_ctx(kind="ARG", parent=parent, sub=l2_cmd, arg_key=arg_key,
                                  total_hint="(自由值位 · no value menu)")
            return []
        val_prefix = tokens[3].lower() if len(tokens) >= 4 else ""
        out: List[Tuple[str, str]] = []
        for v, desc in value_list:
            if val_prefix and not str(v).lower().startswith(val_prefix):
                continue
            out.append((str(v), desc))
        n = len(out)
        # demo Line 148: kind=ARG 但 arg_key=magenta，total_hint=(2 values)
        # 保持 kind=ARG，让 VALUE 也显示 ARG 关键字（与 HTML demo 的 ARG 视觉一致）
        _reset_menu_title_ctx(kind="ARG", parent=parent, sub=l2_cmd, arg_key=arg_key,
                              total_hint=f"({n} values)" if n else "(无匹配)")
        return out

    # 兜底
    _reset_menu_title_ctx(kind="L1", total_hint="(filtered)")
    return []


# ─── @ 上下文检测 ─────────────────────────────────────────
def find_at_context(text_before_cursor: str) -> Optional[Tuple[int, str]]:
    at_positions = []
    for i, ch in enumerate(text_before_cursor):
        if ch == "@":
            if i > 0 and text_before_cursor[i - 1] not in (" ", "\n", "\t"):
                continue
            at_positions.append(i)
    if not at_positions:
        return None
    last_at = at_positions[-1]
    partial = text_before_cursor[last_at + 1:]
    if " " in partial:
        return None
    return last_at, partial


# ─── 显示宽度计算（CJK 字符占 2 列）──────────────────────
# ANSI CSI 序列匹配：\x1b[ ... 直到 字母/@ 终止
_ANSI_CSI_RE = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]')
# OSC 序列（\x1b] ... BEL / ST）也应跳过
_ANSI_OSC_RE = re.compile(r'\x1b\].*?(?:\x07|\x1b\\)')


def _strip_ansi(s: str) -> str:
    """去除 ANSI CSI / OSC 转义序列，只保留肉眼可见字符。"""
    s = _ANSI_OSC_RE.sub('', s)
    s = _ANSI_CSI_RE.sub('', s)
    return s


def _display_width(s: str) -> int:
    """计算字符串在终端中的显示列宽。

    先剔除 ANSI CSI/OSC 转义序列（不占显示宽度，只控制颜色），
    再按 CJK 字符 2 列、其余 1 列计算。
    """
    s = _strip_ansi(s)
    w = 0
    for ch in s:
        cp = ord(ch)
        if (
            0x1100 <= cp <= 0x115F or   # Hangul Jamo
            0x2E80 <= cp <= 0xA4CF or   # CJK Radicals .. Yi
            0xAC00 <= cp <= 0xD7A3 or   # Hangul Syllables
            0xF900 <= cp <= 0xFAFF or   # CJK Compatibility Ideographs
            0xFE10 <= cp <= 0xFE19 or   # Vertical forms
            0xFE30 <= cp <= 0xFE6F or   # CJK Compatibility Forms
            0xFF00 <= cp <= 0xFF60 or   # Fullwidth Forms
            0xFFE0 <= cp <= 0xFFE6 or   # Fullwidth Signs
            0x1F300 <= cp <= 0x1F64F or # Emoticons
            0x1F900 <= cp <= 0x1F9FF or # Supplemental Symbols
            0x20000 <= cp <= 0x2FFFD or # CJK Extension B+
            0x30000 <= cp <= 0x3FFFD    # CJK Extension G+
        ):
            w += 2
        else:
            w += 1
    return w


# ─── ANSI 颜色工具 ─────────────────────────────────────
def ansi_white(s: str) -> str:
    return f"\x1b[97m{s}\x1b[0m"


def ansi_selected(s: str) -> str:
    """原 default 样式反白：7;97（保留向后兼容）"""
    return f"\x1b[7;97m{s}\x1b[0m"


def ansi_dim(s: str) -> str:
    return f"\x1b[2m{s}\x1b[0m"


# ─── Hacker 主题方案4色板（YEL_BOLD/GRN_BOLD/GRN/GRN_DIM） ───
_ANSI_RESET = "\x1b[0m"


def _h_yel_bold(s: str) -> str:
    """Hacker · 黄色粗体（slash 命令、[NN/MM] 计数、底栏 selected）"""
    return f"\x1b[1;33m{s}{_ANSI_RESET}"


def _h_grn_bold(s: str) -> str:
    """Hacker · 绿色粗体（标题 COMPLETION、选中态 /cmd、选中态 description）"""
    return f"\x1b[1;32m{s}{_ANSI_RESET}"


def _h_grn(s: str) -> str:
    """Hacker · 普通绿色（未选中 description）"""
    return f"\x1b[0;32m{s}{_ANSI_RESET}"


def _h_grn_dim(s: str) -> str:
    """Hacker · 暗绿（分隔线、标题行 [TAG] 前后装饰）"""
    return f"\x1b[2;32m{s}{_ANSI_RESET}"


def _h_cyn_bold(s: str) -> str:
    """Hacker · 青色粗体（面包屑父级 slash：SUB /config / ARG /config set）"""
    return f"\x1b[1;36m{s}{_ANSI_RESET}"


def _h_mag_bold(s: str) -> str:
    """Hacker · 洋红粗体（面包屑第 3 段 ARG key：ARG /config set <thinking>）"""
    return f"\x1b[1;35m{s}{_ANSI_RESET}"


def _h_sel_row(s: str) -> str:
    """Hacker · 选中整行：用 SGR 7 reverse 模拟 CRT 绿底反白效果
    （终端 7 反色会把前景/背景互换，前景是 32 绿 → 反转后背景=绿底，黑字，与方案4 sel 视觉一致）。
    额外叠加 1 粗体，让命令/描述在反转后更清楚。"""
    return f"\x1b[7;1;32m{s}{_ANSI_RESET}"


def _h_red_bold(s: str) -> str:
    """Hacker · 红色粗体（错误/失败状态 [ERR]、- 删除行等）"""
    return f"\x1b[1;31m{s}{_ANSI_RESET}"


def _h_grn_bg(s: str) -> str:
    """Hacker · 绿底黑字徽标（[OK]/injected/active/saved/staged）"""
    return f"\x1b[48;5;22m\x1b[1;38;5;46m{s}{_ANSI_RESET}"


def _h_yel_bg(s: str) -> str:
    """Hacker · 黄底深褐字徽标（saved/stale/doing/[WARN]）"""
    return f"\x1b[48;5;3m\x1b[1;38;5;234m{s}{_ANSI_RESET}"


def _h_red_bg(s: str) -> str:
    """Hacker · 红底深褐字徽标（[ERR]/[fail]/conflict/skipped）"""
    return f"\x1b[48;5;52m\x1b[1;38;5;255m{s}{_ANSI_RESET}"


def _h_dbg(s: str) -> str:
    """Hacker · 灰色（与 ansi_dim 同效果，语义上用于 cmd 结果辅助说明）"""
    return f"\x1b[0;38;5;8m{s}{_ANSI_RESET}"


def ansi_green_bold(s: str) -> str:
    """绿色粗体（用于用户输入提示符）。"""
    return f"\x1b[1;32m{s}\x1b[0m"


def ansi_cyan(s: str) -> str:
    """青色（用于 assistant 回复内容）。"""
    return f"\x1b[36m{s}\x1b[0m"


def ansi_gray_line(width: int = 0) -> str:
    """生成灰色分隔线。width<=0 时自动取 min(60, 终端宽度)，最小 20。"""
    if width <= 0:
        try:
            term = os.get_terminal_size().columns
        except (OSError, AttributeError):
            term = 80
        width = max(20, min(60, term))
    return f"\x1b[90m{'─' * width}\x1b[0m"


def ansi_move_up(n: int) -> str:
    return f"\x1b[{n}A" if n > 0 else ""


def ansi_move_down(n: int) -> str:
    return f"\x1b[{n}B" if n > 0 else ""


def ansi_move_left(n: int) -> str:
    return f"\x1b[{n}D" if n > 0 else ""


def ansi_move_right(n: int) -> str:
    return f"\x1b[{n}C" if n > 0 else ""


def ansi_clear_line() -> str:
    return "\r\x1b[K"


def _clear_visual_lines(text: str):
    """清除 text 在当前终端宽度下占据的所有视觉行。
    先 \r 回到当前行行首再上移，确保清除位置正确。\n    执行后光标回到第一视觉行的行首。
    
    使用 _display_width() 计算显示列宽（CJK 占 2 列），而非 len()。
    """
    content_len = _display_width(text)
    if content_len <= 0:
        sys.stdout.write('\r\x1b[K')
        return
    try:
        term_width = os.get_terminal_size().columns
    except (OSError, AttributeError):
        term_width = 80
    visual_lines = max(1, (content_len + term_width - 1) // term_width)
    # up_lines = content_len // term_width 覆盖 visual_lines=1 但内容恰好填满一行、
    # 光标已换行的情况（此时 up_lines=1，仍需上移 + 清除上一行）。
    up_lines = content_len // term_width
    if visual_lines <= 1 and up_lines == 0:
        sys.stdout.write('\r\x1b[K')
        return
    # \r 回到当前视觉行行首，再往上移 up_lines 行到第一视觉行。
    # 不能用 visual_lines-1：当 L 恰为 tw 整数倍时，光标在 visual_lines+1 行。
    sys.stdout.write('\r')
    sys.stdout.write(ansi_move_up(up_lines))
    # 逐行清除
    for i in range(visual_lines):
        sys.stdout.write('\r\x1b[K')
        if i < visual_lines - 1:
            sys.stdout.write('\x1b[B')
    # 回到第一视觉行行首
    sys.stdout.write(ansi_move_up(visual_lines - 1))


def ansi_erase_lines_below(count: int) -> str:
    out = ""
    for _ in range(count):
        out += "\r\x1b[K\n"
    out += ansi_move_up(count)
    return out


# ─── 交互式选择器 ──────────────────────────────────────
def ansi_select(prompt: str, options: list, default_idx: int = 0) -> "str | None":
    """上下键选择 + Enter 确认 + Esc 取消。

    使用 Hacker 方案 B 统一渲染（与 slash 补全菜单视觉一致）：
      1) 标题：时间 TAG + SUB/ARG 关键词 + cyan 面包屑 + (name · N options)
      2) 条目：▶/▢ + [NN/MM] 编号 + 反色选中；支持 options 内彩色 TAG
      3) 分隔线：GRN_DIM ───
      4) 底栏：↑↓ 选 · Enter/Tab 确认 · Esc 取消  /  selected: NN/MM

    Args:
        prompt:  提示语（用于面包屑名称；会自动去除尾部 "(Esc 取消)"）
        options: 选项列表（字符串；可含 ANSI 彩色 TAG 前缀，比如 [SAVE]/[LOAD]/[DEL]）
        default_idx: 初始高亮项索引

    Returns:
        选中的原始字符串（保持原 options[i]，含彩色 TAG 以便后续比对）；
        按 Esc 取消返回 None。
    """
    if not options:
        return None
    total = len(options)
    selected = max(0, min(default_idx, total - 1))

    def _now_tag() -> str:
        return datetime.now().strftime("[%H:%M:%S TAB]")

    def _w() -> int:
        try:
            return os.get_terminal_size().columns
        except (OSError, AttributeError):
            return 80

    def _write_line(s: str, go_down: bool = True):
        sys.stdout.write('\r\x1b[K' + s)
        if go_down:
            sys.stdout.write('\x1b[B')

    # 从 prompt 中去掉尾部 "(Esc 取消)" 作为面包屑显示
    crumb = re.sub(r"\s*\(Esc 取消\)\s*$", "", prompt, flags=re.IGNORECASE).strip() or prompt
    # 推断 kind：包含"管理/主菜单/操作" → SUB；否则 ARG（纯枚举值选择）
    if any(k in crumb for k in ["管理", "生命周期", "主菜单", "操作", "工具", "事件"]):
        _kw = _h_grn_bold("SUB")
    else:
        _kw = _h_grn_bold("ARG")
    _name_hint_cls = "name"  # 对 ansi_select 这类枚举选择，统一语义名 name

    # 渲染所需总行数（供 Erase/MoveUp 使用）：标题(1) + N 条目 + 分隔(1) + 底栏(1)
    ROWS_NEEDED = 1 + total + 1 + 1  # 3 行附加 + N

    def render():
        tw = _w()
        width = max(60, min(96, tw))
        # 1) 标题：TAG HH:MM:SS + ARG/SUB + cyan 面包屑 + (name · N options)
        _tag = _h_grn_dim(_now_tag())
        _crumbs = _h_cyn_bold(f"选择：{crumb}") if crumb else ""
        _inf = ansi_dim(f"({_name_hint_cls} · {total} options)")
        if _crumbs:
            title = f"{_tag} {_kw} {_crumbs} {_inf}"
        else:
            title = f"{_tag} {_kw} {_inf}"
        _write_line(title)

        # 2) 条目：▶/▢ + [NN/MM] + name 区（保留原 ANSI 彩色 TAG）+ desc
        #   这里 options 是单字符串；按「第一个空格前的 [TAG] 样式 + 后面主体」拆前后两列展示，
        #   如无 TAG 则左列留空，整段右列展示（保持 desc 对齐视觉一致）
        for i in range(total):
            idx = i
            NN = idx + 1
            nm_txt = f"[{NN:02d}/{total:02d}]"
            opt = options[idx]
            is_selected = (idx == selected)
            icon = "▶" if is_selected else "▢"
            # 拆分可见「前 TAG 列」与「主体列」
            stripped_opt = _strip_ansi(str(opt))
            tag_part = ""      # 左 TAG 区（仅保留可见前缀 [SAVE]/[LOAD]…）
            main_part = opt    # 主体区（含颜色）
            # 判断是否形如 "[XXX]" 或 emoji 前缀：匹配可见串中前导非中文
            m = re.match(r"^(\[[^\]]{1,8}\]\s*|[^\u4e00-\u9fa5\w]{1,4}\s*)", stripped_opt)
            if m and len(m.group(1)) < 14:
                tag_len = len(m.group(1))
                # 用可见长度反推 opt 中对应的前缀切分点：strip_ansi 长度 tag_len 对应前缀中的原字符数（含 ANSI）
                #   简单做法：直接把 opt 整段放 main，tag_part 用去掉 ANSI 的前缀显示（不带颜色但对齐）
                #   —— 不做严格切分以免破坏彩色；tag_part 给空即可，2 列布局在 main 内已经 self-describe。
                tag_part = ""
                main_part = opt
            if is_selected:
                # 选中：黄粗 ▶ [NN/MM] + 整段 _h_sel_row 反色
                _icon = _h_yel_bold(icon)
                _nm = _h_yel_bold(nm_txt)
                pad_w = max(12, 20)
                # main_part 已可能含 ANSI 颜色，不能再截断；简单用空格做分隔避免过长视觉
                _core = _h_sel_row(f" {main_part} ")
                _write_line(f"  {_icon} {_nm} {_core}")
            else:
                _icon = ansi_dim(icon)
                _nm = ansi_dim(nm_txt)
                # 左 TAG 列 + 右 main（保留原彩色 SAVE/LOAD/DEL 色）；description 后段用绿
                _write_line(f"  {_icon} {_nm}  {main_part}")

        # 3) 分隔线：─ × width
        _write_line(_h_grn_dim("─" * width))

        # 4) 底栏：左 操作提示；右 selected: NN/MM
        sel_no = selected + 1
        left = ansi_dim("  ↑↓ 选 · Enter/Tab 确认 · Esc 取消")
        right = _h_yel_bold(f"selected: {sel_no:02d}/{total:02d}")
        left_w = _display_width("  ↑↓ 选 · Enter/Tab 确认 · Esc 取消")
        pad_n = max(0, width - left_w - _display_width(f"selected: {sel_no:02d}/{total:02d}") - 4)
        _write_line(f"{left}{' ' * pad_n}  {right}", go_down=False)

        # 回到菜单第 1 行（标题行），保证下次重写在同一行覆盖：
        # 总共写出 1 + total + 1 + 1 行；go_down=False 的底栏没下移 → 少 1 → 上移 total+2
        sys.stdout.write(ansi_move_up(1 + total + 1))  # 标题 + N entry + 分隔 + (底栏未下移)
        sys.stdout.flush()

    # ─── 交互循环 ───
    render()
    while True:
        try:
            key = msvcrt.getwch()
        except (OSError, ValueError):
            time.sleep(0.01)
            continue
        # Ctrl+C
        if key == '\x03':
            sys.stdout.write(ansi_erase_lines_below(ROWS_NEEDED + 1))
            sys.stdout.write('\r\x1b[K')
            raise KeyboardInterrupt
        if key == '\x00' or key == '\xe0':
            special = msvcrt.getwch()
            if special == 'H':  # 上
                selected = max(0, selected - 1)
                render()
            elif special == 'P':  # 下
                selected = min(total - 1, selected + 1)
                render()
            continue
        if key == '\x1b':   # Esc 取消
            sys.stdout.write(ansi_erase_lines_below(ROWS_NEEDED + 1))
            sys.stdout.write('\r\x1b[K')
            sys.stdout.flush()
            return None
        if key == '\r' or key == '\n':  # Enter 确认
            # 先清空菜单区域，再显示最终确认行
            sys.stdout.write(ansi_erase_lines_below(ROWS_NEEDED + 1))
            sys.stdout.write('\r\x1b[K')
            # 最终确认行：TAG + SUB/ARG + cyan 面包屑 + GRN "> 选中值"
            _tag = _h_grn_dim(_now_tag())
            crumb_line = _h_cyn_bold(f"选择：{crumb}") if crumb else ""
            _fin = _h_grn("> ") + _h_grn_bold(_strip_ansi(options[selected]))
            if crumb_line:
                sys.stdout.write(f"{_tag} {_kw} {crumb_line} {_fin}\n")
            else:
                sys.stdout.write(f"{_tag} {_kw} {_fin}\n")
            sys.stdout.flush()
            return options[selected]


# ── 主输入函数 ────────────────────────────────────────────
def ansi_prompt(prompt_str: str = "> ", prompt_color: str = "",
                on_help=None, completion_style: str = "default") -> str:
    """
    自定义输入循环，支持 / 和 @ 补全菜单（ANSI 渲染）。
    返回用户输入的字符串。
    Ctrl+C 抛出 KeyboardInterrupt。

    prompt_color：可选的提示符颜色码（如 '\\x1b[1;32m'）。渲染时包裹提示符部分，
    buffer 仍存纯文本，确保 _display_width 和返回切片不受影响。

    on_help：可选回调，无参数。空输入行上按 Backspace(Ctrl+H, 0x08) 时触发；
    一般用于显示帮助。

    completion_style：补全菜单视觉风格。可选值：
      - "default"          原默认样式（白底反白 + 灰色描述）
      - "hacker-plan-b"    方案4 方案B：PLAN 编号风格 ▶ [n/m] + 标题/底栏 3 行
    """
    # Windows: 清空控制台输入缓冲，避免上次输出污染导致首次按键被吞
    if sys.platform == "win32":
        try:
            import ctypes
            STD_INPUT_HANDLE = -10
            handle = ctypes.windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)
            ctypes.windll.kernel32.FlushConsoleInputBuffer(handle)
        except Exception:
            pass

    # === 方案B 菜单高度计算辅助（3 行附加：标题 + 分隔 + 底栏） ===
    def _menu_extra_rows() -> int:
        return 3 if completion_style == "hacker-plan-b" else 0

    def _now_hhmmss_tag() -> str:
        """返回方案B 标题行的时间短码：[hh:mm:ss TAB]"""
        return datetime.now().strftime("[%H:%M:%S TAB]")

    buf: list = list(prompt_str)
    cursor_pos: int = len(buf)
    prev_line_len: int = len(buf)

    def _render_line() -> str:
        """返回渲染用字符串：prompt_color 非空时给提示符部分上色，其余原样。"""
        if prompt_color:
            return (prompt_color + ''.join(buf[:len(prompt_str)])
                    + "\x1b[0m" + ''.join(buf[len(prompt_str):]))
        return ''.join(buf)
    prev_term_width: int = 0  # 追踪终端宽度变化（0 = 首次强制全刷新）
    completions: List[Tuple[str, str]] = []
    selected_idx: int = 0
    showing_menu: bool = False
    max_visible: int = compute_max_visible()  # 根据终端高度动态算，避免截断
    menu_row_count: int = 0
    max_rows_ever: int = 0  # 追踪渲染过的最大行数，确保清除干净
    # 历史翻看：history_idx = -1 表示不在导航态（停在草稿）
    history_idx: int = -1
    history_draft: str = ""  # 进入导航前保存的当前 buffer，便于 ↓ 回到

    def get_term_width():
        try:
            return os.get_terminal_size().columns
        except (OSError, AttributeError):
            return 80

    def _clear_and_redraw(clear_visuals: int, up: int):
        """原子操作：上移 up 行 → 清除 clear_visuals 行 → 写新内容。
        用 \x1b[J 兜底清除光标以下残留（窗口大小改变时终端自动重排可能留下散落字符）。"""
        sys.stdout.write('\r')
        if up > 0:
            sys.stdout.write(ansi_move_up(up))
        for i in range(clear_visuals):
            sys.stdout.write('\r\x1b[K')
            if i < clear_visuals - 1:
                sys.stdout.write('\x1b[B')
        # 兜底：清除光标以下的残余（窗口 resize 后终端重排可能留下额外行）
        sys.stdout.write('\x1b[J')
        if clear_visuals > 0:
            sys.stdout.write(ansi_move_up(clear_visuals - 1))
        sys.stdout.write(_render_line())
        sys.stdout.flush()

    def redraw_line():
        """重绘当前输入行。
        关键：必须 \r\x1b[K（回行首 + 清行尾），不能只 \r。
        否则本行原本有内容（如上一轮的 '，请重试)' 残留）会被新输入部分覆盖，
        用户看到 '> /show-reasoning，请重试)' 这种新旧混杂的乱象。
        
        使用 _display_width() 计算显示列宽（CJK 占 2 列），而非 len()。
        """
        nonlocal cursor_pos, buf, prev_line_len, prev_term_width
        term_width = get_term_width()
        width_changed = (prev_term_width != 0 and prev_term_width != term_width)
        line = ''.join(buf)
        disp_w = _display_width(line)  # 当前行的显示列宽
        cursor_disp = _display_width(''.join(buf[:cursor_pos]))  # 光标位置的显示列宽

        if width_changed:
            # 终端宽度变了 → Windows 已自动重排所有文字，prev_line_len 的假设全部失效。
            # 改用旧宽度重新估算旧内容占的行数，保守清除（加 2 行安全余量）。
            old_tw = prev_term_width if prev_term_width > 0 else term_width
            safety_visuals = max(1, (prev_line_len + old_tw - 1) // old_tw) + 2
            safety_up = prev_line_len // old_tw if prev_line_len > 0 else 0
            _clear_and_redraw(safety_visuals, safety_up)
            prev_term_width = term_width
            prev_line_len = disp_w
            if cursor_disp < disp_w:
                sys.stdout.write(ansi_move_left(disp_w - cursor_disp))
            sys.stdout.flush()
            return

        prev_term_width = term_width
        # 回行首 + 清行尾：先清除之前占据的所有视觉行（含跨行包裹），
        # 再写新内容。旧代码只清当前行（\r\x1b[K），导致输入换行时出现重复行。
        old_visuals = max(1, (prev_line_len + term_width - 1) // term_width) if prev_line_len > 0 else 1
        # 向上移动行数必须用 prev_line_len // term_width（整数除法），
        # 不能用 old_visuals-1：当 prev_line_len 恰为 tw 整数倍时，
        # 光标已自动换到下一行，old_visuals-1 少算一行 → 第一行永远残留。
        up_lines = prev_line_len // term_width if prev_line_len > 0 else 0
        if old_visuals > 1 or up_lines > 0:
            # up_lines > 0 覆盖 old_visuals=1 但内容恰好填满一行、光标已换行的情况
            _clear_and_redraw(old_visuals, up_lines)
        else:
            sys.stdout.write('\r\x1b[K')
            sys.stdout.write(_render_line())
            sys.stdout.flush()
        prev_line_len = disp_w
        if cursor_disp < disp_w:
            sys.stdout.write(ansi_move_left(disp_w - cursor_disp))
        sys.stdout.flush()

    def clear_menu_area():
        """清除菜单区域的所有行，光标回到输入行 cursor_pos 位置。
        只在已有菜单空间内操作（假定 show_menu 已用 \\n 创建了空间），使用 \\x1b[B 避免额外滚动。
        """
        nonlocal menu_row_count
        if menu_row_count > 0:
            # \x1b[B 到菜单首行（空间已由 show_menu 首次激活时创建）
            sys.stdout.write('\x1b[B')
            for i in range(menu_row_count):
                sys.stdout.write('\r\x1b[K')
                if i < menu_row_count - 1:
                    sys.stdout.write('\x1b[B')
            # 上移回输入行
            sys.stdout.write(ansi_move_up(menu_row_count))
            cursor_col = _display_width(''.join(buf[:cursor_pos])) % get_term_width()
            sys.stdout.write('\r')
            sys.stdout.write(ansi_move_right(cursor_col))
            sys.stdout.flush()
            menu_row_count = 0

    def hide_menu():
        nonlocal showing_menu, menu_row_count, completions, selected_idx
        # 用 menu_row_count 判断是否有菜单需要清除（showing_menu 可能已被 update_completions 置 False）
        if menu_row_count > 0:
            clear_menu_area()
        showing_menu = False
        completions = []
        selected_idx = 0
        # 不重置 max_rows_ever：保留已分配的空间，避免 hide→show 循环不断追加 \n 导致终端滚动错乱

    def show_menu():
        nonlocal showing_menu, menu_row_count, completions, selected_idx, max_rows_ever
        if not completions:
            hide_menu()
            return

        showing_menu = True
        total = len(completions)
        visible_count = min(total, max_visible)
        start_idx = 0
        if selected_idx >= max_visible:
            start_idx = selected_idx - max_visible + 1

        # ========== 菜单总行数：方案B = 条目数 + 3（标题/分隔/底栏） ==========
        extra_rows = _menu_extra_rows()
        menu_rows_total = visible_count + extra_rows

        # ========== 空间分配：首次激活或条目数增多时扩展 ==========
        # 关键：扩张 \n 之前先把当前输入行完整清除（用 _clear_visual_lines 逐行 \r\x1b[K）。
        # 否则输入行上有"abyss@XXX:~$ /"，输出第一个 \n 时终端会把这行冻结在屏幕上，
        # 即使 ansi_move_up 把光标移回来，冻结行也永久残留 → 表现为每个 slash 命令
        # 前多出一行 prompt + "/" 的空行。
        input_redrawn = False
        if menu_rows_total > max_rows_ever:
            extra = menu_rows_total - max_rows_ever
            # 1) 先清除当前输入行占据的所有视觉行（含跨行包裹）→ 避免 \n 时"冻结"到屏幕
            _clear_visual_lines(''.join(buf))
            # 2) 再向下扩展空间
            for _ in range(extra):
                sys.stdout.write('\n')
            # 3) 光标回到原输入行（此时 extra 行都是空白）
            sys.stdout.write(ansi_move_up(extra))
            # 4) 最后重新把输入行内容画回来（光标位置由后续绘制统一调整）
            sys.stdout.write(''.join(buf))
            sys.stdout.flush()
            max_rows_ever = menu_rows_total
            input_redrawn = True
        # 菜单正在显示：在已有空间内清除旧内容（用 \x1b[B 避免额外滚动）
        elif menu_row_count > 0:
            sys.stdout.write('\x1b[B')
            for i in range(menu_row_count):
                sys.stdout.write('\r\x1b[K')
                if i < menu_row_count - 1:
                    sys.stdout.write('\x1b[B')
            sys.stdout.write(ansi_move_up(menu_row_count))
            cursor_col = _display_width(''.join(buf[:cursor_pos])) % get_term_width()
            sys.stdout.write('\r')
            sys.stdout.write(ansi_move_right(cursor_col))
        # 菜单曾隐藏（menu_row_count=0）但空间尚存（max_rows_ever>0）：
        # clear_menu_area() 在 hide 时已清空该空间，无需再次清除，直接绘制即可

        # 重绘输入行（先清除之前占据的所有视觉行，含跨行包裹）。
        # 扩容分支里已经画过一次（input_redrawn=True），避免重复绘制。
        if not input_redrawn:
            _clear_visual_lines(''.join(buf))
            sys.stdout.write(''.join(buf))

        # ============= 绘制菜单内容 =============
        # 光标下移 1 行到菜单首行
        sys.stdout.write('\x1b[B')

        def _write_line(s: str, go_down: bool = True):
            """写一行：回行首 + 清行尾 + 写内容；可选下移一行"""
            sys.stdout.write('\r\x1b[K' + s)
            if go_down:
                sys.stdout.write('\x1b[B')

        if completion_style == "hacker-plan-b":
            # ───────── 方案B 渲染：标题 / 条目 / 分隔 / 底栏 ─────────
            tw = get_term_width()
            width = max(60, min(96, tw))  # 菜单视觉宽度（仅用于分隔线填充；实际会折行）
            # 1) 标题行：按 _MENU_TITLE_CTX 拼接 kind + 面包屑
            ctx = get_menu_title_ctx()
            kind = ctx.get("kind", "L1")
            parent = ctx.get("parent", "") or ""
            sub = ctx.get("sub", "") or ""
            arg_key = ctx.get("arg_key", "") or ""
            total_hint = ctx.get("total_hint") or f"({total} items, filtered)"
            _tag = _h_grn_dim(_now_hhmmss_tag())
            # keyword 字：L1→COMPLETION · SUB→SUB · ARG→ARG · VALUE→ARG · FILE→FILE
            if kind == "L1":
                _kw = _h_grn_bold("COMPLETION")
            elif kind == "FILE":
                _kw = _h_grn_bold("FILE")
            elif kind == "SUB":
                _kw = _h_grn_bold("SUB")
            else:  # ARG / VALUE
                _kw = _h_grn_bold("ARG")
            # 面包屑部分：parent / sub / arg_key
            _crumbs = []
            if parent:
                _crumbs.append(_h_cyn_bold(parent))
            if sub and sub != parent:  # 避免 "/config /config" 重复
                # sub 可能是 "/config set" / "staged" / "/logs -l"
                #   如果 sub 开头已是 parent 前缀（含空格），只把"不同部分" cyan（保持 slash 语义）
                _crumbs.append(_h_cyn_bold(sub))
            if arg_key:
                # arg_key 非空 → 第 3 段枚举 key 或第 4 段 value 的分类名
                # 用 magenta 粗体显示，保持 demo 中 ARG 三级 thinking/model 的视觉
                _crumbs.append(_h_mag_bold(arg_key))
            _inf = ansi_dim(total_hint)
            if _crumbs:
                _title_line = f"{_tag} {_kw} {' '.join(_crumbs)} {_inf}"
            else:
                _title_line = f"{_tag} {_kw} {_inf}"
            _write_line(_title_line)

            # 2) 条目行
            CMD_W = 16  # /cmd 列固定宽
            for i in range(visible_count):
                idx = start_idx + i
                if idx >= total:
                    # 空白行填充（仅在末尾有空缺时，一般 visible_count <= total）
                    _write_line("")
                    continue
                name, meta = completions[idx]
                is_selected = (idx == selected_idx)
                NN = idx + 1
                n_mm = f"[{NN:02d}/{total:02d}]"
                icon = "▶" if is_selected else "▢"
                # /cmd 列：截断过长 name，再右 pad 空格
                cmd_pad = name[:CMD_W].ljust(CMD_W)
                # 描述列：meta 若超宽做一次软截断（避免终端折太多行视觉对齐乱），保留可看清的开头
                desc_max = max(20, tw - 4 - 2 - 9 - 2 - CMD_W - 2)  # 4(icon+pad) + 2 + [NN/MM](9) + 2 + CMD_W + 2
                desc = meta if _display_width(meta) <= desc_max else (
                    meta[: max(1, desc_max - 1)] + "…")
                if is_selected:
                    # 选中：icon ▶ 黄粗 → [NN/MM] 黄粗 → (cmd_pad + desc) 整段 _h_sel_row 反色
                    _icon = _h_yel_bold(icon)
                    _nm = _h_yel_bold(n_mm)
                    _core = _h_sel_row(f" {cmd_pad}  {desc} ")
                    _write_line(f"  {_icon} {_nm} {_core}")
                else:
                    # 未选中：▢ dim → [NN/MM] dim → /cmd YEL_BOLD → description GRN
                    _icon = ansi_dim(icon)
                    _nm = ansi_dim(n_mm)
                    _cmd = _h_yel_bold(cmd_pad)
                    _desc = _h_grn(desc)
                    _write_line(f"  {_icon} {_nm}  {_cmd}  {_desc}")

            # 3) 分隔线：GRN_DIM 的 ── 重复
            sep_chars = "─" * width
            _write_line(_h_grn_dim(sep_chars))

            # 4) 底栏：左侧 dim 提示 · 右侧 YEL_BOLD selected: NN/MM
            sel_no = selected_idx + 1
            tip = ansi_dim("  ↑↓ 选 · Enter/Tab 确认 · Esc 取消")
            right = _h_yel_bold(f"selected: {sel_no:02d}/{total:02d}")
            # 空白填充使 right 靠右（使用空格填充）
            left_w = _display_width(
                "  ↑↓ 选 · Enter/Tab 确认 · Esc 取消")
            pad_n = max(0, width - left_w - _display_width(f"selected: {sel_no:02d}/{total:02d}") - 4)
            _write_line(f"{tip}{' ' * pad_n}  {right}", go_down=False)
        else:
            # ───────── default 原样式：保持不变 ─────────
            for i in range(visible_count):
                idx = start_idx + i
                if idx >= len(completions):
                    break
                name, meta = completions[idx]
                is_selected = (idx == selected_idx)
                if is_selected:
                    line = f"  {ansi_selected(name)}  {ansi_dim(meta)}"
                else:
                    line = f"  {ansi_white(name)}  {ansi_dim(meta)}"
                go_down = (i < visible_count - 1)
                _write_line(line, go_down=go_down)

        # ============= 光标导航回输入行 cursor_pos =============
        # 上移：menu_rows_total 行（方案B 含标题/分隔/底栏）
        sys.stdout.write(ansi_move_up(menu_rows_total))
        # 计算从 buf 末尾到 cursor_pos 的视觉行/列偏移（使用显示列宽）
        tw2 = get_term_width()
        disp_w = _display_width(''.join(buf))
        cursor_disp = _display_width(''.join(buf[:cursor_pos]))
        end_row = disp_w // tw2
        cur_row = cursor_disp // tw2
        cur_col = cursor_disp % tw2
        if cur_row < end_row:
            sys.stdout.write(ansi_move_up(end_row - cur_row))
        sys.stdout.write('\r')
        sys.stdout.write(ansi_move_right(cur_col))
        sys.stdout.flush()

        menu_row_count = menu_rows_total

    def update_completions():
        nonlocal completions, showing_menu, selected_idx
        text_before = ''.join(buf[len(prompt_str):cursor_pos])

        at_ctx = find_at_context(text_before)
        if at_ctx:
            _, partial = at_ctx
            new_comps = get_file_completions(partial)
            if new_comps:
                completions = new_comps
                selected_idx = 0
                showing_menu = True
                return
            else:
                completions = []
                showing_menu = False
                return

        if text_before.startswith('/'):
            new_comps = get_command_completions(text_before)
            if new_comps:
                completions = new_comps
                selected_idx = 0
                showing_menu = True
                return
            else:
                completions = []
                showing_menu = False
                return

        completions = []
        showing_menu = False

    # 打印初始提示符（先清行，避免上一轮输出残留）
    colored_prompt = (prompt_color + prompt_str + "\x1b[0m") if prompt_color else prompt_str
    sys.stdout.write('\r\x1b[K' + colored_prompt)
    sys.stdout.flush()

    while True:
        # 直接阻塞读取 msvcrt.getwch()，不要用 kbhit() + 紧密轮询。
        # 5ms 紧密轮询会和 Windows 中文 IME 冲突，导致 IME 提交字符后
        # 程序仍然读不到 Enter，从而"按回车无反应、卡死"。
        # getwch() 在 Windows 上 Ctrl+C 不会自动抛异常，需靠 \x03 字符判断（已处理）。
        try:
            key = msvcrt.getwch()
        except (OSError, ValueError):
            time.sleep(0.01)
            continue

        # 特殊键（两字节序列）
        if key == '\x00' or key == '\xe0':
            special = msvcrt.getwch()
            if special == 'H':  # 上箭头
                if showing_menu and completions:
                    selected_idx = max(0, selected_idx - 1)
                    show_menu()
                elif HISTORY:
                    # 历史翻看：↑ 调到更早一条
                    if history_idx == -1:
                        # 第一次按 ↑：保存当前 buffer 为草稿
                        history_draft = ''.join(buf[len(prompt_str):])
                    history_idx = max(0, history_idx - 1) if history_idx != -1 else len(HISTORY) - 1
                    recall = HISTORY[history_idx]
                    buf = list(prompt_str) + list(recall)
                    cursor_pos = len(buf)
                    redraw_line()
                continue
            elif special == 'P':  # 下箭头
                if showing_menu and completions:
                    selected_idx = min(len(completions) - 1, selected_idx + 1)
                    show_menu()
                elif history_idx != -1:
                    # 历史翻看：↓ 调到更新一条（越界则回草稿）
                    history_idx += 1
                    if history_idx >= len(HISTORY):
                        # 越界：恢复草稿
                        buf = list(prompt_str) + list(history_draft)
                        cursor_pos = len(buf)
                        history_idx = -1
                    else:
                        recall = HISTORY[history_idx]
                        buf = list(prompt_str) + list(recall)
                        cursor_pos = len(buf)
                    redraw_line()
                continue
            elif special == 'K':  # 左箭头
                if cursor_pos > len(prompt_str):
                    cursor_pos -= 1
                    redraw_line()
                continue
            elif special == 'M':  # 右箭头
                if cursor_pos < len(buf):
                    cursor_pos += 1
                    redraw_line()
                continue
            # Windows IME 偶尔以 \x00 + CJK字符 的两字节序列提交首字符：
            # 若 special 是可打印的非 ASCII 字符（CJK 等），作为普通字符插入，不丢弃
            if len(special) == 1 and special.isprintable() and ord(special) > 127:
                buf.insert(cursor_pos, special)
                cursor_pos += 1
                redraw_line()
                update_completions()
                if showing_menu:
                    show_menu()
            continue

        # Ctrl+C
        if key == '\x03':
            sys.stdout.write('\r\x1b[K\n')
            raise KeyboardInterrupt

        # Enter
        if key == '\r' or key == '\n':
            if showing_menu and completions:
                sel_text = completions[selected_idx][0]
                text_before = ''.join(buf[len(prompt_str):cursor_pos])
                at_ctx = find_at_context(text_before)
                if at_ctx:
                    start = len(prompt_str) + at_ctx[0]
                elif text_before.startswith('/'):
                    start = len(prompt_str)
                else:
                    start = len(prompt_str)
                buf[start:cursor_pos] = list(sel_text)
                cursor_pos = start + len(sel_text)
                hide_menu()
                redraw_line()
            else:
                result = ''.join(buf[len(prompt_str):])
                # 入历史（非空 + 不与最后一条重复）
                if result and (not HISTORY or HISTORY[-1] != result):
                    HISTORY.append(result)
                    if len(HISTORY) > HISTORY_MAX:
                        HISTORY.pop(0)
                # 重置历史导航状态
                history_idx = -1
                history_draft = ""
                sys.stdout.write('\n')
                sys.stdout.flush()
                return result
            continue

        # Backspace
        if key == '\x08' or key == '\x7f':
            if cursor_pos > len(prompt_str):
                cursor_pos -= 1
                buf.pop(cursor_pos)
                redraw_line()
                update_completions()
                if showing_menu:
                    show_menu()
                else:
                    hide_menu()
            elif on_help is not None:
                # 空输入行上按 Backspace(Ctrl+H, 0x08) → 触发帮助
                try:
                    on_help()
                except Exception:
                    pass
                # 帮助打印完要重绘 prompt
                redraw_line()
            continue

        # Tab - 确认补全
        if key == '\t':
            if showing_menu and completions:
                sel_text = completions[selected_idx][0]
                text_before = ''.join(buf[len(prompt_str):cursor_pos])
                at_ctx = find_at_context(text_before)
                if at_ctx:
                    start = len(prompt_str) + at_ctx[0]
                elif text_before.startswith('/'):
                    start = len(prompt_str)
                else:
                    start = len(prompt_str)
                buf[start:cursor_pos] = list(sel_text)
                cursor_pos = start + len(sel_text)
                hide_menu()
                redraw_line()
            continue

        # Escape - 关闭菜单
        if key == '\x1b':
            if showing_menu:
                hide_menu()
                redraw_line()
            continue

        # 普通可打印字符
        if len(key) == 1 and key.isprintable():
            buf.insert(cursor_pos, key)
            cursor_pos += 1
            redraw_line()
            update_completions()
            if showing_menu:
                show_menu()
            continue
